Chapter 7 of 7 · 14 min read
Compacting a ninety-minute session
On this page
A compaction that has to run on a live session is a sequence of individual calls, not one policy applied to a transcript. The tempting move is to summarise everything before the last few turns into one recap, which clears the deficit without much thought and hands the operating constraints, the unfinished plan steps and the prohibition somebody stated an hour ago to a summariser whose objective is a readable account of the session. The agent then continues, competently and in good formatting, without the rule that stops it writing a migration — and because the recap reads as a complete history, nothing signals that anything is missing.
The criterion that replaces the recap is narrow and mechanical: for each item, does the next turn's correctness depend on its exact text? Applied honestly it cuts across the obvious categories, because a single tool result can contain both a string the agent must read character for character and six thousand tokens of material nobody will look at again.
Defending the line, item by item
File reads are usually the largest entry and the easiest call, which is worth noticing because it means a compaction of this kind is not really a hard decision — it is a decision that was made badly an hour ago and is being paid for now. File contents are addressable: a path and a git revision reproduce them exactly, on demand, in one tool call. Each read collapses to a reference of about 44 tokens.
That figure is the specimen above divided by what it replaces. A reference is a
path, a revision, a turn number, a line count and one sentence of what the file
established, and three of them are printed here at around forty tokens each — the
48 file reads leave behind 2,100 tokens in the table below, which is where the 44
comes from. What it is standing in for is a file of several hundred lines. The
reason the substitution is safe is not the ratio but the revision: 9f2c1ae
names a specific state of the repository, and asking for that path at that
revision returns the same bytes today, in an hour, and after any number of
intervening commits. A path on its own would not have that property, which is why
the revision is in the reference and not in the prose.
services/api/orders/repository.py @ 9f2c1ae (read turn 31, 780 lines)
→ list_orders() builds the query; ORDER BY created_at DESC, no index used
services/api/orders/serializers.py @ 9f2c1ae (read turn 33, 210 lines)
→ OrderListItem includes customer_name; source of the denormalisation need
db/schema/orders.sql @ 9f2c1ae (read turn 12, 96 lines) [STALE: edited turn 58]The stale marker on the third line is the subtlety that makes this safe. Where the agent has subsequently edited a file, the content at the recorded revision is no longer what is on disk, and a reference that does not say so invites the agent to reason from a version that no longer exists. Marking it stale converts an optional re-read into a required one, which costs a tool call and removes a whole class of confident wrong edit.
The failure it prevents is worth following, because it is not the one the
reference format would suggest. A reference to a revision is never wrong about
that revision — db/schema/orders.sql at 9f2c1ae is exactly what it was at
turn 12, and re-fetching it returns precisely that. The danger is that the agent
is not reasoning about a revision. It is reasoning about the repository as it
stands now, and it edited that file at turn 58. So an accurate reference to an
old state is read as a description of the current state, and the agent proceeds
as though its own edit at turn 58 had never happened. Correct data, wrong
question. The marker works by making the reference say something about the
present rather than only about the past, which is the one thing a
revision-addressed key cannot say on its own.
Test output is where recency turns out to be the wrong criterion. A run that
arrived two turns ago is untouchable by every recency heuristic, and on a suite
of 214 tests with 3 failures, 6,300 of its 6,800 tokens are the names of the 211
tests that passed. What binds the next turn is the three failures, and those are
kept as the assertion text exactly as the runner printed it, because an agent
debugging AssertionError: expected 3 rows, got 0 needs the string and not a
description of it. The rest goes to an artifact file with a pointer.
The split inside that one item is the point, and it is why a per-item rule is not
enough on its own. Two hundred and fourteen tests ran and three failed, so 211
lines of the output are the names of tests that passed, and those 211 lines are
most of the 6,800 tokens. A passing test binds nothing: the next turn does not
behave differently for knowing that test_order_serialisation passed, and if it
ever needs to know, the suite can be run again. The three failures bind
everything, and they bind at the level of the exact characters, because the agent
is about to reason from the difference between an expected row count and an
observed one. So a single tool result contains material at both extremes of the
criterion, and any policy that dispositions whole results — keep the recent ones,
summarise the old ones — gets this item wrong whichever way it decides.
A plan is pinned, and the interesting part is who is allowed to rewrite it. Incomplete steps stay word for word, because a summariser that renders "add a NOT NULL constraint after the backfill completes" as "finish the backfill work" has deleted an ordering requirement. Completed steps collapse to one line each recording what they produced — the migration file that was not written, the index name that was chosen — and that collapsing is done by the agent's own plan-editing tool rather than by the compactor. A plan is a document the agent maintains, not a region of transcript for a summariser to compress.
That leaves the edits and the agent's own reasoning, which is the one large item that is genuinely summarisable. Sixty turns of it compress by a factor of twenty-seven into two block summaries in the state form from the chapter on state documents, generated from the original turns rather than from each other. What comes out is the sequence of edits made and why, the approaches that were tried and abandoned with the reason each failed, and the current position of the work.
The marker a compaction leaves
A compaction that closes seamlessly over what it removed is undiagnosable, both for the agent and for the person reading the trace afterwards. The remedy costs about ninety tokens.
[COMPACTED at turn 61 — 176,520 tokens removed, 4,220 retained.
Covered: turns 4–55. Evicted: 48 file reads (contents; paths and revisions
retained above), full test output for run 3 (see .agent/artifacts/test-run-3.txt),
44,020 tokens of edit history (summarised above).
Originals: .agent/log/sess_a4f1.jsonl lines 118–2,904.
Nothing under "carried forward verbatim" was rewritten.]The first line of that block is doing the work the rest of the compaction cannot do for itself. Everything above it in the request reads as a complete account: a list of file references, a state summary, the recent turns. Nothing in a complete account announces that it is a residue. The marker is the one element whose content is about the compaction rather than about the session, and it converts an absence — which has no representation — into a statement, which does.
Three things follow from that block being present. The agent can distinguish "this did not happen" from "this was removed", which is the difference between confabulating a file's contents and issuing a read. An engineer looking at a bad turn can see immediately whether the information the agent needed was still there when it acted. And the durable log line range makes the removed material recoverable, so a question about what happened at turn 40 has an answer that does not depend on the summary being right.
Making the call stop arriving at 8,000 over
Five changes remove most of that material before the fact, and they are worth comparing by what each would have taken out of the session above.
| Change | Removes from this session |
|---|---|
| Read tool returns a bounded window and registers the file in an index; the agent re-reads on demand | ~129,000 tokens |
| Tool results clamped at the boundary, with overflow written to an artifact | ~6,300 tokens |
| Block summaries generated in the background when a block closes, at a soft threshold near 60% | latency, not tokens |
| Search and survey work delegated to a sub-agent — a second agent with its own separate context — that returns only its state document | ~20,000 tokens per delegation |
| Constraints and commitments pinned on arrival | nothing — it protects what remains |
The first line is the one that matters and the one that meets the most resistance, because putting file contents in the context is what makes an agent feel responsive: it has read the file, it knows the file, it can answer about the file without another round trip. That intuition holds for five files and collapses at fifty, when the contents of files the agent last thought about an hour ago are crowding out the plan. A read tool that returns the requested range and a structural outline, registers the full content in an index, and lets the agent re-read any range in one call, gives up perhaps 400 milliseconds on the occasions where a re-read is needed and buys back two thirds of the window.
Both halves of that trade are in the table. The 48 file reads were 131,700 tokens of a 199,960-token request, which is close to two thirds of everything, so this is not an optimisation at the margin — it is the largest single item in the context by a wide distance. What it costs is one round trip on each occasion a re-read is genuinely needed, and the shape of the session says how often that is: forty-eight files were read and the agent was actively working on the handful its plan names. The intuition that a read tool should return everything is correct about the file being edited and wrong about the forty-odd looked at once, and a bounded window with an index is what lets one tool serve both cases without the agent having to predict in advance which case it is in.
Sub-agent delegation is the same principle applied to whole sub-tasks. When the agent needs to find every call site of a function across a monorepo, that search can run in a child agent with its own context, whose forty turns of grepping and reading never touch the parent transcript; the parent receives a state document listing the call sites with paths and line numbers. The parent's context grows by 600 tokens instead of 20,000, and the child's context is discarded when it finishes.
None of this makes an unbounded session free. A coding agent working for six hours on one task will exhaust any window regardless of how carefully payloads are kept out, because the plan grows, the constraint ledger grows, and the block summaries accumulate. The honest end state is a checkpoint: the agent writes a handoff document — the plan with its current status, the constraint ledger, the references it needs, the open questions — and a fresh session starts from that document with an empty transcript. That is compaction with the loop closed, and it is a better outcome than a session limping along on its ninth generation of summary. Building the handoff writer early is worth it, because it is also the recovery path when a session dies, and it is the artefact a human reads when they take over.
The order to adopt this in
The order matters more than the completeness, because the early steps make the later ones measurable. Instrumentation comes first: record every turn to a durable log, measure the assembled request size on every call, and log what each compaction removed. That alone tells you whether compaction is running at all, how often, and on what — and on most systems the first week of that data reveals that the majority of the context is tool payloads rather than conversation, which changes what you build next.
Then the trigger, since a measured budget with a completion reserve is a day of work and removes the class of failure that ends sessions outright. Then the pinned ledger, extracted at arrival, because it is the defence that does not depend on the summariser being good. Then the fixture set and the item-level checks, which is the point at which changes to the summariser stop being guesswork. Hierarchical compaction comes last, since it addresses drift that only appears in long sessions and it costs storage and money that the earlier steps do not.
What you are building towards is a system where the compaction decision is boring: the binding items were identified when they arrived, the payloads were never in the transcript, the trigger fires with room to spare, and the summariser is only ever asked to compress material that nobody will need exactly. The interesting work moves to the tools, the plan and the ledger, which is where it belongs.
Until then, the decision is the one worked above, and it is made item by item
against a single question about exact text, with a marker left behind recording
what went and where it can be read back. A ninety-minute session compacted that
way ends with the agent still knowing that it must not touch anything under
/migrations, which is the whole of what any of this was for.
Context engineering — eviction and compaction — read the current version at https://daviddominguez.dev/learn/en/context-engineering-eviction-and-compaction/compacting-a-ninety-minute-session