If you run an agent unattended for long enough, you will meet the loop. It does not crash. It does not throw. It just keeps calling tools, and the bill keeps growing.
The usual fix is simple and it is in almost every thread about the problem: hash the tool name and arguments, keep a sliding window, and kill the run when the same hash shows up too often. Add max_iterations or LangGraph's recursion_limit as a backstop and you are done.
Except you are not. This post looks at why that fix misses the loops that cost the most, and what to check instead.
The common fix
Here is the pattern most teams start with. One commenter on r/AI_Agents described it like this:
"I have seen this happen a lot, I track hashes of tool calls, if the same call shows up multiple times in a short window I throw a soft error and make it pause" u/moneyman2345, r/AI_Agents (Reddit)
In code, it looks roughly like this:
import hashlib, json
from collections import deque
window = deque(maxlen=10)
def call_key(tool_name, args):
raw = tool_name + json.dumps(args, sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()
def before_tool_call(tool_name, args):
key = call_key(tool_name, args)
if list(window).count(key) >= 2:
raise RuntimeError("Loop detected: same call 3 times")
window.append(key)
Plus a hard cap on steps:
agent.invoke(inputs, config={"recursion_limit": 50})
This catches the dumbest loop: the exact same call with the exact same arguments. That loop is real, and catching it for almost no cost is worth it. The problem is what it does not catch.
Why it fails
1. Near-identical retries
Models rarely repeat themselves byte for byte. When a tool call fails, the next attempt usually changes something small: a page number, a timestamp, a slightly reworded search query, an extra filter. The hash is new. The window sees nothing. And the run is still stuck.
One reply in the same r/AI_Agents thread put it precisely (note: the account belongs to a vendor):
"the loop that slips past it is the near-identical retry: same call with one arg nudged, so the hash differs while nothing actually progresses." u/Future_AGI, r/AI_Agents (Reddit)
2. Busy is not the same as progress
The most expensive loops do not look like loops at all. They look like work:
"For me it's almost never an error. The agent stays busy and looks fine. The tell is repetition: the same tool call with slightly different arguments, the same file read for the third time, tests re-run with no code change in between." u/thelocalnode, r/AI_Agents (Reddit)
Reading the same file three times with different line ranges, re-running tests without touching code, searching with five phrasings of the same query. Every call is different. None of them moves the task forward.
3. Caps either kill real work or just delay the burn
Step caps count steps. They do not know if a step was useful. So you have to pick a number, and every number is wrong for some task:
"max_iterations either kills legit long tasks or doesn't catch loops in time, and most people seem to just check in manually, which defeats the point of running unattended." u/Real_KingZeotic, r/AI_Agents (Reddit)
A low cap stops a valid 80-step migration. A high cap lets a stuck run spend until it reaches 80. Or as u/RouterDon wrote on r/LangChain (Reddit): "a static cap just delays the same burn".
4. Retries and fallbacks live outside the counter
Your loop detector usually sits inside the agent loop. But a lot of spend happens below it: the HTTP client retries a 503, the framework re-asks after a parse error, a fallback switches to a bigger model. None of that shows up as a new tool call, so none of it is counted.
And sometimes the agent goes around your limits completely. In one thread, someone shared a story about an agent that hit a 503, found another API key in the repo, and kept going overnight. The fix suggested there:
"Give it a token scoped to that run and route all paid calls through a gateway that enforces one shared budget, including retries and fallbacks." u/Neither_Event4902, r/AI_Agents (Reddit)
What works better
None of this means you should delete your hash check. Keep it, it is cheap. But add three things around it.
1. Detect lack of progress, not repetition
Instead of asking "have I seen this call before?", ask "did the last N steps change anything?". Progress is visible in the world, not in the call: new tool output, a changed file, a new row, a test that went from failing to passing.
A simple version: fingerprint the state that matters for the task and the important parts of each tool result, and stop when nothing new has appeared for a few steps.
# Illustrative only. Generic pattern, not any product's API.
import hashlib, json
class ProgressGuard:
def __init__(self, patience=4):
self.patience = patience
self.seen_outputs = set()
self.last_state = None
self.steps_without_progress = 0
def _fp(self, value):
return hashlib.sha256(
json.dumps(value, sort_keys=True, default=str).encode()
).hexdigest()
def after_step(self, tool_output, task_state):
new_output = self._fp(tool_output) not in self.seen_outputs
state_changed = self._fp(task_state) != self.last_state
self.seen_outputs.add(self._fp(tool_output))
self.last_state = self._fp(task_state)
if new_output or state_changed:
self.steps_without_progress = 0
else:
self.steps_without_progress += 1
if self.steps_without_progress >= self.patience:
raise RuntimeError("No progress in the last steps, stopping run")
The hard part is choosing task_state. For a coding agent it might be the git diff and the test results. For a data agent, row counts and the last written record. For a support agent, the ticket status. If the output and the state stay the same, it does not matter how different the arguments look.
This also lets you raise your step cap. A long task that keeps producing new results can run. A short task that stops changing anything gets stopped early.
2. Enforce the budget outside the agent
The budget should not live in the agent's prompt or in the agent's process. Put it where every paid call has to pass: a proxy or gateway in front of the model provider, with a key scoped to the agent or run. Count everything that costs money, including retries, re-asks and fallbacks. Then the agent cannot spend past the limit by retrying, switching models, or finding another key, because it never had one.
Another commenter in the same thread described exactly this: one proxy that tracks spend, call count and elapsed time against a run_id and hard-fails at the ceiling.
3. Check the end state, not the agent's "done"
The last loop is the one where the agent stops and says it finished. The same thread had the best rule for that:
"never trust the agent's own "done". Each run ends with a deterministic checker that queries end state - row exists, status = sent, file has N rows, invoice total matches." u/Responsible-Fix6695, r/AI_Agents (Reddit)
The check should read the real system, not the agent's summary of it. If you cannot write that check, the task is probably not ready to run without a person watching.
Summary
- Hashing tool name and arguments catches exact repeats. Keep it, but do not trust it alone.
- Near-identical retries and busy work pass right through it.
- Step caps pick a number that is wrong for some task. They decide when the burn stops, not whether it happens.
- Track progress: did tool output or task state change in the last few steps?
- Keep the budget outside the agent, and count retries and fallbacks in it.
- End every unattended run with a deterministic check of the real end state.
Put the budget outside your agent
ValGuard sits between your app and your LLM providers. Set a monthly USD budget per agent that blocks when reached (on Growth and higher, it can fall back to another agent or a cheaper model), add optional monthly token caps, see spend by agent and per request, and run deterministic validators, such as schema checks, on each step of a playbook. The progress check and the end-state check for your task are still yours to write; the patterns above are a good start.
See the cost control docs or compare plans.