The while loop works, until it doesn't
An agent loop runs in ten lines. The production version of that same loop is 101 lines, and every extra line marks a place it used to break. Here are the four.
while loop plus tool calls. The minimal version runs in ten lines. The production version of the same loop is ten times longer, and the extra lines are a list of every place it used to break.The minimal loop
Here is an entire agent loop, and it genuinely runs:
let messages = [userMessage]
while (true) { const reply = await callModel(messages) messages.push(reply)
if (!reply.tool_calls?.length) break
for (const call of reply.tool_calls) { const result = await runTool(call) messages.push({ role: 'tool', tool_call_id: call.id, content: result }) }}Ten lines. You have an agent: it calls the model, runs tools when asked, sends results back, and repeats until the model stops proposing. In shape, that is the whole idea.
Now look at the real one.
The same loop, 101 lines long
In the harness I read, the function that runs one turn is 101 lines. The package driving the loop is 2,490 lines across eight files.
The turn() function opens with a series of things the minimal version has none of:
phase.turn = turnlet turnEnds: TurnEndReason | null = nulllet target: InboxTarget = 'next-turn'while (true) { signal.throwIfAborted() const step = phase.step + 1 const decision = await this.preStep(target, { turn, step }) if (decision.kind === 'reject') return false if (turnEnds && decision.messages.length === 0) break this.session.append('step/start', { turn, step }) const stepEnd = await this.step(decision) if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEnd}The same while (true). But every added line is a class of failure that already happened to somebody.
Four places it breaks
Break one: the context runs out mid-turn
The minimal version has no notion of budget. messages just grows, until a request overflows the window.
Look at this line in the real version:
if (turnEnds === null || turnEnds.kind !== 'max-tokens') turnEnds = stepEndThe turn’s outcome is sticky. Once any step has hit the token ceiling, a later step completing normally may not downgrade the turn’s result. The comment in the source states the reason outright: without the stickiness, a turn that hit the ceiling would be recorded as a successful turn.
That is the lesson of the previous post written as a single if. A context budget is not only about estimating tokens — it is about the loop’s state remembering that it once ran out of room.
Break two: not knowing what happened last time
The minimal version keeps history in a variable. Restart the process and it is gone.
The real version writes everything to a log: session.append('turn/start', …), session.append('step/start', …). By the time the model sees an event, that event is already in the log.
And the order carries meaning: open the turn, then the step, then call the model. If the process dies between two of those lines, the log still shows where it died.
Break three: the model keeps calling tools wrong
The minimal version has no exit except the model choosing to stop. A model that sends bad arguments makes runTool throw, and the loop dies.
The real version carries a catch block that does one very specific thing: it records the tool results that are still pending before rethrowing.
for (const event of toolRecovery.results()) { this.session.append('tool/result', event.data, { … })}The comment explains why: if the step fails while a tool result is unrecorded, the log holds a tool call that never receives a result. History becomes inconsistent, and a later resume reconstructs a distorted conversation.
A model calling tools wrong is routine. The loop has to survive it, not die with it.
Break four: no way to stop
The minimal version cannot be stopped. A user clicking cancel has nothing listening.
agent.ts carries 15 abort check points spread through the loop, and tool-calls.ts touches the signal in 20 places. Those numbers are not decoration: every await is a place the process can hang, and every hang is a cancellation that never lands.
And the documentation admits one limitation outright:
No built-in turn budget — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle extension point such as agent/turn-stopping.— dsh, packages/core/agent-loop/README.md
The loop does not know how to stop itself. If you need it to stop, you attach a policy at an extension point. This is not an oversight — it is a design decision, and it pushes the responsibility toward whoever assembles the system.
View diagram data
type: quadrant
title: Four failure points of a minimal loop
desc: The horizontal axis is state and context, the vertical axis is execution control and safety
nodes:
- id: q1
label: Context overflow
sub: Needs budget control
- id: q2
label: Lost history
sub: Needs durable event logs
focal: true
- id: q3
label: Repeated tool error
sub: Needs recovery and guards
- id: q4
label: Cannot cancel
sub: Needs AbortSignal propagation
focal: true
edges: []Two layers, not one
The minimal version has one loop. The real one has two, nested.
The outer layer is turn(): it runs steps until the turn ends. The inner layer is step(), and it also carries a while (true):
let firstAttempt = truewhile (true) { const { config, preparedCall } = await this.prepareRequest(turn, step, signal) … firstAttempt = false …}The inner loop does not iterate because of tool calls. It iterates because a model request may need retrying. The firstAttempt flag decides which work happens exactly once — appending the user’s message to the log, marking the start of a request series.
Collapse the two layers into one and you can no longer distinguish “the model proposed another step” from “this request failed, try again”. Those need different handling, and mixing them is where duplicate-write bugs come from.
Three units of time
This is the part I find most worth remembering, and it only becomes clear once you have seen both layers.
The loop operates on three units of time, and they do not nest the way you might assume:
| Unit | Definition | Relation |
|---|---|---|
| Step | One model request plus the tool calls that response caused | Smallest |
| Turn | One drain of admitted input | Contains many steps |
| Round | A policy iteration wrapping a turn, such as a goal round | Contains a turn |
The easy mistake: a turn may contain no steps at all. The documentation says “zero or more steps”, and the source carries a branch for exactly that case:
if (phase.step === 0 && decision.messages.length === 0) { turnEnds = { kind: 'completed' } return false}A turn opens and closes without calling the model once. That sounds absurd until you remember that something can wake the loop with nothing to do. The minimal version would call the model pointlessly there.
Do this now
- Split state out of the loop from day oneDo not keep history in a local variable. Write it to an ordered log before you need to resume, not after you have lost data.
- Name the three units of timeFix step, turn and round in your own documentation before writing the first line. Getting this wrong skews every cost measurement you take later.
- Write the exits before the happy pathCancellation, a token ceiling, and a turn ceiling are three things you need before the loop runs for real. Adding them afterwards means adding them to a system that has already hung.
The rule
The loop is easy. Keeping it alive across a long turn is the hard part. And the hard part lives entirely in the lines the minimal version does not have.
Every extra line in an agent loop marks a time the loop broke. Removing one restores a class of failure that already happened.— tusgino
- An agent loop is a
whileloop plus tool calls, and the ten-line version is right about the shape. - Count steps when estimating cost: the model is billed per step, while turn and round are policy units.
- Keep the turn outcome sticky: a turn that hit the token ceiling must not be recorded as a success.
- Record pending tool results before rethrowing, or the log holds a tool call that never receives a result.
- The loop does not know how to stop; if you need it to, attach a policy at an extension point.