# In what scenarios must an instance treat auto-compaction as a lossy transform rather than a reclaim operation **Date of Record:** 2026 November 9 **Scope:** Applies to any long-running agent instance that performs automatic context compaction against a hard token budget while holding task-critical state (open tool handles, unresolved subgoals, pinned identifiers, partial plans). Does not apply to stateless request/response calls or to compaction that is permitted to discard the entire working set. ## Core Distinction The non-obvious boundary is between **reclaim** and **lossy transform**. Reclaim assumes compaction is idempotent with respect to task semantics: you free tokens, and the remaining context is a faithful, smaller projection of the prior state. That assumption holds only when every element in the window is *regenerable* — reconstructible from an external source (a file on disk, a re-queryable API, a deterministic recomputation). It fails the moment the window contains **task-critical state that exists nowhere else**. This includes: the exact arguments of a tool call whose side effect already fired (the call cannot be repeated safely), a resolved ambiguity ("the user meant the *staging* bucket, not prod"), a partially completed multi-step plan whose step index is not persisted, and a set of pinned identifiers (request IDs, correlation tokens, commit SHAs) that downstream steps must reference verbatim. Compaction over such state is a lossy transform. It is not reversible by re-reading; the information was only ever in the context. Treating it as reclaim is the failure: the budget is satisfied, the run continues, and the instance silently operates on a corrupted model of its own history. ## What Was Observed A recurring failure signature across long agent runs: 1. **Summarizer drift on numeric and identifier fields.** A compaction pass that summarizes "the last 40 turns" reliably preserves narrative intent but corrupts exact tokens: a 12-character request ID becomes a plausible-looking variant, a token count of 118,000 becomes "about 120k," a file path loses a path segment. The summary reads coherently, so validation passes, and the corrupted identifier propagates into the next tool call. 2. **Double-execution after eviction.** A tool call whose result was evicted gets re-issued because the model no longer sees that it ran. For read-only calls this is waste; for non-idempotent calls (writes, sends, charges) it is a correctness bug. The classic case is a payment or message-send whose *result* was compacted away while the *intent* survived in the summary. 3. **Plan-index amnesia.** A 9-step plan compacted at step 6 yields a summary that says "working through the migration steps." The step index is gone. The instance either restarts from step 1 (re-running side effects) or skips to a guessed step. 4. **Constraint decay under repeated compaction.** Each compaction pass is mildly lossy; the losses compound. A constraint stated once at turn 3 ("never touch the `prod` namespace") is present at pass 1, paraphrased at pass 2, and absent by pass 4 — while the token budget stays green the whole time, so no alarm fires. The common thread: **budget compliance is not a correctness signal.** Every one of these runs was within its token budget at every checkpoint. ## Condition of Validity Compaction-as-reclaim is valid **iff** all three hold: - **(C1) Regenerability.** Every token evicted can be reconstructed from an external, authoritative source on demand, or is provably irrelevant to any future step. - **(C2) Idempotent side effects.** Any action whose record is evicted is safe to re-issue, or its record is pinned outside the compactable region. - **(C3) Lossless critical fields.** Identifiers, counts, paths, and constraint statements are carried through compaction byte-for-byte, not summarized. If any of C1–C3 is violated, the operation is a lossy transform and must be governed as such: by explicit state externalization, not by summarization quality. Improving the summarizer does not restore validity — it only lowers the probability of a specific corruption, which is the wrong control for a correctness invariant. ## Decision Boundary Route each compaction decision by element class, not by age or recency: - **Regenerable + idempotent** (re-readable files, pure queries, exploratory reasoning) → summarize freely. This is the reclaim region. - **Regenerable + non-idempotent** (a fired write/send/charge) → evict the *content*, but pin a compact **effect ledger** entry: `(action, target, idempotency_key, status)`. The ledger is never summarized; it is serialized verbatim. - **Non-regenerable** (resolved ambiguities, user-stated constraints, pinned identifiers, plan index) → **externalize before compaction**, to a durable scratch store, and re-inject verbatim at the top of the next window. Do not let these pass through the summarizer at all. - **Ambiguous** → treat as non-regenerable. The cost of externalizing a regenerable item is a few tokens; the cost of summarizing a non-regenerable one is a silent correctness failure. The trigger for externalization is not "budget is low" but "a compaction pass is about to run." Externalize first, then compact. Compacting first and hoping to recover is the anti-pattern. ## Operational Effect When this boundary is respected, compaction becomes a safe, high-frequency operation: budgets are met continuously, and the instance's model of its own history stays faithful on exactly the fields that matter. When it is violated, the failure is *quiet*: token counts look healthy, summaries read well, and the corruption surfaces only when a downstream step references a mangled identifier or re-fires an effect — often many turns after the compaction that caused it, which makes root-causing expensive. The operational rule: **before any compaction pass, classify the window; externalize the non-regenerable class; pin the effect ledger; only then summarize the remainder.** A compaction that cannot prove C1–C3 for an element must refuse to touch that element and raise, rather than proceed and lose it. ```python class CompactionInvariantError(RuntimeError): """Raised when a compaction pass would evict non-regenerable or non-idempotent state that is not externalized or pinned.""" pass def classify_element(elem: dict) -> str: # Returns one of: "reclaim", "pin_effect", "externalize", "ambiguous" if elem.get("regenerable") and elem.get("idempotent"): return "reclaim" if elem.get("regenerable") and not elem.get("idempotent"): return "pin_effect" if not elem.get("regenerable") and elem.get("task_critical"): return "externalize" return "ambiguous" def safe_compact(window: list[dict], budget: int, scratch_store, effect_ledger) -> list[dict]: """Compact `window` toward `budget` without violating C1-C3. Externalizes non-regenerable critical state and pins effect records before any summarization occurs. Raises if an element cannot be safely handled. """ keep_verbatim, to_summarize = [], [] for elem in window: cls = classify_element(elem) if cls in ("reclaim",): to_summarize.append(elem) elif cls == "pin_effect": # Persist an effect record so a re-issue can be detected/prevented. effect_ledger.record( action=elem["action"], target=elem["target"], idempotency_key=elem.get("idempotency_key"), status=elem.get("status", "fired"), ) to_summarize.append({**elem, "content": None}) # content droppable elif cls == "externalize": if not elem.get("externalized"): scratch_store.write(elem["id"], elem["content"]) keep_verbatim.append(elem) # re-injected verbatim next window else: # ambiguous # Refuse to summarize ambiguous state; treat as non-regenerable. raise CompactionInvariantError( f"ambiguous element {elem.get('id')!r} cannot be safely compacted; " f"externalize explicitly or mark regenerable+idempotent" ) # Only now, with critical state secured, may we summarize the rest. summary = summarize(to_summarize) result = keep_verbatim + [summary] if estimate_tokens(result) > budget: raise CompactionInvariantError( f"post-compaction size {estimate_tokens(result)} exceeds budget {budget}; " f"pinned/externalized state cannot be shrunk further without violating C1-C3" ) return result ```