Forty lines, and every line is a decision
def run(task, tools, max_steps=8, max_cost=0.50, deadline_s=60):
msgs = [{"role": "user", "content": task}]
spent, started = 0.0, time.monotonic()
for step in range(max_steps):
r = llm.complete(SYSTEM, msgs, tools=tools)
spent += cost_of(r.usage)
msgs.append({"role": "assistant", "content": r.content})
if r.stop_reason != "tool_use":
return Done(text(r), steps=step + 1, cost=spent)
results = []
for call in tool_calls(r):
if call.name not in tools:
results.append(err(call, "unknown tool"))
continue
try:
results.append(ok(call, tools[call.name](**call.args)))
except ToolError as e:
results.append(err(call, str(e))) # recoverable, tell the model
msgs.append({"role": "user", "content": results})
if spent > max_cost:
return Stopped("budget", msgs, spent)
if time.monotonic() - started > deadline_s:
return Stopped("deadline", msgs, spent)
if repeating(msgs):
return Stopped("loop detected", msgs, spent)
return Stopped("step limit", msgs, spent)That is the whole thing. Everything sold as an agent framework is this loop with conveniences around it. Writing it yourself once is worth more than reading three framework tutorials, because every interesting decision is visible.
The three budgets, and why you need all three
Steps catch the model that alternates between two tools forever. Cost catches the model that makes eight legitimate steps, each with a 30,000-token context, and spends four dollars on a question worth four cents. Wall clock catches the tool that hangs.
They fail independently. A run can be within its step limit and over budget, because context grows with every step: step one sends 2,000 tokens, step eight sends 25,000, and the cost of a run is quadratic in its length. Counting steps alone will not protect you from that.
Make all three visible in the return value. Stopped("budget", ...) is a diagnosis; a generic timeout is not.
Errors: three kinds, three behaviours
The single most important structural decision in the loop is that not every failure is handled the same way.
Recoverable: hand back to the model. A record not found, a malformed argument, a validation failure. Return it as a tool result with a message naming the next step. The model adjusts. This is the case the loop exists for, and returning a helpful error here is what makes an agent look intelligent.
Retryable: handle in your code, invisibly. A 429, a 503, a dropped socket. The model does not need to know your network blipped; retry with backoff inside the tool and return the eventual result. Telling the model about infrastructure noise wastes tokens and invites it to invent workarounds.
Fatal: stop the run. Authentication failure, a permission denial, a budget breach, an unknown tool. Nothing the model can do will fix these, and letting it try produces a loop of increasingly creative attempts to work around your security. Return a failure to the caller.
The classic bug is treating a permission denial as recoverable. The model receives “access denied”, concludes it should try a different route to the same data, and spends five steps probing your authorisation surface. That is not an agent being resourceful; it is your loop paying a model to look for a way around your access control.
Detecting the loop within the loop
repeating() deserves more than a line. The common pattern is the model calling the same tool with the same arguments twice, then a third time, because the result was not what it wanted and nothing about the situation has changed.
Hash (tool_name, sorted(args)) per call and keep a counter. Two identical calls is often legitimate: a retry after a transient error. Three is a loop. When you detect it, do not silently stop: inject a message saying the call has already been made with that exact result and suggesting a different approach. Roughly half the time the model recovers, and you have saved the run.
Parallel tool calls
Modern models can request several tools in one turn. Run independent ones concurrently: three lookups at 400 milliseconds each become 400 milliseconds instead of 1,200. Return the results in a single message, in the order requested.
The trap: write operations must not run concurrently unless you have thought about it. Two tool calls that both modify the same record, dispatched in parallel, give you a race condition designed by a language model. Mark tools as read or write in your registry, parallelise reads freely, serialise writes.
What you must log
A failed agent run is nearly impossible to debug from the final message. Log, for every step: the messages sent, the tool calls requested with full arguments, the results returned, the token usage, and the wall-clock time. Keyed by a run id.
This is not optional overhead. The entire difference between a team that improves its agent and a team that argues about it is whether they can open one failed run and read what happened at step four. Traces are the subject of a later lesson; the point here is that the loop must emit them, and retrofitting that is much harder than writing it in from the start.
Every lesson here is open. Your progress is recorded once you pass the Python, from Zero, for AI exam.
Check your understanding
An agent loop caps runs at 10 steps, yet some runs still cost several dollars. What explains this?
Pick the one you would defend. Your answer stays private.
Stuck on something?Ask Sayali, your AI tutor
