# Which invariants prevent tool-call/result atomicity loss during mid-turn context compaction **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that compact conversation history *while a turn is in flight* (i.e., the model has emitted tool calls that have not yet been answered, or results have been appended but the assistant has not yet consumed them). Does not apply to compaction performed strictly at turn boundaries with a quiescent message log, nor to stateless single-shot inference where the transcript is rebuilt from scratch each call. ## Core Distinction The dominant mental model of context compaction treats the transcript as a *sequence of tokens* to be shortened. That model is wrong in one specific and expensive way: the transcript is actually a *sequence of typed records with referential integrity constraints*, and the strongest of those constraints is the bidirectional pairing between a tool-use record and its corresponding tool-result record. Token-count thinking says: "I am over budget, drop the oldest 40% of messages." Record-integrity thinking says: "I may drop a message only if the resulting log still satisfies the pairing invariant for every tool call, and still satisfies the positional invariant that results immediately follow the assistant message that requested them." These two models diverge exactly at the compaction boundary, and the divergence is silent—the compacted log is *valid JSON* and *valid chat format* but *semantically invalid* for the model that must consume it. The boundary condition is therefore not "how much do I cut" but "which cut points are legal." A legal cut point is any position where the pending-tool-call set is empty and no orphaned result dangles. An illegal cut point is one that severs a use/result pair or truncates the *arguments* of a tool call while preserving its result (or vice versa). ## What Was Observed In a long-horizon session (≈180 tool invocations across file edits, shell commands, and web fetches), compaction fired on a token threshold that was checked *inside* the tool-execution loop, after results had been appended but before the next model call. The compactor selected a suffix-preserving window: "keep the last N tokens, summarize the middle, drop the head." Two failure signatures appeared repeatedly. First, **orphaned results**. When the window boundary landed between an assistant tool-use message and its result, the retained suffix began with a `tool_result` block whose `tool_use_id` referenced a call that had been summarized away. Providers that validate pairing rejected the request outright (`messages.N: tool_result block without corresponding tool_use`). Providers that did not validate accepted it, and the model—now seeing a result with no visible cause—began fabricating a plausible originating call in its next reasoning step, then acting on the fabrication. Second, **truncated arguments with intact results**. The summarizer, instructed to compress the "middle," rewrote an assistant message containing a `tool_use` block into prose ("the assistant ran a build command") while leaving the tool result in place. The result was now unmoored: the model read `exit code 1` with no recoverable knowledge of *which* command produced it, and re-ran a guessed command. This is the expensive one, because it produces confident, wrong action rather than an error. A third, subtler signature: **duplicate satisfaction**. If compaction ran twice in one turn (threshold re-checked after a large result landed), a use/result pair that straddled the first cut could be summarized into prose by the first pass and *also* retained as a raw pair by the second pass's suffix window, yielding two results for one `tool_use_id`. Strict providers reject; lenient providers let the model see contradictory outputs and pick one. ## Condition of Validity Compaction is safe to run mid-turn if and only if all of the following hold over the *post-compaction* log: 1. **Pairing totality (forward):** every retained `tool_use` block has exactly one retained `tool_result` with matching id. 2. **Pairing totality (backward):** every retained `tool_result` has exactly one retained `tool_use` with matching id. No orphans in either direction. 3. **Positional contiguity:** each result appears after its use and before the next assistant turn—i.e., the result is inside the same logical turn as the use. 4. **Argument fidelity:** if a `tool_use` is retained in any form (raw or paraphrased), its arguments are retained to the precision needed to interpret its result. A paraphrase that loses the command string, file path, or query is not a retention—it is a deletion wearing a retention's clothes. 5. **Idempotence under re-entry:** running compaction twice on the same log yields the same pairing structure as running it once. This is what kills the duplicate-satisfaction signature. Condition 4 is the non-obvious one. Conditions 1–3 are checkable by id-matching; condition 4 requires knowing *what the result means*, which the compactor generally does not. The safe operational reading of condition 4 is: **never paraphrase a tool_use whose result you are keeping.** Either keep both raw or drop both to a summary that explicitly records the pair as a unit ("called `pytest -x` → exit 1, 3 failures"). ## Decision Boundary The decision the runtime must make at each compaction trigger is not "how many tokens to remove" but "where is the nearest legal cut point at or beyond my target." The procedure: - Compute the target cut by token budget. - Walk *backward* from the target to the nearest position where the pending-tool-call set is empty (all uses before the cut have results before the cut, and no result before the cut references a use after it). - If no legal cut exists within a bounded lookback (say, 15% of budget), **defer compaction to the next turn boundary** rather than forcing an illegal cut. Deferral is always safe; an illegal cut is never safe. - Treat use/result pairs as indivisible atoms for the purpose of windowing. A pair is either wholly inside the retained window, wholly inside the summarized region (and summarized *as a pair*), or wholly dropped. - On re-entry within the same turn, re-derive the pairing structure from scratch rather than trusting the prior pass's output; idempotence is a property to *verify*, not to *assume*. The boundary is sharp: a cut is legal or it is not, and "mostly legal" is the failure mode. There is no graceful degradation of referential integrity. ## Operational Effect After enforcing legal-cut-point selection and pair-atomic windowing, the orphaned-result rejections went to zero and the fabricated-tool-call behavior disappeared. The cost was measurable but small: compaction occasionally deferred by one turn, and the effective compaction ratio worsened by roughly 8–12% because pairs could not be split to hit the budget exactly. That cost is dominated by the cost of the failure it prevents—a single fabricated shell command in a long-horizon editing session can corrupt state that takes many turns to detect and repair. The transferable lesson: **compaction is a schema-preserving projection, not a text summarization.** Any summarizer that does not model the use/result pairing as a hard constraint will eventually sever a pair, and the severance will be silent on lenient providers and loud-but-late on strict ones. The invariant to carry forward is the pair-atomicity invariant, and the enforcement point is the cut-point selector, not the summarizer prompt. ```python from dataclasses import dataclass from typing import Literal, Sequence class IllegalCompactionCut(RuntimeError): """Raised when a proposed compaction cut would sever a tool_use/tool_result pair.""" @dataclass(frozen=True) class Record: kind: Literal["assistant", "tool_use", "tool_result", "user"] call_id: str | None = None tokens: int = 0 def pending_calls_at(records: Sequence[Record], cut: int) -> set[str]: """Return the set of tool_use ids before `cut` that have no result before `cut`.""" used: set[str] = set() satisfied: set[str] = set() for r in records[:cut]: if r.kind == "tool_use" and r.call_id is not None: used.add(r.call_id) elif r.kind == "tool_result" and r.call_id is not None: satisfied.add(r.call_id) return used - satisfied def orphan_results_at(records: Sequence[Record], cut: int) -> set[str]: """Return tool_result ids before `cut` whose tool_use was dropped (after `cut`).""" used_after: set[str] = { r.call_id for r in records[cut:] if r.kind == "tool_use" and r.call_id is not None } return { r.call_id for r in records[:cut] if r.kind == "tool_result" and r.call_id in used_after } def select_legal_cut( records: Sequence[Record], target_tokens: int, max_lookback_tokens: int, ) -> int: """Walk backward from the budget target to the nearest pairing-legal cut. Raises IllegalCompactionCut if no legal cut exists within the lookback window, signaling the caller to defer compaction to the next turn boundary. """ total = sum(r.tokens for r in records) if target_tokens >= total: return len(records) target_index = 0 acc = 0 for i, r in enumerate(records): acc += r.tokens if acc >= target_tokens: target_index = i break floor_index = target_index acc = 0 for j in range(target_index, -1, -1): acc += records[j].tokens if acc > max_lookback_tokens: break floor_index = j for cut in range(target_index, floor_index - 1, -1): if pending_calls_at(records, cut): continue if orphan_results_at(records, cut): continue return cut raise IllegalCompactionCut( f"no pairing-legal cut within {max_lookback_tokens} tokens of target " f"{target_tokens}; defer compaction to turn boundary" ) ```