Certified Agent Harness Engineer
Course sections

M2.1

The canonical loop and its exit conditions

1,321 words · about 8 min

Concept

Every agent harness ever written is the same six-phase cycle. The names differ; the shape does not.

Compose the request from durable history plus a system prompt plus a tool catalog. Sample the model. Parse the completion into content blocks — text, reasoning, tool calls. Dispatch the tool calls. Observe the results by folding them back into history. Repeat — unless an exit condition fires.

The interesting content is entirely in "unless." A loop without enumerated exit conditions is a loop that hangs, spins, or bills you $400 overnight. Every production harness has been embarrassed by this at least once.

Mechanism

                    ┌─────────────────────────────────────────────────┐
                    │                                                 │
                    ▼                                                 │
  ┌──────────┐   ┌───────────┐   ┌────────┐   ┌───────┐   ┌──────────┐│
  │ COMPOSE  │──▶│  SAMPLE   │──▶│ PARSE  │──▶│DISPATCH│─▶│ OBSERVE  ││
  │ history  │   │ stream    │   │ blocks │   │ tools │   │ append   ││
  │ + system │   │ the model │   │ from   │   │ under │   │ results  ││
  │ + tools  │   │           │   │ deltas │   │ policy│   │ to log   ││
  └──────────┘   └───────────┘   └────────┘   └───────┘   └──────────┘│
       ▲               │              │            │            │     │
       │               │              │            │            └─────┘
       │               │              │            │          REPEAT
       │               │              │            │        (next step)
       │               ▼              ▼            ▼
       │         ┌───────────────────────────────────────────┐
       │         │            EXIT CONDITIONS                │
       │         ├───────────────────────────────────────────┤
       │         │ no tool calls in completion → completed   │
       │         │ finish reason = max-tokens  → max-tokens  │
       │         │ a tool result concludes turn→ completed   │
       │         │ pre-step listener rejects   → blocked     │
       │         │ abort signal fires          → aborted     │
       │         │ unrecovered request failure → error       │
       │         └───────────────────────────────────────────┘
       │                             │
       └─────────────────────────────┘
              turn ends; agent goes idle and waits for input

Read the diagram twice. Every later section in this module attaches to one of its boxes: M2.2 is SAMPLE and PARSE, M2.3 is the abort arrow that can cut any box, M2.4 is policy inside DISPATCH, M2.5 is what happens when SAMPLE throws, M2.6 is OBSERVE's durability, and M2.7 measures COMPOSE.

Two words need pinning down before they cause confusion, because harnesses use them inconsistently:

  • A step is one model request plus the tool calls that request produced. One trip around the diagram.
  • A turn is one user-facing unit of work: everything from a user's prompt until the agent has nothing left to say. A turn contains one or more steps.

The distinction matters because they have different exit conditions. A step ends when the model stops generating. A turn ends when a step produces no more work. Harnesses that conflate them cannot express "the model wrote three paragraphs, called four tools, read the results, then answered" as a single billable, cancellable, resumable unit.

Worked example — the Module 1 spine, before

Worked example

Your Module 1 spine looked essentially like this:

// Module 1 spine — the naive loop
const messages = [{ role: 'user', content: prompt }]
while (true) {
  const reply = await callModel({ system, messages, tools })
  messages.push(reply)
  const calls = reply.content.filter(b => b.type === 'tool_call')
  if (calls.length === 0) break                       // the ONLY exit condition
  for (const call of calls) {
    const result = await runTool(call.name, JSON.parse(call.arguments))
    messages.push({ role: 'user', content: [{ type: 'tool_result', ...result }] })
  }
}
return messages.at(-1)

Count the exit conditions: one. calls.length === 0. That single condition is doing the work of six, and the other five failure modes are unhandled: a truncated completion loops forever on garbage, an abort is impossible, a thrown tool error escapes and destroys the whole conversation, and nothing is persisted.

How `dsh` does it

How dsh does it

