The job finished in nine seconds. The agent decided for itself when to stop, and it took forty-one tool calls to get there. Four of those tool calls did real work. The other thirty-seven were the model second-guessing itself, verifying the same fact three ways, and calling list_files between every other action because the prompt told it to be thorough. The user got their answer. The bill got a data point that made someone's Slack channel light up on Monday morning.
The agent completed the task. That is not the failure mode this issue is about. The failure mode is that the number of tool calls the agent takes to complete a task is decided by the model in the loop, and the runtime around it has no bounds. When the model decides to finish, it finishes. When the model decides to keep going, it keeps going. The prompt can suggest a ceiling ("use at most five tool calls"); the model will read the suggestion, agree with it in its reasoning trace, and then make its ninth tool call anyway. In production, at scale, some fraction of requests will do forty-one tool calls to a nine-second task, and some smaller fraction will do four hundred to a task the model never decides is complete.
Issue 012 covered the retry budget that keeps a job's total cost bounded when the queue retries it. This issue is the same idea applied one level inward: the four bounds that keep an agent's total cost bounded when the model, in effect, retries itself. All four live in the runtime, not the prompt: a step cap, a wall-clock cap, a cumulative token budget, and a no-progress detector that trips when consecutive steps produce the same tool call. The concept is widely known at conference-talk depth. The runtime that enforces it is not written down anywhere. This issue writes it down.
Why the bounds have to live in the runtime
There is a version of this article that says "put a step limit in your system prompt" and calls it done. That version is wrong for three reasons that show up the moment the code hits real traffic, and the reasons are worth being explicit about because the prompt version is what most teams reach for first.
The model has no reliable state across turns. When the runtime hands the model its next turn, the model sees the message history and the current tool responses. It does not see a counter. If you tell it in the system prompt "you have used seven of your ten tool calls", the model will nod and then take three more calls to answer a question that needed two, because "seven of ten" is a token in the context, not a hard bound on the loop. State that lives in the model's context is state the model treats as advice.
The model does not see wall-clock time either, and the wall-clock is exactly the axis that matters for interactive latency. A slow tool call in the middle of the loop (a database query, an external API, a nested LLM call) can eat thirty seconds without the model noticing, and the next model turn will happily add another five tool calls to the plan because the model's cost function is task completion, not user latency.
The last reason is the honest one. Prompt-based bounds are convenient because they do not require any new code, and code that does not exist cannot be tested. If the bound lives in the runtime, you can unit-test it. You can graph it. You can alert on it. You can look at the p99 of "steps used out of max" as a signal that your step cap is set too low. None of that is possible when the ceiling is a sentence in a system prompt that some percentage of requests will silently ignore.
The four bounds
Four bounds cover the four ways an agent can misbehave. Each catches a failure mode the others miss.
Step cap. A hard maximum on the number of tool-calling iterations, enforced by counting model turns rather than tool executions. Ten to fifteen is the right starting range for most interactive agents in mid-2026; production traces will pull the number one way or the other. Set it just above the p95 of the number of steps your task actually needs, so legitimate multi-step work completes and pathological runs terminate. The step cap catches the "agent kept going" case where the model refuses to emit end_turn.
Wall-clock cap. A hard maximum on elapsed real time from the moment the loop starts. Sixty to a hundred and twenty seconds for interactive agents, longer for background workflows. The wall-clock cap catches the "individual tool calls are slow" case, where a single database query or nested LLM call eats the entire latency budget while the step counter still shows plenty of room. The cap has to be checked at the top of every loop iteration, not just once at the start, and enforced with a real timeout on the currently-executing tool call, or the bound is theoretical.
Cumulative token budget. A hard maximum on total input plus output tokens spent across the whole loop. Token growth in an agent loop is superlinear because each new step appends its tool responses to the message history, and step ten sends step nine's responses back to the model as part of its input. A well-designed loop caps this at 100K to 500K tokens depending on the model's context window and the pricing tier. This bound catches the "the messages have grown into a monster" case where each individual step looks fine but the cumulative context has tripled the per-call cost.
No-progress detector. A tripwire that fires when consecutive steps produce identical tool calls (same tool name, same canonical arguments). The right threshold is two consecutive repeats in mid-2026 practice, though some agent frameworks use three. This is the bound that catches the model-is-in-a-loop case that the other three miss: the model is not over any raw cap yet, but it has decided to call search_docs with the same query three turns in a row because a previous tool response looked ambiguous. That is not a task; it is a loop, and the runtime should end it.

