If an AI agent is stuck calling the same tool, stop new tool execution first. Freeze tools with side effects, persist the run state and recent trace, then cancel the worker with a machine-readable reason such as operator_stop. Do not let an automatic retry, helper agent, or restarted process continue the same run.
The incident is contained when the next unsafe call is blocked, no completed side effect runs twice, the trace says why the run stopped, and a replay of the failing trajectory terminates inside your declared budget. A maximum-turn setting helps contain damage, but it does not prove any of those outcomes by itself.

Stop the loop now, before debugging it
Use this sequence for a live incident:
- Pause dispatch. Stop the planner from scheduling more tool calls or sub-agents.
- Freeze side-effect tools. Block email, payment, CRM update, deployment, database write, and file mutation paths. Read-only inspection can remain available if it cannot trigger hidden writes.
- Save the evidence. Persist the run ID, state version, last successful transition, tool names, canonical arguments, results, errors, token usage, and timestamps.
- Cancel at the runtime. Terminate the worker or runner; a prompt saying “please stop” is not the safety boundary.
- Record one terminal reason. Use a value you can query later:
same_call_repeat,no_progress,fatal_tool_result,budget_exceeded, oroperator_stop. - Check the outside world. Confirm whether the supposedly repeated action already happened. A successful CRM write with a lost acknowledgement is a different incident from a tool that never succeeded.
Do not restart the run until you know whether the last tool invocation committed a side effect. If that fact is unknown, treat the operation as potentially completed and reconcile it by idempotency key or durable operation record.
This is the critical boundary: cancellation stops motion; it does not establish a safe recovery point.
Diagnose the pattern from the trace
“The agent looped” is a symptom, not a cause. Classify the observable pattern before choosing a fix.
| Trace pattern | Likely failure | Evidence to check | Safe next action |
|---|---|---|---|
| Same tool, same arguments, same result | Identical retry | Stable call fingerprint and unchanged state version | Block before the next execution |
| Arguments change slightly, outcome does not | Jitter without progress | Normalized arguments, result class, progress invariant | Stop or require a new strategy |
| Tool A and Tool B alternate | Short cycle | Repeated sequence plus no state advance | Break the cycle and escalate |
| Tool returned success, then runs again | Success replay | Durable operation record and missing terminal transition | Return the recorded result; never replay |
| Same auth, schema, or policy error | Permanent failure retry | Error class and unchanged precondition | Terminate or request human input |
| Timeout or rate limit with a changing backoff state | Potentially recoverable retry | Retry count, deadline, server guidance, changed time/precondition | Retry within a small explicit budget |
| Pagination or polling repeats while cursor/version advances | Productive repetition | Monotonic cursor, version, or completed-work count | Continue within the global cap |
An exact hash catches only the easiest case. A stuck agent can vary whitespace, reorder JSON keys, change an irrelevant filter, or alternate between two tools while learning nothing. That is why a production guard needs both an action fingerprint and an independent progress signal.
Why a hard cap is necessary but insufficient
The runtime—not the model prompt—owns the hard stop. Current agent frameworks expose this boundary in different ways:
- The OpenAI Agents SDK runner guide describes a runner that continues after tool calls or handoffs until it reaches a real stopping point.
- LangChain middleware separates model-call limits, tool-call limits, retry behavior, and human interruption.
- Google ADK documents both a maximum iteration count and an explicit exit path for its LoopAgent.
Those controls cap damage. They cannot know whether update_customer already committed, whether a different search argument found genuinely new information, or whether a permission error can ever succeed. That knowledge belongs to your application state and tool contract.
Treat the layers separately:
| Layer | Question it answers | What it cannot prove |
|---|---|---|
| Hard cap | Has the run exceeded steps, calls, time, tokens, or cost? | Whether the work was progressing |
| Repetition detector | Has this action or short sequence already happened? | Whether repetition was legitimate |
| Progress detector | Did external state or validated output advance? | Whether a side effect is safe to replay |
| Idempotency | Has this business operation already committed? | Whether the agent should continue planning |
| Terminal result | Is this success, retryable, fatal, blocked, or waiting for a human? | Whether the overall user task is complete |
| Recovery policy | Can the run resume, change strategy, return partial work, or escalate? | The global resource ceiling |
If you configure only max_turns, the agent eventually stops—but it can still execute the same write several times before the cap fires. If you configure only duplicate detection, a two-tool cycle can evade it. If you configure only a prompt, probabilistic behavior remains the enforcement layer.
Build a progress-aware LoopGuard
The guard below is framework-neutral TypeScript. It runs before and after each tool execution. The application supplies progressVersion; derive it from durable state, a validated cursor, a completed-work counter, or another external invariant whenever possible.
tstype TerminalStatus = | "success" | "retryable_error" | "fatal_error" | "permission_denied" | "policy_blocked" | "needs_human"; type ProposedCall = { tool: string; args: unknown; progressVersion: number; sideEffectKey?: string; }; type ToolResult = { status: TerminalStatus; progressVersion: number; retryAfterMs?: number; message: string; }; type StopReason = | "step_budget" | "time_budget" | "same_call_repeat" | "no_progress" | "fatal_tool_result" | "needs_human"; const stable = (value: unknown): string => { if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`; if (value && typeof value === "object") { const entries = Object.entries(value as Record<string, unknown>) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, item]) => `${JSON.stringify(key)}:${stable(item)}`); return `{${entries.join(",")}}`; } return JSON.stringify(value); }; export class LoopGuard { private readonly startedAt = Date.now(); private readonly fingerprints = new Map<string, number>(); private steps = 0; private noProgress = 0; private lastProgressVersion: number; constructor( private readonly limits = { maxSteps: 12, maxSameCallExecutions: 2, maxNoProgressExecutions: 3, maxWallMs: 60_000, }, initialProgressVersion = 0, ) { this.lastProgressVersion = initialProgressVersion; } before(call: ProposedCall): StopReason | null { if (this.steps >= this.limits.maxSteps) return "step_budget"; if (Date.now() - this.startedAt >= this.limits.maxWallMs) { return "time_budget"; } const fingerprint = stable({ tool: call.tool, args: call.args, progressVersion: call.progressVersion, }); const previousExecutions = this.fingerprints.get(fingerprint) ?? 0; if (previousExecutions >= this.limits.maxSameCallExecutions) { return "same_call_repeat"; } this.fingerprints.set(fingerprint, previousExecutions + 1); this.steps += 1; return null; } after(result: ToolResult): StopReason | null { if ( result.status === "fatal_error" || result.status === "permission_denied" || result.status === "policy_blocked" ) { return "fatal_tool_result"; } if (result.status === "needs_human") return "needs_human"; if (result.progressVersion > this.lastProgressVersion) { this.lastProgressVersion = result.progressVersion; this.noProgress = 0; } else { this.noProgress += 1; } if (this.noProgress >= this.limits.maxNoProgressExecutions) { return "no_progress"; } return null; } }
Three details matter more than the class name:
- Stable arguments. Sort keys and remove irrelevant defaults before hashing. Never put secrets into logs or fingerprints.
- External progress. Do not accept “I made progress” as an unverified model sentence. Prefer a cursor advance, test pass, row version, accepted artifact, or durable checkpoint.
- Stop before execution. When the same-call limit has already been reached, block the next invocation before it can trigger another side effect.
Pass the checkpoint's persisted progress version as initialProgressVersion when resuming; otherwise the first unchanged result can look like new progress. A success result means that tool operation succeeded, not necessarily that the user's whole task is complete: the outer runner must either perform a validated state transition or return the final answer. needs_human and fatal classes stop the runner immediately.
For concurrency and restarts, keep the fingerprint counts, progress version, and side-effect records in durable storage. An in-memory map protects only one process lifetime.
Make tools idempotent and results terminal
A repeated-call detector can block a third call, but it cannot undo the first two. Every tool that changes the outside world needs a stable operation key owned by the application.
tsasync function updateCustomer(input: { customerId: string; operationId: string; patch: Record<string, unknown>; }): Promise<ToolResult> { const existing = await operations.find(input.operationId); if (existing?.status === "committed") { return { status: "success", progressVersion: existing.version, message: "Already committed; returning the recorded result.", }; } const committed = await operations.commitOnce(input); return { status: "success", progressVersion: committed.version, message: "Customer update committed.", }; }
The operation ID should represent the business action, not one model attempt. A process restart, parallel worker, or slightly rewritten argument must resolve to the same durable record when it means the same action.
Tool results also need explicit terminal semantics:
| Status | May retry? | Required change before another call |
|---|---|---|
success | No equivalent replay | Start a new state transition or finish |
retryable_error | Yes, within a small retry/deadline budget | Time, endpoint health, backoff state, or another relevant precondition |
fatal_error | No | Correct input, code, or strategy |
permission_denied | No automatic retry | Human changes authorization |
policy_blocked | No automatic retry | Human/policy owner resolves the block |
needs_human | No | Resume only after recorded human input |
A tool response such as “there may be more results” is not a terminal contract. The public n8n incident is a useful boundary case: the HubSpot operation succeeded, but the surrounding instructions still represented the job as incomplete, so the tool repeated until the maximum iteration limit. Clearer instructions improved that case, but the runtime and idempotent tool still need to guarantee safety.
Recover without replaying the incident
When a guard fires, do not feed the same transcript back to the same policy and call that recovery. Choose one designed exit:
- Finish: A terminal success exists; return the recorded result.
- Retry: The error is explicitly retryable, the retry budget remains, and a relevant precondition changed.
- Change strategy: Disable the failing tool or supply different validated input.
- Return partial work: Preserve useful output and state what remains incomplete.
- Ask a human: Authorization, destructive action, ambiguous intent, or policy requires input.
- Close the run: Permanent failure, exhausted budget, or no safe next action.
Persist a recovery envelope:
json{ "run_id": "run_123", "stop_reason": "no_progress", "last_good_progress_version": 4, "blocked_call": "search", "side_effect_state": "none_pending", "allowed_next_actions": ["change_strategy", "needs_human"] }
The resume path should create a new attempt linked to the stopped run, not silently reset all counters. Otherwise a restart becomes a way around the guard.
What our five deterministic fixtures showed
We ran a framework-neutral JavaScript simulation in the repository's Node.js environment. It made no provider request, used no credential, and incurred no model cost. The guard used stable argument sorting, a total step budget, a maximum of two executions for the same call, a three-execution no-progress limit, a wall-clock limit, explicit terminal states, and a monotonic progressVersion.
| Fixture | Simulated behavior | Observed result |
|---|---|---|
success_after_one | CRM-like tool returned success immediately | Completed after one execution |
identical_retry | Same search({"q":"same"}) returned retryable/no progress | Third execution was blocked as same_call_repeat; only two tool executions occurred |
alternating_cycle | read and check alternated without progress | Stopped after three executions as no_progress |
changed_state_then_success | Poll cursor and progress version advanced | Three distinct calls were allowed; the run completed on success |
fatal_no_retry | Write tool returned permission_denied | Stopped after one execution |
These fixtures prove the deterministic branching of this guard for those five cases. They do not prove that a model will label progress correctly. Production progress should come from external state, validated output, or a durable checkpoint.
The fourth fixture is as important as the blocked cases: a guard that stops every repeated tool name would break legitimate pagination and polling. Progress—not mere motion—is the deciding signal.
Map the policy to your framework
Use framework controls as the outer adapter, not as the complete design:
| Framework surface | Use it for | Keep in application code |
|---|---|---|
| OpenAI Agents SDK runner and max-turn handling | Bound the runner and handle its stopping/error path | Fingerprints, progress, idempotency, recovery |
| LangChain model/tool-call middleware | Per-run, per-thread, or per-tool ceilings | Business completion and side-effect records |
| Google ADK LoopAgent limits/exit | Bounded workflow loop and explicit escape | Domain progress and safe resume |
| Custom loop | Full control of dispatch and history | All guards, durable state, and tests |
Recheck exact option names and defaults in official documentation before pinning them in production configuration. The durable design principle is stable; framework APIs are not.
Verification checklist
Before reconnecting real tools, replay the captured incident against fakes:
- The next repeated call is blocked before tool execution.
- A success response closes the matching state transition.
- A permanent error cannot enter the retry path.
- A recoverable error retries only after a relevant change and within a deadline.
- A two-tool cycle triggers no-progress detection.
- Productive pagination advances an external cursor and remains allowed.
- A repeated side-effect operation returns its durable recorded result.
- Restarting the process does not reset the operation record or global budget.
- The stopped run exposes one reason and a bounded set of next actions.
- Logs contain no credentials, private payloads, or raw sensitive tool results.
Once the application loop is stable, protect the paid request path separately. The LLM agent API spend kill switch explains how to block the next provider call with atomic budget reservation. Loop control prevents repeated behavior; a spend gate prevents the next paid request. Production agents need both.


