Skip to content
allsrc.dev
Go back

Context Rot: When Your Agent Forgets It Was Denied

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:

  1. 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.
  2. 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

When NOT to use it

Trade-offs and failure modes

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


Part of the agent harness and governance series. Next: durable execution — the retry that pays the invoice twice.



Previous Post
A Regex Is Not A Sandbox
Next Post
The Retry That Paid The Invoice Twice