fix(agent): give [[keep]] turns a budget of their own and stop the fold ceiling where the send-time output check stops

This commit is contained in:
esengine committed 2026-10-17 20:42:45 -07:00
1 parent 15de9ec334
commit a2aef0b953
8 files changed
+187 -12

No files matched your search

+10 -3
View File
@@ -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
+16 -5
View File
@@ -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] {
@@ -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
}
@@ -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)
}
}
+11 -2
View File
@@ -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
}
@@ -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
}
}
@@ -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)
}
}
@@ -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.