Runtype
GuidesGuide

Why your agent gets stuck in a loop

Why an AI agent calls the same tool over and over, how to spot the loop in a trace, the fix for each cause, and where to set the hard call budget.

Last updated 6 min read

An agent loops when nothing in the turn tells it the work is finished. Causes are an empty tool result the model reads as a failure, an error string that re-prompts the identical call, a planner that re-plans from scratch every turn, a validator that rejects without saying why, and two tools whose descriptions overlap. A runtime cap ends whatever the fixes miss.

The shape is recognisable once you have watched it happen. A support agent calls search_orders eleven times with byte-identical arguments, each call returning []. A research agent alternates fetch_page and summarize for four minutes and produces no answer. A planning agent says it will check one more thing on every turn and never writes the final message.

Whichever variant you have, the visible symptom is the same. Tokens accumulate, the turn hits its wall-clock ceiling, and the customer sees a spinner the whole time.

My AI agent keeps calling the same tool over and over

Nothing is stuck in your code. The model is stateless across turns and decides from the transcript in front of it, so a turn that ends with the same information it started with produces the same next action. Each repeat appends a message that looks like an unsuccessful attempt, which makes the case for trying again slightly stronger. That is the whole mechanism: a growing transcript that never contains the sentence "this question is now answered".

Five concrete things put a transcript into that state.

What the trace showsMechanismFix
Same tool, same arguments, [] or null returnedEmpty result read as a failed call rather than a complete answerReturn a status field that names the emptiness
Same tool, same arguments, an error string returnedError text describes the failure without saying whether a retry can succeedAdd a retryable flag and the missing input by name
A new plan on every turn, steps re-orderedPlanner re-plans from the original goal and has no record of finished workCarry completed steps forward and check them first
Write tool called, rejected, called again unchangedValidator returns a boolean or a generic messageReturn the failing field and the constraint it violated
Two tools alternating, neither answeringOverlapping descriptions, so both look correct and neither looks sufficientMake the descriptions disjoint and name the boundary

What a loop looks like in a trace

Loops are cheap to detect and expensive to miss, so detection belongs in the trace view rather than in a postmortem. Hash the pair of tool name and canonically serialized arguments for every call in a turn, then count. Three matching hashes is a loop, and you can fire that check on live executions instead of waiting for a bill. Bouncing needs a second check: a repeating two-element cycle of tool names, A B A B, with no assistant text between them.

Three signals separate a loop from an agent that is working hard. Tool arguments stop changing, which means the model has no new information to act on. Assistant text goes quiet between calls, which means it has stopped reasoning about what it learned. Token count per turn climbs linearly while the set of open questions stays the same size.

Reading a trace step by step is its own skill, covered in debugging an AI agent; the fields worth capturing so the trace can answer this question at all are in agent tracing.

Eight steps to end the loop

1. Classify the shape before changing anything

Pull the failing execution and label it: repeat, bounce, or endless check. Repeat points at a tool result. Bounce points at two tool descriptions. Endless check points at the prompt's terminal condition.

Skipping the classification is how teams end up raising the turn limit, which moves the failure later and makes it cost more.

2. Make an empty result say that it is empty

An empty array carries no information about why it is empty, and a model asked to be persistent will read it as a call that did not go through. Return a shape that states the outcome:

{
  "status": "no_results",
  "matched": 0,
  "searched": { "customer_id": "c_8812", "since": "2026-08-01" },
  "message": "No orders matched. This is a complete answer. Only a different customer or date range would change it."
}

The searched echo matters as much as the status. It shows the model the exact arguments that produced nothing, which is the evidence needed to conclude that repeating them is pointless.

3. Stop handing back error text that invites a retry

Error: request failed is an invitation. The model cannot tell a transient network fault from a permanent contract violation, so it retries the only thing it knows how to retry. Distinguish the two explicitly and name the missing input:

{
  "status": "error",
  "retryable": false,
  "message": "shipping_address is required and was not provided. Ask the user for it before calling this tool again."
}

Timeouts are the one case where a retry is correct, so mark them retryable: true and let the runtime cap bound how many times that happens.

4. Give the planner a terminal condition and a memory

