Section F · Production and debugging

The Agent That Loops Forever

A live-debugging round: an agent that never terminates on some inputs. How to find the cause from traces and how to make the loop impossible, not just unlikely.

The question and what it scores

"Harbourline's claims-intake agent never finishes on about one claim in two hundred. It burns tokens until something upstream kills it. Walk me through finding the cause and making sure it cannot happen again."

This is a live-debugging round, so the interviewer scores process more than recall. Do you ask for traces before theorising? Do you know the handful of ways agent loops actually form? And does your fix make the loop impossible, or only less likely? "Tell the model to stop after three tries" is a less-likely fix, and it gets a polite nod and a follow-up. A step cap enforced in code is an impossible fix, and that is the difference they are listening for.

Six ways an agent loops

An agent is a loop: the model picks an action, code runs it, the result goes back into context, repeat until the model says it is done. Everything that goes wrong comes from the fact that "done" is a decision the model makes, not the code. Anthropic's agent guidance says as much when it recommends stopping conditions, such as a maximum number of iterations, to keep control. These are the six causes that account for nearly every runaway.

  1. No termination condition outside the model. The loop ends only when the model emits a final answer. If it never does, nothing else ends it.
  2. An error the model tries to fix by repeating. The policy lookup returns timeout. The model reasons that it should try again, and the same call comes back with the same error. Each pass looks reasonable alone.
  3. State not updated between steps. A tool that writes (say, "request missing photos") returns a success message that does not change what the next read tool shows. The model sees "photos still missing" and requests them again.
  4. Two tools undoing each other. One normalises the address to UK format, another corrects it to the policy's US format. Each step is a valid response to the last.
  5. The same plan, reworded. The model writes "I will check coverage" in fifteen phrasings. The action hashes differ in the arguments' free-text fields while the intent is identical, which defeats naive duplicate detection.
  6. Context growth until the goal is lost. Every step appends a tool result. By step 40 the original instruction is a tiny fraction of the window and a long way back, and the model starts pursuing a sub-task it found along the way. This one is slow and often shows up as drift before it shows up as a loop.

Note what separates them. Causes 2 to 4 are environment bugs, so a smarter model does not fix them. Cause 5 is a model behaviour that detection must see through. Cause 1 is the missing guard that lets any of the others run forever. That is why the answer has a diagnosis half and a guard half.

Debug from traces, not from the prompt

Ask for the traces of the stuck runs before reading any prompt. Every step should log: run id, step number, tool name, arguments, result status, input and output tokens, and elapsed time. Then compute three signals per run.

  • Step counter. The distribution of steps per run. Healthy intake runs for Harbourline might finish in 4 to 9 steps. The stuck ones sit at the cap, or past it.
  • Repeated (tool, args) hash. Hash the tool name and canonicalised arguments. A count of three or more on the same hash is a loop of causes 2 or 3. If the hash never repeats yet the tool names cycle in a pattern A, B, A, B, you have cause 4.
  • Token growth per step. Plot input tokens by step. A line climbing steadily while the work stays flat is cause 6, or a tool returning a huge payload each time.

Then group the stuck runs by what they share: the same tool, the same error string, the same claim type. Six hundred failures with one cause is a bug in one tool. If they are spread across tools, suspect the missing guard and the context. Reproduce one stuck run from its recorded tool results, since replaying recorded results removes the live environment from the question.

The first question is "what did the tool say?"

Most loops are a tool result the model could not act on. Read the last three tool results in a stuck trace before you touch the prompt. If they are identical errors, you have found it.

Fixes that make the loop impossible

Fixes come in two kinds: those that remove causes and those that bound the damage. You need both, and the bounds go first because they work whatever the cause turns out to be.

Bounds, enforced in code

  • A hard step cap and a hard token cap per run. The cap lives in the harness, not the prompt. The Claude Agent SDK exposes max_turns and max_budget_usd as options, and a run that hits one ends with a result subtype of error_max_turns or error_max_budget_usd. The docs note the cost estimate can overshoot the budget, so leave headroom. Choose the cap from the trace distribution: if healthy runs finish in 9 steps, a cap of 15 to 20 stops waste and rarely cuts a real run.
  • Loop detection on action hashes. Stop, or inject a "you already tried this" message and then stop, when the same hash appears more than twice.
  • A wall-clock timeout on the whole run and on each tool call. Token caps do not catch a tool that hangs.
  • A per-step budget. Truncate tool results to a sensible size, so one step cannot add 80,000 tokens.

