What happens when an AI agent loops: detection and recovery

The invoice is 31 pages long and lists 1,842 model calls for a single support conversation. The agent was asked to check whether an order had shipped. It called get_order, got a 404 because the customer had mistyped the order number, and then called get_order again. And again. The arguments were identical every time. The tool returned the same 404 every time. Forty minutes later the run hit a provider-side timeout, the customer got an apology, and the bill was real.

Nothing in that story is exotic. The agent didn't malfunction in a dramatic way. It did exactly what an agent does: observe a result, decide the task isn't done, choose an action. The failure was that "the task isn't done" and "the same action might work now" were never distinguished.

Loops are the characteristic failure mode of agentic systems, in the same way that a null pointer is characteristic of systems languages. They're not rare, they're not always obvious, and the cost is paid in tokens, latency, and side effects rather than in a stack trace. What makes them dangerous is that a loop looks like work from the outside: the trace is busy, tools are being called, tokens are being spent. Only the outcome is flat.

A loop is a state machine that stops making progress

The useful definition isn't "the agent called a tool twice." Retrying a flaky endpoint twice is correct behavior. A loop is a cycle in the agent's state graph that does not reduce the distance to the goal.

That framing gives you two things. First, it separates productive repetition from degenerate repetition. Second, it tells you what to measure: progress, not activity.

A practical definition you can implement:

A run is looping when, over a window of N steps, the set of distinct states visited stops growing while the step count keeps increasing.

A "state" here is a coarse hash of what the agent knows and what it has tried: the last tool call fingerprint, the task-relevant facts extracted from tool results, and the current plan step. If that hash repeats three times inside a window of six steps, you're in a cycle whether or not the text looks different.

The seven loop shapes

Loops don't all look alike, and the detection signal that catches one will miss another. These are the shapes worth instrumenting.

Shape What it looks like Cheapest signal
Identical repetition Same tool, same arguments, same result Fingerprint equality
Argument churn Same tool, slightly different arguments, same failure Fingerprint on normalized args + error code
Oscillation A → B → A → B between tools or sub-agents State hash cycle detection
Replanning New plan generated each turn, same steps executed Plan-step hash repetition
Retry storm Transient error → retry → same error at higher rate Retry count per error code
Delegation ping-pong Agent A hands to B, B hands back to A Handoff edge counts
Context collapse Summarize history → lose detail → re-fetch → summarize Context size sawtooth

Identical repetition

The simplest case, and the one most teams detect first because it's easy to see in a trace. The fingerprint is (tool_name, canonical_json(arguments)). If the same fingerprint appears three times with the same result hash, further calls cannot produce new information. There is no world in which the fourth call to get_order(order_id="A-1182") returns something different from the first three, unless the underlying data changed — and if it did, your tool result should tell the agent that.

Argument churn

Harder. The agent tries search_orders(query="Hossein"), then search_orders(query="Hossein "), then search_orders(query="hossein"). Three different fingerprints, one piece of information gained: nothing. The fix is to normalize arguments before hashing — trim whitespace, lowercase enumerated values, sort arrays, drop trace IDs and other noise — and to cluster by the tool plus the error code rather than exact equality. Three calls to the same tool returning no_results is a loop even if the query strings differ.

Oscillation

The agent calls lookup_customer, decides it needs the order, calls lookup_order, decides it needs the customer, calls lookup_customer again. Each individual call is reasonable. The sequence is a cycle. This is where state hashing earns its keep: the state after step 2 and the state after step 4 are identical in everything that matters, so the graph is provably cycling.

Oscillation is common when two tools return overlapping but inconsistent data, or when the agent's instructions say "verify the customer before acting" and "verify the order before acting" without saying which check subsumes the other. The model isn't confused; the policy is under-specified.

Replanning

Some agents re-plan on every turn: generate a plan, execute step one, observe, generate a new plan. If the planner has no memory of the previous plan, it will often produce the same plan. The trace shows varied reasoning text and identical actions, which is why token-level inspection doesn't catch it.

Detection: store a hash of the plan's ordered steps and count repeats. Recovery: pass the previous plan into the planner with an explicit instruction to revise rather than regenerate, or stop re-planning after the first two turns unless a step fails.

Retry storm

Distinct from a model loop, but usually reported as one. A tool returns a 429 or a timeout. The agent retries immediately, without backoff, because the retry policy lives in the prompt rather than in the client. The provider sees a burst, throttles harder, and the run fails after burning its budget.

This one belongs in the HTTP layer, not the agent loop. Retries need exponential backoff with jitter, a capped attempt count, and a clear signal to the model when the retries are exhausted — otherwise the model will simply try again at the agent level, which is the retry storm wearing a hat.

Delegation ping-pong

