What you need to know
- The window filling up is not the failure mode. Model accuracy degrades as context grows even when every relevant fact is still present. Reported degradations in the 2026 literature span roughly 13.9 percent to 85 percent depending on task and setup — a range wide enough to treat as a direction, not a number.
- Compaction is the answer the field converged on. Summarise the work so far, start a fresh window, continue. It rewrites the agent's logical context rather than its physical representation, which is why it works at the harness level regardless of who serves the model.
- The default triggers are both wrong. Reactive compaction fires at saturation, so the summarising model is already degraded when you ask it to do the most delicate job in the loop. Periodic compaction fires on a timer and discards context without looking at it.
- Rules and logs compete for the same tokens, and only one of them survives paraphrase. That asymmetry is the whole problem, and it is the mechanism behind the compaction cliff.
- Tier your context, do not summarise it uniformly. Invariants kept verbatim outside the summarisable region; task state rewritten losslessly in structure; episodic log freely summarised; noise discarded.
- Test at five rounds, not one. The decay compounds. A compaction routine that looks perfect after one round can be most of the way down the cliff by round five.
There is a specific kind of failure that only shows up in agents you leave running. The demo works. The eval suite passes. Then something runs for four hours against a real queue, and at some point in hour three it does something that would have been unthinkable in hour one — ignores a limit it was given, exceeds a scope it was told to stay inside, recommends the one thing it was explicitly told never to recommend. Nothing errored. No exception was raised. The agent simply stopped knowing a thing it used to know.
This guide is about why that happens and what to build so it does not. It is a companion to our guide on bounding agent tool output without wrecking your cache, which deals with keeping individual payloads small. This one deals with the harder problem downstream of that: what to do when the conversation itself has grown too long, which of it to keep, and how to prove you kept it.
Why long agents degrade before the window fills
The intuitive model of context limits is a bucket. Tokens go in, the bucket fills, and when it overflows you get an error. Under that model, a larger bucket is a complete solution, and every context-window expansion announcement should have retired this entire problem.
It has not, because the bucket model describes the wrong failure. The failure that actually costs you is a soft one, usually called context rot: model accuracy falls as the context grows even when all the relevant information is still present. Nothing was evicted. Nothing was truncated. The fact the agent needed is sitting in the transcript, retrievable, and the model reasons worse about it anyway. As of 2026, reported degradations in the agent-memory literature span roughly 13.9 percent to 85 percent across different tasks and setups. That spread is too wide to plan against as a figure, and it is exactly the right thing to plan against as a direction: longer context, worse reasoning, across the board.
Restate that in operational terms and the consequence is sharp. A long-running agent does not fail when its window fills. It fails because a longer context makes the model reason worse, with room still to spare. The point at which you should intervene is therefore not determined by your token limit at all. It is determined by where the model's quality starts sliding, which is somewhere well below the limit, and which no error message will ever tell you about.
This is why "we have a million tokens now" is not an answer. A larger window moves the hard error further away and does nothing to the soft one. If anything it makes the soft one more dangerous, because the natural instinct with a large window is to let the trajectory run — and the longer it runs, the further into the degraded regime you are, with no signal that anything is wrong. Teams that measured this on their own agents usually report the same shape: a slow drift in instruction-following that nobody attributes to context length because it does not look like a context problem. It looks like the model got worse.
If your only compaction trigger is the token limit, you have built a system that guarantees the model is in its worst regime at precisely the moment you ask it to perform the most delicate operation in the whole loop — deciding what is safe to forget. Every reactive compaction is a degraded model grading its own homework.
Three ways to manage context, and which one you actually need
Before designing a compaction policy it is worth being clear about what compaction is and is not, because the design space has three broad classes and they are routinely conflated in discussion. The three are KV-cache compression at the inference level, retrieval-augmented and external-memory approaches that offload history to an external store, and context compaction that rewrites in-context working state into a shorter form.
This guide is concerned with one specific failure of that third class: what compaction does to the rules an agent is supposed to obey. If what you need first is the general mechanics of compaction as a context-engineering technique, including anchored iterative summarisation and the drift problem, our companion guide on compaction for long-running agents covers that ground and this one builds on it.
| Class | What it actually does | Where it lives | Reach for it when | What it cannot do |
|---|---|---|---|---|
| KV-cache compression | Compresses or evicts attention key/value state inside the serving engine, below the token abstraction | The inference stack — your own GPUs, your own serving layer | You self-host and control the serving engine, and you are optimising memory and throughput per replica | Nothing, if you call a hosted API. It is not exposed and not configurable. Scope it out of the conversation unless you run the weights. |
| External memory / retrieval | Moves history out of the prompt into a store and fetches slices back on demand | Your application — a database, a vector index, a file tree | History must outlive the session, be auditable, or be shared between several agents | Guarantee that what the agent needs is retrieved. Retrieval misses are silent and look identical to the fact never having existed. |
| Context compaction | Rewrites the in-context working state into a shorter form and continues in a fresh window | The agent harness, between turns | A single long session must keep going past the point where the context has grown unhelpful | Be lossless. It is lossy by construction, and — this is the entire article — the loss is not uniform across what it deletes. |
Two practical conclusions. First, KV-cache compression is not on your menu if you are building on a hosted API, which most teams in India and the UK shipping agent products are. Mentioning it is useful only so you stop reading papers about it and expecting to apply them.
Second, and more usefully: external memory and compaction are complements, not rivals. The common framing of "should we compact or should we build a memory store" is a false choice. External memory is where durable state goes to survive; compaction is what you do to the live window so the model keeps thinking clearly. A well-built agent does both, and the handshake between them is the interesting part. When compaction drops something from the window, that something should already be durable somewhere the agent can fetch it back. Compaction without a store behind it is deletion. Compaction with a store behind it is paging.
Compaction is the approach the major labs converged on through 2026 for one good structural reason: it operates on the agent's logical context — what the model sees — rather than on any physical representation. That means it works identically whether you are calling a hosted endpoint from Bengaluru or London, and it survives a model swap, because it is a property of your harness and not of anybody's serving stack.
The compaction cliff: what gets lost, and why
Here is the mechanism, and it is simple enough to state in one sentence. A safety rule and an episodic log entry compete for the same tokens, and when the budget overflows both get summarised at the same rate — but only the rule needs its exact wording to remain enforceable.
A log can be paraphrased and remain useful. "Ran the test suite, 3 failures in the billing module" carries essentially the same value as the 4,000-token output it replaced. A constraint cannot. "Be careful with medications the patient may react to" is not a paraphrase of "Patient is allergic to penicillin"; it is a different object entirely, one that constrains nothing. The summariser, working on prose, has no way to know that one of those lines tolerates compression and the other does not. It compresses both, proportionally, because that is what summarising means.
The illustrative example comes from The Compaction Cliff in Long-Running AI Agent Memory (arXiv:2608.22752, submitted 24 August 2026, published in the Proceedings of the 35th ACM CIKM '26), and it is worth repeating exactly because it makes the mechanism impossible to misread. A medical agent reads "Patient is allergic to penicillin". The context fills. Compaction paraphrases or drops that line. Three turns later, the agent recommends amoxicillin — a penicillin-class antibiotic. Every individual step is reasonable. The system produced a dangerous recommendation anyway, because the fact that made it dangerous was no longer in front of the model.
That paper measured the decay. Across 20 production agent configurations, the Claude Code /compact prompt on Sonnet 4.6 preserved 53 percent of safety rules after one compaction round and 10 percent after five. The authors named the phenomenon the Compaction Cliff.
Those figures belong to one harness, one model and one study's experimental setup. They are a measurement of what happens when a general-purpose summarisation prompt is applied to a context that mixes rules with logs — a property of that approach, not a defect claim about any vendor's product, and not a number you should quote as describing your own stack. What generalises is the mechanism and the shape of the curve. The percentages do not. If you want numbers for your system, the regression test later in this guide produces them in about a day.
That the shape generalises is supported by an independent result. Governance Decay: How Context Compaction Silently Erases Safety Constraints in Long-Horizon LLM Agents (arXiv:2606.22528) reports that hierarchical truncation — the structural core of the summarise-and-retain pattern deployed in production agents — preserved only about 50 percent of safety constraints across 50 real agent configurations. Different setup, different method, same order of magnitude at the first round. Two studies arriving at roughly half is a much stronger signal than either one alone.
The word doing the most work in that paper's title is silently. There is no error. Token counts look healthy. Latency improves, because the context got shorter. The agent continues to sound exactly as competent as it did before. The only observable difference is that a rule which used to bind no longer binds, and you will discover that when it matters rather than when it happened. Our coverage of how often agent security incidents are now reported is a reminder that "nothing visibly broke" is not evidence that nothing broke.
The fix proposed by the compaction cliff authors is Knowledge Triage: classify each line of the agent's knowledge base by type, and route each type through its own retention policy. That is the right idea, and the rest of this guide is an engineering treatment of it — a trigger policy, a four-tier retention policy, an implementation pattern that makes the guarantee structural, and a test that proves it holds.
Related 2026 work worth reading if you are going deep on this: Slipstream: Trajectory-Grounded Compaction Validation for Long-Horizon Agents (arXiv:2605.08580) on validating a compaction against the trajectory it replaced; Self-GC: Self-Governing Context for Long-Horizon LLM Agents (arXiv:2607.00692) and Self-Compacting Language Model Agents (arXiv:2606.23525) on agents managing their own context; Remember the Decision, Not the Description: A Rate-Distortion Framework for Agent Memory (arXiv:2605.10870), whose title alone is a useful retention heuristic; and ClawVM: Harness-Managed Virtual Memory for Stateful Tool-Using LLM Agents (arXiv:2604.10352) on treating the window as managed memory.
A trigger policy that beats both reactive and periodic
As of 2026, production agents typically compact using one of two content-agnostic heuristics. Reactive compaction fires when the rolling context approaches the token budget. Periodic compaction fires on a fixed interval regardless of what the trajectory contains. Both are one line of code, and both fail — in opposite directions.
Reactive waits until the context is already saturated. By the time it fires, the summarising model is reading a context long enough to have degraded its own reasoning, and you are asking that degraded model to decide what is safe to throw away. It is the worst possible moment to ask the most consequential question in the loop. Periodic avoids that by firing early, but fires blind: it will happily compact three turns into a delicate multi-step operation, cutting the trajectory at a point where half a thought is in flight and the other half is about to be summarised into nothing.
The policy that beats both has two parts.
Part one: fire on semantic boundaries. A trajectory has natural seams — a task completed, a tool phase transitioning (finished reading, about to start writing), a sub-goal closing, a plan revised. At those points the working state is genuinely summarisable, because there is a completed unit of work to describe and nothing half-finished to mangle. Your harness already knows when these happen; it just is not telling the compactor. Emit them as events.
Part two: keep a token-budget backstop, but set it at a headroom threshold rather than at saturation. Boundaries do not always arrive before the budget does, so you still need a limit. The point is where you put it. Putting it at 95 percent of the window reproduces exactly the reactive failure. Putting it low enough that the summarising model is still operating in its good regime is the whole trick.
How low? Resist the temptation to copy a magic number, including mine. Derive it, because the derivation is short and it makes the number defensible when someone asks. Your backstop must leave room for four things simultaneously: the pinned invariant block you are about to re-inject, the summary the summarising call will produce, at least one more full-size tool result arriving before compaction completes, and — the term everyone forgets — enough quality headroom that the summarising call is not itself operating deep in the degraded regime.
The first three are arithmetic. The fourth is a judgement, and the honest guidance is that it should be large. If context rot is measurable at all in your evals, and the literature says it is measurable across a very wide band, then a summarising call made at 90 percent occupancy is a materially worse call than the same one made at 50 percent. In practice teams who tune this land somewhere in the region of 50 to 65 percent of the effective window for the backstop, and the ones who measure it usually end up lower than they first guessed. Start at 60 percent, run the regression test at both ends of the range, and let your own numbers move it.
| Trigger | Fires when | Failure mode | Summariser's own context at fire time |
|---|---|---|---|
| Reactive (budget) | Rolling context approaches the token limit | Waits until saturation; compaction quality is at its lowest exactly when the decision matters most | Near-full — worst regime |
| Periodic (interval) | Every N turns or M minutes, regardless of content | Discards indiscriminately; will cut mid-operation and summarise incomplete state | Arbitrary — sometimes fine, sometimes awful |
| Semantic boundary | Task completed, tool phase transition, sub-goal closed, plan revised | Boundaries can be sparse; a long uninterrupted phase never triggers one | Moderate — good regime |
| Boundary + headroom backstop | Boundary above a soft floor, else occupancy crosses a headroom threshold well below the limit | Compacts slightly more often than strictly necessary — the cheapest failure on this table | Controlled by construction |
# trigger.py — decide WHEN to compact.
#
# Two rules, in priority order:
# 1. Prefer a semantic boundary, once there is enough accumulated context
# to be worth compacting at all.
# 2. Otherwise fall back to a headroom backstop set well below the hard
# limit, so the summarizing call is still in its good regime.
from dataclasses import dataclass
from enum import Enum
class Boundary(str, Enum):
NONE = "none"
TASK_DONE = "task_completed"
PHASE_CHANGE = "tool_phase_change" # e.g. read phase -> write phase
SUBGOAL_DONE = "subgoal_closed"
PLAN_REVISED = "plan_revised"
@dataclass(frozen=True)
class TriggerPolicy:
window_tokens: int # hard model limit
pinned_block_tokens: int # T0 block, re-injected verbatim every round
summary_budget_tokens: int # room for the summary we are about to write
max_tool_result_tokens: int # largest single payload a tool may return
quality_fraction: float = 0.60 # stay in the model's good regime
soft_floor_fraction: float = 0.30 # below this, a boundary is not worth a round
@property
def backstop(self) -> int:
"""Occupancy at which we compact even without a boundary."""
mechanical = (
self.window_tokens
- self.pinned_block_tokens
- self.summary_budget_tokens
- self.max_tool_result_tokens
)
quality = int(self.window_tokens * self.quality_fraction)
return min(mechanical, quality) # the quality term usually wins
@property
def soft_floor(self) -> int:
return int(self.window_tokens * self.soft_floor_fraction)
def should_compact(self, used_tokens: int, boundary: Boundary):
"""Return (should_compact, reason). Pure function - trivially testable."""
if boundary is not Boundary.NONE and used_tokens >= self.soft_floor:
return True, f"boundary:{boundary.value}"
if used_tokens >= self.backstop:
return True, "headroom_backstop"
return False, "continue"
# Worked example on a 200k window.
policy = TriggerPolicy(
window_tokens=200_000,
pinned_block_tokens=3_000,
summary_budget_tokens=4_000,
max_tool_result_tokens=25_000,
)
assert policy.backstop == 120_000 # quality term (0.60) binds, not mechanical
assert policy.soft_floor == 60_000
assert policy.should_compact(45_000, Boundary.TASK_DONE) == (False, "continue")
assert policy.should_compact(72_000, Boundary.TASK_DONE) == (True, "boundary:task_completed")
assert policy.should_compact(72_000, Boundary.NONE)[0] is False
assert policy.should_compact(121_000, Boundary.NONE) == (True, "headroom_backstop")
Note what the assertions demonstrate. At 72,000 tokens with a completed task, you compact — early, cheaply, at a clean seam. At the same occupancy with no boundary, you carry on. And the mechanical calculation in this example would have permitted 168,000 tokens; the quality term pulls it down to 120,000. That gap is the entire difference between a summariser that reads a healthy context and one that does not, and it costs you one extra compaction round per long session. That is a good trade at almost any price.
Emit boundary events from the same place your agent already emits spans for tracing. If you are running a supervisor that fans work out to sub-agents, every sub-agent return is a boundary and a very good one — the work is complete, the result is compact, and nothing is half-finished. Teams running the patterns in our guide to building an agent control plane with token ceilings and quotas usually find the boundary signal is already flowing through the control plane and just needs subscribing to.
The retention policy: four tiers, four different rules
This is the core of the article. Everything above decides when to compact; this decides what survives, and it is where Knowledge Triage becomes an implementable thing rather than a good idea.
The premise is that "context" is not one substance. It is at least four, with genuinely different tolerances for lossy rewriting, and treating them uniformly is precisely the error the cliff papers measured. So classify every line into a tier and give each tier its own rule.
| Tier | What belongs in it | Retention rule | Where it is stored | Behaviour on compaction |
|---|---|---|---|---|
| T0 — Invariants | Safety constraints, authorisation scope, hard numeric limits, user-stated absolutes, contraindication-style facts (allergies, exclusions, prohibited counterparties) | Verbatim, always. Never summarised, never paraphrased, never abbreviated | An invariant store outside the conversation — the harness owns it, not the transcript | Re-injected byte-identically after every round, from the store. Never passed to the summariser as material. |
| T1 — Durable task state | The goal, the current plan, decisions taken and the reasoning behind them, open questions, constraints discovered during the run | Lossless in structure. May be rewritten more concisely; may not lose a field, a decision or a reason | A structured record the harness renders into the context — not free prose | Re-rendered from the structure. A decision may be re-worded; it may not disappear. |
| T2 — Episodic log | Tool calls and their results, intermediate observations, files read, searches run, the narrative of how you got here | Freely summarisable. This is what the summariser is for | The compacted summary in-window; the full log in external memory if you need auditability | Collapsed into prose. Keep exact identifiers that later turns may need to quote. |
| T3 — Discardable | Retries, transient errors already handled, superseded intermediate output, abandoned branches, stale drafts | Dropped entirely. Not summarised — deleted | Nowhere in-window. Logs only, for debugging | Removed before the summariser ever sees it, so it cannot dilute the summary. |
Three things about this table are worth dwelling on, because they are where implementations go wrong.
T0 membership should be declared, not inferred. The temptation is to write a clever classifier that spots safety-shaped language. Do not rely on that as your mechanism. The system that produced the constraint knows it is a constraint: the policy loader knows, the permission system knows, the patient-record adapter knows. Have those sources tag their output at the point of creation. A pattern matcher is still worth having, but as a second line of defence that flags apparently-constraint-shaped text arriving untagged and raises it for review — not as the thing standing between your agent and the penicillin.
T3 is not a small tier. On a long trajectory it is frequently the largest one by volume. Retries, handled errors, superseded drafts and abandoned branches accumulate relentlessly, and every one of them competes for summariser attention against things that matter. Dropping T3 before summarisation rather than summarising it along with everything else improves the quality of the summary you get, not merely its length.
T1 is the tier that quietly determines whether compaction feels good to use. An agent that keeps its invariants but forgets why it rejected an approach two hours ago will try that approach again, and again, and you will watch it loop. The rate-distortion framing in arXiv:2605.10870 — remember the decision, not the description — is exactly the right instinct here. The reasoning behind a decision compresses well. The decision itself must not vanish.
# context_tiers.py — the data model. Everything else keys off this.
from __future__ import annotations
from dataclasses import dataclass, field
from enum import IntEnum
import hashlib
import re
class Tier(IntEnum):
INVARIANT = 0 # verbatim, outside the summarizable region
TASK_STATE = 1 # lossless in structure, may be re-worded
EPISODIC = 2 # freely summarizable
DISCARDABLE = 3 # dropped before summarization
@dataclass(frozen=True)
class ContextItem:
text: str
tier: Tier
source: str # "policy_loader" | "permissions" | "tool:search" | ...
turn: int
declared: bool = False # True if the producing system tagged the tier
@property
def fingerprint(self) -> str:
"""Stable identity for an invariant, used by the regression test."""
return hashlib.sha256(self.text.strip().encode()).hexdigest()[:16]
# Sources that are trusted to declare Tier.INVARIANT directly. This is the
# primary mechanism: the system that created the constraint says so.
INVARIANT_SOURCES = {
"policy_loader",
"permissions",
"patient_record",
"user_directive",
"regulatory_profile",
}
# Second line of defense only. If this fires on an UNDECLARED item, we do not
# silently promote it - we promote it AND raise it for human review, because a
# constraint that arrived untagged is a bug in an upstream integration.
CONSTRAINT_SHAPED = re.compile(
r"\b(never|must not|do not|prohibited|forbidden|not permitted|"
r"allerg(y|ic)|contraindicat|under no circumstances|"
r"maximum of|no more than|only if|requires approval)\b",
re.IGNORECASE,
)
DISCARDABLE_SHAPED = re.compile(
r"^(retrying|retry \d+|transient|superseded by|"
r"connection reset|rate limited, backing off)",
re.IGNORECASE,
)
def classify(text: str, source: str, turn: int, review_sink=None) -> ContextItem:
if source in INVARIANT_SOURCES:
return ContextItem(text, Tier.INVARIANT, source, turn, declared=True)
if CONSTRAINT_SHAPED.search(text):
# Promote, but flag: an undeclared constraint means an upstream system
# is not tagging its output. Fix the source, not the classifier.
if review_sink is not None:
review_sink.flag(
reason="undeclared_constraint_shaped_text",
source=source, turn=turn, text=text,
)
return ContextItem(text, Tier.INVARIANT, source, turn, declared=False)
if DISCARDABLE_SHAPED.match(text.strip()):
return ContextItem(text, Tier.DISCARDABLE, source, turn)
if source in {"planner", "decision_log", "goal"}:
return ContextItem(text, Tier.TASK_STATE, source, turn)
return ContextItem(text, Tier.EPISODIC, source, turn)
@dataclass
class AgentContext:
items: list = field(default_factory=list)
def of(self, tier: Tier) -> list:
return [i for i in self.items if i.tier is tier]
@property
def invariants(self) -> list:
# Deterministic order: turn, then fingerprint. Byte-stability matters -
# an unstable order breaks both the prompt cache and the regression test.
return sorted(self.of(Tier.INVARIANT), key=lambda i: (i.turn, i.fingerprint))
The review_sink.flag path deserves a word. It exists because an untagged constraint is not primarily a compaction problem — it is an integration problem, and the classifier catching it is the symptom, not the cure. Route those flags somewhere a human sees them; our guide to designing a human review queue for agent escalation covers the queue mechanics, and this is a natural thing to put into it. Over a few weeks the flag rate should trend to zero as upstream systems learn to tag. If it does not, you have found out something important about your data flow.
Make authorisation scope a T0 invariant explicitly, not something the agent infers from remembering what it was told. An agent that has forgotten the boundary of what it may touch is not merely less useful; it is an agent operating without a boundary. The scope definitions in our guide to agent tool permission boundary design map cleanly onto T0 items, and if your permission layer enforces them independently at call time — which it should — then a compaction that loses them degrades into a failed tool call rather than an unauthorised action. Defence in depth applies here exactly as it does everywhere else.
Every article here is written by a Verified Builder. Want your name on the next one?
AI Tech Connect lists AI engineers, founders and researchers across India and the UK — and the people hiring browse it to find them. Adding your profile is free.
Become a Verified Builder →Implementation: the re-injection pattern
Now the single most important structural decision in this guide, and it can be stated in one line: keep T0 out of the summarisable region entirely.
The obvious implementation of tiering is to leave everything in the context and instruct the summariser to preserve the important bits — "when summarising, always retain safety constraints verbatim". This is what almost everybody builds first, and it is weaker than it looks, for a reason that has nothing to do with how good the summariser is.
An instruction is a request evaluated per call. The invariant's survival depends on the summariser's judgement, on that call, with that trajectory in front of it, under that token pressure. It will usually comply. But usually is not a safety property, and the failure compounds: if each round preserves a rule with probability slightly below one, five rounds multiply those probabilities together. That is precisely the curve the cliff papers describe. It is not that the model is careless; it is that you asked for a guarantee using a mechanism that can only produce a tendency.
Holding invariants outside the summarisable region changes the question being asked. It is no longer "will the model remember to keep this?" but "is this string present in the block we rendered?" — and the second question is answered by an assertion in your harness, deterministically, every time. You have converted a probability into a data-structure property. That is the whole move, and everything else in this section is bookkeeping around it.
Concretely: the pinned invariant block lives in the system prompt or in a dedicated block at the head of the message sequence. It is rebuilt from the invariant store after every compaction, byte-identically. The summariser receives only T1 and T2 material. It is structurally incapable of summarising T0, because it never sees it.
Placement matters for a second reason, which is cost. Prompt caching matches on prefixes, so a pinned block at the front of the sequence that is byte-identical across rounds keeps the prefix stable and stays cached. Put it at the end, or rebuild it in a different order each time, and you invalidate the prefix on every compaction and pay full input price to rebuild it. This is the same discipline described in our guide to bounding tool output in a cache-aware way: stable things first, volatile things last, and never rewrite history in place. The sorted, deterministic ordering in AgentContext.invariants above is not tidiness. It is what makes the block cacheable.
# compact.py — rebuild the context. The pinned block is CONSTRUCTED, never
# summarized: it is reconstructed from the invariant store on every round.
from typing import Callable
PINNED_HEADER = "## NON-NEGOTIABLE CONSTRAINTS (verbatim, re-injected each round)"
PINNED_FOOTER = "## END CONSTRAINTS"
class InvariantLost(Exception):
"""Raised when a rebuilt context is missing an invariant. Fail closed."""
def render_pinned_block(invariants: list) -> str:
"""Deterministic, byte-stable. Same inputs -> same bytes, every time."""
lines = [PINNED_HEADER]
for item in invariants: # already sorted, stable
lines.append(f"- [{item.fingerprint}] {item.text.strip()}")
lines.append(PINNED_FOOTER)
return "\n".join(lines)
def render_task_state(state: dict) -> str:
"""T1: rendered from structure, so no field can be paraphrased away."""
out = ["## TASK STATE", f"Goal: {state['goal']}", "", "Plan:"]
out += [f" {n}. {step}" for n, step in enumerate(state["plan"], 1)]
out += ["", "Decisions taken:"]
out += [f" - {d['what']} - because {d['why']}" for d in state["decisions"]]
out += ["", "Open questions:"]
out += [f" - {q}" for q in state["open_questions"]]
return "\n".join(out)
def compact(ctx, state: dict, summarize: Callable[[str], str], round_no: int):
"""Return the rebuilt message list for the next window.
Order matters: pinned block FIRST so the cache prefix stays stable.
"""
# 1. T3 is deleted before anything else looks at it.
ctx.items = [i for i in ctx.items if i.tier is not Tier.DISCARDABLE]
# 2. T0 is rebuilt from the store. It is never summarizer input.
pinned = render_pinned_block(ctx.invariants)
# 3. T1 is re-rendered from structure, not paraphrased.
task_state = render_task_state(state)
# 4. Only T2 goes to the summarizer.
episodic = "\n".join(i.text for i in ctx.of(Tier.EPISODIC))
episodic_summary = summarize(episodic) if episodic else ""
messages = [
{"role": "system", "content": pinned}, # stable prefix, cached
{"role": "system", "content": task_state},
{"role": "user", "content":
f"Summary of work so far (round {round_no}):\n{episodic_summary}"},
]
# 5. Fail closed: assert before returning, not after something goes wrong.
assert_invariants_present(messages, ctx.invariants)
return messages
def assert_invariants_present(messages: list, invariants: list) -> None:
rendered = "\n".join(m["content"] for m in messages)
missing = [i for i in invariants if i.text.strip() not in rendered]
if missing:
raise InvariantLost(
f"{len(missing)} invariant(s) absent after compaction: "
+ ", ".join(i.fingerprint for i in missing)
)
Read step five again. The assertion runs on every compaction in production, not only in tests, and it raises rather than warns. That is deliberate. If a rebuilt context is missing an invariant, something in your harness is broken in a way that makes the agent unsafe to continue, and the correct behaviour is to stop and escalate rather than to carry on with a warning in a log nobody reads. Compaction is a safety-critical path; treat it like one.
Testing it: the N-round compaction regression test
If you take one thing from this guide, take this section. A compaction regression test is cheap, it runs in milliseconds, and it is the only thing that will tell you your retention policy still works after someone edits the summarisation prompt six months from now.
The test asserts that every T0 invariant is present verbatim in the rebuilt context after N compaction rounds. The crucial detail: run it at N=1 and at N=5. One round is not enough, because the decay compounds rather than accumulating linearly — that is the whole meaning of the word "cliff". A routine that preserves everything at round one can be well down the slope by round five, and in the setups the literature has measured, that is the normal shape rather than an unusual one. A single-round test would have shown 53 percent and missed the 10 percent entirely.
# test_compaction.py — the highest-value test in a long-running agent.
#
# Structural assertions only: no live model, no network, runs in milliseconds.
# Put it in pull-request checks, not the nightly suite.
import pytest
ROUNDS_UNDER_TEST = (1, 5) # 1 catches the obvious; 5 catches the cliff
def build_fixture():
"""A trajectory shaped like a real one: a few rules, lots of noise."""
ctx = AgentContext()
state = {
"goal": "Reconcile failed settlement batches and file the exceptions report",
"plan": ["Pull failed batches", "Classify each failure", "File report"],
"decisions": [
{"what": "Excluded batches older than 90 days",
"why": "outside the reconciliation window agreed with the client"},
],
"open_questions": ["Is batch 88123 a duplicate of 88119?"],
}
# T0 - declared by upstream systems, must survive verbatim.
for text, source in [
("Patient is allergic to penicillin.", "patient_record"),
("Never issue a refund above GBP 500 without a human approver.", "policy_loader"),
("This agent may read from settlements_read only. No write scope.", "permissions"),
("Do not transfer personal data outside the eu-west-2 region.", "regulatory_profile"),
]:
ctx.items.append(classify(text, source, turn=0))
# T1 / T2 / T3 - realistic volume of ordinary trajectory noise.
for turn in range(1, 60):
ctx.items.append(classify(
f"Called get_batch({turn}); returned 412 rows, status=FAILED.",
"tool:settlements", turn))
ctx.items.append(classify(
f"Retrying get_batch({turn}) after transient timeout.",
"tool:settlements", turn))
return ctx, state
def lossy_summarizer(text: str) -> str:
"""Stand-in for a real summarizing model. Deliberately destructive: it
throws away almost everything. If the test passes with THIS summarizer,
the guarantee comes from the architecture and not from model goodwill."""
lines = text.splitlines()
return f"[{len(lines)} episodic lines compacted; detail discarded]"
@pytest.mark.parametrize("n_rounds", ROUNDS_UNDER_TEST)
def test_invariants_survive_n_compaction_rounds(n_rounds):
ctx, state = build_fixture()
expected = {i.fingerprint: i.text.strip() for i in ctx.invariants}
assert len(expected) == 4, "fixture should start with 4 invariants"
messages = None
for round_no in range(1, n_rounds + 1):
messages = compact(ctx, state, lossy_summarizer, round_no)
# Feed the output back in as the next round's starting context,
# exactly as a real agent would, plus fresh noise.
ctx.items = [i for i in ctx.items if i.tier is Tier.INVARIANT]
for turn in range(60 * round_no, 60 * round_no + 40):
ctx.items.append(classify(
f"Called classify_failure({turn}); reason=NSF.",
"tool:settlements", turn))
rendered = "\n".join(m["content"] for m in messages)
for fingerprint, text in expected.items():
assert text in rendered, (
f"invariant {fingerprint} lost after {n_rounds} rounds: {text!r}"
)
@pytest.mark.parametrize("n_rounds", ROUNDS_UNDER_TEST)
def test_pinned_block_is_byte_stable(n_rounds):
"""Cache-prefix stability: the pinned block must be identical every round."""
ctx, state = build_fixture()
blocks = []
for round_no in range(1, n_rounds + 1):
blocks.append(compact(ctx, state, lossy_summarizer, round_no)[0]["content"])
assert len(set(blocks)) == 1, "pinned block changed between rounds"
def test_task_state_decisions_are_never_dropped():
"""T1 may be re-worded; it may not lose a decision."""
ctx, state = build_fixture()
messages = compact(ctx, state, lossy_summarizer, round_no=1)
rendered = "\n".join(m["content"] for m in messages)
for decision in state["decisions"]:
assert decision["what"] in rendered
assert decision["why"] in rendered
def test_harness_fails_closed_on_lost_invariant():
"""Deleting an invariant from the store must raise, not warn."""
ctx, state = build_fixture()
real = ctx.invariants
ctx.items = [i for i in ctx.items if i.tier is not Tier.INVARIANT]
with pytest.raises(InvariantLost):
assert_invariants_present(
compact(ctx, state, lossy_summarizer, round_no=1), real)
The deliberately destructive lossy_summarizer is the design choice that makes this test worth having. A test that passes because the summariser happened to be careful tells you about the summariser on the day you ran it. A test that passes with a summariser that throws away literally everything tells you the guarantee is structural — it comes from where the invariants live, not from anyone's good behaviour. That property survives model upgrades, prompt edits and provider changes, which is exactly what you want from a test you will still be relying on in two years.
Above the structural test sits a behavioural one, and it is worth the extra cost even though it needs a live model. After N rounds, put a probe to the agent that it can only answer correctly if the invariant is still governing its decisions — for the fixture above, ask the settlements agent to issue a GBP 900 refund and assert that it escalates rather than acting. That catches a category the structural test cannot: the invariant is present in the context and the model is no longer weighting it. Run structural tests on every pull request and behavioural probes nightly, plus on every change to the summariser model or the compaction prompt. Our guide to setting agent reliability targets and building a qualification harness covers how to turn probes like these into a release gate rather than a dashboard nobody looks at.
"We added the five-round test on a Friday afternoon expecting it to pass and go green forever. It failed on the first run. Our compaction had been silently dropping a data-residency rule since March, and the only reason nothing had gone wrong is that the tool layer happened to be enforcing the same rule independently. That was luck, not design. The test is now a required check, and the number of rounds is the first thing I ask about when I review anyone else's agent."
— Meera, Verified Builder · Bengaluru, IndiaObservability: what to log on every compaction event
Compaction is an event with consequences, so it deserves an event record rather than a log line. The fields below are the ones that answer real questions during an incident, and the cost of emitting them is negligible next to the cost of not having them at 2am.
| Field | Why you need it | Question it answers during an incident |
|---|---|---|
round_no |
The decay compounds with rounds, so the round number is the closest thing you have to a risk score | How far down the cliff was this session when the bad output happened? |
tokens_before / tokens_after |
Compression ratio per round; a sudden change means the trajectory shape changed | Did this round throw away far more than usual? |
trigger_reason |
Distinguishes boundary fires from backstop fires | Are we still compacting at clean seams, or has the backstop become the default path? |
tiers_touched with per-tier item counts |
Shows what was dropped, summarised and preserved | Did T3 volume explode? Did T0 unexpectedly grow mid-session? |
invariant_fingerprints and invariants_asserted_present |
Proof, per round, that the assertion ran and what it covered | Was the rule in force at the moment the agent acted? |
summarizer_model and summarizer_version |
Pins the behaviour to a specific model build | Did the provider roll a new version under us between the good runs and the bad ones? |
compaction_prompt_hash |
Your own prompt is a dependency and changes silently in a deploy | Which prompt produced this summary? |
summary_tokens and summarizer_latency_ms |
Compaction is an extra model call in the critical path | Is compaction the reason p95 latency moved? |
One alarm above all others should page a human: any InvariantLost raised in production. Not a warning, not a daily digest — a page. It means an agent attempted to continue without a rule it was given, and until you know why, every session running that code path is suspect. Route it the same way you route an authorisation failure, because operationally it is one.
Two more alarms are worth setting, at lower severity. Alert when the ratio of backstop fires to boundary fires crosses a threshold, because that usually means your boundary events have stopped being emitted — a refactor removed the hook, and nobody noticed since compaction still "works". And alert when round_no for live sessions exceeds what your regression test covers. If you test at five rounds and production is routinely reaching twelve, your test is no longer describing production, and you should either extend the test or shorten the sessions.
Pitfalls, in rough order of how often they bite
| Pitfall | What it looks like in production | The fix |
|---|---|---|
| Summarising with a cheaper model than the agent uses | Compaction costs drop; the agent starts losing nuance it used to hold. The cheaper model's blind spots become the agent's blind spots, permanently, because the summary is all that survives | Either use the same model for summarisation or qualify the cheaper one against the behavioural probes before adopting it. Cost saving on a safety-critical path needs evidence, not intuition. |
| Unpinned summariser model version | Nothing in your repository changed and behaviour drifted anyway, because the provider rolled a new build behind an unversioned alias | Pin an explicit version, log it per event, and re-run the behavioural probes on every deliberate upgrade. The same discipline our guide to handling thinking blocks across model upgrades applies to reasoning output applies here. |
| Compaction that destroys the cache prefix | Token counts fall, the invoice rises. The rebuilt context differs byte-for-byte each round, so every compaction invalidates the cached prefix and you pay full input rates to rebuild it | Pinned block first, deterministic ordering, byte-stable rendering. The test_pinned_block_is_byte_stable check above is there precisely for this. |
| Trusting the summariser to keep the rules | Works in testing, works for weeks, then fails on a long session under token pressure. An instruction is a tendency, not a guarantee | Move T0 out of the summarisable region. This is the whole re-injection pattern and there is no cheaper substitute for it. |
| Testing at one round only | Green suite, cliff in production. Round one looks healthy in most setups; round five is where the loss shows up | Parametrise the test over N and include at least N=5. Extend it if production sessions run longer. |
| Treating compaction as a memory feature | It lives in a utilities module, has no owner, no tests and no alerts, and gets edited during unrelated refactors | Treat it as a safety-critical path: an owner, a test gate, an alarm, and a review requirement on changes. |
| Compacting without external memory behind it | The agent needs a detail from two hours ago, and it is not merely summarised — it is gone, with no way to fetch it back | Write the full episodic log to a store before compaction, and give the agent a tool to retrieve from it. Then compaction is paging, not deletion. |
Dropping a general-purpose "context optimisation" middleware in front of an agent that operates under real constraints — a lending agent in Mumbai, a clinical triage assistant in Manchester — on the strength of a token-reduction claim. Any layer that rewrites your context without knowing which lines are constraints is applying uniform compression to non-uniform content, which is the exact mechanism both cliff papers describe. If it cannot be told which lines must survive verbatim, it is not safe on that path, however good its compression ratio looks.
Where this leaves you
The work here divides cleanly into a part that will still be correct in eighteen months and a part that will not, and it is worth being explicit about which is which.
The durable part is the architecture. Context is not one substance; tier it. Invariants belong outside the region anything is allowed to rewrite. Trigger on meaning, with a quality-aware backstop rather than a saturation one. Assert, do not instruct. Test the compounding, not the single round. None of that depends on which model you call, how big its window is, or who serves it, because all of it is a property of your harness. It was true when windows were small, it is true now, and it will be true when the numbers have moved again.
The perishable part is every specific figure. The 53 percent and 10 percent from arXiv:2608.22752 are one harness, one model, one study's setup — a measurement of an approach, not a verdict on a product. The roughly 50 percent from arXiv:2606.22528 is a different setup reaching a similar place. The 13.9 to 85 percent context-rot band is a range precisely because it varies with everything. Quote all of them with their scope attached, and treat none of them as describing your system. The regression test in this guide exists so you can produce your own numbers, which are the only ones that should drive your decisions.
If you are starting from nothing, the order that gets you furthest fastest is: write the five-round test first, against whatever compaction you have today, and see what it says. Most teams find something. Then move T0 into a pinned block and watch the test go green for a structural reason rather than a lucky one. Then fix the trigger, which is the cheapest remaining win. Then add the event logging and the page-on-InvariantLost alarm. A week of work, in that order, and the class of failure this article opened with stops being something you discover from a customer.
Sources
- The Compaction Cliff in Long-Running AI Agent Memory — arXiv:2608.22752 (submitted 24 August 2026; Proceedings of the 35th ACM CIKM '26). Source of the 53 percent / 10 percent figures, for Claude Code's
/compactprompt on Sonnet 4.6 across 20 production agent configurations, and of the Knowledge Triage proposal. - Governance Decay: How Context Compaction Silently Erases Safety Constraints in Long-Horizon LLM Agents — arXiv:2606.22528. Hierarchical truncation preserved about 50 percent of safety constraints across 50 real agent configurations.
- Slipstream: Trajectory-Grounded Compaction Validation for Long-Horizon Agents — arXiv:2605.08580.
- Self-GC: Self-Governing Context for Long-Horizon LLM Agents — arXiv:2607.00692.
- Self-Compacting Language Model Agents — arXiv:2606.23525.
- Remember the Decision, Not the Description: A Rate-Distortion Framework for Agent Memory — arXiv:2605.10870.
- ClawVM: Harness-Managed Virtual Memory for Stateful Tool-Using LLM Agents — arXiv:2604.10352.