The diagram shows where each bound sits inside the loop. The two raw budgets (wall-clock and tokens) are checked at the top of every iteration, so a slow tool call cannot slip past the check by running while the flag would have been set. The no-progress detector runs after the model call because it needs the current step's tool calls to compare against the previous. The step cap is the final gate, checked after tool execution so a legitimate final step still gets to run.
The runtime, in about thirty lines
The implementation below is deliberately minimal. It uses no framework, has no dependencies beyond the LLM SDK and the standard library, and is the exact shape most teams should paste into a production codebase on the first pass. The four bounds are the four raise BudgetExceeded sites; adding any fifth bound is a matter of adding a fifth site.
import time
import json
import hashlib
class BudgetExceeded(Exception):
def __init__(self, reason, messages, steps, tokens):
self.reason = reason
self.messages = messages
self.steps = steps
self.tokens = tokens
def step_hash(tool_calls):
"""Canonical hash over the set of tool calls made in one step."""
key = sorted(
(tc.name, json.dumps(tc.arguments, sort_keys=True))
for tc in tool_calls
)
return hashlib.sha256(str(key).encode()).hexdigest()
def run_agent(user_request, tools, llm,
max_steps=10, max_wall_seconds=60,
max_tokens=100_000, max_repeats=2):
messages = [{"role": "user", "content": user_request}]
total_tokens = 0
start = time.monotonic()
last_hash, repeats = None, 0
for step in range(1, max_steps + 1):
if time.monotonic() - start > max_wall_seconds:
raise BudgetExceeded("wall_clock", messages, step, total_tokens)
if total_tokens > max_tokens:
raise BudgetExceeded("tokens", messages, step, total_tokens)
response = llm.call(messages=messages, tools=tools, timeout=max_wall_seconds)
total_tokens += response.usage.input_tokens + response.usage.output_tokens
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "end_turn":
return {"result": response.content, "steps": step, "tokens": total_tokens}
h = step_hash(response.tool_calls)
repeats = repeats + 1 if h == last_hash else 0
if repeats >= max_repeats:
raise BudgetExceeded("no_progress", messages, step, total_tokens)
last_hash = h
for tc in response.tool_calls:
result = execute_tool(tc, tools, remaining=max_wall_seconds - (time.monotonic() - start))
messages.append({"role": "tool", "tool_call_id": tc.id, "content": result})
raise BudgetExceeded("step_cap", messages, max_steps, total_tokens) Three details in the code are worth calling out because they are the ones that catch teams off guard. The wall-clock uses time.monotonic rather than time.time, because the wall-clock cap has to survive a system clock adjustment mid-loop. The tool executor is passed a remaining argument, so a single tool call cannot silently blow the wall-clock budget by taking longer than the entire cap allows. And the BudgetExceeded exception carries the partial state (messages, step count, token count) so the caller can render a fallback response, log the truncated agent trace, and attribute the failure to whichever bound tripped.
Common mistakes
Four failure modes come up often enough to name directly, and they are the specific ones that teams find in a retrospective rather than a design review.
Bounds in the prompt only. The version of this article that says "put max_iterations: 5 in the system prompt" is the mistake this article is written against. Prompt bounds are advisory. The model will treat them as suggestions, and a small percentage of requests will ignore them. If the ceiling matters (and if you are shipping to real users, it matters), the ceiling has to live in code the model cannot see.
Step cap set to the token budget's math. Teams pick a step cap by dividing "budget per request" by "average tokens per step", get a number like fifteen, and use it. That number is the wrong shape because agent loops have long tails: one percent of requests need forty steps, and one in ten thousand needs a hundred. The step cap should be the number that terminates pathological runs, not the number that fits the average. Set it to the p95 of production step counts plus a small buffer, and check the trip rate as a separate metric.
No no-progress detection. The three raw caps (steps, wall-clock, tokens) will eventually kill a stuck agent, but "eventually" costs money and latency. A no-progress detector on consecutive identical tool calls fires within one extra step of the stuck behaviour, saves the difference between two wasted calls and thirty, and is thirty lines of code. Add it. Teams that add it later usually find they have been paying for the missing detector for months.
Returning partial state as if it succeeded. When any of the four bounds trips, the runtime has partial state: some tool calls executed, some tokens spent, some messages accumulated. The wrong move is to return the last model response and treat the trip as a soft warning. The right move is a specific error path that names which bound tripped, records the partial state for debugging, and returns a deterministic fallback response to the user. Agents that soft-fail on budget hits are agents whose logs show ten times more "success" than production quality actually warrants.
The takeaway
An agent loop is a queue the model gets to feed. Without runtime bounds, the queue drains only when the model decides it should, and the model's decision function is task completion, not budget or latency. The four bounds together (step cap, wall-clock cap, cumulative token budget, no-progress detector) close the loop by shifting the "stop" decision from the model back to code that can be tested, graphed, and alerted on. The runtime is thirty lines. The version that puts the ceiling in a prompt is the one you rewrite after the first bill that makes the Slack channel light up.
Production checklist
Enforce every bound in the runtime, never the prompt. A prompt is advice; a
raiseis a bound.Set the step cap at the p95 of your production step count plus a small buffer. Trip rate becomes a signal that the cap is misconfigured or the task is drifting.
Enforce the wall-clock cap on every loop iteration, and pass the remaining budget into each tool execution so a single slow tool cannot silently blow the total.
Count tokens from the SDK usage response, not by re-tokenising the messages. The provider's counter is authoritative and matches the bill.
Hash the set of tool calls per step (name plus canonical-JSON arguments) and trip on two consecutive identical hashes as the no-progress signal.
On any bound trip, raise a specific
BudgetExceededwith the reason, the partial messages, the step count, and the token total. Return a deterministic fallback to the user; log the trace for debugging.Instrument the four bounds as separate counters (steps used, wall-clock used, tokens used, no-progress trips). Graph them alongside the p50/p95/p99 of each axis.
Re-tune the bounds every quarter. Model behaviour and task shape both drift; a cap that was right last quarter may be either too tight or too generous now.
Further reading
Anthropic, "Building effective agents" - anthropic.com/engineering/building-effective-agents
LangChain, "Agent executor and iteration limits" - python.langchain.com/docs/how_to/agent_executor
OpenAI, "Function calling and tool use" - platform.openai.com/docs/guides/function-calling
Restate, "Durable execution for agent loops" - restate.dev