To stop an agent looping on the same failed tool call, add a no-progress guard that halts when the same (tool, arguments, error) tuple repeats 2 to 3 times, because LangGraph's default recursion_limit of 25 and LangChain's default max_iterations of 15 only cap total steps, not repetition. Recursion and iteration limits are backstops that fire after the budget is already spent, so they turn a stuck run into a wasted one instead of catching it early. Fix the two root causes first: tool descriptions that never state a stop condition, and lossy scratchpad summarization that hides that a tool was already tried. LangChain's community requested a native progress-aware guard in issue #36139, but it was closed in June 2026 without shipping, so this remains code you build yourself.
Your agent has been running for ninety seconds. It calls run_sql, gets a syntax error, calls run_sql with the same query, gets the same error, and does it again, and again, until the framework finally throws a recursion-limit exception and the whole run dies with nothing to show for it. AI agents looping on the same failed tool call is one of the most common and most expensive ways a production agent fails, and the default guardrails do almost nothing to stop it. LangGraph's recursion limit and LangChain's max-iterations counter both cap the total number of steps, but neither one notices that every step is identical.
This is not a rare edge case. LangChain issue #26019, filed in September 2024, describes it exactly: "The agent repeatedly calls the same tool without processing the output or providing a response to the user." In January 2026, LangGraph issue #6731 reported a text-to-SQL agent that looped until it hit "Recursion limit of 20 reached without hitting a stop condition." By March 2026 the pattern was common enough that the community opened issue #36139 asking for a built-in "progress-aware termination" guard. That request was closed as an external feature request in June 2026 without a native implementation shipping, which means the fix is still something you build yourself.
This post shows how to build it. We will name the failure signature precisely (a repeated (tool, arguments, error) tuple across consecutive steps), explain the two root causes we see most often across the production agent systems we audit at Particula Tech, and walk through a no-progress guard you can install as LangChain middleware. The core idea is small: hash what the agent just did, and if it does the identical thing twice in a row with the same result, stop, because a step that changes nothing is not progress no matter how much budget is left.
01 · Why recursion and tool-call limits do not catch stuck loops
Recursion and tool-call limits only cap the total number of steps an agent takes; they never inspect whether those steps make progress. An agent that repeats one identical failing call will burn the entire budget and then die with a limit error instead of stopping early with a diagnosis.
The recursion limit is the maximum number of super-steps a LangGraph graph runs before it raises an error. According to LangGraph's documented default (2026), an agent with no configured limit stops at 25 steps with the message "Recursion limit of 25 reached without hitting a stop condition. You can increase the limit by setting the recursion_limit config key." The classic LangChain AgentExecutor has the analogous knob. According to its reference documentation (2026), max_iterations defaults to 15, the maximum number of agent steps before the loop ends, and setting it to None can produce a genuinely infinite loop.
One thing to be careful about: the number in your traceback is whatever you configured, not a fixed law. In LangGraph issue #6731 the reporter had set recursion_limit to 20, so the agent looped exactly 20 times before failing. Leave it alone and you get 25. Either way the value is a ceiling, not an explanation. Worth noting too, that same issue only reproduced on langgraph 1.0.6 and not on the 0.6.x line, a reminder that loop behavior can shift across framework versions and belongs in your regression tests.
The takeaway: a step limit is a backstop against a truly infinite loop, not a detector of a stuck one. It answers "has this run gone on too long" and never "is this run getting anywhere." Keep the limit, but do not mistake it for the fix. If you are still deciding how many reasoning steps an agent should take in the first place, our guide to tuning the reasoning-step count in agent loops covers the healthy-loop side of the same question; this post is about the pathological one.
| Guard | What it counts | Catches a stuck loop? | Default |
|---|---|---|---|
LangGraph recursion_limit | total super-steps | No, only after the budget is exhausted | 25 |
AgentExecutor max_iterations | total agent steps | No, only after the budget is exhausted | 15 |
| No-progress guard | repeated (tool, args, result) tuples | Yes, halts on the 2nd or 3rd identical step | none built in |
02 · The failure signature: repeated (tool, arguments, error) tuples
A no-progress loop has a precise, machine-detectable signature: the same tool, called with the same arguments, returning the same result or error, on consecutive steps. If you can compute that tuple, you can catch the loop long before any recursion limit fires.
A no-progress loop is a sequence of agent steps in which the observable state does not change: identical action in, identical observation out. The unit to track is the tuple (tool_name, canonical(arguments), result_signature). Canonicalizing the arguments matters, because {"q": "SELECT 1"} and {"q":"SELECT 1"} describe the same call and must hash the same way, so serialize with sorted keys and normalized whitespace before you hash.
Not every repeat is a loop, and this is the distinction that separates a useful guard from an annoying one. An agent polling a job-status endpoint calls the same tool with the same arguments on purpose, but the result moves from "running" to "done", so the result signature differs and the guard stays silent. A retry with exponential backoff is fine for the same reason, as long as the error eventually changes. The signature you actually want to catch is the degenerate one where the third element, the result, is also identical: same call, same failure, no new information entering the loop. That is the moment to stop, and it is usually visible on the second or third occurrence, not at step 25.
According to LangChain issue #26019 (September 2024), this signature was described in plain language before anyone had named it: the agent "repeatedly calls the same tool without processing the output." Processing the output is exactly what a stuck agent fails to do. It re-emits the same action because, as far as its context is concerned, nothing about the situation has changed.
03 · Root cause: tool descriptions that never say when to stop
The most common root cause of a stuck loop is a tool whose description tells the model how to call it but never tells it when to stop calling it. The model has no stated exit condition, so on failure it does the only thing the description sanctions: call the tool again.
According to Gabriel Anhaia's dev.to walkthrough (April 2026), this is the first root cause. The tool description encodes inputs and behavior but no stop condition, so the model treats a failed call as an invitation to retry the exact same call. The second root cause is subtler and gets its own section below: when tool results are large, the scratchpad summarization that keeps the context window manageable can erase the record that the tool was already called, so the model re-derives the same next action from scratch.
The description-level fix is cheap, and you should do it first. Write tool descriptions that state the exit condition explicitly. Instead of "Runs a SQL query and returns rows," write "Runs a SQL query and returns rows. If this returns a syntax error, do not call it again with the same query; revise the query or report the error to the user." That single sentence gives the model the exit it was missing. This is the same discipline we cover in our guide to making agents use tools correctly, and repeat-until-recursion-limit sits near the top of the list in the agent mistakes worth engineering out.
Anhaia's second recommendation pairs with the description fix: feed a compact, visible tool-call history back into the prompt on every step. A few lines of "you already called run_sql with this query and got this error" give the model a chance to see its own repetition even after summarization has compressed the raw results. The description tells it when to stop in the abstract; the history shows it that the stop condition has already been met.
04 · Build a no-progress guard to detect an agent looping on the same tool call
The durable fix is a no-progress guard: code that hashes the recent (tool, arguments, result) tuples and halts the agent when it sees the same tuple N times in a row. In LangChain 1.0 and later, the natural home for this is middleware.
Middleware, introduced when LangChain 1.0 and LangGraph 1.0 went generally available in October 2025, is a hook layer around the create_agent runtime where you can inspect and modify state before and after each model or tool step. LangChain ships built-in middleware for human-in-the-loop, summarization, and PII redaction; a no-progress guard is a custom one you add in the same slot. To be clear about currency: no native no-progress middleware ships as of mid-2026, because issue #36139's request for one was closed in June 2026 without adoption. This is code you own.
The guard itself is small. Hash a bounded signature of each completed tool step, keep a short window of recent hashes, and raise a clear, catchable error when one repeats past your threshold:
import hashlib
import json
from collections import deque
class NoProgressError(RuntimeError):
"""Raised when an agent repeats an identical tool step with no new result."""
class NoProgressGuard:
def __init__(self, max_repeats: int = 3, window: int = 6):
self.recent: deque[str] = deque(maxlen=window)
self.max_repeats = max_repeats
def _signature(self, tool_name: str, args: dict, result: object) -> str:
payload = json.dumps(
{"tool": tool_name, "args": args, "result": str(result)[:2000]},
sort_keys=True,
)
return hashlib.sha256(payload.encode()).hexdigest()
def after_tool(self, tool_name: str, args: dict, result: object) -> None:
sig = self._signature(tool_name, args, result)
self.recent.append(sig)
if self.recent.count(sig) >= self.max_repeats:
raise NoProgressError(
f"Tool '{tool_name}' produced an identical call and result "
f"{self.max_repeats} times; halting. Last result: "
f"{str(result)[:200]}"
)The constants are yours to tune, and it is worth being precise about where they come from. Anhaia's article suggests a starting point of stopping after the same tool repeats three times (max_same_tool=3) inside a small window, paired with a per-run token budget of around 50,000 tokens. Treat those as one author's illustrative example values, not framework defaults. Across the agent stacks we harden, we start strict, halting on the second identical tuple for tools that should never legitimately repeat (a pure read query, a deterministic calculation), and loosen the threshold only for tools that poll or retry by design. The one rule we do not bend: the guard raises a named, logged error, never a swallowed exception, so a halt is a diagnosable event rather than a silent dead end.
05 · Debounce hooks and stop conditions in tool schemas
Two lighter-weight guards complement the middleware: a debounce hook that short-circuits an identical call before it executes, and stop conditions written directly into the tool schema so the model has an exit encoded in the contract it reads.
A debounce guard is a wrapper on the tool itself that remembers the last (arguments, result) it produced and, on an identical repeat within the same run, returns a terminal message instead of re-executing. Something like "You already called this with these arguments and got this error. Change the input or stop." This saves the cost of the redundant call, and more importantly it injects a different observation into the loop, which is frequently enough on its own to break the model out. The middleware guard from the previous section catches the loop after it happens; the debounce wrapper tries to prevent the second identical execution in the first place.
Stop conditions in the tool schema are the structural version of the description fix. Encode, in the tool's contract, the states after which it must not be re-called. A well-designed tool returns a structured result with an explicit terminal flag, for example {"ok": false, "error": "syntax", "retryable": false}, so both your guard code and the model can act deterministically on "this cannot succeed as-is." A retryable: false result is a signal your debounce wrapper and your middleware can key off directly, without inferring intent from free text.
The layers stack, cheapest first. This is roughly the order we install them:
If you are choosing between a plain function-calling loop and a ReAct-style agent for this workload, the loop dynamics differ, and our comparison of function calling versus ReAct agent patterns covers where each one tends to get stuck and how the guard placement changes.
| Layer | Where it lives | Fires | Cost to add |
|---|---|---|---|
| Description / schema stop condition | tool definition | model reads the exit condition | minutes |
| Debounce wrapper | around the tool | before an identical re-execution | low |
| No-progress middleware | agent runtime hook | on N repeated tuples | moderate |
| Recursion / iteration limit | framework config | total-step backstop | already there |
06 · How context summarization loss re-triggers loops
Long-running agents summarize their scratchpad to stay under the context window, and lossy summarization can delete the very record that a tool was already tried, which re-triggers a loop you thought you had broken. The fix is to keep tool-call history out of the lossy part of the context.
Here is the mechanism. When the raw result of a tool call is large (a 10,000-row query dump, a long document), summarization compresses it to reclaim tokens. A naive summarizer keeps the interesting content and drops the bookkeeping, so the fact that run_sql was already called and already failed disappears from the model's view. The model, now blind to its own history, re-derives the identical next action and the loop restarts. Your no-progress middleware will still catch it, but you have paid for the round trips and the summarization pass to get back to where you were.
According to LangChain's 1.1 release notes (late 2025), a model-profile-driven summarization middleware shipped with more flexible trigger points, precisely to make long-session behavior more predictable. That helps, but the guard against loop re-triggering is on you: preserve a compact, structured tool-call ledger (tool name, args hash, terminal flag) in a region of context that summarization does not touch. Then both the model and your no-progress guard keep seeing "this was already tried" even after the bulky result has been compressed away. The principle is the same restorable-compression discipline that governs long-horizon agents generally: summarize the payload, never the fact that the step happened.
07 · Testing loop termination before production
Test loop termination the way you test any failure path: with deterministic fixtures that force the agent into a repeat, then assert that your guard halts it in a bounded number of steps with a clear error, not a recursion-limit crash.
Build a fixture tool that always returns the same error, point the agent at a task that will make it call that tool, and assert two things. First, the run stops at your guard's threshold (say, 3 identical calls). Second, it stops with a NoProgressError you can log and route, not a GraphRecursionError at step 25. Then add the mirror-image test that keeps the guard honest: a fixture whose result legitimately changes on the third call (a poll that resolves from "running" to "done"), and assert the guard does not fire. A guard that halts legitimate retries is worse than no guard, because it fails healthy runs while looking like it is protecting them.
Termination behavior is a reliability property, not an accuracy one, and that distinction changes how you measure it in production, which we cover in reliability versus accuracy as separate agent metrics. Track the rate of no-progress halts as a first-class metric. A rising halt rate almost always means a tool's description or a schema stop condition has drifted, or an upstream API started failing, not that the guard is misbehaving. The guard becomes a sensor for tool-surface regressions, which is a second payoff on top of stopping the loops.
08 · Bottom line: what to build, what to skip
Do not wait for the framework to ship a native no-progress guard. Issue #36139's request for one was closed in June 2026 without adoption, so build the guard yourself, and build it in this order:
retryable flag.(tool, arguments, result) tuples and halts at 2 to 3 repeats with a named, logged error.recursion_limit (25) and max_iterations (15) as a backstop**, never as your primary defense.What to skip is just as important. Do not "fix" a stuck loop by raising the recursion limit; that converts a 25-step waste into a 100-step waste and hides the real problem. Do not set max_iterations=None, which removes the last backstop and invites a genuinely infinite loop. And do not rely on the model to self-correct from an observation that never changes, because an identical result carries no new information for it to correct against. The whole point of the guard is to change the observation or end the run.
This ordering, cheap description fixes first and custom middleware only where they are not enough, is exactly what we install and pressure-test in Particula Tech's agent architecture audits: we instrument the loop, reproduce the stuck signature against fixtures, and leave the no-progress guard and its regression tests in place so the same failure cannot ship twice. Start with one tool description today. Pick the tool your agent calls most, add a stop condition, and watch how many of your "recursion limit reached" incidents were really one unchanged call repeated until the budget ran out.
09 · FAQ
Quick answers to the questions this post tends to raise.




