Engineering research / agents

Detecting Stuck Agent Loops: Repeated Tool Calls, State Fingerprints, Progress Signals and Step Budgets

Why framework turn limits catch agent loops late and expensively, the shapes stuck runs take, how to fingerprint calls and results to detect repeats and oscillations, how a progress signal catches paraphrase loops, and what to do when a detector fires.

By Sandeep Belgavi · Published 2026-11-01

Agent and AI engineering · Practical technical guide · 1 November 2026

Why framework turn limits catch agent loops late and expensively, the shapes stuck runs take, how to fingerprint calls and results to detect repeats and oscillations, how a progress signal catches paraphrase loops, and what to do when a detector fires.

A run that never fails

An agent at Belgavi's Book Store is asked why Jenya's return for CASE-6012 shows no refund. It searches the orders table, finds nothing it recognises, rephrases the search, finds nothing again, and keeps rephrasing. No tool call fails. No exception is raised. The run ends only when the framework's turn limit stops it, thirty model calls later, with an apologetic answer that could have been given after three.

Stuck loops are the agent failure that monitoring sees least, because every individual step looks healthy. They cost tokens and latency, they hold locks and rate-limit capacity, and when the looped call has side effects, such as sending a message or opening a case, they do damage.

What the frameworks give you

Every agent framework has a hard stop, and every one is a budget, not a detector. The OpenAI Agents SDK raises MaxTurnsExceeded when a run exceeds max_turns; the default in version 0.24.0 is 10, and max_turns=None disables it. LangGraph raises GraphRecursionError when a graph exceeds its recursion limit in super-steps; its documentation gives a default of 10,007 steps from version 1.1.4, and 10,000 between 1.0.6 and 1.1.3. A limit high enough not to cut off legitimate long runs is far too high to catch a loop cheaply, and a limit low enough to catch loops cheaply cuts off legitimate long runs. That is the trade-off a detector avoids, by looking at what the steps are rather than how many there are.

The shapes a stuck run takes

ShapeWhat repeatsCaught by
Exact repeatSame tool, same arguments, same resultCall fingerprint
OscillationA, B, A, B: two calls alternatingCall fingerprint over a window
Paraphrase loopArguments change, result does notResult fingerprint, or a progress signal
Re-plan loopThe plan changes, task state does notProgress signal
Legitimate pollingSame call, same result, until a job finishesMust be exempted, or it is a false alarm

The last row is the reason naive repeat detection fails. A run that checks a job status every few seconds produces exactly the pattern of an exact-repeat loop until the job finishes. Any detector needs to know which tools are polling tools, and those need their own bound, in time or attempts, enforced by the tool.

Fingerprints of calls and results

Illustrative python, checked for syntax; adapt names and storage to your system.

import hashlib, json
from collections import deque

def fp(obj):
    return hashlib.sha256(json.dumps(obj, sort_keys=True, separators=(',', ':')).encode()).hexdigest()[:16]

class LoopDetector:
    def __init__(self, repeats=3, window=8, no_progress=8, polling=frozenset()):
        self.repeats, self.no_progress, self.polling = repeats, no_progress, polling
        self.recent = deque(maxlen=window)
        self.since_progress = 0

    def observe(self, tool, args, result, state):
        """Return a reason to stop, or None. state: the task fields that count as progress."""
        call, res, st = fp([tool, args]), fp([tool, result]), fp(state)
        progressed = not self.recent or st != self.recent[-1][2]
        self.since_progress = 0 if progressed else self.since_progress + 1
        self.recent.append((call, res, st, tool in self.polling))
        if tool in self.polling:
            return None                       # bounded by the polling tool itself
        same_call = sum(1 for c, r, s, poll in self.recent if c == call and r == res)
        same_result = sum(1 for c, r, s, poll in self.recent if r == res and not poll)
        if same_call >= self.repeats:
            return 'same call and result %d times' % same_call
        if not progressed and same_result >= self.repeats:
            return 'same result %d times without progress' % same_result
        if self.since_progress >= self.no_progress:
            return 'no progress for %d steps' % self.since_progress
        return None

Two details matter. Arguments must be normalised before hashing, with sorted keys and canonical numbers, or a model that reorders JSON fields defeats the detector. And the state passed in must be the task state that counts as progress, such as the case status, the documents retrieved and the actions taken, not the whole conversation, which changes on every step by definition. The companion example in agent-decision-lab runs this detector against recorded synthetic runs and checks each shape.

A worked comparison with the companion lab

The lab generates 40 synthetic runs: about half healthy, some polling a job status for 3 to 9 calls, and 12 stuck in exact repeats, oscillations or paraphrase loops. The step budget is 30.

DetectorLoops caught earlyWorking runs stoppedSteps spent inside loops
Step budget only0 of 120319
Same tool and arguments, 3 in 8 steps10 of 12095
Same call or same result without progress12 of 12048
No progress for 8 steps12 of 12091
Same call or result, polling not exempted12 of 12748

The argument-only detector misses the paraphrase loops, which change their arguments every time. The progress-only detector catches everything but later, after eight idle steps instead of about four. Removing the polling exemption turns the best detector into one that stops seven working runs. These are properties of the synthetic fixtures, chosen to show the mechanisms; your own runs will have different proportions, so replay recorded traces through any detector before enforcing it.

What to do when a detector fires

  1. Tell the model, once. Add a short message saying which call repeated and that its result will not change. Many loops end here, because the model was missing exactly that observation.
  2. Restrict, then stop. If it repeats again, remove the looping tool for the rest of the run or force a final answer without tools.
  3. Return a partial result honestly. "I could not find order details for CASE-6012; a person will check" is a better outcome than thirty calls.
  4. Escalate side-effecting loops at once. A repeated send or create is not a quality problem. Stop it on the second repeat and rely on idempotency keys so the repeats that happened did nothing.
  5. Record the fingerprints. Store the reason, the repeated fingerprint and the step number with the run, so loops become a metric and a regression test.

Measuring and testing detectors

  • Replay a week of recorded runs through the detector offline and count would-be stops per tool, before enforcing anything.
  • Label a sample of the stops: true loops, legitimate repetition, and runs that were near their answer. That ratio, not the loop count, decides the thresholds.
  • Add each new loop shape you find to an evaluation set with an expected outcome, such as stopped within five steps with a partial answer.
  • Track steps per successful run and loop stops per thousand runs as release metrics; a prompt change that doubles either is a regression even if task success holds.

Failure modes

  • Volatile results. Results that include timestamps or request identifiers never repeat. Strip volatile fields before hashing.
  • A progress signal that always moves. If "state" includes a step counter or the latest message, the progress detector never fires.
  • Polling without its own bound. Exempting a polling tool from the detector moves the risk into the tool; give it a deadline.
  • Thresholds tuned on one model. A model upgrade changes how runs look. Re-run the offline replay on every model change.

Sources and scope

Checked against these sources on 1 November 2026. Framework defaults were read from the OpenAI Agents SDK v0.24.0 source and the LangGraph documentation. Run counts and step totals come from the synthetic lab fixtures, not from production traffic.

Turn limits stop loops late; detectors stop them early. Fingerprint normalised calls and results over a window, track a task-state progress signal for paraphrase loops, exempt polling tools that carry their own bound, and replay recorded runs before enforcing thresholds.

Views are my own and do not represent any employer, past or present.