Causes, removed at the source

  • Tool errors are classified. A retryable error (timeout) is retried by the tool wrapper with backoff, once, not by the model. A terminal error (policy_not_found) ends the run in a named state, failed, with the reason attached, and the claim goes to a human. The model is never asked to decide whether to keep banging on a closed door. Anthropic's guidance on tool design applies here: change the tool so mistakes are harder to make.
  • Idempotent tools. A repeated "request missing photos" returns the existing request instead of sending a second email. Repetition then costs a step and nothing else. See the same idea for services in chapter 13.
  • State the model can see. A write tool returns the new state, not "ok", so the next step starts from the changed world.
  • Progress checks. Every N steps, compare the set of facts gathered so far to the last check. No new facts in 3 steps means stop, or ask for human help.
  • Compact the context. Summarise old tool results and re-state the task at the end of the context, so the goal does not drift out of reach.

The harness below shows the bounds and the terminal-error rule in about fifty lines. It uses a fake model so it runs as written.

loop_guard.py
import hashlib
import json
from collections import Counter


class Stop(Exception):
    """Raised by the guard. The run ends in a named terminal state."""

    def __init__(self, reason):
        super().__init__(reason)
        self.reason = reason


class Guard:
    def __init__(self, max_steps=12, max_tokens=60_000, max_repeats=2):
        self.max_steps, self.max_tokens, self.max_repeats = max_steps, max_tokens, max_repeats
        self.steps, self.tokens, self.seen = 0, 0, Counter()

    def check(self, tool, args, tokens_used):
        self.steps += 1
        self.tokens += tokens_used
        if self.steps > self.max_steps:
            raise Stop("max_steps")
        if self.tokens > self.max_tokens:
            raise Stop("max_tokens")
        key = hashlib.sha256(json.dumps([tool, args], sort_keys=True).encode()).hexdigest()
        self.seen[key] += 1
        if self.seen[key] > self.max_repeats:
            raise Stop(f"repeated_call:{tool}")


def run(model_step, tools, task):
    """model_step(history) -> {"tool", "args", "tokens"} or {"final": str}"""
    guard, history = Guard(), [task]
    try:
        while True:
            step = model_step(history)
            if "final" in step:
                return {"state": "done", "answer": step["final"], "steps": guard.steps}
            guard.check(step["tool"], step["args"], step["tokens"])
            result = tools[step["tool"]](**step["args"])
            if result.get("terminal"):               # tool error the model cannot fix
                return {"state": "failed", "reason": result["error"], "steps": guard.steps}
            history.append(result)
    except Stop as s:
        return {"state": "stopped", "reason": s.reason, "steps": guard.steps}


if __name__ == "__main__":
    # a model that keeps asking for a policy the lookup tool cannot find
    always_same = lambda h: {"tool": "lookup_policy", "args": {"policy_id": "HX-001"}, "tokens": 900}
    flaky = lambda policy_id: {"error": "timeout"}                      # retryable, but never succeeds
    print(run(always_same, {"lookup_policy": flaky}, "check claim C-9"))
    missing = lambda policy_id: {"error": "policy_not_found", "terminal": True}
    print(run(always_same, {"lookup_policy": missing}, "check claim C-9"))

The two demo runs end in different named states. The first, a flaky tool the model keeps retrying, is stopped on the third identical call. The second, a missing policy, fails at step one because the tool declared the error terminal. Both produce an outcome the claims workflow can route, which is the point: a stuck run is turned into a result.

The worked answer

About 90 seconds aloud

"First I'd pull traces for the stuck runs and compute steps per run, repeated tool-and-argument hashes, and tokens per step. That tells me which of six causes I'm in: no outside termination, a retry on an error the model can't fix, state that doesn't change between steps, two tools fighting, the same plan reworded, or a context that has swallowed the goal. I'd read the last three tool results of a stuck run, because it is usually a tool error. Then I'd fix in two layers. Bounds in code: a step cap and token cap from the healthy distribution, loop detection on action hashes, a wall-clock timeout. Then causes: classify tool errors so terminal ones end the run with a reason and a human handoff, make write tools idempotent, return new state from writes. Every stuck run ends in a named state the workflow can route. I'd alert on cap hits, since a cap that fires often is a bug report. [In my own project this surfaced as X.]"

Name the trade-offs. A tight cap saves money and sometimes cuts a legitimate long claim, so route cap hits to a person, never a silent retry. Loop detection by exact hash misses reworded plans, so pair it with the progress check. Compaction saves tokens and can drop a detail the agent needs later. The recommendation flips for open-ended research agents, where long runs are normal: there the cap is a budget in dollars, set by the task, with a checkpoint for human approval at intervals. The related design question, whether to use an agent at all, is in chapter 04, and the budget angle is in chapter 05.

