How to stop runaway AI agent loops before they burn budget
Why AI agent loops run away, what they cost as context grows, and how to bound them with iteration limits, token budgets, deadlines and repeated-action checks.
To stop a runaway AI agent loop, give every loop that calls a model at least one hard bound that does not depend on the model deciding to stop: a maximum number of iterations, a token or dollar budget per run, or a wall-clock deadline. Ideally use all three, plus a check that halts when the agent repeats the same action. When a bound trips, end the run with a clear status and whatever partial result exists instead of retrying.
The reason this matters is arithmetic. An agent loop is not a fixed-cost operation. Each iteration usually sends the whole growing transcript back to the model, so a loop that runs ten times longer than expected can cost far more than ten times as much.
Why agent loops run away
A typical agent is a while loop: send the transcript to the model, run the tool it asks for, append the result, repeat until the model returns a final answer. The exit condition belongs to the model. Common ways that goes wrong:
- A tool keeps failing (a 403, a missing file, a schema error) and the model keeps trying variations instead of giving up.
- The goal is impossible or ambiguous, so the model never judges itself done.
- Two agents hand work back and forth, each deciding the other should handle it.
- A planner re-plans after every step and the plan never converges.
- The model emits malformed tool calls, the framework returns a parsing error as an observation, and the cycle repeats.
Research on this failure mode treats it as a class of bug that can be found statically. IAL-Scan (Hou et al., arXiv:2607.01641) reports detecting infinite agentic loops at 91.9% precision across 6,549 real repositories, and lists the harms: cost exhaustion, model denial of service, unbounded context growth, and repeated side effects such as sending the same email or opening the same ticket many times.
What a runaway loop costs
Example (illustrative price of $3 per 1M input tokens, output ignored):
Starting context: 5,000 tokens. Each step adds 2,000 tokens.
Input tokens at step k = 5,000 + 2,000 * (k - 1)
Expected run, 10 steps: 50,000 + 2,000 * 45 = 140,000 tokens ~ $0.42
Runaway run, 200 steps: 1,000,000 + 2,000 * 19,900 = 40,800,000 tokens ~ $122
20x the steps, about 290x the cost, for one run.Multiply that by every user who can trigger the agent and the problem is obvious. Growing context is also why a reasoning model with a high thinking budget inside a loop is particularly expensive; see reasoning-budget-in-loop and full-history-resend.
The bounds, in order of importance
1. An iteration cap that is actually on
Most frameworks expose one: recursion_limit in the run config for LangGraph (and for LangChain 1.x's create_agent, which runs on LangGraph), max_turns in the OpenAI Agents SDK, max_iter on a CrewAI agent, and max_iterations on the legacy AgentExecutor, now in langchain-classic. Check the default in the version you use, and check what your code passes. A common trap, called out in the IAL-Scan paper, is a bound whose default is a sentinel such as None, 0 or -1 that the framework reads as unlimited. A config field named max_steps that defaults to None is not a bound.
# Looks bounded, is not: None means "no limit" in many agent loops.
class AgentConfig(BaseModel):
max_steps: int | None = None
# Bounded by default; callers can raise it, but never remove it.
class AgentConfig(BaseModel):
max_steps: int = Field(default=15, ge=1, le=100)2. A token or dollar budget per run
An iteration cap does not bound cost when each iteration gets more expensive. Track usage from each response (every major SDK returns input and output token counts) and stop when the run crosses a budget. This also covers tools that call models internally.
class RunBudget {
private spentUsd = 0;
constructor(private readonly limitUsd: number) {}
record(usage: { input_tokens: number; output_tokens: number }, price: { in: number; out: number }) {
// price is USD per 1M tokens for the model used on this call
this.spentUsd += (usage.input_tokens * price.in + usage.output_tokens * price.out) / 1_000_000;
}
assertRemaining() {
if (this.spentUsd >= this.limitUsd) {
throw new BudgetExceeded("Run stopped at $" + this.spentUsd.toFixed(2));
}
}
}
const MAX_STEPS = 15;
const budget = new RunBudget(0.5);
const deadline = Date.now() + 120_000;
for (let step = 0; step < MAX_STEPS; step++) {
budget.assertRemaining();
if (Date.now() > deadline) throw new DeadlineExceeded();
const res = await client.messages.create({ model, max_tokens: 1024, messages, tools });
budget.record(res.usage, PRICES[model]);
if (res.stop_reason !== "tool_use") return res;
messages.push({ role: "assistant", content: res.content });
messages.push({ role: "user", content: await runTools(res.content) });
}
throw new StepLimitReached(MAX_STEPS);3. A wall-clock deadline
A deadline catches loops that are slow rather than long, such as tools that hang and get retried. It also protects anything downstream of the run that is holding a connection or a lock.
4. Repeated-action and no-progress detection
Hash each tool call (name plus normalised arguments). If the same call appears three times in a run, or the same error comes back twice in a row, stop and report it. This ends the most common runaway pattern, a model retrying a failing tool, many iterations before the cap would.
import hashlib, json
from collections import Counter
seen = Counter()
def check_repeat(tool_name: str, args: dict, limit: int = 3) -> None:
key = hashlib.sha256((tool_name + json.dumps(args, sort_keys=True)).encode()).hexdigest()
seen[key] += 1
if seen[key] >= limit:
raise RepeatedAction(f"{tool_name} called {seen[key]} times with the same arguments")Loops that are not called agents
The same reasoning applies to code nobody thinks of as an agent loop:
- Retry wrappers around a paid call need a maximum attempt count, exponential backoff with jitter, and no retries on errors that will not fix themselves (400s, auth failures, content policy refusals). See retry-storm.
- Polling loops for async generation jobs (video, image, fine-tuning) need a deadline and a call to the provider's cancel endpoint on timeout, because the job keeps billing after your process stops reading. See orphaned-async-job.
- Self-correction loops ("validate the JSON, and if it fails ask again") need a cap like any other loop.
- Browser or computer-use agents that attach a screenshot every step multiply image tokens by step count. See vision-in-loop-uncapped.
What to do when a bound trips
- Return a distinct status (step limit, budget, deadline, repeated action) so callers and dashboards can tell these apart from success and from ordinary errors.
- Keep the partial transcript for debugging, but do not automatically restart the run. An automatic restart turns one bounded runaway into an unbounded series of them.
- Log the run's token usage and cost with the status. A rising rate of limit hits is an early signal that a prompt, tool or model change broke something.
- Put a per-user or per-tenant budget above the per-run budget, so a user who restarts a failing run over and over is still bounded. LLM spending limits covers that layer.
Pick bounds from observed data. Look at the distribution of steps per successful run and set the cap comfortably above the high end, then revisit it when prompts or models change. A cap that is too tight shows up as a spike in step-limit statuses, which is easy to see and cheap to fix.
How IntentiAI helps
The agent-loop-unbounded rule looks for loops that call a model or agent with no exit, and for agent bounds whose default is an unlimited sentinel such as None or -1. no-budget-guard reports when there is no token or dollar cap anywhere on the path to a paid call, and the retry, polling and vision rules above cover the other loop shapes. Run npx intentiai in the repository; add --local to keep everything on your machine.
Static analysis has limits here. It sees that a bound is missing or defaults to unlimited; it cannot see how many steps your agents take in production, and it does not trace exits hidden deep inside called functions. Pair it with runtime tracking of steps and cost per run. See the cost audit overview and the docs.