From LLM to HarnessPart 4/11

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.

A minimal while loop with four broken branches marked around it
TL;DR — An agent loop is a 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:

agent-loop.ts — the minimal version
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:

agent-loop/src/agent.ts — the real one, abridged
phase.turn = turn
let turnEnds: TurnEndReason | null = null
let 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 = stepEnd

The 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.

Four failure points of a minimal loopThe horizontal axis is state and context, the vertical axis is execution control and safetyContext overflowNeeds budgetcontrolLost historyNeeds durableevent logsRepeated tool errorNeeds recoveryand guardsCannot cancelNeeds AbortSignalpropagation
Figure 1 — Four failure points of a minimal loop · tusgino
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):

agent-loop/src/agent.ts — the inner layer
let firstAttempt = true
while (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:

UnitDefinitionRelation
StepOne model request plus the tool calls that response causedSmallest
TurnOne drain of admitted inputContains many steps
RoundA policy iteration wrapping a turn, such as a goal roundContains 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

  1. 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.
  2. 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.
  3. 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 while loop 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.

Frequently asked questions

How is an agent loop different from an ordinary while loop?
In shape, it is not. It calls the model, runs tools if the model asked, and repeats. The difference lies in everything that must hold for the loop to survive a long turn.
Why not keep the loop short and readable?
Because every extra line marks a time the loop broke. Removing one restores a class of failure that already happened, usually a silent one.
What is the difference between turn, step and round?
A step is one model request plus the tool calls that response caused. A turn is one drain of admitted input and contains many steps. A round is a policy iteration wrapping a turn.
Does a harness limit how many times the loop may run?
Usually not, and this is a documented limitation: there is no built-in turn budget. Tool calls or steering continue the current turn, so a bound must cancel from an extension point.
Tú (tusgino)Tú (tusgino)

AI Software Engineer in Da Nang. I write about production system architecture and what each choice costs.