Who Decides the Agent Is Done? Three Stop Conditions, and Why "The Model Said So" Isn't One
Most first agents end their loop when the model stops calling tools — which means the model, not you, decides what "done" means. Here are the three stop conditions a real loop needs, and the twenty lines that turn a silent wrong answer into another iteration.
A beginner's first agent almost always has one stop condition: the loop runs until the model stops asking for tools. That works right up until the day it doesn't — and the day it doesn't, you get a CSV-cleaning agent that has called the same read_rows tool nineteen times, burned through a chunk of budget, and declared the file clean while three columns still hold the string "NULL".
The loop needs someone to decide it's over. Handing that decision entirely to the model is the single most common structural bug in a first agent, and it's fixable with about twenty lines of ordinary code.
🌱 What the loop actually looks like
Strip an agent down and it's a while loop with three moving parts:
while True:
reply = model(messages) # 1. think
if reply.tool_calls: # 2. act
results = run_tools(reply.tool_calls)
messages += [reply, results]
continue
return reply.text # 3. stop
Line 3 is the stop condition, and notice who owns it: the model. The loop ends when the model happens to produce a turn with no tool call. That's not a decision anyone made — it's a side effect of what the model felt like emitting.
Two failure modes fall straight out of that:
- It stops too early. The model writes a confident summary — "I've cleaned the dataset and standardized all date formats" — without ever calling the tool that would have done it. The loop exits happy. Nothing was cleaned.
- It never stops. The model gets stuck re-reading state, calling
list_filesthenread_rowsthenlist_filesagain, each turn slightly rephrasing itself. Left alone, it will do this until your bill or your patience runs out.
Both are the same bug. The loop has no independent notion of "done."
The three stop conditions
A production agent loop ends for one of exactly three reasons, and you should be able to point at the line of code for each.
1. Success: a verified goal check
Not "the model said it finished" — a check you run against the world after the model claims to be done.
For a data-cleaning agent, that's a function that opens the output file and asserts the properties the task actually required:
def goal_met(path):
df = load(path)
return (
df["order_date"].dtype == "datetime64[ns]"
and not df["email"].isin(["NULL", "", "n/a"]).any()
and df["order_id"].is_unique
)
Cheap, deterministic, and it doesn't care how eloquent the model's summary was. When the model emits a text-only turn, you run goal_met. If it passes, the loop exits successfully. If it fails, you don't exit — you feed the failure back as a tool result and let the model keep working:
checker: order_date is still dtype object; email has 3 rows equal to "NULL"
This one change converts a large class of silent wrong answers into another iteration. It is the highest-value twenty lines in the whole agent.
2. Budget: a hard ceiling you set in advance
Every loop needs a limit that does not depend on the model's judgment. Pick at least two:
- Turns — max iterations of the loop (start around 10–15 for a narrow task).
- Tokens or cost — a running total across the whole run.
- Wall-clock — a deadline, especially if tools hit slow external systems.
When a ceiling trips, the run ends in a distinct state: not success, not failure, but exhausted. That distinction matters downstream, because an exhausted run is often worth retrying with more budget while a failed one is not.
3. No-progress: the loop is moving but going nowhere
Budgets catch runaway loops eventually, but expensively. A no-progress detector catches them in three turns instead of fifteen.
The cheapest useful version: hash each tool call — name plus arguments — and stop if the same hash appears three times without any intervening state change.
signature = (call.name, json.dumps(call.args, sort_keys=True))
recent.append(signature)
if recent.count(signature) >= 3:
raise NoProgress(signature)
A slightly richer version tracks whether the observable state moved: did the row count change, did a file get written, did any check flip from failing to passing? If nothing in the world changed across two turns, the agent is spinning, and more turns will not help.
⚠️ Be careful not to over-trigger this on legitimately repetitive work. An agent that calls read_rows on page 1, page 2, page 3 is making progress — the arguments differ. Hash the arguments, not just the tool name.
What each condition should hand back
The termination reason is part of the agent's output, not a log detail. A caller that only sees "here's the text" cannot tell a clean success from a timeout dressed in confident prose.
| Reason | Exit state | Sensible next move |
|---|---|---|
| Goal check passed | success |
Accept the artifact |
| Budget exhausted | exhausted |
Retry with a larger budget, or escalate |
| No progress detected | stuck |
Escalate to a human — retrying rarely helps |
| Goal check failed at max turns | failed |
Escalate with the specific failing assertion |
✅ Return the reason as structured data alongside the result. ❌ Don't collapse all four into a boolean.
The uncomfortable part
Writing a real goal check means writing down what "done" actually means for your task — precisely enough that code can evaluate it. That's the work people skip, because it's much easier to let the model's closing paragraph stand in for verification.
If you can't write the check, that's diagnostic information: the task isn't specified well enough for an agent to be trusted with it yet. Fix the specification first. An agent that can't tell you why it stopped is an agent you can only supervise by reading every transcript.