In multi-agent systems, a router hands a task to a specialist, the specialist decides it lacks context and hands back, the router re-routes to the same specialist. Each hop is cheap; the cycle is not. Track handoff edges as a directed graph and alert on any edge traversed more than twice in a run.

Context collapse

The agent's history grows, a summarizer trims it, the summary drops the detail the agent needed, the agent re-fetches it, the history grows again. The token count sawtooths and the agent re-derives the same facts forever. This shape is insidious because each step looks like sensible context management.

Detection: compare the number of distinct tool results retained across summarization boundaries. If the same document is retrieved three times in a run, summarization is destroying information faster than retrieval replaces it.

Why models loop in the first place

It's tempting to treat looping as a model defect. In practice, most loops are design defects that the model faithfully executes.

The agent has no memory of what it tried. Conversation history is not the same as an attempt log. A model reading a truncated history sees a tool result and a user request, not "I already tried this twice." If you want the model to avoid repeating itself, you have to tell it what it already did, compactly.

Success is never defined. "Find out why the customer's order is late" has no completion predicate. The agent can keep gathering evidence indefinitely. Agents with an explicit, checkable success condition loop far less, because the model has something to satisfy rather than something to approximate.

Errors invite retries. A tool that returns {"error": "internal error"} with a 200 status tells the model nothing about whether retrying is sensible. A tool that returns {"ok": false, "code": "not_found", "retryable": false} gives the model a reason to change strategy. Error design is loop prevention.

The toolset is too broad. An agent with fourteen tools and a vague goal will explore. Exploration is fine once; it's a loop when the exploration graph has cycles and no visited-set.

Forced tool use. Some configurations require a tool call on every turn. If the model has nothing left to call, it will call something again. That's a configuration that manufactures loops.

💡 Practical note: before adding loop detection, check whether your system prompt tells the model what to do when it's stuck. Many don't. "If a tool returns not_found twice for the same identifier, stop and ask the user to confirm the identifier" prevents a whole class of loops more cheaply than any runtime guard.

Detection at three layers

Loop detection belongs at three levels, because each catches what the others miss.

Layer 1: framework limits. Most agent frameworks ship a step cap. LangGraph exposes recursion_limit and raises GraphRecursionError when a graph exceeds it; the default was raised from 25 to 1000 steps in LangGraph 1.0.6, which is a good reminder to set it explicitly rather than inherit a default. The graph API also exposes RemainingSteps as a managed value so a node can degrade gracefully before the limit is hit. The OpenAI Agents SDK counts agent-loop turns and raises MaxTurnsExceeded when max_turns is exceeded. These are necessary but blunt: they stop the run, they don't tell you why it was looping, and a limit high enough for your longest legitimate task is high enough to spend real money before it fires.

Layer 2: in-process progress guard. This is where you get signal. A small guard object observes every step and decides whether the run is making progress. It has three jobs: fingerprint steps, maintain budgets, and emit a structured loop_detected event with enough detail to debug.

Layer 3: platform-level alarms. Aggregate spend per conversation, p99 steps per run, and tool-call counts per task type. A single looping run is annoying; a prompt change that makes 8% of runs loop is an incident. Aggregate metrics catch the second case, which in-process guards can't.

Here's a guard that's small enough to reason about:

import hashlib, json, time
from collections import Counter, deque
from dataclasses import dataclass, field

def canonical(obj) -> str:
    if isinstance(obj, dict):
        return json.dumps({k: obj[k] for k in sorted(obj)}, separators=(",", ":"), default=str)
    return json.dumps(obj, separators=(",", ":"), default=str)

def fingerprint(tool: str, args: dict, result_code: str) -> str:
    norm = {
        "tool": tool,
        "args": args,
        "code": result_code,
    }
    return hashlib.sha256(canonical(norm).encode()).hexdigest()[:16]

@dataclass
class LoopGuard:
    max_steps: int = 24
    max_tokens: int = 180_000
    max_seconds: float = 90.0
    window: int = 6
    repeat_threshold: int = 3
    steps: int = 0
    tokens: int = 0
    started: float = field(default_factory=time.monotonic)
    recent: deque = field(default_factory=lambda: deque(maxlen=6))
    counts: Counter = field(default_factory=Counter)

    def observe(self, tool: str, args: dict, result_code: str, tokens: int) -> str | None:
        self.steps += 1
        self.tokens += tokens
        fp = fingerprint(tool, args, result_code)
        self.recent.append(fp)
        self.counts[fp] += 1

        if self.counts[fp] >= self.repeat_threshold:
            return "repeat"
        if len(self.recent) == self.window and len(set(self.recent)) <= 2:
            return "oscillation"
        if self.steps >= self.max_steps:
            return "step_budget"
        if self.tokens >= self.max_tokens:
            return "token_budget"
        if time.monotonic() - self.started >= self.max_seconds:
            return "time_budget"
        return None

Three details in that code matter more than the code itself.

