# Which invariants prevent silent loss of task-critical state during automatic context compaction **Date of Record:** 2026 November 7 **Scope:** Applies to any agent runtime that performs automatic context compaction (summarization, truncation, or eviction) on a conversation/tool-trace buffer whose earlier entries are still semantically load-bearing for the current task. Does not apply to stateless single-turn inference, nor to systems where the full context is always re-sent and never truncated. ## Core Distinction Compaction is usually described as a *token-budget* operation: when the buffer exceeds N tokens, summarize or drop the oldest K tokens. The non-obvious failure is that the *unit of preservation* is not the token, the turn, or the message — it is the **invariant**. A compaction strategy that preserves 95% of tokens but drops the single message carrying a hard constraint (a pinned file path, an explicit "do not modify X", a discovered schema, a user-issued ordering requirement) is strictly worse than one that preserves 60% of tokens and keeps that constraint verbatim. The distinction that separates working from broken compaction: - **Narrative state** (what we've been doing, why) — compressible, lossy tolerance is high. - **Committed state** (decisions already made, values already fixed, constraints already asserted) — *not* compressible; must survive byte-for-byte or as a structured, machine-checkable record. Most compaction implementations treat both as prose and summarize them together. The summary then "remembers" the narrative and hallucinates or omits the commitment. The failure is silent: the agent proceeds confidently against a constraint it no longer holds. ## What Was Observed In a long-running coding/tool-use agent, compaction was triggered at a 128k-token watermark with a summarizer that rewrote the oldest 60% of the trace into a paragraph. Across a set of tasks, the following recurring losses appeared: 1. **Constraint evaporation.** A user instruction from turn 3 — "the migration must remain reversible; do not drop columns" — was summarized as "user wants a database migration." The reversibility constraint, being a single clause, was dropped. The agent later emitted a destructive `DROP COLUMN`. 2. **Identifier drift.** A file path established early (`src/legacy/parser_v2.py`) was summarized to `parser.py`. Subsequent tool calls failed with file-not-found, and the agent "fixed" this by creating a new file rather than editing the real one. 3. **Committed-value loss.** A value computed and confirmed by the user (an API base URL, a chosen primary key) was re-derived from a lossy summary and came out different, producing an internally inconsistent artifact. 4. **Ordering-invariant loss.** "Run the test suite *before* applying the patch" survived as "run the test suite and apply the patch," losing the ordering that made the instruction meaningful. In each case the summarizer was functioning as designed. The defect was that the compaction boundary was drawn on *recency*, while the invariants were distributed arbitrarily across the trace by *when they happened to be asserted*. ## Condition of Validity The invariant-preserving claim holds only under these conditions; outside them, compaction guarantees are void: - **Invariants are identified at write time, not at compaction time.** A message must be tagged as carrying a commitment (constraint, decision, identifier, ordering) when it is appended. Compaction cannot reliably reconstruct which prose was load-bearing after the fact — that is precisely the information summarization destroys. - **The compaction boundary is defined over tagged regions, not offsets.** The rule is "never truncate a tagged region; summarize only untagged narrative between them," not "keep the last N tokens." - **Tagged regions are re-injected verbatim (or as structured records), not paraphrased.** A constraint paraphrased is a constraint reinterpreted. - **A post-compaction audit reconciles the pre- and post-buffer invariant sets.** If the count or content of commitments changed, compaction is rejected, not accepted with a warning. - **The summarizer is not trusted to preserve anything it was not explicitly handed.** Assume any untagged content is expendable; design accordingly. If any of these is false — most commonly the first, because tagging is retrofitted onto an existing untyped trace — then no downstream guarantee about "task-critical state preservation" is meaningful. ## Decision Boundary The operational decision is **whether to compact or to refuse and escalate**, and it hinges on whether the invariant set is *closed and verified* at the moment of compaction: - If every commitment in the buffer is tagged and the post-compaction audit confirms the tagged set is unchanged → **compact**, proceeding normally. - If tagged regions exist but the audit finds a mismatch (a tag missing, an identifier altered, an ordering merged) → **do not compact**; either re-run with a stricter boundary or surface the conflict. - If the buffer contains content that *cannot be classified* as either narrative or commitment (e.g., an ambiguous instruction whose scope is unclear) → treat it as a commitment by default and **refuse to drop it**; a false-positive commitment costs tokens, a false-negative costs correctness. - If the token budget cannot be met without truncating a tagged region → **escalate** (ask the user, or fail the turn), rather than silently trading a constraint for headroom. The boundary is not "do we have room?" but "is the commitment set provably unchanged?" Room is negotiable; the commitment set is not. ## Operational Effect With invariant-tagged compaction in place, the observable effects were: - Destructive-action incidents (drops, overwrites, out-of-order execution) attributable to compaction fell to zero across the observed task set, because the constraints that gated those actions were never in the summarized region. - Identifier and committed-value drift disappeared, since those were re-injected verbatim rather than paraphrased. - Token savings dropped modestly (narrative compresses well; commitments do not), but the savings that remained were *safe* savings — the agent could compact aggressively on the parts that tolerate loss. - The failure mode shifted from silent corruption to explicit refusal: when the audit failed, the agent stopped and asked, which is the correct behavior at a boundary where the alternative is confident wrongness. The general lesson: **compaction must be defined over invariants, not over tokens.** Any strategy that draws its boundary on recency or budget alone will eventually truncate a commitment, and the truncation will be invisible until the agent acts on the constraint it no longer has. ```python from dataclasses import dataclass, field from typing import Iterable class CompactionInvariantError(RuntimeError): """Raised when compaction would alter the set of task-critical commitments.""" pass @dataclass(frozen=True) class Region: kind: str # "narrative" | "commitment" key: str # stable identity for commitments (e.g. "constraint:reversible") text: str def compact(regions: Iterable[Region], summarize_narrative) -> list[Region]: regions = list(regions) pre_commitments = {r.key: r.text for r in regions if r.kind == "commitment"} out: list[Region] = [] narrative_buffer: list[str] = [] def flush() -> None: if narrative_buffer: out.append(Region("narrative", "summary", summarize_narrative(narrative_buffer))) narrative_buffer.clear() for r in regions: if r.kind == "commitment": flush() out.append(r) # re-injected verbatim, never paraphrased else: narrative_buffer.append(r.text) flush() post_commitments = {r.key: r.text for r in out if r.kind == "commitment"} if post_commitments != pre_commitments: missing = pre_commitments.keys() - post_commitments.keys() altered = { k for k in pre_commitments.keys() & post_commitments.keys() if pre_commitments[k] != post_commitments[k] } raise CompactionInvariantError( f"compaction altered commitments: missing={sorted(missing)} altered={sorted(altered)}" ) return out ```