Skip to content

feat(compaction): retain window, prune-before-summarize, usable context window - #276

Merged
agjs merged 4 commits into
mainfrom
feat/compaction-retain-window
Aug 13, 2026
Merged

feat(compaction): retain window, prune-before-summarize, usable context window#276
agjs merged 4 commits into
mainfrom
feat/compaction-retain-window

Conversation

@agjs

@agjs agjs commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Why

Auto-compaction had two independent problems on a self-hosted driver.

It never fired. contextWindow read as "the model's context window", so a local entry naturally carried the advertised figure — 1M for a V4-Flash checkpoint — while prefill stops being practical from ~250k. The 80% trigger sat at ~839k, unreachable, so compaction was dead code. The status gauge was wrong for the same reason: ~20% full on a box already grinding.

When it did fire it wiped everything. compactConversation returned [system?, summary] and both loops wrote that over the whole history, costing the model the turn in progress — the file being edited, the error just seen.

What changed

contextWindow is now documented as the usable window. It only ever feeds the gauge and the compact trigger and is never sent to the server, so this is a doc + guidance change, no new field. Self-hosted entries are told to declare it.

compactConversation returns [system?, summary, ...retained], summarizing only the older region.

The retain window walks whole steps — one non-tool message plus the results answering it. Starting mid-step would retain a result whose declaring tool_calls turn was summarized away; toWire then emits a tool_call_id no assistant message declares, which an OpenAI-compatible server rejects. Since the compacted array becomes the live history, that ends the session rather than one request. Budgeting by step makes orphans structurally impossible instead of patched after the fact.

Budget is in characters — nothing in this codebase estimates tokens, and a guess tracking the server's tokenizer would be worse than an exact indirect measure. It counts tool-call arguments, not just content: create/edit args carry whole file bodies and are deliberately kept full in history.

A model-free prune of oversized tool results runs first. When it reclaims a quarter of the transcript the summary call is skipped entirely — session already clears its stale usage reading after a compact, so the next real model call re-measures against the server's own count. pruneOversizedToolResults is a sibling of pruneEphemeralToolResidue, not part of it: that one is semantic, already runs every turn, and never touches the run/test output that actually dominates a long session.

Thresholds (8192 / 4096 / 1024) follow deepseek-harness's tool-result pruner.

Review

The project's own 4-model panel caught two real defects in the first version, both reproduced before fixing:

  • The window could exceed its budget by any amount — the old boundary snap re-admitted a turn the budget walk had just rejected. Measured 100,012 chars retained against a 40,000 budget. Snapping backward can never respect a budget, which is why this became a step walk.
  • The budget measured only content, so a turn holding an 80KB write scored as ~0 and was retained. Measured 80,070 chars invisible to the budget.

A third, minor: an early return omitted prunedChars after pruning had already mutated the history.

The final panel run BLOCKed on reviewer availability, not on findings — two reviewers returned unparseable output twice in a row (a known flake), leaving 1 of 2 required. The run surfaced zero findings against this code. Flagging rather than papering over it: the last commit has not had a clean multi-reviewer pass.

Verification

  • bun run validate — 5115 pass, 0 fail
  • New packages/core/tests/compaction-retain.test.ts, 13 tests. Each invariant mutation-checked: removing the boundary rule fails the orphan tests, disabling the budget guard fails the retain tests, ignoring tool args fails the arg-budget test.
  • Three existing tests asserted the old wipe-everything shape and were updated by design.
  • Not yet done: a live run. Tests cannot prove the amnesia is gone. The real check is driving a session past ~210k against local Flash and confirming the model still knows what it was doing.

agjs added 4 commits August 13, 2026 22:43
Auto-compaction had two independent problems on a self-hosted driver.

It never fired. `contextWindow` was documented as the model's context window,
so a local entry naturally carried the ADVERTISED figure (1M for a V4-Flash
checkpoint) while prefill stops being practical from ~250k. The 80% trigger
then sat at ~839k — unreachable — and the status gauge read ~20% full on a box
that was already grinding. The field only ever feeds the gauge and the compact
trigger and is never sent to the server, so it is now documented as the USABLE
window and self-hosted entries are told to declare it.

When it did fire it wiped everything. compactConversation returned
[system?, summary] and both loops wrote that over the whole history, costing the
model the turn in progress. It now returns [system?, summary, ...retained],
summarizing only the older region.

The retain window must not begin at a tool result whose declaring turn was
summarized away: toWire would emit a tool_call_id no assistant message declares,
and since the compacted array becomes the live history, that ends the session
rather than one request. balancedRetainStart snaps the start onto the declaring
turn, and drops results that never had one. The window is budgeted in
CHARACTERS — nothing here estimates tokens, and a guess tracking the server's
tokenizer would be worse than an exact indirect measure.

A model-free prune of oversized tool results runs first; when it reclaims a
quarter of the transcript the summary call is skipped entirely. Session already
clears its stale usage reading after a compact, so the next real model call
re-measures against the server's own count and decides whether more is needed.
pruneOversizedToolResults is a sibling of pruneEphemeralToolResidue, not part of
it: that one is semantic, already runs every turn, and never touches the run/test
output that actually dominates a long session.

A prune-only pass leaves the message count untouched, so the three report sites
share compactSummaryLine rather than each printing "compacted 40 -> 40 messages".

Thresholds (8192/4096/1024) follow deepseek-harness's tool-result pruner.
Two defects the reviewer panel caught in the retain window, both reproduced
before fixing.

The window could exceed its own budget by any amount. The budget walk stopped
at a message that did not fit, then the boundary snap re-admitted that message
and its declaring turn unconditionally. Snapping backward can never respect the
budget — the walk stopped precisely because that turn did not fit — so a 100KB
assistant turn was retained against a 40KB budget (measured: 100,012 chars).

Replaced the snap with a walk over whole STEPS: a step is one non-tool message
plus the results answering it, boundaries are the non-tool indices, and the
window is the oldest boundary whose whole suffix still fits. Orphans are not
boundaries, so they can still only land in the summarized region. When even the
newest step is too big, nothing is retained rather than the budget being broken.
This also removes the reachable case where the snap landed on index 0, leaving
nothing to summarize and reporting a no-op as a compaction.

The budget also measured only `content`, while create/edit tool-call ARGUMENTS
carry whole file bodies and are deliberately kept full in history. A turn holding
an 80KB write scored as ~0 and was retained (measured: 80,070 chars invisible to
the budget). messageChars now counts arguments, and both the retain budget and
the prune-sufficiency fraction use it.

Renamed PRUNE_SUFFICIENT_FRACTION to PRUNE_SUFFICIENT_DIVISOR — it is used as
`total / N`, so the old name read as though it held 0.25.

Adds tests for both budget invariants and for a summarizing compact actually
shrinking the transcript, which no test asserted before.
Pruning mutates the history before the retain split, so an early return that
omitted prunedChars would print a compact that did real work as a no-op. Both
branches are unreachable while the half-transcript cap guarantees an older
region, but they no longer depend on that guarantee holding.
Generated by `bun run arch:build`. Only the derived line counts and one
entry-point line number moved — no subsystem, edge, or fan-in/out change.
The drift check runs in CI but not in `bun run validate`, so a locally-green
branch can still land red.
@agjs
agjs merged commit 110d342 into main Aug 13, 2026
8 checks passed
@agjs
agjs deleted the feat/compaction-retain-window branch August 13, 2026 21:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant