Compaction aims precisely: stale thinking, superseded reads, staged caps (#56) - #58
Merged
Conversation
Compaction rewrites the message array in place and the session file holds only that working history, so shortening a session was the same as destroying the record of it. Part 1 made that loss small; the stages in #56 make it large — dropped thinking, stubbed results, summarized-away turns, none of it recoverable from anything smith keeps. Every message is now appended to transcript.jsonl beside session.json before compaction can reach it: one JSON object per line, append-only, never read on the normal path. Beyond not destroying the user's record, it is the only basis on which anyone can later check whether 80/50, the truncation caps and the recency window were good numbers — without it, the next calibration is guesswork again. This lands before the rest of #56 so it is already collecting evidence while those numbers are in use. A session recorded before the log existed is seeded into it once, on its first resume, and told that its next turn will compact hard — that is the longest transcript the user has and otherwise the only one that never gets a raw copy, and an unannounced deep compaction reads as a bug rather than as the upgrade working. A log that cannot be written warns once and is then given up on: a record of the session must not be able to take the session down with it. While here, build_agent takes the session rather than its pieces. Passing messages and the calibration ratio separately is how one call site came to carry the transcript without what had been learned about measuring it. Part of #56. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed caps Compaction reached its target with one blunt instrument: truncate tool results oldest-first to 2 KB, then summarize. It mangled output before it had exhausted the things that were genuinely dead, and paid for the reach with fidelity it did not have to spend. Two stages now run before anything lossy is considered, and both are free. Thinking from turns that are over is bulky and worthless; the current turn's is preserved byte for byte, because Anthropic validates its signature. A read superseded by a later identical read is not information; only read_file, grep and glob take part, because running `make test` twice is not a duplicate but a before and an after, and collapsing the earlier one deletes exactly the comparison being made. What is left is truncated far harder than before. 300 stale results at the old 2 KB floor is ~150k tokens, more than the whole budget; at 512 bytes it is ~37k, and results already carrying a marker collapse to a single line instead of keeping their head forever. Candidates are taken largest first rather than oldest first, so reclaiming a given number of tokens mangles as few results as possible. Stages 1 and 2 leave the last three real turns alone, so compaction cannot truncate the file being edited out from under the model — given up only as a last resort, when there is no boundary to summarize at and the alternative is a request the provider will reject outright. Real turns: LLM::Message gains a `synthetic` flag for the continuations the agent injects itself, without which three of them inside one turn consume the whole window and it protects nothing. safe_cut_index uses it too — cutting just before a continuation strands it referring to a response that is no longer there. Token accounting is now incremental. The estimator used to re-walk the whole history once per truncation candidate, which is quadratic in the number of tool results; each message is measured once and the total adjusted by the delta. Bytes rather than tokens, so the running total agrees exactly with what estimate_tokens says over the finished array. Closes #56. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Closes #56. Two commits: the raw transcript log first, so it is already collecting evidence while the numbers below are in use, then the stages themselves.
Together with #55 this finishes #53.
The transcript record (15f66a8)
Compaction rewrites the message array in place and
session.jsonholds only that working history, so shortening a session was the same as destroying the record of it. #55 made that loss small; the stages in this PR make it large.Every message is appended to
transcript.jsonlbesidesession.jsonbefore compaction can reach it — one JSON object per line, append-only, never read back on the normal path. It is also the only basis on which anyone can later check whether 80/50, the caps and the window were good numbers; without it, the next calibration is guesswork again.The flush points are where messages can be lost: at the top of
compact_history, since compaction is the only thing that removes them, and in anensureonAgent#send, because the last turn's messages arrive after the final compaction check. A session recorded before the log existed is seeded once on its first resume and told that its next turn will compact hard. A log that cannot be written warns once and is then given up on.Also there:
build_agenttakes the session rather than its pieces. Passingmessagesandcontext_ratioseparately is how one call site came to carry a transcript without what had been learned about measuring it — a bug found reviewing #55.The stages (43ec951)
Compaction reached its target with one blunt instrument: truncate oldest-first to 2 KB, then summarize. Now four stages run, free ones first, each stopping as soon as the target is met — so a compaction often reaches it without mangling any tool output at all.
Stage 0 — stale thinking. Bulky, worthless once its turn is over, free in fidelity terms. The current turn is preserved byte for byte, because Anthropic validates the signature on a thinking block (
anthropic.cr:243-247). A message that is only thinking keeps it: an assistant message with no blocks serializes tocontent: null, which providers reject, and no amount of reclaim is worth that.Stage 1 — superseded reads. Read the same file five times and only the newest is kept; the earlier ones become a note saying why. Only
read_file,grep,globtake part.bashis deliberately excluded — runningmake testtwice is not a duplicate, it is a before and an after, and collapsing the earlier one deletes exactly the comparison the model is making. Stubs keep theirtool_call_idandis_error.Stage 2 — staged caps. 300 stale results at the old 2 KB floor is ~150k tokens, more than the whole budget; at 512 bytes it is ~37k, and a result already carrying a marker collapses to one line instead of keeping its head forever. It recognises its own past work from the marker in the text — no side table of state, so the policy survives a session round-trip through disk. Candidates are taken largest first rather than oldest first, so reclaiming a given number of tokens mangles as few results as possible.
The recency window. Stages 1 and 2 leave the last three real turns alone, so compaction cannot truncate the file being edited out from under the model. It is given up only as a last resort: when there is no boundary to summarize at, one oversized
catinside the only turn there is gets cut anyway, because protecting the work in hand is worth less than a request the provider will accept at all.Real turns.
LLM::Messagegainssynthetic, set on the continuations the agent injects itself (agent.cr:220,agent.cr:284). Without it, three continuations inside one turn consume the whole window and it protects nothing.safe_cut_indexuses it too — cutting just before a continuation strands it referring to a response that is no longer there. Matching on the text of the known constants would have worked until someone reworded one, and the failure would have been silent.Incremental accounting. The estimator re-walked the whole history once per truncation candidate, quadratic in the number of tool results. Each message is now measured once and the total adjusted by the delta. Bytes rather than tokens, so the running total agrees exactly with what
estimate_tokenssays over the finished array.Testing
854 examples, green.
assert_tool_pairingon every new path, as before.The examples are built so each one is about the stage it names: a small tail of quiet turns keeps the recency window from exceeding the target on its own, which would otherwise have made every case a story about the summarizer. Worth naming:
read_filecalls collapse; duplicatebashcalls do not.safe_cut_indexnever lands on a synthetic boundary.Not in this change
Anchoring checkpoints to a stable message id instead of an absolute index — #57. Reading the raw transcript back (a
smith transcriptcommand, or restoring from it). Replacing thebytes // 4heuristic with a real tokenizer.🤖 Generated with Claude Code