From a2aef0b953984fe31e5eccddfa5944fb96817822 Mon Sep 17 00:00:00 2001 From: esengine <359807859@qq.com> Date: Sat, 17 Oct 2026 19:23:39 -0700 Subject: [PATCH] fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops --- docs/SPEC.md | 13 ++- internal/runtime/agent/compact.go | 21 +++-- internal/runtime/agent/compact_kept_marked.go | 38 +++++++++ .../runtime/agent/compact_threshold_test.go | 5 +- internal/runtime/agent/compact_user_turns.go | 13 ++- .../runtime/agent/keep_marked_budget_test.go | 85 +++++++++++++++++++ internal/runtime/agent/output_budget_test.go | 22 +++++ .../studio/unreleased/13320-marked-budget.md | 2 + 8 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 internal/runtime/agent/compact_kept_marked.go create mode 100644 internal/runtime/agent/keep_marked_budget_test.go create mode 100644 release-notes/studio/unreleased/13320-marked-budget.md diff --git a/docs/SPEC.md b/docs/SPEC.md index b49e4cd823..5b3651bb99 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -377,10 +377,17 @@ digest, so a constraint keeps its protection across repeated compaction. A turn past those bounds folds like any other content. Prefix it with `[[keep]]` (keep policy `user_marked`, on by default) to hold it verbatim regardless of -size. That drop is never silent: compaction telemetry carries `user_kept` and +its own size. + +Marked turns have a budget of their own: together they may spend `window×15%`, +newest first, and the oldest fold into the digest past it. A kept turn rides +into every later fold, so an unbounded mark would grow what each fold carries +until its candidate could not land under the trigger. + +That drop is never silent: compaction telemetry carries `user_kept` and `user_dropped` counts, and a committed checkpoint that had to fold one of your -turns emits a warning naming `[[keep]]` — the projection reads as complete -either way, so the count is the only thing that distinguishes them. +turns, marked or not, emits a warning. The projection reads as complete either +way, so the count is the only thing that distinguishes them. Two properties bound that loss. Each fold re-derives its digest from the canonical transcript rather than from the previous digest, so digests do not diff --git a/internal/runtime/agent/compact.go b/internal/runtime/agent/compact.go index 522e411c15..af3a26ee91 100644 --- a/internal/runtime/agent/compact.go +++ b/internal/runtime/agent/compact.go @@ -139,13 +139,19 @@ func (a *contextWindow) compactTrigger() int { } // hardInputCeiling is a physical input-safety boundary, not another user -// compaction threshold. Reply budgets are resolved independently at send time. +// compaction threshold. On a provider whose reply shares the window it stops +// where the send-time output check stops, so a prompt the check would refuse is +// one the fold has already been told it must free. func (a *contextWindow) hardInputCeiling() int { window := a.effectiveContextWindow() if window <= 0 { return 0 } - return max(1, window-protocolReserveTokens) + reserve := protocolReserveTokens + if sharesContextWindow(a.svc.prov) && a.configuredOutputBudget(0) > 0 { + reserve = max(reserve, outputBudgetReserveFor(window)) + } + return max(1, window-reserve) } // recentTailBudget is the content-construction budget for the recent verbatim @@ -356,12 +362,15 @@ func (a *contextWindow) keepIndexes(region []provider.Message, policy KeepPolicy // Retention applies only to messages since the latest digest. A failed tool // result is bounded by its own budget, since last fold's survivors sit past // the digest and would otherwise be kept again. - var failures []int + var failures, marked []int for i, m := range region { if i >= policyStart && shouldKeepMessage(m, policy) { - if m.Role == provider.RoleTool { + switch m.Role { + case provider.RoleTool: failures = append(failures, i) - } else { + case provider.RoleUser: + marked = append(marked, i) + default: keep[i] = true } } @@ -372,7 +381,9 @@ func (a *contextWindow) keepIndexes(region []provider.Message, policy KeepPolicy } } a.keepFailuresWithinBudget(region, keep, failures) + markedDropped, markedTokens := a.keepMarkedWithinBudget(region, keep, marked) retention := a.keepUserTurns(region, keep) + retention.MarkedDropped, retention.MarkedDroppedTokens = markedDropped, markedTokens pinned := slices.Clone(keep) for i, m := range region { if !pinned[i] { diff --git a/internal/runtime/agent/compact_kept_marked.go b/internal/runtime/agent/compact_kept_marked.go new file mode 100644 index 0000000000..d2ea09f8aa --- /dev/null +++ b/internal/runtime/agent/compact_kept_marked.go @@ -0,0 +1,38 @@ +package agent + +import ( + "slices" + + "reasonix/internal/contract/provider" +) + +const keptMarkedWindowFrac = 0.15 + +// 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)) +} + +// keepMarkedWithinBudget retains the newest marked turns that fit the budget and +// returns the rest, which fold into the digest and are counted. +func (a *contextWindow) keepMarkedWithinBudget(region []provider.Message, keep []bool, marked []int) (dropped, droppedTokens int) { + budget := a.keptMarkedBudget() + for _, i := range slices.Backward(marked) { + cost := fixedTokenEstimate(region[i]) + if cost > budget { + dropped++ + droppedTokens += cost + continue + } + keep[i] = true + budget -= cost + } + return dropped, droppedTokens +} diff --git a/internal/runtime/agent/compact_threshold_test.go b/internal/runtime/agent/compact_threshold_test.go index e4139651ac..49d17d1cdb 100644 --- a/internal/runtime/agent/compact_threshold_test.go +++ b/internal/runtime/agent/compact_threshold_test.go @@ -20,9 +20,10 @@ func TestCompactTriggerIndependentOfOutputBudget(t *testing.T) { if got := a.window().compactTrigger(); got != want { t.Fatalf("trigger after larger output budget = %d, want %d", got, want) } + // The reply reserve is a constant of the window, never the budget's size. hard := a.window().hardInputCeiling() - if hard != 128_000-protocolReserveTokens { - t.Fatalf("hard ceiling = %d, want window-protocolReserve", hard) + if want := 128_000 - outputBudgetReserveFor(128_000); hard != want { + t.Fatalf("hard ceiling = %d, want the window less the output reserve (%d)", hard, want) } } diff --git a/internal/runtime/agent/compact_user_turns.go b/internal/runtime/agent/compact_user_turns.go index 87385733e0..a1965e2145 100644 --- a/internal/runtime/agent/compact_user_turns.go +++ b/internal/runtime/agent/compact_user_turns.go @@ -15,6 +15,8 @@ type userTurnRetention struct { Kept int Dropped int DroppedTokens int + // MarkedDropped counts [[keep]] turns the marked-turn budget could not hold. + MarkedDropped, MarkedDroppedTokens int } // keepUserTurns protects the user's own words from summarizer judgement: a @@ -32,8 +34,8 @@ func (a *contextWindow) keepUserTurns(region []provider.Message, keep []bool) us continue } if keep[i] { - // Already held by the keep policy — a [[keep]] marker, which is the - // documented way past the size budget below. + // Already held by the keep policy: a [[keep]] marker, which has a + // budget of its own. ret.Kept++ continue } @@ -76,6 +78,13 @@ func (a *contextWindow) keptUserTurnsBudget() int { // it the drop is invisible: the projection reads as complete, and the escape // hatch is only useful to someone told it exists at the moment it is needed. func (a *contextWindow) noticeDroppedUserTurns(ret userTurnRetention) { + if ret.MarkedDropped > 0 { + a.svc.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, + Text: fmt.Sprintf("%s you marked [[keep]] %s past the room [[keep]] has, so the oldest now survive only through the summary.", + pluralTurns(ret.MarkedDropped), wereOrWas(ret.MarkedDropped)), + Detail: fmt.Sprintf("compaction folded %d [[keep]] turn(s) (~%d tokens) past the marked-turn budget of %d", + ret.MarkedDropped, ret.MarkedDroppedTokens, a.keptMarkedBudget())}) + } if ret.Dropped == 0 { return } diff --git a/internal/runtime/agent/keep_marked_budget_test.go b/internal/runtime/agent/keep_marked_budget_test.go new file mode 100644 index 0000000000..70f8fab369 --- /dev/null +++ b/internal/runtime/agent/keep_marked_budget_test.go @@ -0,0 +1,85 @@ +package agent + +import ( + "fmt" + "strings" + "testing" + + "reasonix/internal/contract/provider" +) + +func markedTurn(n, chars int) provider.Message { + return provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("[[keep]] turn %d ", n) + strings.Repeat("c", chars)} +} + +func keptMarkedTokens(kept []provider.Message) (tokens int, turns map[string]bool) { + turns = map[string]bool{} + for _, m := range kept { + if m.Role == provider.RoleUser && isUserMarked(m) { + tokens += fixedTokenEstimate(m) + turns[strings.Fields(m.Content)[2]] = true + } + } + return tokens, turns +} + +// [[keep]] holds a turn verbatim inside a budget of its own. With no bound a +// session that marks turn after turn grows what every fold must carry until the +// candidate cannot land under the trigger, and a fold that cannot land is +// refused again every round. +func TestMarkedTurnsStayWithinTheirBudgetAndTheNewestSurvive(t *testing.T) { + a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} + budget := a.window().keptMarkedBudget() + if budget <= 0 { + t.Fatalf("budget = %d, want a positive bound", budget) + } + var region []provider.Message + for i := range 20 { + region = append(region, markedTurn(i, 40_000)) + } + kept, fold, retention, _ := a.window().partitionFoldWith(region, a.keepPolicy) + tokens, turns := keptMarkedTokens(kept) + if tokens > budget { + t.Fatalf("%d tokens of [[keep]] turns kept, budget %d", tokens, budget) + } + if !turns["19"] || turns["0"] { + t.Fatalf("kept %v, want the newest turn kept and the oldest folded", turns) + } + if retention.MarkedDropped == 0 || retention.MarkedDropped != 20-len(turns) || len(fold) != retention.MarkedDropped { + t.Fatalf("retention reports %d dropped marked turns, fold has %d messages, %d kept", retention.MarkedDropped, len(fold), len(turns)) + } +} + +func TestMarkedTurnsUnderTheirBudgetAllStay(t *testing.T) { + a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} + var region []provider.Message + for i := range 4 { + region = append(region, markedTurn(i, 2_000)) + } + kept, fold, retention, _ := a.window().partitionFoldWith(region, a.keepPolicy) + if _, turns := keptMarkedTokens(kept); len(turns) != 4 || len(fold) != 0 || retention.MarkedDropped != 0 { + t.Fatalf("kept %v, fold %d, dropped %d; want all four kept", turns, len(fold), retention.MarkedDropped) + } +} + +// Marked turns carried through folds must not accumulate: a turn kept at one +// fold rides into the next fold's region. +func TestMarkedTurnsCarriedThroughFoldsDoNotAccumulate(t *testing.T) { + a := &Agent{agentConfig: agentConfig{contextWindow: 200_000}, keepPolicy: KeepUserMarked} + budget := a.window().keptMarkedBudget() + var carried []provider.Message + next := 0 + for round := 1; round <= 8; round++ { + region := []provider.Message{formatSummaryMessage("earlier work")} + region = append(region, carried...) + for range 5 { + region = append(region, markedTurn(next, 40_000)) + next++ + } + kept, _, _, _ := a.window().partitionFoldWith(region, a.keepPolicy) + if tokens, _ := keptMarkedTokens(kept); tokens > budget { + t.Fatalf("round %d: %d tokens kept, budget %d", round, tokens, budget) + } + carried = kept + } +} diff --git a/internal/runtime/agent/output_budget_test.go b/internal/runtime/agent/output_budget_test.go index 8c299bc67b..0772797982 100644 --- a/internal/runtime/agent/output_budget_test.go +++ b/internal/runtime/agent/output_budget_test.go @@ -490,3 +490,25 @@ func TestOutputBudgetKeepsAWindowProportionalMarginOnALargeWindow(t *testing.T) t.Fatalf("a prompt 1%% above its estimate plus the %d-token budget is %d, past the %d window", got, actual+got, window) } } + +// The prompt size at which the send-time output check refuses a request is the +// size at which a fold must free room. The fold's hard ceiling used to sit above +// it, so a prompt in between was neither folded nor sendable, and the person +// saw the refusal beside a fold that declined for being worth too little. +func TestTheFoldCeilingStopsWhereTheSendTimeOutputCheckStops(t *testing.T) { + prov := &sharedWindowTestProvider{budget: 8 * 1024, shared: true} + a := &Agent{agentConfig: agentConfig{contextWindow: 128_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}} + w := a.window() + ceiling := w.hardInputCeiling() + atCeiling := provider.Request{Messages: []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("x", 4)}}} + if _, _, err := w.effectiveOutputBudget(atCeiling); err != nil { + t.Fatalf("a tiny prompt is refused: %v", err) + } + if want := 128_000 - outputBudgetReserveFor(128_000); ceiling != want { + t.Fatalf("hardInputCeiling = %d, want the window less the output reserve (%d)", ceiling, want) + } + prov.shared = false + if got := w.hardInputCeiling(); got != 128_000-protocolReserveTokens { + t.Fatalf("a provider whose reply does not share the window keeps the protocol reserve only, got %d", got) + } +} diff --git a/release-notes/studio/unreleased/13320-marked-budget.md b/release-notes/studio/unreleased/13320-marked-budget.md new file mode 100644 index 0000000000..f261553e9c --- /dev/null +++ b/release-notes/studio/unreleased/13320-marked-budget.md @@ -0,0 +1,2 @@ +- 修复:`[[keep]]` 标记的回合现在有自己的保留预算(窗口的 15%,新的优先),超出的旧回合并入摘要并提示;共享窗口的模型在回复空间用尽前就会压缩。见 #13320、#12773。 +- Fixed: [[keep]] turns now have their own budget (15% of the window, newest first); older ones fold with a notice. Shared-window models fold before the reply runs out of room. See #13320, #12773.