entry
No. 7
date
filed under
debugging, tool use, agents
checked against
11 sources

Agent Infinite Loop: How to Debug Agents That Never Stop

An agent loop ends when the model replies without asking for a tool. If it never does, the loop runs until something outside it gives up: your budget, a rate limit or a timeout. The fix has two halves. Put hard limits around the loop so a stuck run ends cleanly, and then use the runs that hit those limits to find out why the model kept going, which is usually a repeated tool call, an error it cannot act on, or a task with no clear finish line.

This post covers both halves, with the default limits in the main SDKs (some are higher than you might expect) and working code for a loop guard you can drop into a hand-written loop.

Why agent loops don't stop on their own

Every tool-calling agent runs the same cycle. The model either asks for tools or answers. Your code runs the tools, sends back the results and calls the model again. The OpenAI Agents SDK documents it this way: the loop ends when the output is a final output with no tool calls. The Claude Agent SDK describes the same rule: turns continue until Claude produces output with no tool calls.

Nothing in that cycle forces an end. Anthropic's Building effective agents post says agents should gain "ground truth" from the environment at each step, and that it is common to include stopping conditions, such as a maximum number of iterations, to keep control.

How often does it go wrong? The best data comes from multi-agent systems. "Why Do Multi-Agent LLM Systems Fail?" by Cemri and colleagues annotated 1,642 execution traces from seven frameworks, including ChatDev, MetaGPT and Magentic-One. Two of the failure modes it found are loop problems: step repetition, the unnecessary reiteration of steps already completed, at 15.7 percent, and being unaware of termination conditions at 12.4 percent. Premature termination, the opposite problem, came in at 6.2 percent. Those figures are for multi-agent setups, but the same two patterns can show up in a single agent's loop.

Step 1: Put hard limits around the loop

Limits do not fix a loop. They make it cheap and visible. Check what your framework does by default before you rely on it.

Claude Agent SDK. max_turns and max_budget_usd both default to no limit. max_turns counts tool-use round trips only. When a limit is hit, the run ends with a ResultMessage whose subtype is error_max_turns or error_max_budget_usd, and the docs call a budget a good default for production agents. The budget is checked after each model response arrives, so spend can pass the cap by the cost of that response, plus anything running subagents spend before they stop. Leave headroom.

OpenAI Agents SDK. Runner.run takes max_turns, and going over it raises MaxTurnsExceeded. The docs don't print the default, but in version 0.24.0 of the package DEFAULT_MAX_TURNS is 10, and max_turns=None turns the limit off. If you'd rather return a fallback answer than raise, pass an error handler:

from agents import Agent, Runner, RunErrorHandlerInput, RunErrorHandlerResult


def on_max_turns(data: RunErrorHandlerInput) -> RunErrorHandlerResult:
    return RunErrorHandlerResult(
        final_output="I couldn't finish this within the step limit.",
        include_in_history=False,  # don't save the fallback into the session
    )


agent = Agent(name="Support", instructions="Answer billing questions.")
result = Runner.run_sync(
    agent,
    "Why was I charged twice?",
    max_turns=8,
    error_handlers={"max_turns": on_max_turns},
)

LangGraph. The limit is recursion_limit, and it counts super-steps, not model calls. Nodes that run in parallel share one super-step. When the graph passes the limit, it raises GraphRecursionError. Older tutorials say the default is 25, and langchain-core still declares DEFAULT_RECURSION_LIMIT = 25. But in langgraph 1.2.20, the value filled in when you pass none is 10007, read from the LANGGRAPH_DEFAULT_RECURSION_LIMIT environment variable. That is far more steps than most agent tasks need, so set it yourself.

This two-node graph has no exit, and a limit of 6 stops it:

from typing import TypedDict
from langgraph.graph import StateGraph, START
from langgraph.errors import GraphRecursionError


class State(TypedDict):
    count: int


def step(state: State) -> dict:
    return {"count": state["count"] + 1}


builder = StateGraph(State)
builder.add_node("a", step)
builder.add_node("b", step)
builder.add_edge(START, "a")
builder.add_edge("a", "b")
builder.add_edge("b", "a")  # no exit: this graph never stops on its own
graph = builder.compile()

try:
    graph.invoke({"count": 0}, {"recursion_limit": 6})
except GraphRecursionError as e:
    print(str(e).splitlines()[0])

Output:

Recursion limit of 6 reached without hitting a stop condition. You can increase the limit by setting the `recursion_limit` config key.

LangGraph's docs also show a gentler pattern: add a RemainingSteps key to the state, and route to a wrap-up node when only a couple of steps remain, so the user gets a best-effort answer instead of an exception.

