An agent with no definition of done will find more to do
Give a capable model a goal, tools and a loop, and left alone it will keep going. It will re-verify a fact it already verified. It will check one more source. It will politely offer to also update the spreadsheet. This is not a defect in the model. There is nothing in the loop that says stop.
So you supply the stop. Four budgets, and every serious agent has all four:
BUDGET = {
"steps": 12, # loop iterations
"usd": 0.50, # hard spend cap per run
"seconds": 120, # wall clock
"writes": 3, # actions that change the world
}The fourth is the one people leave out and the one that saves you. Blast radius is a count of irreversible actions: refunds issued, emails sent, rows deleted, files published. Cap it separately, because the run that costs ₹4 and takes nine seconds can still send the same message to 900 people.
The prompt is a request; the counter is a rule
Writing "you have a maximum of 8 tool calls, do not exceed this" in a system prompt feels like setting a limit. It is not one. The model has no reliable count of actions it has taken — it is reading a transcript, not consulting a register — and even a model that counts perfectly is still the party deciding whether to comply. You have written a suggestion.
A counter in your loop is different in kind. It does not persuade; it returns.
This distinction generalises. Anything you actually need guaranteed — spend caps, allowed domains, which tables are writable, whether this account can be charged — lives in the code that executes tool calls. The prompt is for guidance and taste. The harness is for rules.
Stopping rules beyond "budget exhausted"
Budgets are the backstop. You also want the agent to stop for good reasons:
- Success criterion. State it concretely enough to check. Not "reconcile the accounts" but "every row in
pendinghas either a matched payment ID or a reason code." - Give-up criterion. "If the supplier portal returns 403 twice, stop and escalate." Without one, the agent grinds against a wall that is not going to move.
- No-progress detector. If two consecutive steps produce the same tool name with the same arguments, halt. It is five lines and it catches most runaway loops:
if (call.name, call.args) == last_call:
return stop("no progress: repeated identical call", messages)
last_call = (call.name, call.args)What to do when you hit the limit
This is where most implementations quietly do damage. Two failure modes, both common:
- Returning a partial result formatted exactly like a complete one. Now the caller believes 14 invoices were processed when 8 were.
- Throwing an exception that discards everything the run learned in ninety seconds.
Do neither. Hand back a structured account of the stop:
Stopped after 12 steps (step budget). Reconciled 8 of 14 invoices. The remaining 6 all failed at the same point: no GST number on the supplier record. Next action: add GST numbers for Rakesh Traders, Coastal Supply, and four others listed below.
That is useful to a human, and it is useful as input to the next run. A partial result that is honest about being partial is a good outcome. A partial result pretending to be complete is worse than a crash, because a crash gets investigated.
Set them low first
Start every new agent tighter than feels reasonable — 5 steps, one write, thirty seconds — and watch what hits the ceiling. The runs that die at the limit tell you more about the task than the runs that succeed. Raise limits with evidence, one at a time.
Before you move on