How to Stop an AI Agent Tool Loop—and Keep It Stopped
Stop new tool execution, preserve verified results, and reconcile any uncertain write before resuming. A turn limit contains a loop; progress checks and operation records make recovery safer.
On this page

To stop an AI agent tool loop, block new calls at the tool executor, stop the outer runner, and check whether its last write already committed before resuming. Preserve the trace and return a terminal result that the caller honors. Raising the iteration limit or asking the model to stop can leave the same operation running again.
A useful fix must answer two separate questions: “May this agent execute another call?” and “What happened to the previous operation?” A hard budget answers the first. Verified progress and a business operation record help answer the second.
Stop the loop now, before debugging it
For an active incident, work from the execution boundary outward:
- Close dispatch for the affected task. Block new tool calls and child-agent work, including automatic retries. Put the stop flag where all executors consult it, rather than only in the chat UI.
- Record what is running. Save the task ID, attempt IDs, pending operation IDs, last verified checkpoint, tool results, and the stopping reason. Keep credentials and sensitive arguments out of diagnostic logs.
- Request cancellation through the runtime you control. Confirm what actually stopped. Closing a stream or displaying “Stopped” does not establish that the worker or an in-flight request stopped.
- Reconcile pending writes. Look up the business operation in the destination or its durable operation record. If an acknowledgement was lost, record the outcome as unknown until reconciliation establishes it.
- Return a terminal result. Make the caller finish, return partial work, or wait for human input. It must not catch the stop and silently launch a fresh agent attempt.
Cancellation does not undo an email, payment, or database update already committed. It also does not prove that an in-flight write failed. A timeout wrapper such as Promise.race can stop waiting while the underlying request continues; it is not a write cancellation mechanism.
If you use Amazon Bedrock AgentCore, its documented StopRuntimeSession API is a runtime-specific cancellation route. Its stop-request idempotency token protects that request, not the agent's earlier business writes. Other runtimes need their own supported cancellation path and confirmation.
Diagnose repeated calls from the trace
Start with what the executor observed. Model explanations can help interpret intent, but they do not establish progress or a committed transaction.
| Observable pattern | Check next | Appropriate response |
|---|---|---|
| Identical read, identical result | Was the result attached to the correct tool-call ID? Did the planner consume it? | Use the existing verified evidence; block another equivalent read if it adds nothing |
| Slightly different arguments, unchanged outcome | Normalize irrelevant defaults and inspect domain progress | Stop useless variation or choose a different strategy |
| Two tools alternate | Compare the sequence against the last verified checkpoint | Apply a no-progress limit even when exact calls differ |
| A successful write runs again | Check operation ID, recorded receipt, and the caller's completion condition | Return the recorded result; do not repeat the write |
| Permission or validation error repeats | Check the error classification and unchanged precondition | End the retry path or request the required human action |
| A timeout follows a write | Check whether the destination committed it | Reconcile before any replay |
| Polling returns the same status | Check the polling interval, deadline, and attempt budget | Allow bounded waiting if intended; do not label elapsed time as progress |
| Pagination returns more validated records | Check cursor lineage, uniqueness, and completion | Continue within the remaining task budget |
An action fingerprint should represent the meaningful tool and normalized input. Sorting JSON keys catches reordered arguments; domain normalization can catch irrelevant whitespace or defaults. Do not normalize away a field that changes the operation. A hash is also not a privacy guarantee for predictable sensitive values; keep protected inputs out of public logs.
For a blocked read-only call, first inspect its recorded result. If it already answers the question, finish from that evidence. If it does not, return a precise partial result or try a permitted approach that can add information. Resetting the counter to repeat the same read is not recovery.
Why does the agent call tools after success?
Tool success and task completion are different conditions. A successful page fetch may leave pages to read; a confirmed update may complete the whole request. The application must validate that distinction and expose it to the runner.
Also inspect the driver. OpenAI's function-calling documentation distinguishes auto, required, a forced function, and none. If each request still requires a tool call, an ordinary final answer may not be available on that request. That is one possible cause of continued calls; check the actual request rather than assuming all loops share it.
A historical n8n HubSpot case reported successful updates followed by repeated calls until the iteration cap. Clearer instructions improved that case. A clearer completion condition is useful, but runtime termination and duplicate-write protection still need executable rules.
Set limits that count the right work
Budget the user task across attempts, not just one invocation of a helper. A model turn can propose several tools; a tool can perform several internal retries; a restarted workflow can create a new agent invocation. Count each relevant resource at the boundary that consumes it.
| Control | Counted unit and scope | Important limitation |
|---|---|---|
| Model-call cap | Actual model requests in the configured run or thread | Does not necessarily count every tool execution |
| Tool-call cap | Tool invocations, optionally for a named tool | A blocked tool may leave the model or other tools running |
| Workflow iteration cap | Cycles through an enclosing workflow | A cycle may contain multiple model and tool calls |
| Task-wide budget | Work across retries, child agents, new turns, and resumed attempts | Requires shared accounting; a fresh local counter cannot enforce it |
| Deadline | Admission of new work after an absolute time | Cannot roll back an already submitted operation |
Current LangChain JavaScript middleware makes these differences concrete. modelCallLimitMiddleware supports run and thread limits. toolCallLimitMiddleware also supports these scopes, with thread persistence requiring a checkpointer and the same thread ID. Run limits reset on a new user turn. The tool limiter's default continue behavior blocks excess calls while allowing the model to continue; use the documented error or applicable end behavior when your caller must terminate. End behavior has restrictions when multiple tool calls are pending.
The same documentation specifies two retries after the initial tool execution by default in toolRetryMiddleware, and default onFailure: "continue" returns a tool message to the model. The model may then propose a new call. Retry classification is version-dependent, so pin your version and account for both middleware retries and application retries. Three internal attempts followed by another planner call are more work than one apparent tool step.
The OpenAI runner guide describes continuation after tools and handoffs, along with final results, failures, and approval pauses. Handle each path explicitly. An approval pause is not completed work. Google ADK's LoopAgent documentation describes iteration cycles and an explicit exit; its legacy workflow templates are superseded for Python and Go in ADK 2.0. An iteration count should not be presented as a universal count of model requests or tools.
Build a guard the caller cannot ignore
Use a sticky terminal state, check admission before each model request and tool dispatch, and accept progress only from application validation. A useful terminal response includes the reason, last verified checkpoint, uncertain operation IDs, and allowed next actions.
The following JavaScript prototype is intentionally serial and framework-neutral. It contains no SDK or external client. Its planner callback supplies one proposed action per turn; an SDK adapter that batches calls must check every tool separately and count model requests separately. The application owns the proposal's normalized inputKey, polling classification, and stable operationId; these are not trusted model assertions.
observe is a trusted domain adapter. It validates the raw result against the task and returns a checked envelope: status, a nondecreasing integer revision, a Boolean done, and—for a write—an authoritative receipt. Increment the revision only for newly validated work, such as accepted records or a confirmed state transition. A tool's “success” sentence is insufficient.
export async function runTask({ state, limits, now, next, execute, observe }) {
const finish = (status, reason) => {
state.terminal ??= {
status, reason, revision: state.revision,
uncertain: [...state.operations].filter(([, v]) => v.state === "unknown")
.map(([id]) => id)
};
return state.terminal;
};
const admit = () => {
if (state.terminal) return false;
if (now() >= limits.deadline) {
finish("partial", "deadline"); return false;
}
return true;
};
if (state.running) throw new Error("Serial prototype: concurrent entry rejected");
state.running = true;
try {
while (admit()) {
if (state.models >= limits.models) return finish("partial", "model_budget");
state.models++; // Reserve before asking the planner.
let call;
try { call = await next(); }
catch { return finish("blocked", "planner_error"); }
if (!admit()) break;
if (!call) return finish("partial", "no_action");
if (typeof call.inputKey !== "string" || typeof call.tool !== "string" ||
!["read", "write"].includes(call.kind) ||
(call.kind === "write" && !call.operationId)) {
return finish("blocked", "invalid_proposal");
}
let op;
if (call.kind === "write") {
op = state.operations.get(call.operationId);
if (op && op.inputKey !== call.inputKey)
return finish("blocked", "operation_conflict");
if (op?.state === "unknown") return finish("blocked", "reconcile_write");
if (op?.state === "committed") {
state.last = op.result; // Existing receipt, no new execution.
continue; // Repeated planning is still bounded by the model budget.
}
}
if (state.tools >= limits.tools) return finish("partial", "tool_budget");
const key = JSON.stringify([call.tool, call.inputKey, state.revision]);
const repeats = state.seen.get(key) ?? 0;
if (!call.poll && repeats >= limits.same)
return finish("partial", "same_call");
state.seen.set(key, repeats + 1);
state.tools++;
if (call.kind === "write") {
op = { state: "unknown", inputKey: call.inputKey };
state.operations.set(call.operationId, op); // Reserve before dispatch.
}
let result;
try { result = await observe(call, await execute(call)); }
catch { return finish("blocked", call.kind === "write"
? "reconcile_write" : "unverified_result"); }
if (!result || !["ok", "retryable", "fatal", "human"].includes(result.status) ||
!Number.isSafeInteger(result.revision) || result.revision < state.revision ||
typeof result.done !== "boolean" || (result.done && result.status !== "ok")) {
return finish("blocked", call.kind === "write"
? "reconcile_write" : "unverified_result");
}
if (call.kind === "write") {
if (result.status !== "ok" || typeof result.receipt !== "string" || !result.receipt)
return finish("blocked", "reconcile_write");
op.state = "committed"; op.result = result;
}
state.last = result;
state.idle = result.revision > state.revision ? 0 : state.idle + 1;
state.revision = result.revision;
if (state.terminal) return state.terminal; // An operator stop remains sticky.
if (result.status === "human") return finish("needs_human", "human_input");
if (result.status === "fatal") return finish("blocked", "fatal_result");
if (result.done) return finish("completed", "verified_done");
if (state.idle >= limits.idle) return finish("partial", "no_progress");
}
return state.terminal;
} finally { state.running = false; }
}For a fresh offline task, initialize its owned state and limits as follows. The numbers are demonstration choices, not framework defaults or production recommendations.
const state = {
models: 0, tools: 0, revision: 0, idle: 0, last: null,
terminal: null, running: false, seen: new Map(), operations: new Map()
};
const limits = { models: 8, tools: 6, same: 2, idle: 3, deadline: 1000 };The caller awaits runTask, returns its terminal envelope to the requester, and exits. It does not catch partial or blocked and recreate the state. retryable permits another planning step inside the same budgets; a real adapter must additionally enforce a retry allowance, backoff, and retry eligibility. This prototype conservatively sends every uncertain or failed write to reconciliation rather than automatically replaying it.
The deadline is checked at admission boundaries. It does not interrupt a hanging next or execute; configure supported transport deadlines and cooperative cancellation separately. If an operator sets state.terminal during a write, the result may still be reconciled into the operation record, but the runner returns the original stop and dispatches no further call.
What counts as real progress?
Count a page only after validating its cursor lineage and accepting new records. Count a write after checking its authoritative receipt or destination state. Count a task complete after checking its actual acceptance conditions, not after seeing done: true in model output.
Unchanged polling is allowed waiting, not progress. Give it a deliberate interval and a bounded allowance. In the prototype, application-classified poll bypasses the identical-call check, but still consumes the tool budget, idle allowance, and deadline. A new timestamp, longer backoff, or different wording does not reset the no-progress counter.

