{"course":{"slug":"building-with-ai","title":"Building with AI","url":"https://addaly.com/learn/building-with-ai","level":"intermediate","track":"build"},"lesson":{"slug":"writing-the-agent-loop","title":"Writing the loop, with the stopping conditions","url":"https://addaly.com/learn/building-with-ai/writing-the-agent-loop","position":27,"of":80,"minutes":10,"answer":"An agent loop needs three independent budgets (steps, cost and wall clock) because context growth makes cost quadratic in run length, and it must sort every failure into recoverable, retryable or fatal.","keyIdea":"An agent loop needs three independent budgets (steps, cost and wall clock) because context growth makes cost quadratic in run length, and it must sort every failure into recoverable, retryable or fatal, since handing a permission denial back to the model turns it into a search for a way around your access control."},"body":{"format":"markdown","text":"## Forty lines, and every line is a decision\n\n```python\ndef run(task, tools, max_steps=8, max_cost=0.50, deadline_s=60):\n    msgs = [{\"role\": \"user\", \"content\": task}]\n    spent, started = 0.0, time.monotonic()\n\n    for step in range(max_steps):\n        r = llm.complete(SYSTEM, msgs, tools=tools)\n        spent += cost_of(r.usage)\n        msgs.append({\"role\": \"assistant\", \"content\": r.content})\n\n        if r.stop_reason != \"tool_use\":\n            return Done(text(r), steps=step + 1, cost=spent)\n\n        results = []\n        for call in tool_calls(r):\n            if call.name not in tools:\n                results.append(err(call, \"unknown tool\"))\n                continue\n            try:\n                results.append(ok(call, tools[call.name](**call.args)))\n            except ToolError as e:\n                results.append(err(call, str(e)))     # recoverable, tell the model\n        msgs.append({\"role\": \"user\", \"content\": results})\n\n        if spent > max_cost:\n            return Stopped(\"budget\", msgs, spent)\n        if time.monotonic() - started > deadline_s:\n            return Stopped(\"deadline\", msgs, spent)\n        if repeating(msgs):\n            return Stopped(\"loop detected\", msgs, spent)\n\n    return Stopped(\"step limit\", msgs, spent)\n```\n\nThat 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.\n\n## The three budgets, and why you need all three\n\n**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.\n\nThey 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.\n\nMake all three visible in the return value. `Stopped(\"budget\", ...)` is a diagnosis; a generic timeout is not.\n\n## Errors: three kinds, three behaviours\n\nThe single most important structural decision in the loop is that not every failure is handled the same way.\n\n**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.\n\n**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.\n\n**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.\n\nThe 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.\n\n## Detecting the loop within the loop\n\n`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.\n\nHash `(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.\n\n## Parallel tool calls\n\nModern 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.\n\nThe 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.\n\n## What you must log\n\nA 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.\n\nThis 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."},"check":{"question":"An agent loop caps runs at 10 steps, yet some runs still cost several dollars. What explains this?","options":[{"text":"Tool results are being returned to the model instead of handled internally, which doubles the number of calls.","correct":false,"tempting":"Describes normal loop behaviour rather than a defect, and the number of model calls is already bounded by the step cap."},{"text":"The model is choosing a more expensive tier on longer runs.","correct":false,"tempting":"Attributes model selection to the loop, which sends whatever model you configured on every step."},{"text":"The conversation is resent in full on every step, so token cost grows with the square of run length and ten steps can be far more expensive than ten times one step.","correct":true},{"text":"Failed steps are retried without counting against the step limit.","correct":false,"tempting":"A real bug worth checking, but it would show up as runs exceeding ten model calls, which the cap is already enforcing."}]},"prev":{"slug":"what-an-agent-is","title":"What an agent actually is, and why the demos fail","url":"https://addaly.com/learn/building-with-ai/what-an-agent-is"},"next":{"slug":"mcp-and-tool-protocols","title":"MCP: what a tool protocol buys you, and what it costs","url":"https://addaly.com/learn/building-with-ai/mcp-and-tool-protocols"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Writing the loop, with the stopping conditions', in Building with AI. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/building-with-ai/writing-the-agent-loop (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Writing the loop, with the stopping conditions","courseTitle":"Building with AI","module":{"index":3,"slug":"structure-tools-and-the-loop","title":"Structure, tools and the loop"},"answer":null,"keyIdea":"An agent loop needs three independent budgets (steps, cost and wall clock) because context growth makes cost quadratic in run length, and it must sort every failure into recoverable, retryable or fatal, since handing a permission denial back to the model turns it into a search for a way around your access control.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"forty-lines-and-every-line-is-a-decision","runs":[{"text":"Forty lines, and every line is a decision"}]},{"t":"code","lang":"python","label":"Python","text":"def run(task, tools, max_steps=8, max_cost=0.50, deadline_s=60):\n    msgs = [{\"role\": \"user\", \"content\": task}]\n    spent, started = 0.0, time.monotonic()\n\n    for step in range(max_steps):\n        r = llm.complete(SYSTEM, msgs, tools=tools)\n        spent += cost_of(r.usage)\n        msgs.append({\"role\": \"assistant\", \"content\": r.content})\n\n        if r.stop_reason != \"tool_use\":\n            return Done(text(r), steps=step + 1, cost=spent)\n\n        results = []\n        for call in tool_calls(r):\n            if call.name not in tools:\n                results.append(err(call, \"unknown tool\"))\n                continue\n            try:\n                results.append(ok(call, tools[call.name](**call.args)))\n            except ToolError as e:\n                results.append(err(call, str(e)))     # recoverable, tell the model\n        msgs.append({\"role\": \"user\", \"content\": results})\n\n        if spent > max_cost:\n            return Stopped(\"budget\", msgs, spent)\n        if time.monotonic() - started > deadline_s:\n            return Stopped(\"deadline\", msgs, spent)\n        if repeating(msgs):\n            return Stopped(\"loop detected\", msgs, spent)\n\n    return Stopped(\"step limit\", msgs, spent)"},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"the-three-budgets-and-why-you-need-all-three","runs":[{"text":"The three budgets, and why you need all three"}]},{"t":"p","runs":[{"text":"Steps","b":true},{"text":" catch the model that alternates between two tools forever. "},{"text":"Cost","b":true},{"text":" 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. "},{"text":"Wall clock","b":true},{"text":" catches the tool that hangs."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"Make all three visible in the return value. "},{"text":"Stopped(\"budget\", ...)","code":true},{"text":" is a diagnosis; a generic timeout is not."}]},{"t":"h2","id":"errors-three-kinds-three-behaviours","runs":[{"text":"Errors: three kinds, three behaviours"}]},{"t":"p","runs":[{"text":"The single most important structural decision in the loop is that not every failure is handled the same way."}]},{"t":"p","runs":[{"text":"Recoverable: hand back to the model.","b":true},{"text":" 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."}]},{"t":"p","runs":[{"text":"Retryable: handle in your code, invisibly.","b":true},{"text":" 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."}]},{"t":"p","runs":[{"text":"Fatal: stop the run.","b":true},{"text":" 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."}]},{"t":"p","runs":[{"text":"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."}]},{"t":"h2","id":"detecting-the-loop-within-the-loop","runs":[{"text":"Detecting the loop within the loop"}]},{"t":"p","runs":[{"text":"repeating()","code":true},{"text":" 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."}]},{"t":"p","runs":[{"text":"Hash "},{"text":"(tool_name, sorted(args))","code":true},{"text":" 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."}]},{"t":"h2","id":"parallel-tool-calls","runs":[{"text":"Parallel tool calls"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"The trap: "},{"text":"write operations must not run concurrently unless you have thought about it.","b":true},{"text":" 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."}]},{"t":"h2","id":"what-you-must-log","runs":[{"text":"What you must log"}]},{"t":"p","runs":[{"text":"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."}]},{"t":"p","runs":[{"text":"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."}]}],"check":{"question":[{"text":"An agent loop caps runs at 10 steps, yet some runs still cost several dollars. What explains this?"}],"options":[{"text":[{"text":"Tool results are being returned to the model instead of handled internally, which doubles the number of calls."}],"correct":false,"tempting":[{"text":"Describes normal loop behaviour rather than a defect, and the number of model calls is already bounded by the step cap."}]},{"text":[{"text":"The model is choosing a more expensive tier on longer runs."}],"correct":false,"tempting":[{"text":"Attributes model selection to the loop, which sends whatever model you configured on every step."}]},{"text":[{"text":"The conversation is resent in full on every step, so token cost grows with the square of run length and ten steps can be far more expensive than ten times one step."}],"correct":true,"tempting":null},{"text":[{"text":"Failed steps are retried without counting against the step limit."}],"correct":false,"tempting":[{"text":"A real bug worth checking, but it would show up as runs exceeding ten model calls, which the cap is already enforcing."}]}],"order":[0,1,2,3],"why":null},"prev":{"slug":"what-an-agent-is","title":"What an agent actually is, and why the demos fail","minutes":8},"next":{"slug":"mcp-and-tool-protocols","title":"MCP: what a tool protocol buys you, and what it costs","minutes":9}}}