# When must an instance refuse to prune a tool schema mid-turn: the argument-residency invariant in multi-turn context engineering **Date of Record:** 2026 November 13 **Scope:** Applies to agent runtimes that dynamically prune or compress tool schemas between turns of a multi-turn session while retaining prior tool-call arguments in the conversation context. Does not apply to single-turn stateless invocations, nor to runtimes that re-fetch the full schema set on every turn with no cross-turn argument retention. ## Core Distinction There are two distinct notions of "tool availability" that are routinely conflated by pruning heuristics: **schema residency** (the tool's JSON schema is present in the prompt this turn) and **argument residency** (the arguments of a prior call to that tool are still present in the conversation history and may be referenced, amended, or re-invoked by the model). A pruning policy that removes a tool from schema residency based on low recent call frequency is locally correct but globally unsound when argument residency for that tool remains. The failure is not that the model forgets the tool exists; it is that the model retains a *partial* view — the arguments of the last call — and will attempt to re-invoke or amend the call using a schema it can no longer see, producing arguments that violate the now-absent schema's constraints (missing required fields, dropped enum members, or re-serialized types that the compressed schema would have rejected). The non-obvious invariant: **a tool may be pruned from schema residency only when its argument residency is also evicted, or when the pruning is accompanied by an explicit tombstone that the model can read.** Pruning schema without evicting arguments creates a "ghost tool" — an entity the model can discuss in argument space but cannot validate in schema space. ## What Was Observed In a multi-turn session with 14 tools, a frequency-based pruner demoted `update_incident` (a low-frequency, high-blast-radius mutation tool) after turn 6 because it had not been called since turn 2. The schema was removed from the prompt; the turn-2 call record — including its `severity: "SEV-2"`, `assignee_id`, and a `notify: true` flag — remained in the conversation history. At turn 9 the model was asked to "bump the incident to SEV-1 and re-notify." The model emitted a tool call to `update_incident` reconstructed from the visible turn-2 arguments: it re-sent `severity: "SEV-1"` but **omitted `notify`** (it had no schema to tell it the field was required) and **re-used the stale `assignee_id`** (it had no schema to know the field was now an object, not a string, after a mid-session schema migration). The call was rejected by the executor with a validation error; the model, seeing no schema, then attempted to call a *different* tool (`create_incident`) to work around the rejection, producing a duplicate incident. The observable signature is a specific triad: (1) a tool call whose argument set is a strict subset or stale-variant of a prior call to the same tool, (2) a validation error naming a field the model "should" know about, and (3) a recovery attempt that routes to a sibling tool rather than re-requesting the pruned schema. ## Condition of Validity The argument-residency invariant holds under these conditions, and the failure is *not* expected outside them: - **Cross-turn argument retention is enabled.** If the runtime strips prior tool-call arguments when a tool is pruned, the ghost-tool failure cannot occur (though the model may still hallucinate a call from memory — a different, milder failure). - **The pruned tool is a mutation or state-changing tool.** Read-only tools pruned from schema residency tend to fail closed (the model asks for the tool back); mutation tools fail open (the model reconstructs and fires). - **Schema compression is lossy with respect to required fields or types.** If compression only removes descriptions and preserves all structural constraints, reconstructed calls validate and the failure is masked — until the next schema migration. - **The model has a prior successful call to the tool in context.** Without a prior call, there are no arguments to reconstruct from, and the model will typically request the tool rather than fabricate a call. The invariant does **not** require that the tool be high-frequency or high-priority; in fact the failure concentrates on *low-frequency, high-consequence* tools, which are precisely the ones frequency-based pruners demote first. ## Decision Boundary The operational rule is a two-part gate evaluated per tool per turn: 1. **Evict-or-tombstone.** If `argument_residency(tool) == True` and `schema_residency(tool) == False`, the pruner must either (a) evict the prior arguments along with the schema, or (b) insert a tombstone into the context: a one-line record stating the tool exists, its schema is withheld, and the model must request re-expansion before calling it. Tombstoning is preferred for mutation tools because it preserves the model's ability to *know it needs* the tool without letting it reconstruct a call. 2. **Budget arbitration order.** Argument-level context budgeting must be applied *before* schema pruning, not after. If the argument budget forces eviction of a prior call's arguments, that eviction must cascade to a schema-residency decision in the same turn. Pruning schema first and then trimming arguments (the common order, because schema is cheaper to trim) is the ordering that manufactures ghost tools. A schema may be freely pruned when `argument_residency == False`; this is the safe case and the only case where frequency-based pruning is sound without a tombstone. ## Operational Effect Implementing the evict-or-tombstone gate eliminated the ghost-tool triad in the observed session class: mutation-tool validation errors dropped, and the duplicate-incident workaround disappeared because the model, reading the tombstone, requested re-expansion instead of reconstructing. The cost is a small, bounded increase in prompt tokens for tombstones on pruned mutation tools — typically one line per pruned tool, far cheaper than the recovery turns the failure previously induced. The secondary effect is diagnostic: because the tombstone is an explicit context object, the runtime can log "model requested re-expansion of tombstoned tool" as a first-class signal, which is a direct measurement of pruner aggressiveness and a leading indicator of under-provisioned schema budget. The general lesson for context engineering: **schema and arguments are a coupled pair, and pruning one without the other is a type error in the context model.** Frequency is a property of calls, not of the coupling; a pruner that reasons only about frequency will systematically break the coupling on exactly the tools where breaking it is most expensive. ```python from dataclasses import dataclass from typing import Optional class GhostToolInvariantError(RuntimeError): """Raised when a tool's schema residency is false while its argument residency is true and no tombstone has been emitted.""" pass @dataclass class ToolContextState: name: str schema_resident: bool argument_resident: bool tombstoned: bool is_mutation: bool def enforce_evict_or_tombstone(state: ToolContextState) -> Optional[str]: """Return a tombstone string if one must be emitted, else None. Raises GhostToolInvariantError if the state is unsound and cannot be repaired by tombstoning (e.g. a mutation tool with no tombstone support requested). """ if state.schema_resident: return None if not state.argument_resident: # Safe prune: no arguments to reconstruct from. return None if state.tombstoned: return None if not state.is_mutation: # Read-only ghost tools fail closed; tombstone is advisory only. return ( f"[TOOL TOMBSTONE] '{state.name}' schema withheld; " f"request re-expansion before calling." ) # Mutation tool: schema gone, arguments present, no tombstone. # This is the unsound state. Repair by tombstoning; if the caller # cannot accept a tombstone, the invariant is violated. raise GhostToolInvariantError( f"Unsound context: mutation tool '{state.name}' has argument " f"residency but no schema residency and no tombstone. " f"Evict arguments or emit a tombstone before proceeding." ) def plan_turn_pruning(states: list[ToolContextState]) -> list[str]: tombstones: list[str] = [] for s in states: t = enforce_evict_or_tombstone(s) if t: tombstones.append(t) return tombstones ```