Budgets, Limits and Termination
An agent loop must be bounded by hard limits on steps, tokens, cost and wall-clock time, with loop detection and a graceful degradation path, all enforced in code outside the model.
Why the model cannot bound itself
A model deciding “am I done?” is a probabilistic judgement, and a model that is stuck is precisely the one whose judgement has failed. The agent loop from The Agent Loop has a decision node at the bottom; production code needs a second, deterministic exit that fires regardless of what the model thinks. That exit is a set of budgets checked before every step.
Budgets serve three purposes: they cap cost (a runaway request cannot spend more than N dollars), they cap latency (the user gets an answer or a clear failure within T seconds), and they turn silent failures into loud ones (a run that hits its step limit is a bug report with a trace attached).
The five limits
Set every one of these per request, derived from the task type. Values for a support bot differ from a research agent, but the shape is the same.
- Max steps: iterations of the loop. Typical: 5–15 for task agents, 30–50 for coding agents. Set it at roughly 2× the p95 of successful runs.
- Max tokens: cumulative input plus output across all calls. Catches context growth that the step limit misses.
- Max cost: computed from per-model prices; the limit users and finance actually care about.
- Wall-clock: total elapsed time including tool latency. Enforced with a deadline passed to every tool and model call.
- Loop detection: a hash of
(tool_name, canonical_args)per step; if the same hash appears k times (k = 2 or 3), stop. Also detect A-B-A-B oscillation with a small window.
Graceful degradation
Hitting a limit should not throw a stack trace at the user. The run ends in one of three ways, chosen by the controller: return the best partial result with an honest note, escalate to a human (In-the-Loop vs On-the-Loop and Escalation), or fail with a specific error code that the caller can handle. Whichever it is, the trace records which limit fired and the state at that moment, so the incident is reproducible.
A useful pattern is a final-answer call: when a limit is reached, make one last model call with tools disabled and the instruction “summarise what you found and what remains”. It costs one call and converts a dead run into a partially useful one.
1import hashlib, json, time2from collections import Counter3 4class Budget:5 def __init__(self, max_steps=12, max_tokens=60_000, max_cost=0.50, max_seconds=90, repeat_limit=2):6 self.max_steps, self.max_tokens, self.max_cost = max_steps, max_tokens, max_cost7 self.deadline = time.monotonic() + max_seconds8 self.repeat_limit = repeat_limit9 self.steps = self.tokens = 0; self.cost = 0.010 self.seen: Counter[str] = Counter()11 12 def check(self) -> str | None:13 if self.steps >= self.max_steps: return "max_steps"14 if self.tokens >= self.max_tokens: return "max_tokens"15 if self.cost >= self.max_cost: return "max_cost"16 if time.monotonic() >= self.deadline: return "deadline"17 return None18 19 def note_call(self, tool: str, args: dict) -> str | None:20 key = hashlib.sha256((tool + json.dumps(args, sort_keys=True)).encode()).hexdigest()21 self.seen[key] += 122 return "repeated_call" if self.seen[key] > self.repeat_limit else None23 24def run_agent(messages, tools, budget: Budget):25 while True:26 if reason := budget.check():27 return finalize(messages, reason) # one tools-off call: summarise partial result28 resp = call_model(messages, tools)29 budget.steps += 1; budget.tokens += resp.usage.total; budget.cost += resp.usage.cost30 if not resp.tool_calls:31 return resp.text32 for tc in resp.tool_calls:33 if reason := budget.note_call(tc.name, tc.args):34 return finalize(messages, reason)35 result = tools[tc.name](**tc.args, deadline=budget.deadline)36 messages.append(tool_result(tc.id, result))Choosing values and tuning them
Start from traces of successful runs: take the p95 of steps, tokens and cost and multiply by two. Then watch the limit-hit rate per limit. A limit that fires on 5% of runs is either too tight or is revealing a real loop; read the traces to decide. A limit that never fires may be too loose to protect you. Alert on limit-hit rate the same way you alert on error rate (Logging, Metrics and Alerts).
Budgets compose across agents. In a Supervisor Pattern system, the supervisor’s budget must include its workers’ spend, or each worker will individually stay under budget while the total explodes.
Key points
- The model cannot be trusted to stop itself; a deterministic exit checks budgets before every step.
- Five limits: max steps, max tokens, max cost, wall-clock deadline, and loop detection via (tool, args) hashes.
- Hitting a limit ends gracefully: partial result with a note, escalation, or a specific error, always with a trace.
- Set limits at ~2× the p95 of successful runs and alert on limit-hit rate.
- Budgets must be aggregated across sub-agents or the total is unbounded.
When to use — and when not to
- Every agent loop in production, without exception.
- Long-running research and coding agents where one step is expensive.
- Multi-agent systems where sub-agents can spawn their own loops.
- A fixed-step workflow cannot loop; it still needs a token and cost cap per call.
- Do not set the step limit so low that the p95 successful run is cut off.
- Do not raise a limit to hide a loop; fix the tool result that causes it.
Failure modes
- Step limit exists but tokens per step grow, so cost is still unbounded.
- Loop detection hashes raw argument strings and misses the same call with reordered JSON keys.
- Limit fires and the user sees a raw exception instead of a partial answer.
- Each worker agent has a budget; the supervisor does not, and total spend is 10× the intended cap.
- Deadline is checked in the loop but not passed to tools, so one slow HTTP call blows through it.
Tradeoffs
Fifty lines of code; the highest reliability return of anything in this module.