Agent Repeats the Same Tool Call Until It Hits Step Limit
For developers debugging agent runs that loop on the same tool call and terminate on a step cap. This runbook shows how to confirm whether the problem is schema ambiguity, missing state updates, retry policy, nondeterministic tool output, or planner prompt defects, then fix each one with concrete config and test checks.
TL;DR — If an agent keeps issuing the same tool call until it exhausts its step budget, the most common cause is that the tool result does not change the agent-visible state in a way the planner can recognize as progress. Start by tracing one failed run, diffing consecutive tool calls and tool outputs, and checking whether the tool response is missing a terminal signal, returning unstable data, or being retried on a non-retryable error. Reading time: ~6 min
The scenario
It is Tuesday at 3:40 PM and your support channel lights up because an internal agent workflow that usually resolves tickets in 20 seconds is now burning through every run budget. The trace shows the model calling the same tool with the same arguments 12 or 20 times in a row, then ending with a generic "step limit reached" or "max iterations exceeded" error. Nothing obvious changed in infra, but you did ship a prompt tweak and a small tool wrapper refactor this morning. Product thinks the model got "dumber"; you need to prove whether this is a planner problem, a tool contract problem, or a retry loop you accidentally enabled.
Symptoms
- Repeated identical tool invocations in a single run trace, for example:
step=5 tool=search_tickets args={"query":"INV-1042"}
step=6 tool=search_tickets args={"query":"INV-1042"}
step=7 tool=search_tickets args={"query":"INV-1042"}
- Run terminates with one of these messages:
step limit reached
max iterations exceeded
agent stopped after 20 steps
workflow terminated: recursion/loop guard triggered
- Tool logs show either identical successful responses or identical failures with automatic retries:
POST /tools/search_tickets 200 148ms
POST /tools/search_tickets 200 151ms
POST /tools/search_tickets 200 147ms
or
POST /tools/search_tickets 500 33ms
retrying attempt=2 reason=server_error
retrying attempt=3 reason=server_error
- The user-visible result is a timeout, generic failure, or partial answer with no conclusion.
- Traces show no state transition between steps: same scratchpad summary, same selected tool, same arguments.
- If your framework exposes token/tool traces, the model rationale often contains variants of:
I should try the search again.
No result yet; I will call the tool once more.
Likely causes
| Cause | How common | Quick check |
|---|---|---|
| Tool response does not include a clear terminal/progress signal | Very common | ```bash |
| jq '.steps[] | {tool: .tool_name, args: .arguments, output: .output}' run.json | less |
| Agent state is not updated from tool output | Very common | ```bash
jq '.steps[] | {step: .index, state: .agent_state_summary}' run.json
``` |
| Retry policy retries non-retryable failures or hides errors | Common | ```bash
grep -RniE 'retry|backoff|max_attempts' .
``` |
| Tool output is nondeterministic, so the planner never recognizes completion | Common | ```bash
for i in 1 2 3; do curl -s http://localhost:8080/tools/search -d '{"query":"INV-1042"}' -H 'content-type: application/json'; echo; done
``` |
| Prompt/planner instructions reward tool use but do not define stop conditions | Common | ```bash
grep -RniE 'stop|done|sufficient|final answer|if the tool returns' prompts/ system*
``` |
| Idempotency/cache layer is broken, so duplicate calls look like fresh work | Less common | ```bash
grep -RniE 'idempotency|cache|dedupe' .
``` |
## Step-by-step diagnosis
1. Trace one failed run end-to-end.
```bash
jq '.steps[] | {step: .index, tool: .tool_name, args: .arguments, output: .output, state: .agent_state_summary, error: .error}' run.json
This is your problem if you see 3+ consecutive steps with the same tool and identical args. If the output is also identical or semantically identical, jump to Fixes → Tool response does not include a clear terminal/progress signal or Fixes → Agent state is not updated from tool output.
-
Check whether the tool ever returns a machine-readable "done" or "no more work" field.
jq '.steps[] | select(.tool_name=="search_tickets") | .output' run.json | head -20This is your problem if outputs are free-form text like
"Found 1 result"with no stable field such asstatus,found,next_action,completed, oritems.length. Jump to Fixes → Tool response does not include a clear terminal/progress signal. -
Compare agent-visible state before and after each tool call.
jq '.steps[] | {step: .index, state: .agent_state_summary}' run.jsonThis is your problem if the state summary does not change after successful tool calls, or only changes in irrelevant metadata like timestamps. Jump to Fixes → Agent state is not updated from tool output.
-
Inspect retry configuration and logs for hidden replay.
grep -RniE 'retry|backoff|max_attempts|shouldRetry|429|5..' .This is your problem if the wrapper retries on
400,404, validation errors, or semantic "not found" responses, or if it converts all errors into a generic retriable exception. Jump to Fixes → Retry policy retries non-retryable failures or hides errors. -
Call the tool directly several times with the same input.
for i in 1 2 3; do curl -s http://localhost:8080/tools/search_tickets -H 'content-type: application/json' -d '{"query":"INV-1042"}' | jq -S .; doneThis is your problem if outputs differ in ordering, ephemeral IDs, timestamps, scores, or random text despite identical input. Jump to Fixes → Tool output is nondeterministic.
-
Read the planner/system prompt for stop criteria.
grep -RniE 'use tools|continue|stop|done|final answer|sufficient evidence|do not call the same tool' prompts/ .This is your problem if the prompt says to keep searching until certain, but never says when to stop, or does not forbid repeating identical calls without new inputs. Jump to Fixes → Prompt/planner instructions reward tool use but do not define stop conditions.
-
Check dedupe/idempotency on the tool boundary.
grep -RniE 'Idempotency-Key|request_hash|dedupe|memoize|cache_key' .This is your problem if duplicate requests are treated as new work and there is no request hash or cache key for identical tool calls within a run. Jump to Fixes → Idempotency/cache layer is broken.
Fixes
Tool response does not include a clear terminal/progress signal
Return structured fields the planner can test, not prose.
{
"status": "completed",
"found": true,
"items": [{"id": "INV-1042", "state": "open"}],
"next_action": "respond",
"message": "Ticket found"
}
If the tool can legitimately return no result, encode that explicitly:
{
"status": "completed",
"found": false,
"items": [],
"next_action": "ask_user_for_another_identifier",
"message": "No ticket matched query"
}
If your wrapper currently emits text, change it to JSON and validate it.
npm install zod
import { z } from "zod";
const SearchResult = z.object({
status: z.enum(["completed", "needs_input", "error"]),
found: z.boolean(),
items: z.array(z.object({ id: z.string(), state: z.string() })),
next_action: z.string(),
message: z.string()
});
Verify it worked:
curl -s http://localhost:8080/tools/search_tickets -H 'content-type: application/json' -d '{"query":"INV-1042"}' | jq '.status, .next_action, .found'
Agent state is not updated from tool output
Persist a normalized summary from each tool result into the planner-visible state.
const result = await searchTickets(args);
state.last_tool = "search_tickets";
state.search = {
query: args.query,
found: result.found,
item_count: result.items.length,
ticket_ids: result.items.map(x => x.id),
status: result.status,
next_action: result.next_action
};
Then gate duplicate calls:
if (
state.last_tool === "search_tickets" &&
state.search?.query === args.query &&
state.search?.status === "completed"
) {
throw new Error("duplicate_tool_call_blocked: identical completed search_tickets call");
}
Verify it worked:
jq '.steps[] | {step: .index, state: .agent_state_summary}' run-fixed.json
You should see state change after the first successful call, and no identical second call.
Retry policy retries non-retryable failures or hides errors
Restrict retries to transport/transient failures only: timeouts, 429, and 5xx. Do not retry 4xx validation errors or semantic misses.
function shouldRetry(status: number, errCode?: string) {
if (errCode === "ETIMEDOUT" || errCode === "ECONNRESET") return true;
if (status === 429) return true;
if (status >= 500 && status <= 599) return true;
return false;
}
Expose the real failure to the agent:
{
"status": "error",
"retryable": false,
"code": "VALIDATION_ERROR",
"message": "query must be at least 3 characters"
}
If you use env-configured retries, pin them:
export TOOL_MAX_ATTEMPTS=2
export TOOL_BACKOFF_MS=250
Verify it worked:
grep -n 'retrying attempt' app.log | tail
You should not see retries for 400/404/validation cases.
Tool output is nondeterministic
Normalize ordering and strip volatile fields before returning results to the agent.
const normalized = results
.map(({ id, state, title }) => ({ id, state, title }))
.sort((a, b) => a.id.localeCompare(b.id));
return {
status: "completed",
found: normalized.length > 0,
items: normalized,
next_action: normalized.length ? "respond" : "ask_user_for_another_identifier",
message: normalized.length ? "Ticket found" : "No ticket matched query"
};
If a score is required, round it:
score: Math.round(score * 1000) / 1000
Verify it worked:
for i in 1 2 3; do curl -s http://localhost:8080/tools/search_tickets -H 'content-type: application/json' -d '{"query":"INV-1042"}' | jq -S .; done
All three outputs should be byte-stable except for fields you intentionally exclude.
Prompt/planner instructions reward tool use but do not define stop conditions
Add explicit stop rules and duplicate-call guards to the system prompt.
When a tool returns status="completed", do not call the same tool again with identical arguments unless new user input or new state changes the query.
If found=false and next_action="ask_user_for_another_identifier", stop using tools and ask the user for a different identifier.
If a tool returns retryable=false, do not retry; explain the error or choose a different tool.
Before each tool call, compare the proposed call to the previous call. If tool name and arguments are identical and no new evidence exists, produce a final answer instead of calling the tool again.
Trade-off: aggressive stop rules can reduce recovery from flaky tools. Pair this with the retry fix above so only transient failures retry automatically.
Verify it worked:
grep -Rni 'do not call the same tool again with identical arguments' prompts/ system*
Then rerun the failing case and confirm the trace stops after one completed call.
Idempotency/cache layer is broken
Cache identical tool calls within a run by hashing tool name + normalized args.
import crypto from "node:crypto";
function key(toolName: string, args: unknown) {
return crypto.createHash("sha256").update(toolName + JSON.stringify(args)).digest("hex");
}
const k = key("search_tickets", args);
if (runCache.has(k)) return runCache.get(k);
const result = await searchTickets(args);
runCache.set(k, result);
return result;
If the tool causes side effects, use an idempotency key instead of blind replay:
Idempotency-Key: run_8f3b_step_search_tickets_1
⚠️ Do not add response caching to mutating tools like "create_ticket" without scoping by run/request and understanding side effects. Bad idempotency on write operations can hide duplicate writes or return stale success.
Verify it worked:
grep -n 'cache hit' app.log | tail
You should see duplicate reads served from cache during a single run.
Prevention
- Add a loop detector in the orchestrator and fail fast with a useful error.
const signature = `${toolName}:${JSON.stringify(args)}`;
seen[signature] = (seen[signature] || 0) + 1;
if (seen[signature] >= 2) {
throw new Error(`loop_guard: repeated tool call ${signature}`);
}
- Add CI tests for stable tool outputs.
for i in 1 2 3; do node test-call.js >> /tmp/out.jsonl; done
uniq /tmp/out.jsonl | wc -l
Expect 1 for deterministic read tools.
- Validate tool schemas at the boundary and reject prose-only responses.
if (!result.status || typeof result.found !== "boolean") {
throw new Error("invalid_tool_contract: missing status/found");
}
- Emit metrics for duplicate-call rate and step-limit terminations.
agent_duplicate_tool_call_total{tool="search_tickets"} 17
agent_step_limit_terminated_total{workflow="ticket_lookup"} 4
Alert when either spikes after a deploy.
- Pin retry behavior in config, not ad hoc code paths.
{
"retry": {
"max_attempts": 2,
"retry_on": [429, 500, 502, 503, 504],
"never_retry_on": [400, 401, 403, 404, 422]
}
}
- Add a pre-merge trace replay test using a known looping case.
node replay-run.js fixtures/looping-run.json --assert-no-identical-consecutive-calls
This catches prompt edits and wrapper refactors that reintroduce the loop before they hit production.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI