fix(agent): cap verbatim retention by the compaction trigger, not only the window

User turns, retained failures and [[keep]] turns were budgeted as shares of
the window. On a 1M window under the 100K cost trigger those shares plus the
tail passed the trigger, so every fold was refused as still at or above it.
Each budget is now also held to a share of the trigger.
This commit is contained in:
esengine committed 2026-10-25 01:55:42 -07:00
1 parent 463e2f76f0
commit e1c38d3036
6 files changed
+100 -17

No files matched your search

@@ -6,17 +6,16 @@ import (
"reasonix/internal/contract/provider"
)
const keptFailuresWindowFrac = 0.05
const (
keptFailuresWindowFrac = 0.05
keptFailuresTriggerFrac = 0.10
)
// keptFailuresBudget caps what retained failures may spend of a checkpoint. A
// retained failure rides into the next fold's region, so without its own bound
// the retention grows by each round's failures until the acceptance ceiling.
func (a *contextWindow) keptFailuresBudget() int {
window := a.effectiveContextWindow()
if window <= 0 {
return keptUserTurnsFloorTokens
}
return max(1, int(float64(window)*keptFailuresWindowFrac))
return a.retentionBudget(keptFailuresWindowFrac, keptFailuresTriggerFrac)
}
// keepFailuresWithinBudget retains the newest failed tool results that fit the
@@ -6,18 +6,17 @@ import (
"reasonix/internal/contract/provider"
)
const keptMarkedWindowFrac = 0.15
const (
keptMarkedWindowFrac = 0.15
keptMarkedTriggerFrac = 0.20
)
// keptMarkedBudget caps what [[keep]] turns may spend of a checkpoint. A turn the
// person marked is held verbatim, and a kept turn rides into the next fold's
// region, so without its own bound the retention grows by every marked turn
// until the candidate cannot land under the trigger and the fold is refused.
func (a *contextWindow) keptMarkedBudget() int {
window := a.effectiveContextWindow()
if window <= 0 {
return keptUserTurnsFloorTokens
}
return max(1, int(float64(window)*keptMarkedWindowFrac))
return a.retentionBudget(keptMarkedWindowFrac, keptMarkedTriggerFrac)
}
// keepMarkedWithinBudget retains the newest marked turns that fit the budget and
@@ -0,0 +1,20 @@
package agent
const keptUserTurnsTriggerFrac = 0.10 // user turns: share of the compaction trigger
// retentionBudget is what one kind of verbatim retention may spend: a share of
// the window, held to a share of the trigger. The tail already stops at half
// the trigger; window shares alone let retention and tail together pass it on a
// window much larger than the trigger, and every fold is then refused as still
// at or above it.
func (a *contextWindow) retentionBudget(windowFrac, triggerFrac float64) int {
window := a.effectiveContextWindow()
if window <= 0 {
return keptUserTurnsFloorTokens
}
budget := int(float64(window) * windowFrac)
if trigger := a.compactTrigger(); trigger > 0 {
budget = min(budget, int(float64(trigger)*triggerFrac))
}
return max(1, budget)
}
@@ -0,0 +1,67 @@
package agent
import (
"strings"
"testing"
"reasonix/internal/contract/provider"
)
// A window far larger than the trigger (a 1M window under the 100K cost
// trigger) must not let verbatim retention plus the tail reach the trigger, or
// every fold is refused as "still at or above trigger".
func TestRetentionBudgetsLeaveRoomUnderTheTrigger(t *testing.T) {
a := &Agent{agentConfig: agentConfig{contextWindow: 1_000_000, compactRatio: defaultCompactRatio,
budgets: CompactionBudgets{ContextSoftLimitTokens: 102_400}}}
w := a.window()
trigger := w.compactTrigger()
if trigger != 102_400 {
t.Fatalf("compactTrigger = %d, want 102400", trigger)
}
held := w.recentTailBudget() + w.keptUserTurnsBudget() + w.keptFailuresBudget() + w.keptMarkedBudget()
if held >= trigger {
t.Fatalf("tail + retention budgets = %d, must leave room below the trigger %d", held, trigger)
}
}
func TestRetentionBudgetsKeepTheirWindowShareWhenTheTriggerIsLoose(t *testing.T) {
a := &Agent{agentConfig: agentConfig{contextWindow: 128_000, compactRatio: defaultCompactRatio,
budgets: CompactionBudgets{ContextSoftLimitTokens: -1}}}
w := a.window()
if got, want := w.keptUserTurnsBudget(), 6_400; got != want {
t.Fatalf("keptUserTurnsBudget = %d, want %d", got, want)
}
if got, want := w.keptFailuresBudget(), 6_400; got != want {
t.Fatalf("keptFailuresBudget = %d, want %d", got, want)
}
if got, want := w.keptMarkedBudget(), 19_200; got != want {
t.Fatalf("keptMarkedBudget = %d, want %d", got, want)
}
}
// The failing report: a long run of large user turns on a 1M window under the
// 100K trigger. Held whole they filled the candidate past the trigger.
func TestUserTurnRetentionStaysUnderTheTriggerOnALargeWindow(t *testing.T) {
a := &Agent{agentConfig: agentConfig{contextWindow: 1_000_000, compactRatio: defaultCompactRatio,
budgets: CompactionBudgets{ContextSoftLimitTokens: 102_400}}}
w := a.window()
turn := provider.Message{Role: provider.RoleUser, Content: strings.Repeat("x", 20_000)}
cost := fixedTokenEstimate(turn)
var region []provider.Message
for range 20 {
region = append(region, turn, provider.Message{Role: provider.RoleAssistant, Content: "ok"})
}
keep, ret := w.keepIndexes(region, 0)
held := 0
for i, k := range keep {
if k && region[i].Role == provider.RoleUser {
held += cost
}
}
if budget := w.keptUserTurnsBudget(); held > budget {
t.Fatalf("user turns held %d tokens, over the budget %d", held, budget)
}
if held > w.compactTrigger()/10 || ret.Dropped == 0 {
t.Fatalf("held %d of trigger %d, dropped %d: retention must leave room under the trigger", held, w.compactTrigger(), ret.Dropped)
}
}
+1 -5
View File
@@ -64,14 +64,10 @@ func (a *contextWindow) keptUserTurnsBudget() int {
if a.budgets.UserTurnKeepTokens > 0 {
return a.budgets.UserTurnKeepTokens
}
window := a.effectiveContextWindow()
if window <= 0 {
return keptUserTurnsFloorTokens
}
// A fraction and nothing else: an absolute floor on top of it would let a
// small window spend a large share of itself on retention, which pads the
// candidate past the acceptance ceiling and fails the fold outright.
return max(1, int(float64(window)*keptUserTurnsWindowFrac))
return a.retentionBudget(keptUserTurnsWindowFrac, keptUserTurnsTriggerFrac)
}
// noticeDroppedUserTurns reports the turns the budget could not hold. Without
@@ -0,0 +1,2 @@
- 修复:大窗口模型下,用户消息很大的长会话会反复报"折叠后仍不低于压缩阈值",自动压缩和 `/compact` 都卡住;现在原样保留的用户消息按压缩触发线封顶。关联 #14434。
- Fixed: on large-window models, long sessions with big user messages got stuck on "still at or above the compaction threshold"; verbatim user turns are now capped by the trigger. Refs #14434.