The fingerprint includes the result code, not the full result. Two calls to get_order that both return not_found are the same state. Two calls that return different orders are progress, even with identical arguments — which can happen legitimately if the agent is polling a job.

The window check catches oscillation, where no single fingerprint repeats enough times to trip the counter but the set of states stops growing. Two distinct states alternating forever is a classic pattern, and a per-fingerprint counter alone will miss it.

Budgets are checked on every step, not at the end. A guard that only reports after the fact is a receipt, not a guard.

Recovery: break the cycle without breaking the task

Detection is the easy half. Recovery is where designs diverge, and where a bad choice turns a loop into a wrong answer.

There are five recovery strategies, and they differ in how much they trust the model to recover.

1. Inject the attempt history and ask for a different approach. Cheapest, most likely to preserve the task. Append a system-level note: "You have called get_order three times with order ID A-1182; each returned not_found. Do not call it again with the same identifier. Either ask the user to confirm the identifier or use search_orders by email." This works surprisingly often because the model genuinely didn't have that context.

2. Constrain the toolset. Remove the looping tool from the available tools for the rest of the run, or set the tool choice to "none" for one turn and require a text answer. This forces a decision. It's useful for "the agent must respond now" cases and terrible for tasks that genuinely need more tool use.

3. Force a final answer from what's known. If the run has gathered enough to be useful, end it with a structured summary plus an explicit statement of what couldn't be determined. The output should say "I couldn't find order A-1182" rather than pretending success.

4. Fall back to a deterministic path. For support and operations workflows, there is usually a non-AI path: a form, a search page, a queue for a human. Breaking a loop by routing to that path is often better than any amount of model cajoling.

5. Escalate to a human with the trace. The right choice when the loop is a symptom of ambiguity the system can't resolve: conflicting records, a policy exception, a customer asking for something outside the toolset.

The decision between them is mostly about reversibility and confidence:

Situation Strategy Why
Loop before any side effect, tools still useful Inject history, continue Preserves the task
Loop after partial progress, answer is derivable Force final answer with caveats User gets something true
Loop on a write tool Stop, do not retry, escalate Repeated writes are dangerous
Loop caused by missing data Deterministic fallback Model can't invent the data
Loop caused by policy ambiguity Human handoff Needs a decision, not a guess

Recovery must be safe before it is smart

The most expensive bug in loop recovery is a recovery path that re-executes a side effect. If the agent called create_refund twice and your loop breaker restarts the run from the top, you have now issued three refunds and a support ticket.

Two rules prevent this.

Never restart a run that has committed side effects. Resume from the last checkpoint with the side-effect ledger intact, or terminate. A restart is only safe when the run is provably read-only so far.

Make writes idempotent at the tool boundary. A refund tool should take an idempotency key derived from the run and the business object, not from the model's arguments. Then a duplicate call returns the original result instead of creating a second refund. This is the same discipline you'd apply to any retrying client, and it's the only thing standing between a loop and a duplicate charge.

def create_refund(order_id: str, amount_cents: int, *, run_id: str) -> dict:
    key = f"refund:{order_id}:{run_id}"
    existing = refunds.find_by_idempotency_key(key)
    if existing:
        return {"ok": True, "refund_id": existing.id, "deduplicated": True}
    ...

Note that the key uses run_id, not the amount. If the model hallucinates a different amount on the second attempt and the key includes the amount, you get two refunds with different values — the worst possible outcome.

Checkpoint, don't restart

Frameworks with durable execution let you resume a run from the last completed step. That's the right primitive for loop recovery: when the guard trips, persist the state, terminate the current attempt, and either resume with a modified instruction or hand off.

The practical requirements for resume to work:

  • State is serializable, including the attempt log and the side-effect ledger.
  • Tool results are recorded, so a resumed run doesn't re-call read tools for data it already has.
  • The resume path is a different code path from the initial path, with its own budget.
  • Resumed runs carry a resumed_from reference so the support view can show one logical task with multiple attempts.

Without these, "resume" is just "retry the whole thing," which is how a loop becomes a loop with a larger budget.

What to log so the postmortem is possible

When a loop happens in production, the trace needs to answer why, not just that it happened. Log these per step:

  • step index and elapsed time;
  • tool name, canonical arguments, and a hash of the raw arguments;
  • result status, error code, and a hash of the result body;
  • the model's stated reason for the action, if it produces one;
  • the current plan step, if the agent has a plan;
  • tokens consumed this step and cumulatively;
  • the guard's current verdict, even when it's ok.

Then one event at the end with the guard's decision, the strategy used, and the final status. If your loop detection is working, this event should be queryable as loop_detected with a shape field matching the taxonomy above. That field is what lets you say "we have a replanning problem" instead of "the agent is weird sometimes."

The arithmetic that justifies the guard

