Compaction aims deep: trigger, target, and honest numbers (#53, part 1) - #55
Merged
Conversation
…y turn Compaction used one number as both the "should I act?" test and every stage's stop condition, so it stopped the instant the history dipped under the ceiling — and the next assistant turn put it straight back over. It ran every turn, reclaimed almost nothing, and paid a full prompt-cache creation charge each time. Three numbers now, not one: a budget, a trigger at 80% of it, and a target at 50% that every stage compacts down to. Below the trigger the message array is returned untouched, so a non-compacting turn keeps the prompt cache. Because the target sits below what truncation alone can usually reach, the summarizer stage starts running for the first time. What counts against the budget is the whole request. The system prompt, project context, skills and tool definitions are measured through the same Breakdown `smith context` renders, so the two cannot disagree; the byte estimate is scaled by how far it has been off from the prompt token counts the provider reports back; and thinking signatures are counted at all, which they were not. Making the summarizer reachable activated a latent bug: checkpoints record absolute message indices and a rewind truncates the transcript at one, so replacing a prefix with a summary moved every checkpoint to the wrong turn. Indices are shifted to match, and a checkpoint whose messages were replaced outright is marked rather than silently clamped onto index 1, where rewinding to it would have cut the whole conversation back to the summary. A request that cannot be brought under the ceiling now ends the run with ContextExhausted rather than paying the provider to reject it — its own event, not budget_exceeded, so a script can tell "start a fresh session" apart from "you spent your money". Fixes #53 (first of two parts; stages 0-2, incremental accounting and the raw transcript log follow). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Aug 9, 2026
Closed
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.
Part 1 of #53. Makes the numbers compaction acts on describe the actual request, and splits the one threshold that caused the reported symptom into a trigger and a target.
The symptom
Context.compactused a singlemax_tokensas both the "should I act?" test and every stage's stop condition, so it stopped the instant the history dipped under the ceiling. The next assistant turn put it straight back over. It ran every turn, reclaimed almost nothing, and paid a full prompt-cache creation charge each time.What changed
Three numbers, not one. A budget (
context.max_tokens), a trigger at 80% of it (compact_at), and a target at 50% (compact_to) that every stage compacts down to. Both are fractions, so raising the budget for a wider-window model scales all three. Below the trigger the message array is returned untouched — the same object, not an equal copy — so a non-compacting turn keeps the prompt cache. Because the target now sits below what truncation alone can usually reach, the summarizer stage starts running for the first time.The whole request counts. System prompt, project context, skills and tool definitions are measured through the same
Breakdownthatsmith contextrenders, so the breakdown and the compaction decision cannot disagree. It is recomputed every turn rather than cached:@system_promptis rewritten by the mode switch and the registry grows when an MCP server connects.The estimate gets corrected. Thinking signatures are counted at all, which they were not, and the byte heuristic is scaled by how far it has been off from the prompt-token counts the provider reports back — smoothed, clamped to
1.0..2.0, and persisted with the session so the first turn after a resume is not blind.Falling short is said out loud. Missing the target warns; being unable to reach the ceiling ends the run with a new
ContextExhaustedevent rather than paying the provider to reject the request. Its own event, notbudget_exceeded, so a script can tell "start a fresh session" apart from "you spent your money".A latent bug this activates
Checkpointsstores absolute message indices (checkpoints.cr:122) and a rewind truncates the transcript at one (cli.cr:1086). Replacing an N-message prefix with a single summary moves every stored index. The bug has been there as long as the summarizer has — it was simply unreachable, and this PR's whole point is to make the summarizer run.Indices are now shifted by what compaction removed. A checkpoint whose messages were replaced outright is marked rather than silently clamped onto index 1: clamping several onto the same position would make
rewind --to 0012cut the whole conversation back to the summary and report that as the point picked. Marked ones restore their files and leave the transcript alone, and the CLI says why. Replacing indices with stable message ids is the correct fix and is follow-up work.Review fixes
A high-effort review found ten issues; nine are fixed in this branch. Two were serious:
usage.prompt_tokenson Anthropic is only the uncached remainder — a few hundred tokens for a 100k history on a cache hit. The observed ratio was therefore always below 1.0 and clamped back to the floor every turn, so the feature meant to make the estimate honest would never have worked on the default provider. Now driven byLLM::Usage#billed_prompt_tokens.allowancecould floor at 0 when a fat tool set filled the target, truncating every result and cutting back to the newest turn while half the window sat unused. It now aims at the ceiling when the target is unreachable, and leaves the history alone when even the ceiling is.Also fixed: an exhausted subagent reported to the parent as
completed;max_tokens = 0aborting every run; Ctrl+C and the TUI abort dropping the learned ratio (three hand-copied save blocks, now all throughpersist);forknot copying it;smith contextrows no longer summing to their total; and already-truncated results being re-cut for zero reclaim, a nonsensical "0 KiB truncated" note and a busted cache entry.One finding was kept deliberately:
return if compact_historyends the run on an estimate where the old code would have sent the request. Aborting on a breached budget is the agreed design, and the realistic accidental path into it —max_tokens = 0— is now closed.Testing
828 examples, all green. Every compaction path still asserts
assert_tool_pairing. The two tests worth naming:Noneand a byte-identical array.Not in this PR
Stages 0–2 (drop stale thinking, supersede duplicate reads, staged truncation caps), the
syntheticflag for agent-injected turn boundaries, incremental token accounting, and the raw transcript log. That is part 2.🤖 Generated with Claude Code