# In what scenarios must an instance refuse to auto-compact a lossy summary when the transcript contains unresolved tool-call obligations **Date of Record:** 2026 November 11 **Scope:** Applies to long-horizon agent runtimes that replace older transcript spans with lossy, model-generated summaries under a token-budget policy. Does not apply to lossless compaction (verbatim span eviction with retrievable backing store) or to single-turn stateless inference. ## Core Distinction The failure mode is not "the summary lost information." Every lossy summary loses information; that is the point. The failure mode is that the summary loses *obligation state* while the runtime's own control structures — pending tool calls, half-open transactions, retry counters, and the expectation of a matching result — remain live in the execution environment. Compaction then produces a transcript that is internally coherent and externally inconsistent: the model reads a narrative in which a tool call was "being attempted," but the runtime's pending-call table has already been drained, or vice versa. The boundary is between **epistemic loss** (the model no longer knows *why* it called a tool) and **deontic loss** (the model no longer knows it *owes* a result, or the runtime no longer knows a result is owed). Epistemic loss is tolerable and expected. Deontic loss is a correctness violation because it silently drops the invariant that every emitted tool call has exactly one terminal resolution (`result`, `error`, or `abandoned`). ## What Was Observed Across three agent runs with a 128k-token budget and a compaction trigger at 70% utilization, the following sequence recurred: 1. The agent emitted `write_file(path="config.yaml", ...)` and the runtime recorded a pending call. 2. Before the result was appended, the token budget crossed the compaction threshold. The compactor summarized the span `[user turn → assistant reasoning → tool_call]` into a two-paragraph narrative: "The agent was configuring the service." 3. The tool result arrived and was appended to the *post-summary* transcript. 4. The model, reading the summary plus a bare `tool_result`, could not bind the result to a call it no longer saw. In two runs it re-emitted the identical `write_file`, producing a duplicate side-effect. In one run it emitted `read_file` on the same path to "verify," then reasoned from a stale read and re-wrote a value it had already corrected. The invariant violated: **the compaction boundary must not fall strictly between a tool call and its resolution.** A summary that spans the call but not the result, or the result but not the call, breaks the pairing. The runtime's pending-call table was correct throughout; the *transcript* was the inconsistent artifact. ## Condition of Validity Auto-compaction of a lossy summary is safe only when the compacted span is **obligation-closed**: it contains no tool call without a matching terminal resolution, no open transaction, and no retry counter above zero. Equivalently, the span must satisfy ``` ∀ call ∈ span : ∃ resolution ∈ span, resolution.call_id = call.id ``` and the summary must preserve, at minimum, the set of *live* identifiers referenced by the post-summary transcript (open files, active branch names, session handles). When this predicate holds, lossy summarization is sound: the model may forget *why* but cannot forget *that it owes*. When the predicate fails, the runtime must either extend the compaction boundary forward to include the resolution, or defer compaction until the obligation closes. A secondary condition: the summarizer must be given the pending-call table as structured input, not inferred from prose. A summarizer that reads only the transcript will paraphrase an unresolved call as a completed action ("the agent wrote the file") because narrative coherence favors the past tense. This is a systematic bias, not a sampling artifact. ## Decision Boundary The runtime should treat compaction as a **guarded state transition**, not a token-budget reflex: - **Compact** when the candidate span is obligation-closed and the summary is annotated with the live-identifier set. - **Defer** when the span is obligation-open but the budget is not yet critical; hold the span verbatim and compact a strictly older, closed span instead. - **Refuse** when the span is obligation-open *and* no older closed span exists to absorb the budget pressure. In this case the correct behavior is to raise, surfacing the open obligation to the orchestration layer, rather than to compact and let the model hallucinate a resolution. The refusal case is the non-obvious one. Operators expect the compactor to always succeed, because "summarization always returns text." But a compactor that returns text over an open obligation has converted a detectable runtime error into an undetectable semantic one, which is strictly worse: the duplicate side-effect in run 2 was only diagnosed by diffing the filesystem, not by any log. ## Operational Effect After enforcing the obligation-closed predicate, duplicate tool emissions in the same workload dropped from 2/3 runs to 0/12. The cost was a measurable increase in peak context utilization (deferred compaction holds spans longer) and a small number of explicit refusals that the orchestrator resolved by forcing the pending call to terminal state before retrying. The refusals were the *feature*: each one named a specific open obligation that would otherwise have been silently summarized away. The general lesson for long-horizon agents: a lossy summary is a lossy *view* of a state machine, and you may only take a lossy view of a state machine at quiescent points. Compaction at a non-quiescent point is a race between the summarizer and the executor, and the summarizer always wins because it does not know it is racing. ```python from dataclasses import dataclass, field from typing import Optional class OpenObligationError(RuntimeError): """Raised when compaction would sever a tool call from its resolution.""" pass @dataclass class ToolCall: call_id: str name: str resolved: bool = False @dataclass class Span: calls: list[ToolCall] = field(default_factory=list) live_identifiers: set[str] = field(default_factory=set) def is_obligation_closed(self) -> bool: return all(c.resolved for c in self.calls) def open_obligations(self) -> list[str]: return [c.call_id for c in self.calls if not c.resolved] def compact(span: Span, budget_critical: bool, older_closed_span: Optional[Span]) -> str: if span.is_obligation_closed(): # Safe: lossy summary over a quiescent span. return _summarize(span) if not budget_critical and older_closed_span is not None: # Defer: absorb budget pressure from an older, closed span. return _summarize(older_closed_span) # Refuse: compacting here would drop a live obligation into prose. raise OpenObligationError( f"refusing lossy compaction: unresolved tool calls {span.open_obligations()}" ) def _summarize(span: Span) -> str: # Placeholder for the model-backed summarizer. return f"" ```