M4.4
Implement compaction
1,511 words · about 9 min
The concept
ConceptCompaction replaces a contiguous span of old history with a summary. Three questions decide whether an implementation is correct: where does the boundary go, what must never be dropped, and what happens if it fails halfway through.
What must never be dropped
Three categories are non-negotiable.
The original task statement. The user's actual request is the agent's objective function. An agent that has compacted away what it was asked to do will confidently complete a task nobody assigned. This is the single most damaging compaction failure, because it is silent — the agent keeps working, just on the wrong thing.
Recent turns, verbatim. The immediate context of what is happening right now must survive intact. This is what retainTokens buys: with the default retainRatio of 0.16, the most recent 16% of the window is never summarised. Recent turns carry the concrete state — the file just edited, the error just seen — that a summary would abstract into uselessness.
Pending tool calls and their results, together. An assistant message containing a tool call and the tool result answering it are a matched pair. Split them and you produce a request the provider will reject or the model will be confused by: a call with no result, or a result with no call. This is not a quality concern but a well-formedness one.
The mechanism: boundary selection
MechanismselectCompactableRange in packages/compaction/compaction-basic/src/region.ts picks the boundary in three moves.
First it cross-checks that the meter's view matches the session's surface, throwing compaction: token-meter surface does not match the current session surface otherwise. Acting on a stale measurement would compact the wrong span.
Then it walks the priced nodes backwards, accumulating tokens until it reaches retainTokens:
let accumulated = 0
let keepFromIdx = pricedNodes.length
for (let index = pricedNodes.length - 1; index >= 0; index -= 1) {
accumulated += pricedNodes[index]!.tokens
keepFromIdx = index
if (accumulated >= retainTokens) break
}
if (keepFromIdx === 0) return nullNote that keepFromIdx is assigned before the break test, so the node that crosses the budget is itself retained — retention is always at least retainTokens, never less. If the entire surface is within the retained tail, the function returns null: there is nothing old enough to compact, and the honest answer is to do nothing.
Then it snaps the boundary to a tool-pairing-balanced cut:
while (keepFromIdx > 0) {
if (toolPairingBalancedBefore(session, surfaceNodes[keepFromIdx]!)) break
keepFromIdx -= 1
}The boundary only ever walks backwards, growing the retained tail. That direction is a safety property: moving the boundary backwards can only ever retain more verbatim history, never less. toolPairingBalancedBefore (in packages/compaction/compaction/src/tool-pairing.ts) answers whether any unanswered tool call crosses that cut, by folding +n for each assistant message's tool-call blocks and -1 for each tool result. A balanced cut has no call in flight across it. The fold is cached per session, keyed on session.surface.replaceGeneration and the processed entry count, and it rejects corrupt surface state loudly — a tool/result with no matching call throws rather than returning false.
The documentation states the resulting guarantee precisely: "Region boundaries preserve tool-call/result pairing but not whole turns." Pairing is an invariant; turn integrity is not. A turn can be split, and the summary must carry what the split lost.
The mechanism: transaction structure
MechanismCompaction can fail mid-flight — the summarisation call can time out, be cancelled, or crash the process. compactSurfaceRegion handles this with a durable, log-recorded lock, and the ordering is the whole design:
validate region
inspect lock state → assert inactive
append 'compaction/start' ← synchronously, before any await
prepare
await summarize ← the only slow step
assert surface still stable
commit: append 'compaction/summary', then the replacement user/message
append 'compaction/end' ← lastcompaction/start is appended synchronously, adjacent to validation, before anything can yield. And compaction/end is appended last, after the commit. docs/subsystems/compaction.md explains why in one sentence worth memorising: releasing the lock last "turns a crash mid-operation into a detectable orphaned lock ... rather than a compaction/end that falsely claims compaction finished." A lock that outlives a crash is recoverable. A false completion record is not.
The stability assertion between summarisation and commit closes the other race. While the summariser was running, history may have changed. Two checks exist: assertWholeSurfaceUnchanged for automatic compaction, and the looser assertSelectedSpanStable for manual /compact — both throwing SurfaceChangedError. Automatic compaction runs inside a turn where nothing should change, so it demands total stability; manual compaction runs against a live session, so it only demands that the span it selected is intact.
The commit itself preserves the M2.6 log-versus-projection distinction. dsh deliberately does not add a new surface event type for summaries. Instead the summary rides on an ordinary user/message carrying a surface operation:
surfaceOp: { op: 'replace', start, end }The original events remain in the log forever; the surface — the projection the model sees — hides them. This upholds the repo invariant that model-visible content is always reconstructable from the log, and it means a compaction can be audited after the fact. The replacement's sourceEventSeqs names the start event, the summary event, and every shadowed sequence, so the provenance chain is complete.
Verifying fidelity: be honest
Here is where the field is genuinely immature, and pretending otherwise would be dishonest.
There is no reliable automated check that a summary faithfully represents what it replaced. That is a hard problem: it is roughly as hard as the summarisation itself. You cannot diff a summary against the original, because a good summary is not a compression of the text but a restatement of its meaning. Using a second model to judge fidelity inherits every failure mode of LLM-as-judge (Module 8, M8.3) — and the judge must read the original, which is the content you are trying to stop sending.
What dsh actually verifies is much weaker, and it is worth being exact about how weak. In summarizeCompaction:
const framedSummaryTokenCount = dependencies.meter.estimateMessage(checkpointMessage)
if (framedSummaryTokenCount >= prepared.shadowedTokenCount) {
throw new Error(`summary is not smaller than the shadowed content (${framedSummaryTokenCount} estimated framed tokens >= ${prepared.shadowedTokenCount})`)
}That is a convergence check, not a fidelity check. It guarantees compaction makes progress — a summary that is larger than what it replaced would make the situation worse and could loop — and nothing more. A summary that shrinks the history while omitting the original task passes this check cleanly.
The real mitigations are structural, not verificational:
- Prescribe the summary's shape.
dsh's compaction instruction (inpackages/compaction/compaction-basic/README.md) mandates eight sections: Primary Request and Intent, Key Technical Concepts, Files and Code, Errors and Fixes, Pending Jobs, Current Work, Next Step, Critical Context. Naming the sections makes omission visible in a way that "summarise this" does not. "Primary Request and Intent" is first because the task statement is what must never be dropped. - Demand verbatim identifiers. The instruction requires preserving exact file paths, commands, error strings, and identifiers. These are the tokens the agent will need to act, and they are exactly what a fluent summary paraphrases away.
- Prevent summary-of-summary drift. The instruction states that if a prior
<compacted-summary>block exists, it is a previous checkpoint and must not be copied forward verbatim. Without this, repeated compaction degrades geometrically as each summary summarises the last. - Keep the original in the log. The strongest mitigation is that fidelity failure is recoverable: because compaction only shadows the surface, the original events are still there to be read by a human debugging what went wrong.
Tradeoffs
The retained-tail-plus-summary shape is one point in a design space. Alternatives include summarising the middle while keeping both the first turns and the last (preserving the task statement structurally rather than by instruction), or dropping the oldest turns outright with no summary at all — free and lossy in an obvious, predictable way rather than a subtle one. dsh does not implement the first, and its instruction-based approach to preserving intent is a weaker guarantee than a structural one.
Compaction is also, in dsh, a human command, not a model tool — the README says so explicitly. A model that can compact its own context can hide its own history, which makes debugging and auditing much harder. That is a defensible safety choice, and it is not universal.
Two more honest limitations from the repo's own list: an envelope that alone approaches the window is not compaction's problem — compaction shrinks derived history, never the system prompt, tools, or session prefix. And overflow classification depends on adapters correctly recognising and tagging context-length errors, which is provider-specific and drifts.
How others differ
Claude Code's auto-compact operates on similar principles with a user-visible boundary and a manual command. Some harnesses summarise incrementally — a rolling summary updated each turn — which spreads cost evenly and avoids a large pause, at the cost of continuous drift with no clean checkpoint. Others do no summarisation at all and simply truncate from the front, which is honest about being lossy but loses the task statement first, which is the worst possible thing to lose.
Check yourself
5 questions on M4.4