Chapter 79 · Agent loops and control
The loop: plan, act, observe, repeat
The inversion from chapter 1, finally built. What the loop is, the four states it can be in, and why most of this section is about making it stop rather than making it go.
10 min read
Chapter 1 drew a line: a workflow is a program that calls a model; an agent is a model that calls your program. Seventy-seven chapters later, this is where the inversion happens.
Everything before now has been a program calling a model at known points. The loop gives up that control, and the rest of this section is about the machinery that makes giving it up survivable.
The loop is twelve lines
// The whole idea. Everything else in this section is a bound on it.
while (!done) {
const response = await callModel(messages, tools) // plan
if (response.stopReason !== 'tool_use') { done = true; break }
for (const call of response.toolCalls) {
const result = await executeToolRequest(call, scope, context) // act
messages.push(toolResultMessage(call, result)) // observe
}
}It is short because the hard parts are elsewhere: the five gates in executeToolRequest, and the bounds this section adds around the while.
The loop is easy to write and hard to stop. Almost everything in section 7 is about termination, resumability and legibility — not about making it capable.
Four states, not two
A run is usually modelled as running or finished. It has four states and conflating them is the source of most of the bugs in chapter 90.
export type RunState =
| { kind: 'running'; step: number }
// The model decided it was done and produced an answer.
| { kind: 'answered'; text: string }
// ⚠️ A bound stopped it. There may be useful partial work.
| { kind: 'halted'; reason: 'step_limit' | 'time_limit' | 'spend_limit'; partial: Message[] }
// ⚠️ Waiting on something outside the loop. NOT failed, NOT running.
| { kind: 'suspended'; on: 'confirmation' | 'clarification'; token: string }suspended is the state people omit, and its absence is why confirmation implementations so often leak. A run waiting for a human is not running — it holds no resources, it survives a deploy, and it must be resumable hours later. Chapter 87 builds that.
halted is distinct from failed because partial work is usually worth something. A run that found three of four facts before hitting the step limit should report the three, not apologise.
The real loop
// src/agent/loop.ts — the version the rest of this section refers to.
export async function runLoop(run: Run): Promise<RunState> {
const budget = new Budget(run.limits) // chapter 81
while (true) {
// ⚠️ Bounds checked BEFORE the call, not after. Checking after means
// the call that exceeded the budget was still paid for.
const bound = budget.check()
if (bound) return { kind: 'halted', reason: bound, partial: run.messages }
const response = await callModel({
messages: run.messages,
tools: selectTools(run.question, run.scope, { maxTokens: BUDGET.tools }),
model: modelFor(run.question, { step: run.step }),
})
budget.record(response.usage)
if (response.stopReason !== 'tool_use') {
return { kind: 'answered', text: response.text }
}
run.messages.push({ role: 'assistant', content: response.content })
const results: ToolResultMessage[] = []
for (const call of response.toolCalls) {
const result = await executeToolRequest(call, run.scope, run.context)
run.trace.push(traceEntry(run.step, call, result))
// Suspension exits the loop entirely. Chapter 66's rule.
if (result.kind === 'needs_confirmation') {
await persistRun(run) // chapter 87
return { kind: 'suspended', on: 'confirmation', token: result.token }
}
results.push(toolResultMessage(call, result))
}
run.messages.push({ role: 'user', content: results })
run.step++
await persistRun(run) // ⚠️ every step, not at the end
}
}Persisting every step rather than at the end is what makes a crashed run recoverable rather than lost, and it costs one small write per step.
What the model sees each turn
The thing that most affects loop behaviour and is least often examined.
// Turn 3 of a run. The model sees everything, every time.
[
{ role: 'user', content: 'Why is the Henderson account past due?' },
{ role: 'assistant', content: [{ type: 'tool_use', name: 'get_account_summary', ... }] },
{ role: 'user', content: [{ type: 'tool_result', content: '...' }] },
{ role: 'assistant', content: [{ type: 'tool_use', name: 'get_open_invoices', ... }] },
{ role: 'user', content: [{ type: 'tool_result', content: '...' }] },
]Two consequences.
Cost grows quadratically with steps. Every turn resends the whole conversation, so an eight-step run costs roughly 36 turn-equivalents of input, not 8. This is the arithmetic behind chapter 47's routing argument and chapter 81's spend limit.
// Measured on Atlas, same question, varying depth.
// 1 step: 2,900 input tokens
// 3 steps: 11,400
// 5 steps: 24,800
// 8 steps: 52,100 ⚠️ 18x the single-step cost for 8x the stepsTool results become context and compete with retrieval. A verbose tool result crowds out the chunks, which is chapter 60's budget applied inside a loop.
// So tool results are rendered to a budget, with the overflow summarised
// rather than truncated — a half-table is worse than a stated count.
export function renderToolResult(result: ToolResult, budget = 600): string {
const full = JSON.stringify(result.data)
if (countTokens(full) <= budget) return full
return JSON.stringify({
...result,
data: result.data.slice(0, 10),
truncated: { shown: 10, total: result.data.length,
note: 'Narrow the query to see the rest.' },
})
}When to run a loop at all
The decision from chapter 2, now with numbers.
| Route | Share of Atlas traffic | Steps | Cost index |
|---|---|---|---|
| Retrieval only | 71% | 0 | 1.0 |
| Single tool call | 18% | 1 | 1.4 |
| Fixed workflow | 5% | 2–3 | 2.1 |
| Loop | 6% | 2–6 | 8.3 |
Six percent of requests, eight times the cost each. The loop is right for those six percent and catastrophic as a default — which is the same conclusion as chapter 2 and now has a multiplier attached.
// The router decides, and its errors are asymmetric: a loop request
// misrouted to retrieval costs one failed cheap attempt; a retrieval
// request misrouted to the loop costs 8x, every time.
if (route.kind !== 'planning') return cheapPath(question, scope)
return runLoop(newRun(question, scope, LIMITS))The loop is not more capable than its tools
Worth stating because it is a common misconception.
A loop cannot answer a question whose answer is not reachable through some sequence of tool calls. It adds the ability to choose a sequence at runtime and nothing else. If check_inventory does not expose a date range, no amount of looping produces a historical stock figure.
A loop buys you unknown ordering, not unknown capability. If the answer is not reachable through your tools, the loop will spend eight steps discovering that.
Which is why chapter 80's most valuable behaviour is stopping early and saying so.
The failure mode: the loop that was a workflow
Atlas's past-due investigation runs the loop. Over a month, 412 runs, and the trace shows the same sequence 379 times:
get_account_summary -> get_open_invoices -> get_payment_history -> answer
Ninety-two percent of runs take the identical path. The loop is choosing an order that was never in doubt, at eight times the cost of a function that calls three tools in sequence.
// The fix: promote the common path to a workflow, keep the loop for the
// 8% that deviate. Chapter 2's distinction, discovered by measurement
// rather than assumed at design time.
export async function investigatePastDue(accountName: string, scope: Scope) {
const account = await resolveAccount(accountName, scope)
if (account.kind !== 'resolved') return account
// Three parallel reads — safe, per chapter 76 — in one round trip.
const [summary, invoices, payments] = await Promise.all([
getAccountSummary({ accountId: account.value.id }, scope),
getOpenInvoices({ accountId: account.value.id }, scope),
getPaymentHistory({ accountId: account.value.id, days: 90 }, scope),
])
// ⚠️ Escalate to the loop only when the fixed path is insufficient.
if (needsFurtherInvestigation(summary, invoices, payments)) {
return runLoop(newRun(question, scope, LIMITS, { seed: [summary, invoices, payments] }))
}
return generate(question, { summary, invoices, payments })
}Result: p50 for that question shape fell from 6.2s to 1.4s, cost fell 71%, and correctness rose from 0.89 to 0.94 — because a fixed path cannot take a wrong turn.
-- The query that finds these. Run it monthly.
select array_agg(tool order by step) as path, count(*) as runs
from trace_steps
where created_at > now() - interval '30 days'
group by run_id
having count(*) between 2 and 6
-- ...then group the paths and look for any above ~10% of runs.A loop whose trace shows the same path most of the time is a workflow that has not been written yet. Measure the paths before assuming the ordering was unknowable.
Key points
- The loop is twelve lines. The hard parts are the gates inside it and the bounds around it.
- Four states, not two.
suspendedis not running and not failed;haltedusually has useful partial work. - Check bounds before the model call, not after.
- Persist every step. A crashed run is then recoverable for the cost of one small write.
- Cost grows quadratically with steps: an eight-step run is 18× a single-step one, not 8×.
- Render tool results to a budget and summarise the overflow rather than truncating.
- Six percent of Atlas's traffic needs a loop and it costs eight times as much. Router errors are asymmetric.
- A loop buys unknown ordering, not unknown capability.
- A trace showing one dominant path is a workflow waiting to be written: 71% cheaper, 4.4× faster, and more correct.