Prevent duplicate writes across retries and restarts
Assign an operation ID to the business action, such as a particular approved order confirmation. Keep it stable across model attempts, worker retries, and reconnects. A new tool-call ID identifies an attempt; it must not automatically create a new business operation.
Store the operation ID with an input fingerprint, state, and result. Reject reuse of the same ID with different meaningful input. For an already committed operation, return its recorded receipt. For a pending or unknown operation, reconcile before execution.
The prototype's Map and lookup-then-reserve sequence protect only its single serial owner. They do not provide exactly-once execution across parallel processes or restarts. Production needs atomic reservation with a uniqueness constraint or equivalent transaction, shared durable task budgets, and a destination idempotency contract or a reliable reconciliation procedure. A ledger entry alone cannot atomically cover a write to an unrelated external service.
If the destination committed but the worker crashed before recording the receipt, replaying from an empty map duplicates the action. The correct recovery asks the destination about the existing operation. Where it cannot establish the outcome, retain the unknown state and escalate rather than promising exactly-once behavior.

Validate containment with fakes before reconnecting tools
We ran the code above with an offline planner, fake executor, fake clock, and synthetic operation records. The domain observer in these checks accepted only deliberately constructed test results. No agent SDK, model request, or external business tool was used.
| Synthetic scenario | Observed behavior |
|---|---|
| Identical read without progress | Two executions; the next equivalent call was blocked before dispatch |
| Alternating tools without progress | Three executions; terminal no_progress |
| Validated pagination | Advancing revisions remained allowed; validated completion returned completed |
| Unchanged polling | Exempt from identical-call blocking, but stopped at the idle allowance |
| Successful operation proposed again | Recorded receipt reused; executor ran once; model budget still stopped repeated planning |
| Write outcome lost or malformed | Operation stayed unknown; runner returned reconcile_write |
| Same operation ID, different input | operation_conflict before a second execution |
| Fatal result or human requirement | Immediate terminal result, with no next dispatch |
| Deadline and exhausted tool budget | New work blocked at the relevant boundary |
| Re-entry after a stop; stop during a write | Terminal state retained; no further planning or dispatch |
These checks establish the prototype's branches for the supplied cases. They do not test real model convergence, external transaction semantics, transport cancellation, or durable concurrency. Before integration, add your captured incident as a redacted fake trajectory and assert both the terminal reason and the number of actual executor calls. A log saying “blocked” is not enough if the executor still ran.
Recover from the stopped run
Choose recovery from the verified state:
- Task already complete: Return the existing result and close the request.
- Useful work exists, but the task is incomplete: Return partial work with the last checkpoint and what remains.
- Read failed transiently: Retry only when the policy permits it, the relevant precondition allows it, and the original task budget has capacity.
- A different method can add evidence: Record the strategy change and use the remaining budget. Keep the failing action blocked.
- Write may have committed: Reconcile the same operation ID first. Do not turn it into a new operation to get around the record.
- Human input or permission is required: Pause with the specific decision needed, then resume only after that input is recorded.
A stopped attempt stays terminal. An approved recovery can create a linked attempt, but it retains the task-wide budget, deadline policy, verified checkpoint, and operation history. Restoring a transcript without these controls lets the same incident start again.
For example, a partial response can say: “Retrieved and validated pages 1–3. Page 4 is unavailable; further identical reads are blocked. No writes are pending. Allowed next steps: return these results or use the approved alternate source.” An uncertain-write response should instead identify the operation awaiting reconciliation and exclude blind retry from its allowed actions.
Frequently asked questions
Should I just increase the maximum iterations?
Increase it only when a verified trajectory needs more useful work and the task budget allows it. A limit reached after repeated unchanged results is a reason to inspect the loop, not evidence that the limit is too low. In n8n's Tools Agent, Max Iterations defaults to 10 model runs; it is not a count of every tool invocation or a task-wide cost ceiling.
Can a clearer prompt stop retry loops?
It can improve behavior, especially when the instructions never define completion or the tool response is ambiguous. The AWS author's Strands loop article illustrates clearer success feedback and pre-execution hooks. Its reported demo is not a guarantee for other workloads. Put hard enforcement in the executor and caller so safety does not depend on the model following the prompt.
How do I revoke an AI agent mid-task safely?
Close dispatch for the task, request supported runtime cancellation, confirm the remaining worker and operation states, and return an operator-stop result. Preserve any unknown writes for reconciliation. Revocation stops future authorized work; it does not reverse committed actions. Follow the runtime's supported controls rather than assuming that closing its UI cancels execution.
Is a tool-call cap enough to stop all costs?
No. A tool cap may still allow model requests, other tools, child agents, or new invocations. Put all paid request paths through a shared budget gate. The separate LLM agent API spend kill switch guide explains pre-provider admission using atomic worst-case reservation and usage reconciliation; that cost boundary complements the behavior and recovery controls here.
Sources8
External pages this guide links to, in the order they appear. Last updated Oct 7, 2026.
Sources8
External pages this guide links to, in the order they appear. Last updated Oct 7, 2026.
- 1.StopRuntimeSession APIdocs.aws.amazon.com/bedrock-agentcore/latest/APIReference/API_StopRuntimeSession.html
- 2.function-calling documentationdevelopers.openai.com/api/docs/guides/function-calling
- 3.n8n HubSpot casecommunity.n8n.io/t/agent-goes-into-infinite-tool-call-loop/174472
- 4.LangChain JavaScript middlewaredocs.langchain.com/oss/javascript/langchain/middleware/built-in
- 5.OpenAI runner guidedevelopers.openai.com/api/docs/guides/agents/running-agents
- 6.LoopAgent documentationadk.dev/agents/workflow-agents/loop-agents
- 7.n8n's Tools Agentdocs.n8n.io/integrations/builtin/cluster-nodes/root-nodes/n8n-nodes-langchain.agent/tools-agent
- 8.Strands loop articledev.to/aws/how-to-prevent-ai-agent-reasoning-loops-from-wasting-tokens-2652





