Skip to main content

How to Stop an AI Agent Tool Loop—and Keep It Stopped

10 min readAI API

A practical way to stop repeated AI agent tool calls without losing the trace, replaying a side effect, or mistaking a hard cap for a complete fix.

AI agent loop guard checking repetition and idempotency before execution, then routing success, retryable results, and fatal errors to separate terminal paths.

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.

AI agent loop guard checking repetition and idempotency before execution, then routing success, retryable results, and fatal errors to separate terminal paths.

Stop the loop now, before debugging it

Use this sequence for a live incident:

  1. Pause dispatch. Stop the planner from scheduling more tool calls or sub-agents.
  2. 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.
  3. Save the evidence. Persist the run ID, state version, last successful transition, tool names, canonical arguments, results, errors, token usage, and timestamps.
  4. Cancel at the runtime. Terminate the worker or runner; a prompt saying “please stop” is not the safety boundary.
  5. Record one terminal reason. Use a value you can query later: same_call_repeat, no_progress, fatal_tool_result, budget_exceeded, or operator_stop.
  6. 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 patternLikely failureEvidence to checkSafe next action
Same tool, same arguments, same resultIdentical retryStable call fingerprint and unchanged state versionBlock before the next execution
Arguments change slightly, outcome does notJitter without progressNormalized arguments, result class, progress invariantStop or require a new strategy
Tool A and Tool B alternateShort cycleRepeated sequence plus no state advanceBreak the cycle and escalate
Tool returned success, then runs againSuccess replayDurable operation record and missing terminal transitionReturn the recorded result; never replay
Same auth, schema, or policy errorPermanent failure retryError class and unchanged preconditionTerminate or request human input
Timeout or rate limit with a changing backoff statePotentially recoverable retryRetry count, deadline, server guidance, changed time/preconditionRetry within a small explicit budget
Pagination or polling repeats while cursor/version advancesProductive repetitionMonotonic cursor, version, or completed-work countContinue 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:

LayerQuestion it answersWhat it cannot prove
Hard capHas the run exceeded steps, calls, time, tokens, or cost?Whether the work was progressing
Repetition detectorHas this action or short sequence already happened?Whether repetition was legitimate
Progress detectorDid external state or validated output advance?Whether a side effect is safe to replay
IdempotencyHas this business operation already committed?Whether the agent should continue planning
Terminal resultIs this success, retryable, fatal, blocked, or waiting for a human?Whether the overall user task is complete
Recovery policyCan 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.

ts
type 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:

  1. Stable arguments. Sort keys and remove irrelevant defaults before hashing. Never put secrets into logs or fingerprints.
  2. 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.
  3. 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.

ts
async 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:

StatusMay retry?Required change before another call
successNo equivalent replayStart a new state transition or finish
retryable_errorYes, within a small retry/deadline budgetTime, endpoint health, backoff state, or another relevant precondition
fatal_errorNoCorrect input, code, or strategy
permission_deniedNo automatic retryHuman changes authorization
policy_blockedNo automatic retryHuman/policy owner resolves the block
needs_humanNoResume 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.

FixtureSimulated behaviorObserved result
success_after_oneCRM-like tool returned success immediatelyCompleted after one execution
identical_retrySame search({"q":"same"}) returned retryable/no progressThird execution was blocked as same_call_repeat; only two tool executions occurred
alternating_cycleread and check alternated without progressStopped after three executions as no_progress
changed_state_then_successPoll cursor and progress version advancedThree distinct calls were allowed; the run completed on success
fatal_no_retryWrite tool returned permission_deniedStopped 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 surfaceUse it forKeep in application code
OpenAI Agents SDK runner and max-turn handlingBound the runner and handle its stopping/error pathFingerprints, progress, idempotency, recovery
LangChain model/tool-call middlewarePer-run, per-thread, or per-tool ceilingsBusiness completion and side-effect records
Google ADK LoopAgent limits/exitBounded workflow loop and explicit escapeDomain progress and safe resume
Custom loopFull control of dispatch and historyAll 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.

#AI Agents#Tool Calling#Agent Reliability#API Guardrails
Share: