Long runs outgrow the window. The obvious fix is to keep the system prompt and the last N messages, which is four lines of code and two serious bugs.
Bug one: context rot. On turn 3 a policy denied the agent’s attempt to issue a refund. On turn 5 the user said “never contact the customer directly.” By turn 12 both are gone, and the agent — now perfectly fluent — retries the refund and drafts an email to the customer. Nothing in your telemetry looks wrong.
Bug two: orphaned tool calls. An assistant message with tool_calls and the
tool messages answering it are one atomic unit. Cut between them and every provider
rejects the request with a 400 — and it only happens once the window actually fills,
which is to say in production, on your longest and most valuable run.
TL;DR
Two rules make compaction safe:
- Some messages are load-bearing and are never dropped — the system prompt, the original goal, security constraints, approval verdicts, and policy denials. A summary that omits “the human rejected this transfer” is worse than no summary, because it launders a decision into an absence.
- Tool-call pairing must survive. Compact by turn group, never by message.
OWASP: ASI06 — the omission variant.
Runnable code:
patterns/harness/context_compaction/
Both bugs, as program output
uv run python -m patterns.harness.context_compaction.demo
=== WITHOUT the pattern (keep the last 7 messages) ===
size: 8 messages, ~510 tokens
original goal survived: False
the CONSTRAINT survived: False
the DENIAL survived: False
BROKEN: tool result at position 1 has no matching call
=== WITH the pattern ===
size: 11 messages, ~330 tokens
original goal survived: True
the CONSTRAINT survived: True
the DENIAL survived: True
orphaned messages: none
what replaced the dropped middle:
[compacted 16 earlier messages] | tools used: fetch_invoices x8
=== over a whole run, the window never overflows ===
turns: 15, peak window: ~1287 tokens (budget 2000)
compactions: 6, tokens saved: ~4680
Note that the compacted version is both smaller and more correct than the naive one.
The pattern
guard = Compaction(policy=CompactionPolicy(
max_tokens=2_000,
trigger_at=0.8, # compact before the provider rejects you, not after
keep_recent_turns=2,
))
1. Compact by turn group. group_turns binds an assistant message to the tool
results answering it, and compaction only ever drops whole groups. This is the
difference between a feature and an intermittent 400.
2. Denials are pinned implicitly. Any tool result starting with
DENIED by policy: survives compaction without anyone remembering to mark it.
Forgetting a refusal is how an agent retries what it was already refused, and the
failure is silent.
3. Trigger before the limit. Compacting at 80% means you are never one long tool result away from a rejected request.
4. Replace, don’t just delete. Dropped groups become a summary message including a count of preserved denials, so the run keeps a trace of its own middle.
5. The default summariser is deterministic. digest() is a structured count, not
prose, so tests are stable and nothing hallucinates. An LLM summariser is pluggable —
better prose, one more thing that can invent a fact. Choose deliberately, and if you
choose the LLM, evaluate it: a summariser that quietly drops “the transfer was
rejected” has produced exactly the failure this pattern exists to prevent, with more
confidence.
When to use it
- Agentic loops with no fixed step count, especially ones reading large tool output: log analysis, codebase work, multi-document research.
- Any agent where a single tool result can be large. One
catof a log file is enough to blow a window. - Long-lived conversational sessions that accumulate across days.
When NOT to use it
- Short, bounded runs. If your p99 run is 8k tokens against a 200k window, compaction is a lossy transformation solving a problem you do not have.
- When you can retrieve instead of remember. The strongest version of this pattern is often not compacting a huge tool result but never putting it in the window: write it to a file or store, keep the handle, let the agent re-read the part it needs. Compaction is what you do when the context genuinely is the working set.
- Anywhere the full transcript is the deliverable — audits, legal review, incident timelines. Compact what the model sees; never compact what the audit log keeps. That is precisely why those two patterns write independently.
Trade-offs and failure modes
- Compaction is lossy, and the loss is invisible to the model. The agent cannot tell “this never happened” from “this was compacted away.” The summary helps; it does not eliminate the problem.
- Pinning has no garbage collection. Pin enough over a long session and the pinned set alone exceeds the budget. Real systems need priority tiers or a cap on pinned tokens with an explicit eviction rule.
- Prompt caching interacts badly. Rewriting the front of the context invalidates cached prefixes, so aggressive compaction can increase cost even as it reduces tokens. Compact from the middle and keep the head byte-stable.
keep_recent_turnsis a blunt instrument. Two turns of small results and two turns of 30k-token results are very different budgets. A token-aware tail is the natural next iteration.- An LLM summariser breaks replay determinism. Two identical runs diverge after the first compaction, which breaks the replay-based resume in the approval gate. Pair it with real checkpointing.
Frequently asked questions
Why not just use a model with a bigger window?
Because cost scales with what you send, not with what the window allows, and because attention degrades over very long contexts even when they technically fit. A 200k window is permission to be careless, not a reason to be.
Should I summarise with an LLM or drop mechanically?
Start mechanical. It is deterministic, free, and testable. Move to an LLM summariser only when you can show that the mechanical digest is losing something that matters — and then evaluate the summariser against labelled examples that include a denial you require it to preserve.
How do I know if I have context rot?
Look for agents retrying actions that were already denied, or violating a constraint
stated earlier in the same run. Both are highly diagnostic, and both are visible in
the decision trace as a repeated
tool_requested for something already tool_denied.
Does this help with the “lost in the middle” problem?
Somewhat, as a side effect: compaction removes the middle, so what remains is the head and the recent tail — the positions models attend to best. That is a happy accident rather than the design goal.
References
- OWASP GenAI Security Project, Top 10 for Agentic Applications 2026 — ASI06
- Anthropic context-engineering guidance — the window as a managed budget; compaction and offloading rather than accumulation
- Microsoft, Agent Framework —
CompactionStrategyandContextWindowCompactionStrategyas framework primitives - Runnable code and tests:
patterns/harness/context_compaction/
Part of the agent harness and governance series. Next: durable execution — the retry that pays the invoice twice.