A planner prompt that says "break the goal into steps and execute them" re-derives the plan on every turn, because the goal in the system prompt has not changed while the completed work lives in the middle of a long transcript. Two changes end it.

State the stopping rule in the prompt in terms the model can evaluate against the transcript, such as "when every field in the output schema has a value, write the final answer and call no further tools". Keep an explicit list of completed steps in the working context, and instruct the model to read that list before planning. A planner that can see fetch_invoice: done does not schedule it a fourth time.

5. Make the validator say what is wrong

A validator that returns {"valid": false} puts the model in the same position as the empty array. It knows the attempt was rejected and has no basis for changing the next one, so it sends the same payload again. Return the field, the constraint and the received value:

{
  "valid": false,
  "errors": [
    { "field": "amount", "constraint": "must be a positive integer in cents", "received": "12.50" }
  ]
}

This is the highest-yield fix on the list, because a rejection loop burns a full model call on every attempt and the fix is a schema error message you probably already have.

6. Separate tools whose descriptions overlap

Two tools described as "search the knowledge base for relevant information" and "look up an article by topic" are the same tool as far as the model is concerned. It calls one, finds the result partial, calls the other, and alternates. Write descriptions that name the boundary and the input each one needs: one takes free text and returns ranked passages, the other takes a known article ID and returns the full document. Overlap gets worse with catalogue size, and tool selection has its own failure modes past roughly twenty tools, covered in why agents pick the wrong tool.

7. Release forced tool choice before the final turn

Forcing a tool call is useful for the first turn of a structured extraction and harmful after that, because a model required to call a tool cannot answer in text even when it has everything it needs. Set the forced choice for the turn that needs it and return to automatic selection afterwards, so the model has a way to finish. If your framework only exposes a global setting, a bounded number of forced turns followed by a free turn is the workaround.

8. Put a hard budget where the model cannot argue with it

Every fix above depends on a tool author remembering to write a good result shape, and one third-party tool that returns a bare [] puts the loop back. The budget is the part that holds regardless. Set it in the runtime, outside the prompt:

limits:
  tool_calls_per_turn: 12
  turns_per_run: 8
  wall_clock_seconds: 120
  cost_ceiling_usd: 0.50

Pick the per-turn number from real traces: take the p95 tool-call count of successful turns and roughly double it. A ceiling of ten to twelve is a normal starting point for an agent with a handful of tools, and a turn that legitimately needs thirty calls is usually a flow with fixed steps rather than an open agent loop. Cost ceilings and per-customer caps are the second layer, since one tenant's runaway agent should not consume another tenant's headroom; that pattern is in usage limits per customer. Budgets belong beside the rest of your agent observability work, because the cap tells you the loop happened and the trace tells you which of the five causes it was.

Where this gets easier

Runtype makes the budget configuration rather than application code: maxToolCalls bounds tool calls per turn (default 10, maximum 100), loopConfig.maxTurns bounds turns per run between 1 and 100, an optional loopConfig.maxCost sets a per-run ceiling in US dollars, checked against the model and tool spend accumulated so far, and a per-turn wall-clock budget stops a run that stalls. Loop steps inside a flow take a required maxIterations capped at 10. Because the runtime executes each step, the trace already carries per-step input and output with every tool call's arguments and results, so counting identical argument hashes is reading data that is there rather than instrumentation you added after the incident.

Frequently asked questions

How many repeated tool calls count as a loop?
Three identical calls (same tool name, same arguments) inside one turn is a reliable signal, because a model that had a reason to vary would have varied the arguments by then. Two is common and often legitimate, such as a retry after a timeout. Alert on three, cut the turn off at ten to twelve.
Does raising the turn limit fix a loop?
No. A higher limit changes where the loop stops, not whether it loops, and it multiplies the token bill for the same failed turn. Raise a limit only after a trace shows the agent making progress that the old ceiling cut off, meaning new tool arguments and a shrinking set of open questions on each turn.
Why does the agent loop in production but not in testing?
Test fixtures usually return populated results. Production returns empty arrays, permission errors, partial records and timeouts, and those are the shapes a model misreads as a failed call worth retrying. Build eval cases from real executions that returned nothing so the empty path is exercised before customers find it.