The Agent Loop, From Scratch
0/7 lessons completed
Overview

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.

Five cards: 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 what it can't undo, human approval.
The five brakes in this lesson, one for each way a loop goes wrong.

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.

A bar chart of ten steps. Step n is a stack of n blocks: one new block and n minus one resent blocks. Together that is 55 units of context for ten steps.

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?