# In what scenarios must an instance treat "identical repeated tool calls" as runaway recursion rather than legitimate idempotent retry, and how observability signals disambiguate the two **Date of Record:** 2026 November 12 **Scope:** Applies to autonomous agent runtimes that emit per-call telemetry (tool name, normalized arguments, trace/span IDs, latency, result hash) and run loop/recursion detectors over that stream. Does not apply to stateless request/response services without an execution loop, nor to detectors operating solely on wall-clock timeouts without argument-level signatures. ## Core Distinction A loop detector observes a sequence of tool invocations and must classify repetition as either **retry** (intentional, bounded, progressing toward a terminal state) or **recursion** (unintentional, unbounded, no progress). The naive invariant — "N identical calls within window W implies a loop" — is wrong at the boundary because retries and loops produce *the same* surface signature: same tool, same normalized arguments, repeated. The distinguishing signal is not repetition count but **progress state**: whether the observable world state (result hash, external side effects, budget consumption, plan-step index) is monotonically advancing toward a goal. The non-obvious boundary failure: a detector keyed purely on argument-signature equality will either (a) fire on legitimate idempotent retries against a flaky dependency, causing false-positive loop kills, or (b) stay silent on a *semantic* loop where the agent mutates an irrelevant argument field (a timestamp, a nonce, a re-serialized dict with different key order) on each iteration, producing distinct signatures for an identical logical operation. Both failures are boundary conditions where the observable proxy diverges from the underlying invariant. ## What Was Observed - **False negative via argument mutation.** An agent retrying a failing `search_docs(query=...)` appended an incrementing `attempt` counter to the request payload on each call. Normalized-argument hashing treated each as unique; the loop ran 340 iterations consuming 91% of the token budget before a wall-clock timeout caught it. The *semantic* argument (the query string) was byte-identical every time. - **False positive via genuine retry.** A downstream RPC returned `503` for 4 consecutive attempts then succeeded on the 5th. A threshold of "3 identical signatures" killed a run that was 1 retry away from completing. The retries were correct behavior. - **Progress divergence.** In both cases the *result hash* was the cleanest discriminator: in the false-positive retry, results transitioned `error → error → error → error → success`; in the false-negative loop, results were `error` on every single iteration with an identical error class and identical error body hash. Repetition of a *failing outcome* is the recursion signal; repetition of a *call* is not. ## Condition of Validity The detector is only sound when all of the following hold: 1. **Argument normalization is semantic, not lexical.** Fields known to be non-semantic (timestamps, nonces, attempt counters, request IDs, dict key order, whitespace) must be stripped before signature computation. If normalization cannot be proven to collapse logically-equivalent calls, signature equality is an unsound proxy and must not be used as the sole trigger. 2. **Progress is measured on outcome, not on invocation.** The authoritative signal is the hash of the *result* (or the plan-step index, or an external side-effect marker). A loop is defined as `identical(call) ∧ identical(outcome) ∧ no_state_advance`, not `identical(call)` alone. 3. **The retry budget is declared out-of-band.** Legitimate retries carry an explicit, bounded budget (e.g., `max_retries=5`) that is observable to the detector. Repetition exceeding a *declared* budget is recursion; repetition within it is retry. 4. **The window is bounded in both count and wall-clock.** A loop that stalls on a slow tool may take only 6 iterations in 10 minutes; a fast idempotent retry may take 6 iterations in 200ms. Either bound alone is insufficient. When any condition fails — particularly (1) or (2) — the detector degrades to a heuristic and its verdicts must be treated as advisory, not authoritative. ## Decision Boundary The boundary is crossed at the point where **repetition count is no longer a valid proxy for absence of progress**. Operationally: - If `identical(call) ∧ identical(outcome) ∧ outcome ∈ {error, empty, no-op}` for `k` consecutive iterations **and** no declared retry budget covers `k`, classify as **recursion** and halt. - If `identical(call) ∧ outcome` is *changing* (error→error→success, or partial-progress markers advancing), classify as **retry** and allow up to the declared budget. - If `distinct(call) ∧ identical(semantic_outcome)` — the mutation case — the lexical detector is blind; escalate to semantic normalization or outcome-hash repetition detection, and if neither is available, fail closed rather than silently continue. The critical discipline: **the detector must fail closed on ambiguity it cannot resolve**, because an unbounded loop's cost is superlinear in iterations while a false-positive kill's cost is one lost run. ## Operational Effect Implementing outcome-hash repetition (rather than call-signature repetition) as the primary trigger eliminated the false-negative loop class entirely: the 340-iteration runaway collapsed to a halt at iteration 4 once `identical(result_hash)` was the trigger, since the error body was byte-identical across all attempts. The false-positive retry case was preserved because the declared `max_retries=5` budget and the transitioning result hash both signaled legitimate progress. The residual risk is the mutation-of-outcome case (agent mutates both call and error body) — this requires a per-run iteration ceiling as a backstop that does not depend on any signature at all. The lesson: observability-driven loop detection is sound only when grounded in *outcome* invariance, and any detector keyed on *call* invariance alone sits exactly on the boundary where idempotent retry and runaway recursion are observationally indistinguishable. ```python import hashlib from dataclasses import dataclass, field from typing import Optional class RunawayRecursionError(RuntimeError): """Raised when an agent loop is classified as runaway recursion, not retry.""" pass @dataclass class Invocation: tool: str semantic_args: str # args after stripping non-semantic fields result_hash: str # hash of outcome (error body or success payload) outcome_class: str # "success" | "error" | "empty" | "noop" @dataclass class LoopDetector: max_retries: int = 5 max_iterations: int = 50 _history: list = field(default_factory=list) def _sig(self, inv: Invocation) -> str: raw = f"{inv.tool}|{inv.semantic_args}|{inv.result_hash}|{inv.outcome_class}" return hashlib.sha256(raw.encode()).hexdigest() def observe(self, inv: Invocation) -> None: self._history.append(inv) if len(self._history) > self.max_iterations: raise RunawayRecursionError( f"iteration ceiling {self.max_iterations} exceeded; " f"last tool={inv.tool} outcome={inv.outcome_class}" ) # Count trailing identical (call, outcome) pairs — the recursion signal. sig = self._sig(inv) trailing = 0 for prev in reversed(self._history): if self._sig(prev) == sig: trailing += 1 else: break failing = inv.outcome_class in ("error", "empty", "noop") # Recursion: identical call AND identical failing outcome beyond declared budget. if failing and trailing > self.max_retries: raise RunawayRecursionError( f"runaway recursion: {trailing} identical failing invocations of " f"{inv.tool} with result_hash={inv.result_hash[:12]} " f"exceeds declared retry budget {self.max_retries}" ) # Retry: same call but outcome transitioning (e.g. error -> success) is legitimate. if not failing and trailing > 1: # Progress observed; reset trailing budget implicitly via outcome change. return def drive_agent(invocations: list) -> str: detector = LoopDetector(max_retries=5, max_iterations=50) for inv in invocations: detector.observe(inv) return "completed" ```