Why Your Agent Stops Too Early: The Missing Termination Signal
"Done" in most agent loops means the model stopped asking for tools — indistinguishable from stuck, confused, or bored. Making completion an explicit, validated claim takes about twenty lines.
The agent reported success. Of the six emails it was asked to triage, three were never touched.
Nothing errored. No tool failed. The model simply produced a turn with no tool call, and the loop did the only thing it knows how to do when that happens: it returned. "Done" is not a decision the agent made — it is the absence of a decision, and your loop cannot tell the difference between finished, stuck, and bored.
How a loop actually decides it's finished
Strip an agent loop to its bones and termination is one line:
if not reply.tool_calls:
return reply.text
That is the entire completion check in most implementations. The model stops requesting tools, so the loop stops. There is no comparison against the original request, no ledger of what remains, no verification that anything happened at all.
Which means the model ends the run whenever generating prose feels more natural than generating a tool call — after a partial result, after an ambiguous tool response, or after a long stretch of work when summarizing feels like a reasonable next move.
The three shapes this takes
Premature completion. Six items requested, two handled, a confident summary written. The summary is often accurate about what was done and silent about what wasn't, which is what makes it slip past review.
False success on a failed step. A tool returns something unexpected, the model interprets it charitably, and reports the task complete. The run "succeeded" with nothing accomplished.
The opposite failure — no stopping at all. The model can't tell it's finished, so it re-checks work it already did, re-reads files it already read, and burns turns until it hits a cap. Same root cause: nothing defines done.
Make completion a claim the agent has to defend
The fix is to stop treating termination as an emergent behavior and make it an explicit, checkable act.
Give it a finishing tool. Instead of ending on silence, require an explicit call:
finish_task(items_completed: string[], items_skipped: string[], reason_skipped: string)
Now ending the run is a tool call like any other — visible in the trace, structured, and inspectable. A run that ends without one is a detectable anomaly rather than a normal outcome.
Validate the claim in code, not in the model. The handler for finish_task compares items_completed against the work items the run started with. Mismatch → return an error the model must act on:
3 of 6 items unaccounted for: [thread_4471, thread_4478, thread_4502].
Handle them or list them in items_skipped with a reason.
The model cannot argue with this, because the check happens outside it, against state the loop owns.
Enumerate the work up front. This only functions if the run has an explicit list of items to work through. Deriving it from the request as a first step — and storing it in the loop, not in the model's context — gives you something to check against later. ⚠️ If the checklist lives only in the conversation, it can be summarized away, and then completion validation is checking against a copy the model can rewrite.
Before and after
→ Before: "Triage the flagged emails in my inbox." The model handles what fits comfortably, writes a summary, run ends, no signal that anything is missing.
→ After: first tool call is list_flagged_threads(), returning six IDs the loop records. Each is handled with an explicit call. The model calls finish_task(items_completed: [4 ids], items_skipped: [2 ids], reason_skipped: "no matching rule"). The handler confirms all six are accounted for and the run ends. Two items were still skipped — but you know that, and so does whoever reads the output.
The difference is not that the agent got better. It's that partial work stopped being indistinguishable from complete work.
Also cap the other direction
While defining completion, define exhaustion: a maximum turn count, and ideally a maximum token spend, enforced by the loop. When a run hits it, don't fail silently — have it emit the same structured report, with everything outstanding listed under skipped. A run that ran out of budget and a run that finished cleanly should produce the same shape of output, differing only in the contents.
The takeaway
Most agent loops define success as "the model stopped asking for things." That's a definition of silence, not of done. Enumerate the work where the loop can see it, make finishing an explicit tool call, and validate that call against the enumeration in ordinary code. Silence should be an error condition, not a completion path.