Compaction used to rewrite conversation history in place. A prefix-cache hit requires a byte-identical prefix, so every rewrite of already-sent history costs full price for every token from that point on — automatically on DeepSeek, via cache_control on Anthropic. This release treats that as a design constraint rather than an afterthought.
Measured against a real 0.14.2 checkout, on a synthetic workload whose tool mix (bash 41%, edit/write 36%, read 19%, search 4%) is sampled from 808 archived runs of a production agent built on yoagent, and whose file-size distribution comes from that agent's repository:
| session | hit rate | history rewrites | DeepSeek | Anthropic |
|---|---|---|---|---|
| 300 turns | 93.83% → 95.69% | 34 → 8 | −9.2% | −15.2% |
| 1200 turns | 94.24% → 95.39% | 169 → 35 | −16.9% | −20.4% |
| 2400 turns | 94.77% → 95.27% | 415 → 70 | −21.3% | −22.8% |
The rewrite count is the sharper signal. 0.14.2 kept its hit rate up by compacting constantly down to a small context; a small context caches well, it just costs more to keep rebuilding.
What changed
Compaction stopped churning. Tool-output truncation is now idempotent — the truncation marker is charged against the line budget, so re-truncating returns the text byte for byte. Previously the marker pushed the result over the limit and the next pass re-cut it, restating the count (950 lines truncated became 3 lines truncated) and invalidating the prefix a second time. Markers carry no drifting counts, and generated summaries inherit the timestamp of what they replace, so the same history always compacts to the same bytes.
A compaction bug that can produce provider 400s is fixed. Level 2's boundary could land mid-turn, summarizing away an assistant message while its tool results stayed — an orphaned tool_use/tool_result pair that providers reject. Reproduced on 0.14.2 with keep_recent: 7, keep_first: 4. Boundaries now snap to turn starts.
Tool output is bounded where it can be bounded well. Command output takes a head+tail cut fine — first error at the top, summary at the bottom. A file read does not: the middle is the part that was asked for. So read_file now pages itself (DEFAULT_READ_MAX_LINES, 500) with a header stating the true total, and is exempt from truncation via the new per-tool budgets in tool_output_max_lines_overrides. Everything else is capped as it enters the context rather than retroactively.
The compaction target adapts. A fixed ratio cannot know how fast a session is growing, so the room it leaves collapses as history accumulates — the gap between compactions fell from 36 turns to 22 over a long session. compact_headroom_turns targets the interval directly:
target = budget − turns × growth_per_turn
One interpretable knob — how often you are willing to compact — that held the interval at ~35 turns from a 300-turn session through a 2400-turn one, where a fixed ratio decayed to 22, and that self-adjusts to workload.
Upgrading
yoagent = "0.14" will not pick this up automatically. Three defaults change what the model sees, each revertible in one line:
| change | revert with |
|---|---|
| tool output capped on append | truncate_tool_output_on_append: false |
| unqualified read returns 500 lines | ReadFileTool { max_lines: usize::MAX, ..Default::default() } |
| compaction target adapts to growth | compact_headroom_turns: None |
ContextConfig gained four public fields and ReadFileTool gained max_lines, so exhaustive struct literals need ..Default::default(). default(), new(), from_context_window(), and functional-update syntax are unaffected.
Background
The design, the formula, and the reasoning behind each default are written up in Prompt Caching → Cache-Stable Compaction, including why there is no cost-benefit gate on compaction and why these decisions were judged in dollars rather than hit rate — a larger context can raise the hit rate while raising the bill.
Credit to @lloydzhou: bash-agent documents the economics that prompted this work, and reported the measurement gap that opened #99.
Full detail in CHANGELOG.md.