Step 2: Read the stop reason before you blame the model

In a hand-written loop on the Claude API, the stop_reason tells you why each response ended, and several values look like loop problems when they are not.

  • tool_use: the model wants a tool. This is the only value that should send you around the loop again.
  • end_turn: the model is done. Anthropic notes that an empty end_turn response can happen when you add text right after tool results, so send tool results with no extra text.
  • max_tokens: the response was cut off. If it ends in an incomplete tool_use block, retry with a higher max_tokens.
  • pause_turn: a server-side tool loop, such as web search, hit its iteration limit, 10 by default per request. Send the content back to continue. It is not your loop repeating.
  • refusal: the model declined. Anthropic's docs suggest reading stop_details and retrying on a fallback model, not sending the same request around the loop.

A loop that only continues on tool_use and treats everything else as an exit to be handled cannot spin on a truncated or refused response.

Step 3: Detect repeated calls, not just long runs

A turn cap catches every runaway, but only at the end, after you've paid for every wasted turn. Most loops are visible much earlier: the agent calls the same tool with the same arguments again and again. That is the step repetition failure from the MAST study, and it is cheap to detect.

The guard below fingerprints each call by tool name and arguments, with keys sorted so argument order doesn't matter. On the third identical call it refuses to run the tool and tells the model why. If the model repeats the call anyway, the guard ends the run. It also enforces a turn cap and a token budget.

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


def fingerprint(name: str, args: dict) -> str:
    """Same tool + same arguments -> same fingerprint, whatever the key order."""
    blob = json.dumps({"tool": name, "args": args}, sort_keys=True)
    return hashlib.sha256(blob.encode()).hexdigest()[:12]


@dataclass
class LoopGuard:
    max_turns: int = 20           # model calls per run
    max_tokens: int = 200_000     # input + output tokens across the run
    max_repeats: int = 3          # identical tool calls before we step in
    turns: int = 0
    tokens: int = 0
    calls: Counter = field(default_factory=Counter)
    nudged: set = field(default_factory=set)

    def after_model_call(self, input_tokens: int, output_tokens: int) -> str | None:
        """Call once per model response. Returns a stop reason, or None to go on."""
        self.turns += 1
        self.tokens += input_tokens + output_tokens
        if self.turns >= self.max_turns:
            return f"turn limit ({self.max_turns})"
        if self.tokens >= self.max_tokens:
            return f"token budget ({self.tokens} of {self.max_tokens})"
        return None

    def check_tool_call(self, name: str, args: dict) -> tuple[str, str | None]:
        """Returns ("run", None), ("nudge", message) or ("stop", reason)."""
        fp = fingerprint(name, args)
        self.calls[fp] += 1
        n = self.calls[fp]
        if n < self.max_repeats:
            return "run", None
        if fp not in self.nudged:
            self.nudged.add(fp)
            return "nudge", (
                f"Not run: you have called {name} with these exact arguments {n} times. "
                "Use the earlier result, change the arguments, "
                "or finish and say what is blocking you."
            )
        return "stop", f"{name} repeated {n} times after a warning"

And the loop that uses it, built on the Anthropic Python SDK's messages.create. execute_tool is your own function:

def run_agent(client, model, tools, execute_tool, user_query, guard=None):
    guard = guard or LoopGuard()
    messages = [{"role": "user", "content": user_query}]
    while True:
        response = client.messages.create(
            model=model, max_tokens=2048, tools=tools, messages=messages,
        )
        stop = guard.after_model_call(response.usage.input_tokens, response.usage.output_tokens)
        if response.stop_reason != "tool_use":
            return response, response.stop_reason       # end_turn, max_tokens, refusal...
        if stop:
            return response, f"guard: {stop}"

        messages.append({"role": "assistant", "content": response.content})
        results = []
        for block in response.content:
            if block.type != "tool_use":
                continue
            action, detail = guard.check_tool_call(block.name, block.input)
            if action == "stop":
                return response, f"guard: {detail}"
            if action == "nudge":
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": detail, "is_error": True})
                continue
            try:
                content, is_error = execute_tool(block.name, block.input), False
            except Exception as e:                      # say what failed
                content, is_error = f"{type(e).__name__}: {e}", True
            results.append({"type": "tool_result", "tool_use_id": block.id,
                            "content": content, "is_error": is_error})
        messages.append({"role": "user", "content": results})

Run against a fake client that asks for search_orders with the same arguments on every turn, the loop makes four model calls and returns guard: search_orders repeated 4 times after a warning. With max_turns=2, it stops after two calls with guard: turn limit (2).