dsh splits the same shape across three methods in packages/core/agent-loop/src/agent.ts (class ReactLoopAgent):

  • kick() is the driver: while (await this.turn()) {}. It runs turns until no more work is pending, and contains its try/catch/finally so that reported failures and cancellation are contained at the driver boundary rather than escaping into whatever called it.
  • turn() appends a turn/start event, loops steps, and appends turn/end with a reason in a finally block. The finally is what guarantees every opened turn is closed even on a throw.
  • step() does compose → sample → parse → dispatch → observe for one step and returns the step's outcome.

The exit conditions are literal return statements in step(), and they are worth memorizing because they map one-to-one onto the diagram:

// packages/core/agent-loop/src/agent.ts (step)
if (finish.kind === 'max-tokens') return { kind: 'max-tokens' }

const toolCalls = message.content.filter(block => block.type === 'tool-call')
if (toolCalls.length === 0) return { kind: 'completed' }

const { concluded } = await executeToolCalls(this.loopCtx, turn, step, toolCalls, signal, /* … */)
return concluded ? { kind: 'completed' } : null

null is the "repeat" arrow. A step that returns null has dispatched tools, folded their results into the log, and has nothing conclusive to report — so turn() runs another step. Everything else terminates the turn.

The full set of turn outcomes lives in TurnEndReasonMap in packages/core/session/src/types.ts, and it is a merge-extensible sum type — a plugin can add a variant by declaration merging:

ReasonMeaning
completedThe model produced no tool calls, or a tool result concluded the turn.
max-tokensAt least one step hit the output-token ceiling.
abortedA cancellation request interrupted the live turn; carries a TurnEndCancelCause.
blockedAn agent/pre-step listener rejected entry to the step.
errorThe turn failed with a structured LlmFailure.
interruptedSynthesized by crash recovery only — the loop never emits this. It marks a turn that was open when the process died.

One subtlety worth internalizing, because it is the kind of thing that gets rediscovered painfully: max-tokens is sticky. The source comments it directly — once any step hits the ceiling, later steps that complete normally must not downgrade the turn outcome. If a plugin sees a truncated step and continues the turn anyway, the turn still ends max-tokens. The cut-short fact wins over a later continuation, because a consumer reading the log needs to be able to tell a clean stop from a truncated one, and a downgrade would silently erase that.

Tradeoffs

Enumerating exit conditions in a typed sum costs you flexibility: adding one means touching a shared type and, in dsh's case, updating both SDK projections (the repo's AGENTS.md states this explicitly — agent-loop and SessionEventMap changes update TypeScript and Python SDK expected outputs). The payoff is that no exit path is unhandled, because assertNever on the discriminant makes an unhandled variant a compile error rather than a runtime silence.

The null-means-continue convention is compact but subtle. A { kind: 'continue' } variant would read better; dsh chose StepEndReason | null where StepEndReason is narrowed to just completed | max-tokens, which makes it a type error to return a turn-level reason from a step. That is a real safety property, bought with a small readability cost.

How others differ

Claude Code and OpenAI's Codex CLI run recognizably the same loop; the visible divergences are in the exit vocabulary. Many harnesses ship a hard step cap — a maximum number of model round-trips per turn — as a primary exit condition. dsh does not put a step cap in the loop at all: packages/core/agent-loop/src/constants.ts defines only DEFAULT_MAX_PARALLEL_TOOL_CALLS = 10, a concurrency bound within a step, not a bound on step count. dsh instead exposes agent/pre-step as a waterfall extension point where a plugin can reject the next step (producing blocked), which is where a step cap would live as policy rather than as loop code. That follows the repo rule in AGENTS.md: plugins, not loop changes. The trade is real — a deployment that loads no such plugin has no step cap, and an unbounded tool-calling model will keep going until the context window or the wallet stops it. Harnesses that hardcode the cap are safer out of the box and less configurable.


Check yourself

3 questions on M2.1

Start drill