Loop detection can feel like defensive engineering for a rare event until you price it. Suppose a support agent costs $0.004 per step in input and output tokens at current mid-tier pricing, and 2% of 20,000 daily conversations loop for an average of 25 extra steps before something stops them. That's 400 conversations × 25 steps × $0.004, or $40 a day, plus the latency, plus the support tickets from users who waited ninety seconds for an apology. At 1,000 conversations a day it's noise. At 100,000 it's a line item someone asks about in a budget review.

The picture gets worse when tools have costs of their own. A loop over a search API bills per query. A loop over a mapping service bills per lookup. A loop that sends email is not a cost problem at all; it's a reputational one. The guard exists because the marginal cost of one extra step is not the marginal cost of the hundredth.

There's also a latency budget argument. Users tolerate a slow answer and they don't tolerate an answer that arrives after they've given up. A loop spends the entire budget of the request on work that will be discarded, so the useful part of the run — the eventual fallback or handoff — starts with nothing left. Detecting at step 8 instead of step 40 doesn't just save tokens; it leaves time to do something useful with the remaining request.

Tuning thresholds without punishing legitimate work

Every loop threshold has a false positive cost. Set them too tight and you break long tasks that are genuinely progressing. Set them too loose and you pay for the loops.

Three techniques keep the tuning honest.

Measure on successful runs first. Take a week of runs that ended with a good outcome and compute the distribution of steps, distinct tools, and repeat counts. Your step budget should sit above the p99 of successful runs, not at a guess. If successful runs use 30 steps at p99, a limit of 12 is a bug.

Distinguish "same call" from "same call with the same result." A retry after a 503 that now returns 200 is progress. Only count fingerprints that include the result code, and only trip when the result also repeats.

Segment by task type. A research task legitimately revisits sources; a refund task should never call the same write tool twice. Different budgets per task type are worth the configuration complexity, because one global limit will either hamstring research or under-protect writes.

A useful default for a support-style agent: max 20 steps, max 3 identical (tool, args, result_code) fingerprints, and a 60-second wall clock. For a research agent: 40 steps, 5 repeats, 180 seconds. Both should have a token cap that's a hard number the business signed off on.

Designing loops out instead of catching them

The best loop detection is a design that doesn't produce loops. Five changes remove most of them.

Give every task a completion predicate. "Resolve the customer's shipping question" is not checkable. "Determine whether order X has shipped and by what date, or report that it cannot be determined" is. The second tells the agent when to stop.

Return stop signals from tools. A tool result should say whether the outcome is final. {"ok": false, "code": "not_found", "retryable": false, "final": true} is a stronger instruction than any sentence in a system prompt, because it's attached to the observation the model is reasoning about.

Keep the toolset small and non-overlapping. Two tools that both look up orders will be used interchangeably, and the agent will oscillate between them. If you need a second one, make the description state exactly when it applies.

Pass the attempt log, not just the transcript. A compact list of "tried X, got Y" is more useful to a model than ten thousand tokens of raw history, and it's cheaper.

Separate planning from execution. Plan once, execute steps, replan only on failure with the failed step and its error in context. This removes the replanning loop entirely, at the cost of some flexibility on tasks that genuinely need dynamic planning.

When a loop is actually the right behavior

Not every repeat is a bug. Polling a job status every two seconds for up to a minute is a loop by any graph definition and is exactly what you want. Waiting for a human approval and re-checking is a loop. Retrying a rate-limited call with backoff is a loop.

The distinguishing feature is whether each iteration can plausibly change the outcome and whether the iteration has a bound that maps to a real-world constraint. Polling has a natural bound: the job finishes or the SLA expires. Retrying has a natural bound: the provider's retry budget. Degenerate loops have no bound except the one you impose.

That's why loop detection should be phrased as "no new information within a budget" rather than "repeated action." The first catches pathology. The second catches polling, retries, and every waiter ever written.

A recovery runbook you can hand to on-call

  1. Confirm the shape from the trace: identical, churn, oscillation, replanning, retry storm, delegation, or context collapse.
  2. Check whether any write tool committed before the loop started. If yes, freeze the run and reconcile side effects before anything else.
  3. Check whether the loop is confined to one run or spreading. More than a handful per hour means a prompt, tool description, or upstream dependency changed.
  4. If a single run: terminate it, verify the side-effect ledger, and pick a recovery strategy from the table above.
  5. If spreading: roll back the prompt or tool definition to the last known good version, then investigate with the loop_detected events grouped by shape and prompt_version.
  6. Add the failing case to the eval set with an explicit expectation about the maximum number of tool calls.
  7. Update the guard's thresholds only after you know the distribution of the good runs.

Loops aren't a sign that the model is broken. They're a sign that the agent's definition of progress was left implicit, and the model filled the gap with persistence. Persistence is usually a virtue. In an agent, it needs a boundary.

Post a Comment

Previous Post Next Post