feat(compaction): retain window, prune-before-summarize, usable context window - #276
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Auto-compaction had two independent problems on a self-hosted driver.
It never fired.
contextWindowread 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.
compactConversationreturned[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
contextWindowis 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.compactConversationreturns[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_callsturn was summarized away;toWirethen emits atool_call_idno 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.
pruneOversizedToolResultsis a sibling ofpruneEphemeralToolResidue, 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:
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
prunedCharsafter 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 failpackages/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.