Four Guards, One Termination Argument
Ask what stops an agent run. If the answer is "the model will stop calling tools eventually," the run is not a program — it is a wager. The protocol offers exactly one natural stop: the model returns no tool calls. A confused model never returns it. Every other guarantee has to come from your code.
Four guards cover the exits you actually need:
| Guard | Fires when | Guarantees | Failure it prevents |
|---|---|---|---|
| Max steps | iterations reach the cap | hard termination | a runaway loop draining tokens overnight |
| Done tool | the model calls done(answer) explicitly |
early exit on real success | accepting fluent prose that never met the goal |
| Token budget | cumulative spend reaches the per-task limit | bounded cost | one task consuming the whole month's budget |
| No-progress detector | N identical observations in a row | stall detection | echo steps repeating one call forever |
They are not redundant. The done tool lets a three-step task end at step 3 instead of always burning the full cap; the cap saves the run whose done signal never arrives; the budget bounds runs the cap would otherwise allow to be ruinous; the no-progress detector catches the specific failure where steps keep being taken and nothing changes — new iterations, same observation.
Check order matters as much as presence. Goal first, because verified success is the best ending. Then no-progress, then budget, then steps. Each stop reason carries different information: max_steps says the goal check never passed, budget_exceeded says cost was the binding constraint, no_progress says the loop was alive but not moving. A single boolean "stopped" erases exactly the signal you need to tune the run — this is the guard rail layer of The Core Agent Loop.
Implementing the Four Guards
One function decides the reason; the loop only obeys it:
import hashlib, json
MAX_STEPS = 12
MAX_TOKENS = 60_000 # per task, cumulative over every model call
STALL_LIMIT = 3
def digest(obs): # normalized so key order and whitespace do not mask a stall
return hashlib.sha1(json.dumps(obs, sort_keys=True).encode()).hexdigest()
def stop_reason(state):
if state["goal_met"]: return "done"
if state["repeats"] >= STALL_LIMIT: return "no_progress"
if state["tokens"] >= MAX_TOKENS: return "budget_exceeded"
if state["steps"] >= MAX_STEPS: return "max_steps"
return None
def run(goal, ask, act, verify_goal):
state = {"steps": 0, "tokens": 0, "prev": None, "repeats": 0,
"goal_met": False, "answer": None}
while stop_reason(state) is None:
thought, used = ask(goal, state) # returns model text, tool call, token usage
state["tokens"] += used # budget counts every call, not just the last
state["steps"] += 1
if thought["tool"] == "done": # explicit done signal, not inferred prose
state["answer"] = thought["args"]["answer"]
state["goal_met"] = verify_goal(state["answer"]) # verifier accepts or rejects
continue
obs = act(thought["tool"], thought["args"])
state["repeats"] = state["repeats"] + 1 if digest(obs) == state["prev"] else 0
state["prev"] = digest(obs)
return {"status": stop_reason(state), "steps": state["steps"],
"tokens": state["tokens"], "answer": state["answer"]}
Two real terminations from the same task type:
step 7 tokens=41,820 observation identical to steps 5 and 6
stop: no_progress (3 identical observations)
status=no_progress steps=7 answer=null
step 11 tokens=61,004 >= 60,000
stop: budget_exceeded
status=budget_exceeded steps=11 answer=null
The subtle lines: tokens += used on every call, because re-sending a growing transcript means early steps are the cheap ones and the budget must be charged continuously; state["goal_met"] = verify_goal(...) accepts the done tool only after an independent check — the model declaring done is a proposal, not a fact; and the digest comparison, which counts an observation as new only when its normalized content changes, is what distinguishes "still working" from "spinning in place."
Exiting Early, Escalating Cleanly
A stop reason is only useful if the caller can act on it. Split the four outcomes into two classes:
- Success:
done, with an answer the goal verifier checked. Nothing else qualifies. - Bounded failure:
max_steps,budget_exceeded,no_progress. The run is safe — cost was capped, no damage was done — but the task is unfinished, and unfinished tasks need an owner: retry with a narrower goal, hand to a worker queue, or escalate to a person.
The goal verifier is where teams cut corners. It must be a deterministic function over the answer — a JSON schema check, "the record now has status refunded," "the file exists in the bucket" — never the model's self-assessment. A verifier that trusts done(answer) turns the done tool into a rubber stamp, and the loop degenerates into a race to declare completion.
Budget accounting deserves the same rigor. Charge every call: the initial prompt, each revision, each tool result that gets re-sent in the transcript. Plans that grow the transcript — like the replanning strategy in Planning Strategies for Agents — spend faster than step count suggests, so metering at the call site rather than per turn is what keeps budget_exceeded truthful; the mechanics live in Metering Token Usage.
Watch for guards misfiring on legitimate work:
- Cap too tight. A cap of 4 on a task that genuinely needs 7 turns every run into a failure that looks like model incompetence. Tune it from p95 step counts on real traces, not from taste.
- Detector tripping on polling. Checking a build status five times returns five identical observations by design. Let a tool declare itself pollable and exclude those results from the digest comparison.
- Invisible budget. A budget nobody reads is a guard that only exists in code review.
- All failures treated alike.
no_progresspoints at a bad tool or prompt;budget_exceededpoints at transcript growth. Routing both to the same retry hides which one you have.
Once a run stops for the right reason with the right classification, the remaining risk is what happens when the tools themselves fail — the subject of Error Recovery in Agent Runs.
Quiz
1. Why is a max-step guard mandatory even when you have a goal check?
2. What must verify_goal check before a done signal is accepted?
3. A run ends with status no_progress. What does that tell you that max_steps would not?
4. Why does the token budget increase on every model call rather than at the end of the run?
Flashcards
Question
What four guards bound an agent run?
Click to reveal answer
Answer
Max steps (termination), explicit done tool (verified early exit), per-task token budget (cost bound), no-progress detector (stall detection).
Question
In what order should stop conditions be checked, and why?
Click to reveal answer
Answer
Goal, then no-progress, then budget, then steps. Verified success is the best ending, and each remaining reason tells you a different thing about why the task failed.
Question
Why is the done tool a proposal rather than a decision?
Click to reveal answer
Answer
The model can claim completion while criteria are unmet. A deterministic verifier over the answer must confirm it before goal_met is set.
Question
How does the no-progress detector distinguish a stall from real work?
Click to reveal answer
Answer
Hash each normalized observation and count consecutive repeats; three identical digests means no_progress. Polling tools should be excluded so legitimate repeated checks do not trip it.
Question
How should a caller treat the four stop reasons?
Click to reveal answer
Answer
done → success with a verified answer. max_steps, budget_exceeded, no_progress → bounded failure: safe and cheap to stop, but the task still needs a retry, a narrower goal, or a human.
Revision Notes
Key Takeaways
- 1. Termination must come from your code — the protocol only stops when the model returns no tool calls
- 2. The cap guarantees exit; the done tool, budget, and stall detector decide how a run ends before the cap
- 3. Accept done only after a deterministic goal verifier, and charge tokens on every call
- 4. Classify the stop reason: no_progress, budget_exceeded, and max_steps point at different fixes
Interview Tips
- • List the four guards and say which one guarantees termination — interviewers probe for that distinction
- • Walk the check order and justify goal before budget before steps
- • Explain why self-reported completion needs an independent verifier, with an example of a false done
- • Describe how you would set the cap: p95 step counts from traces, not a round number
Cheat Sheet
Stopping Conditions and Budgets — Cheat Sheet
Four guards
| Guard | Fires on | Failure prevented |
|---|---|---|
| max steps | iterations ≥ cap | runaway loop, tokens drained overnight |
| done tool | explicit done(answer) + verifier |
unverified fluent prose accepted as success |
| token budget | cumulative spend ≥ limit | one task eating the whole budget |
| no-progress | 3 identical observation digests | echo steps repeating one call forever |
Check order: goal → no_progress → budget → max_steps
Skeleton
while stop_reason(state) is None:
thought, used = ask(goal, state)
state["tokens"] += used # charge every call, transcript grows each round
if thought["tool"] == "done":
state["goal_met"] = verify_goal(thought["args"]["answer"]) # deterministic check
continue
obs = act(thought["tool"], thought["args"])
state["repeats"] = state["repeats"] + 1 if digest(obs) == state["prev"] else 0
state["prev"] = digest(obs)
Stop reasons
done→ success (only verdict that is)max_steps/budget_exceeded/no_progress→ bounded failure; classify before retrying
Tuning: size the cap from p95 step counts on real traces; exempt pollable tools from the digest check; meter tokens at the call site.
Continue Learning
Before You Start
- No prerequisites listed.