Skip to content

fix(compaction): keep folded user turns out of summarizer judgement - #8419

Merged
esengine merged 1 commit into
main-v2from
fix/compaction-keeps-user-turns
Aug 11, 2026
Merged

fix(compaction): keep folded user turns out of summarizer judgement#8419
esengine merged 1 commit into
main-v2from
fix/compaction-keeps-user-turns

Conversation

@esengine

Copy link
Copy Markdown
Owner

Problem

Only the first user turn was pinned verbatim through a fold
(pinnedPrefixLen). Every later one entered the fold region, where its survival
came down to a single summarizer judgement call — with two fallbacks that rarely
apply: landing inside the recent tail, or the user having manually typed
[[keep]] under a policy bit that was off by default.

So a constraint stated at turn 8 of a 40-turn session ("do not change the public
API", "Go 1.24 compatible", "no new dependencies") could disappear from the
projection while the digest still read as perfectly reasonable. That is the
failure mode where a summary looks right and has quietly dropped a requirement.

The asymmetry is the point: the code a constraint governs stays re-derivable
from the workspace, while the constraint itself exists nowhere but the
transcript. Token count is the wrong thing to rank on; cost of loss is the right
one.

Change

Every user turn in the fold region now stays verbatim within a budget, carried
on the existing kept channel (keepIndexespartitionFoldForProjection)
rather than a new mechanism. pinnedPrefixLen is deliberately untouched — it is
the cache prefix boundary (msgs[:head] opens the projection) and must stay a
contiguous prefix.

Three properties make the protection hold:

  • Structural, not a keep-policy bit. A stated constraint is a fact about the
    task, not content a policy flag should be able to switch off. It sits at the
    same level as isCompactionSummary in the partition.
  • Ignores policyStart. The keep policy is scoped to messages after the
    latest digest so it cannot grow without bound; user turns are bounded by
    budget instead, which is what lets protection survive repeated compaction
    rather than lapsing at the next checkpoint.
  • Fixed token estimate. A threshold tracking the last turn's calibrated
    ratio would keep a turn at one checkpoint and fold it at the next. Merged with
    fixedPinnableUserTurn's duplicate expression into fixedTokenEstimate.

Retention is bounded, not unconditional. compact_partition_test.go recorded
that an earlier revision hoisted early user turns and it padded candidates past
the acceptance ceiling — which fails compaction outright instead of degrading it.
One turn may spend 1500 tokens, all of them min(8192, window x 5%), oldest
first (the recent tail already covers the newest turns, and an old turn has
survived more folds than a new one). Past those bounds a turn folds like any
other content, so KeepUserMarked joins the default policy and [[keep]] is now
the documented escape hatch for an oversize turn.

Tests that had fixed the defect as correct behavior

TestCompactKeepsMidSessionUserTurns read like a guard for this behavior but
asserted the opposite: the mid-session turn must fold, and the fact then had
to come back via a fake summarizer echoing it. Its reply is now
"Standing facts: none" — dropping the fact entirely — so the turn has to
survive on its own. It runs the real a.compact() and asserts on
visibleContext(a), i.e. the provider-visible projection.

TestPartitionFoldsAllUserTurnsByDefault becomes
TestPartitionKeepsSmallUserTurnsVerbatim, joined by guards for the per-turn
ceiling, the total budget, and retention across a prior digest.
TestKeepUserMarkedRequiresUserPrefixMarker moves down to isUserMarked: at
keepIndexes level every small user turn is kept regardless, which would have
hidden a broken marker match.

Verification

gofmt -l .        clean
golangci-lint     0 issues
repolint          113571 — byte-identical to the main-v2 baseline (verified via stash)
go test ./...     all green, zero failures

repolint's two remaining findings (desktop/frontend/wailsjs/go/models.ts,
repo file-size total) are pre-existing on main-v2. The baseline was not
widened: a 6-line comment and a +2-line growth in boot.go that this change
introduced were fixed in the code instead.

Scope

This closes the "summarizer dropped it" failure mode only. It does not track
constraint lifecycle — if turn 8 says "do not change the public API" and turn
30 says "actually, go ahead", both now survive verbatim and the model reads the
resolution itself. Status tracking (active / satisfied / superseded) is a
separate concern and not attempted here.

Cache-impact: low - the stable system-prompt prefix (base prompt + tools + memory) is byte-identical; the only change is which messages compose the post-checkpoint projection, and compaction is already a declared cache reset point. Kept user turns are capped at 5% of the window, well inside the 50% acceptance ceiling.
Cache-guard: internal/agent/cachehit_e2e_test.go (unchanged, passing) plus TestCompactKeepsMidSessionUserTurns, which drives the real compact() and asserts on the provider-visible projection.
System-prompt-review: esengine - internal/boot/boot.go changes one default policy value (KeepUserMarked added to the nil-config default); no system-prompt text, tool schema, or memory content is touched.
Documentation-impact: updated - docs/SPEC.md "What survives a fold" rewritten (it still described the removed early-user-turn hoist), with the matching section added to docs/SPEC.zh-CN.md.

A constraint stated mid-session ("do not change the public API") existed
nowhere but the transcript, yet only the first user turn was pinned and the
rest reached the fold region — so whether it survived was one summarizer
judgement call. The code it constrains stays re-derivable from the workspace;
the constraint does not. That asymmetry of loss, not the token count, is what
decides here.

Every user turn in the fold region now stays verbatim within a budget, on the
existing kept channel rather than a new one. Three properties make it hold:

- It is structural, not a keep-policy bit. A stated constraint is a fact about
  the task, so it is not something a policy flag can switch off.
- It ignores policyStart. The keep policy is scoped to messages after the
  latest digest so it cannot grow forever; user turns are bounded by budget
  instead, which is what lets protection survive repeated compaction.
- It measures with a fixed estimate. A threshold tracking the last turn's
  calibrated ratio would keep a turn at one checkpoint and fold it at the next.

Retention is bounded, not unconditional: hoisting user turns without a budget
is what padded an earlier revision's candidates past the acceptance ceiling,
failing compaction outright instead of degrading it. One turn may spend 1500
tokens and all of them min(8192, window*5%), oldest first — the recent tail
already covers the newest turns. Past those bounds a turn folds like any other
content, so KeepUserMarked joins the default policy and [[keep]] becomes the
documented way past the size budget.

Two tests had fixed the defect as correct behavior.
TestCompactKeepsMidSessionUserTurns asserted the mid-session turn must fold and
then relied on a fake summarizer echoing the fact back; its reply now drops the
fact entirely, so the turn has to survive on its own. The partition test that
asserted all user turns fold is rewritten with budget, total-budget and
across-digest guards. TestKeepUserMarkedRequiresUserPrefixMarker moves down to
isUserMarked, which is the only level that can still observe the marker match.
@esengine
esengine requested a review from SivanCola as a code owner August 11, 2026 16:41
@github-actions github-actions Bot added agent Core agent loop (internal/agent, internal/control) config Configuration & setup (internal/config) v2 Go rewrite (1.x) — main-v2 branch, active development labels Aug 11, 2026
@esengine
esengine merged commit 1bd48cd into main-v2 Aug 11, 2026
24 checks passed
@esengine
esengine deleted the fix/compaction-keeps-user-turns branch August 11, 2026 16:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Core agent loop (internal/agent, internal/control) config Configuration & setup (internal/config) v2 Go rewrite (1.x) — main-v2 branch, active development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant