---
title: "The while loop works, until it doesn't"
description: "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."
pubDate: 2026-09-22
author: tusgino
tags: [ai-engineering, architecture, agents]
series:
  id: tu-llm-den-harness
  title: "From LLM to Harness"
  order: 4
cover: /images/covers/vong-while-chay-duoc-toi-luc-khong.svg
coverAlt: "A minimal while loop with four broken branches marked around it"
lang: en
faq:
  - q: "How is an agent loop different from an ordinary while loop?"
    a: "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."
  - q: "Why not keep the loop short and readable?"
    a: "Because every extra line marks a time the loop broke. Removing one restores a class of failure that already happened, usually a silent one."
  - q: "What is the difference between turn, step and round?"
    a: "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."
  - q: "Does a harness limit how many times the loop may run?"
    a: "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."
---

<TLDR>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.</TLDR>

## The minimal loop

Here is an entire agent loop, and it genuinely runs:

```ts title="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:

```ts title="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:

```ts
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.

```ts
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:

<Quote cite="dsh, packages/core/agent-loop/README.md">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.</Quote>

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.

```diagram
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)`:

```ts title="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:

| 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:

```ts
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.

<Callout type="warn" title="If you count turns to estimate cost, you are counting the wrong unit">
The model is billed per step, not per turn. A turn may hold ten steps or none. Counting turns and multiplying by a per-request price is the fastest way to be wrong by a large multiple.
</Callout>

## Do this now

<Steps>
<Step title="Split state out of the loop from day one">Do not keep history in a local variable. Write it to an ordered log before you need to resume, not after you have lost data.</Step>
<Step title="Name the three units of time">Fix step, turn and round in your own documentation before writing the first line. Getting this wrong skews every cost measurement you take later.</Step>
<Step title="Write the exits before the happy path">Cancellation, 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.</Step>
</Steps>

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

<Quote cite="tusgino">Every extra line in an agent loop marks a time the loop broke. Removing one restores a class of failure that already happened.</Quote>

<KeyTakeaways>
- 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.
</KeyTakeaways>