A few design choices worth noting:

  • Nudge before you stop. Returning the refusal as a tool result gives the model one chance to change course. Marking it is_error is a choice of this guard; Anthropic's docs define the field for tool calls that failed.
  • Exact match is deliberately narrow. It won't catch an agent that changes one word of a search query each time. For that, also track whether results are new: if the last few tool results are identical, the agent isn't learning anything.
  • Count per run, not per session. Reset the guard for each user request, or a long, healthy session will eventually trip it.

Step 4: Fix the cause in the transcript

Every run that hits a limit is a failing test case. Save the transcript and read the last few turns. Most stuck runs fall into one of these patterns.

The tool error gives the model nothing to work with. Anthropic's tool docs say that when a tool request is invalid or missing parameters, Claude will retry 2 to 3 times with corrections before apologizing. A vague error makes those retries guesses. The docs' advice is to write instructive errors: instead of "failed", say what went wrong and what to try next, such as "Rate limit exceeded. Retry after 60 seconds." Why your agent calls the wrong tool has more on error messages and the other tool-design problems that lead here.

The arguments are malformed every time. If the model keeps sending a string where your tool wants an integer, the same docs recommend strict tool use, which guarantees inputs match your schema. Structured outputs vs function calling covers how to turn it on.

The task has no finish line. This is the MAST "unaware of termination conditions" mode. "Research our competitors" never finishes. "Find the pricing pages of these three companies and return a table with one row each" does. Say what done looks like in the prompt, and if the output has a fixed shape, ask for it with a schema so the model knows when it has everything.

The result is too large to use. An agent that can't find the value it needs in a huge tool response can end up calling the tool again with the same arguments. Paginate, trim and say how many results were left out.

The context ran long. In long runs, old results pile up and early instructions get buried. The Claude Agent SDK compacts history automatically and notes that compaction may drop instructions from early in the conversation, so it recommends keeping persistent rules in CLAUDE.md, which is loaded on every request. The same idea applies to any framework: keep the stop rule somewhere it can't be summarized away.

Step 5: Make it a regression test

Once a loop is fixed, keep it fixed. Add the stuck task to your eval suite and track turns per task and tool calls per task alongside pass rate. A prompt change that raises the average turn count can be a loop on the way back. The agent evals starter guide covers how to set up those checks.

Checklist

  • Set a turn cap and a cost or token budget explicitly; don't trust the defaults.
  • Handle the limit error and return a useful fallback.
  • Continue the loop only on tool_use; handle max_tokens, pause_turn and refusal on their own.
  • Fingerprint tool calls and step in on the third identical one.
  • Write tool errors that say what to try next.
  • Turn on strict mode for tools with exact argument types.
  • State what done looks like in the task.
  • Save every limit-hit transcript and turn it into an eval case.

FAQ

Why is my AI agent stuck in an infinite loop?

The loop only ends when the model answers without calling a tool. Common reasons it keeps calling are tool errors it can't act on, malformed arguments it keeps repeating, results too large to read and a task with no clear end point.

What is the default max turns in the OpenAI Agents SDK?

In version 0.24.0 of the openai-agents package, DEFAULT_MAX_TURNS is 10. Going over it raises MaxTurnsExceeded, and max_turns=None turns the limit off.

What is the default recursion limit in LangGraph?

In langgraph 1.2.20 the value filled in when you pass none is 10007, read from the LANGGRAPH_DEFAULT_RECURSION_LIMIT environment variable. Older tutorials say 25, which is the value langchain-core declares. Set recursion_limit yourself.

Does the Claude Agent SDK limit turns by default?

No. Both max_turns and max_budget_usd default to no limit. When you set one and the run hits it, the result subtype is error_max_turns or error_max_budget_usd.

How do I detect an agent repeating the same tool call?

Hash each call's tool name and arguments with sorted keys and count the hashes. On a repeat, return a tool result telling the model to use the earlier result or change approach, and end the run if it repeats again.

Sources

Next in the notebook

  1. No. 2Why Your Agent Calls the Wrong Tool (and How to Fix It)

    A debugging checklist for tool-calling agents: overlapping tools, vague descriptions, ambiguous parameters, noisy output and bad errors, with a fix for each.

  2. No. 6Structured Outputs vs Function Calling: Which to Use When

    Structured outputs vs function calling: use tools when the model must act, a JSON schema when you need the answer in a fixed shape, and strict mode for both.

  3. No. 3How to Write Evals for an AI Agent: A Practical Starter Guide

    Build your first agent eval suite step by step: pick real tasks, grade outcomes instead of paths, measure consistency with pass^k and read the transcripts.