Chapter 2: Making it safe to run · Lesson 3 of 7 · 8 min read
Stopping on purpose
Step limits, cost budgets, loop detection, done-checks and human approval: the brakes every loop needs before it touches anything real.
Our loop ends only when the model decides it's done. In a demo that's fine. In production, three things go wrong:
- It never stops. The model keeps calling the same tool with slightly different arguments, hoping for a better result.
- It stops too early. It says "Done!" when a step actually failed.
- It gets expensive. Every step resends the whole conversation, so cost grows faster than the number of steps.
Each one needs its own brake.

Brake 1: a step limit
The simplest guard: count iterations and bail out.
MAX_STEPS = 15
for step in range(MAX_STEPS):
reply = call_model(messages, tools=tools)
messages.append(reply)
if not reply.tool_calls:
return reply.content
run_tools(reply.tool_calls, messages)
return "Stopped: step limit reached. Partial progress is in the log."Pick the limit from real runs: look at how many steps successful tasks take, and set the limit at two or three times that. Hitting it should be rare and loud, so log it.
Brake 2: a cost budget
Steps aren't equal. A step with 80,000 tokens of context costs far more than one with 2,000. Track what you actually spend:
budget_usd = 0.50
spent = 0.0
for step in range(MAX_STEPS):
reply = call_model(messages, tools=tools)
spent += cost_of(reply.usage) # input + output tokens × price
if spent > budget_usd:
return f"Stopped: budget of ${budget_usd} used up."
...Why cost grows faster than steps: step n resends all n − 1 earlier steps. Ten steps of similar size cost roughly 55 "units", not 10. Lesson 4 shows how to trim.

Brake 3: loop detection
A cheap trick that catches most stuck agents: remember recent tool calls and stop if the same call repeats.
seen = {}
for call in reply.tool_calls:
key = (call.name, json.dumps(call.arguments, sort_keys=True))
seen[key] = seen.get(key, 0) + 1
if seen[key] >= 3:
messages.append(tool_result(call, "ERROR: you already tried this 3 times. "
"Try a different approach or explain what is blocking you."))
continue
...Notice we don't kill the run: we tell the model it's repeating itself. Often that's enough to change course.
"Done" isn't proof
When the model stops calling tools, it's claiming to be finished. For tasks that change something (a file written, an email sent, a ticket closed) check the claim in code before trusting it:
answer = run_agent(task, tools, call_model)
if not os.path.exists("report.md"):
answer = run_agent(
task + "\n\nYou said you were done, but report.md does not exist. Finish the task.",
tools, call_model,
)A done-check is ordinary code that inspects the world, not another model call asking "are you sure?".
Ask before the irreversible
Remember lesson 2's split between reading and doing? For tools that can't be undone (refunds, deletes, emails to customers) pause and ask a human:
NEEDS_APPROVAL = {"refund_order", "send_email", "delete_file"}
if call.name in NEEDS_APPROVAL and not human_approves(call):
result = "DENIED by a human reviewer. Do not retry this action."Recap
| Problem | Brake |
|---|---|
| Runs forever | Step limit |
| Runs up a bill | Cost budget |
| Repeats itself | Loop detection |
| Says done when it isn't | Done-check in code |
| Does something it can't undo | Human approval |
Next, the last lesson: the messages list keeps growing. What should stay in it, and what belongs somewhere else?
Keep reading, free
Leave your email to unlock every lesson in our free text courses on this device. You'll also hear about new lessons and the video course launch.
You'll only hear from us when there's something new: a course, an ebook or a discount. Unsubscribe anytime. Privacy