Interview probes

Q1: "Why not just tell the model in the system prompt to stop after three failed attempts?"

"Because the prompt is a request and the harness is a guarantee. The model has to count its own attempts across a growing context, and the loop is exactly the situation where it has lost track. I'd still keep the sentence in the prompt, since it cuts the rate of loops. But the limit that matters is the step cap in code, which holds when the model is confused."

Q2: "How do you set the step cap?"

"From the data. I take the step counts of successful runs, look at the 99th percentile, and set the cap a little above it. If successful intake runs top out near 9 steps, 15 or 20 is a cap that almost never cuts a good run and stops a bad one early. Then I watch the cap-hit rate. If it climbs, either the task changed or a tool broke, and both are worth knowing."

Q3: "The hashes never repeat, but the run still goes on forever. What now?"

"Then the arguments differ in a free-text field, or the cycle is across tools. I'd canonicalise the arguments to the fields that matter, such as the claim and policy ids, and drop the model's free-text reasoning from the key. I'd also look for a repeating tool-name sequence. And I'd add the progress check: if three steps add no new facts to the working state, stop. That works whatever the surface form of the actions."

Q4: "When the cap fires, what does the user or workflow see?"

"A terminal state with a reason, not a timeout. The claim moves to an adjuster queue with the trace link, the steps taken and the reason, say max steps or repeated call. For a customer-facing chat I'd give a short honest message and a handoff, not another attempt. Partial work is kept, so the human does not start over."

Q5: "Should the model retry a failed tool call, or the code?"

"The code, for transient errors, once or twice with backoff, invisible to the model. The model sees only the final outcome. For errors that will not heal, the tool says so with a terminal flag. If I leave retries to the model, every retry costs a full model call and context, and the model has no reliable way to tell a transient error from a permanent one."

Q6: "How would you catch this class of bug before production?"

"Replay tests with fault injection: run the agent against recorded cases where I make one tool time out, return an empty result, or return the same error forever, and assert the run ends in a named state within the cap. I'd add the stuck production traces to that suite. Chapter 08 covers evaluating the path an agent took and not only its answer, which is where these checks belong."

✓ Check yourself

Answer from memory before opening each one.

  1. A trace shows the agent calling request_photos five times for one claim, each time with a slightly different message text, and each reply says "sent". What are two separate bugs here?

    Show answer

    The write tool is not idempotent, so every call sends another request, and its reply does not change the state the next step reads, so the model cannot see the work is done. Exact-argument hashing also misses the loop because the message text differs. See fixes.

  2. Token usage per step rises by about 4,000 each step while tool names vary and no hash repeats. Which cause is this, and what do you do first?

    Show answer

    Context growth. Check whether a tool returns large payloads and truncate them, then compact old results and restate the goal. A token cap bounds it in the meantime. See causes and traces.

  3. Your team lead wants to raise the step cap from 15 to 50 "because some claims need more steps". What do you check before agreeing?

    Show answer

    Whether those claims succeed past step 15 in the traces. If successful runs end by step 9, claims reaching 15 are almost all loops, and 50 turns each into three times the waste. If a real class needs more steps, raise the cap for that class, with a token cap alongside.

  4. The policy tool returns 503 for four minutes, then recovers. The agent's run is 90 seconds. Design the tool's behaviour so the run neither loops nor fails needlessly.

    Show answer

    The wrapper retries twice with jittered backoff inside the tool call, then returns a terminal error with a retryable flag for the workflow, which requeues the claim after a delay. The model never sees repeated 503s to react to. See fixes.

  5. Why can a loop detector that compares the last two actions pass an A, B, A, B cycle, and what catches it?

    Show answer

    Consecutive actions always differ, so a last-two comparison never fires. A count of each hash across the whole run catches it by the third repeat, and a progress check catches it when no new facts arrive.

Takeaway

  • Agent loops form because "done" is the model's decision; most cases are an unusable tool result, a state that does not change, or two tools in conflict.
  • Debug from traces: steps per run, repeated tool-and-argument hashes, and tokens per step show the cause before you read a prompt.
  • The step, token and time caps live in the harness; a prompt instruction lowers the rate but cannot bound the loop.
  • Set caps from the distribution of healthy runs, and treat every cap hit as a signal to investigate.
  • Classify tool errors: transient ones retry inside the tool, terminal ones end the run with a named state and a human handoff.
  • Idempotent tools that return the new state make repetition cheap and visible.

Loops are visible failures; the harder one is a fluent answer that is wrong and looks fine, covered next. → Confident and Wrong in a High-Risk Workflow

Further reading