Skip to content
4 min read · 724 words

Inside one turn

The full pipeline diagram for runner.run(rawCtx), the four invariants that govern error paths, and the two event buses the runner exposes.

Turn Runner covers the construction contract and the TurnContext shape.

The pipeline diagram

TIP

Every node is a link. If a stage surprises you, click it before you patch around it.

Four invariants the diagram does not show

These matter precisely when something goes wrong. Get them wrong and your error paths will silently disagree with your code.

1. turnEnd always fires

Every terminal node — clean exit, input failure, dispatch failure, output failure — funnels into turnEnd. If you wire telemetry to exactly one event, wire it here. Nothing else is guaranteed.

2. run() does not reject on pipeline failure

Errors emit on the observability bus as error and skip the next stage of the turn — a throw in input middleware skips dispatch and output; a throw in dispatch skips output; a throw in output ends the turn. Within the failing pipeline itself, the harness wires an error handler that catches throws at every level, so upstream post-steps still resume as if downstream had finished. The catch site for the error is your observer, not a try/catch around run(). Tool-handler errors and DispatchContext.ack / DispatchContext.nack failures during dispatch are wrapped and re-emitted as error too — they also do not throw out.

One honest caveat on invariant 2

There is exactly one path that does reject run() synchronously: schema validation of the raw turn context. Before any pipeline runs, the runner constructs a TurnContext from the raw input, and turnContextSchema is checked inside that constructor. A bad shape throws E_INVALID_TURN_CONTEXT out of run() as a rejected promise — there is no observer to emit to yet, because the turn has not started, and turnStart / turnEnd do not fire. This is the difference between pipeline errors (caught and emitted) and pre-pipeline errors (thrown). If you await runner.run(rawCtx) with no try / catch, an invalid rawCtx will reject the await. Either guard the call or validate upstream.

3. Abort is silent by design

When the abort signal fires — or middleware throws an AbortError — the pipeline short-circuits with no error event. turnEnd still fires. The consumer's abort handler owns any user-visible signal; the runner refuses to invent one.

4. The dispatch loop is its own contract

The middle of this diagram is a single box on purpose. The iteration loop, ack / nack semantics, and the executor seam live in LLM Dispatch. Treating dispatch as a black box from the runner's vantage is the whole point of the seam.

Two event buses

TurnRunner exposes two event interfaces. The methods look symmetric — they are not.

ts
// Functional bus — participates in product behavior
runner.on('message', listener)
runner.once('thought', listener)
runner.off('toolCall', listener)

// Observability bus — instrumentation only
runner.observe('turnStart', listener)
runner.observeOnce('turnEnd', listener)
runner.unobserve('error', listener)

(See TurnRunner.on, TurnRunner.observe, TurnRunner.unobserve.)

The rule, repeated everywhere it matters

If removing the listener changes the agent's behavior, it belongs on the functional bus. If your observe('error') handler decides what the user sees, you wired product behavior to telemetry. Move it.

Streaming a message to a UI is functional — remove the listener, the user sees nothing. Recording a span is observability — remove the listener, the agent behaves identically. The test is mechanical, not aesthetic.

BusEventsUse it for
Functionalmessage, thought, toolCallStreaming content to the UI, threading model output into downstream behavior, anything that has to happen for the agent to function.
ObservabilityturnStart, turnEnd, dispatchStart, dispatchEnd, iterationStart, iterationEnd, turnGateOpen, turnGateClosed, toolExecutionStart, toolExecutionEnd, log, errorSpans, metrics, audit logs, debug UIs, telemetry sinks.

The split is structural, not stylistic. The two buses run on separate emitters so the surface itself enforces the rule: a functional listener and an observability listener cannot end up on the same event, and code reaching for observe() is code that has declared "removing this changes nothing." Telemetry can be wired without auditing whether the listener accidentally became load-bearing. If your observe('error', …) decides whether the user sees a refusal, you put it on the wrong bus.

See Events for payload shapes and the aDelta / full / isComplete semantics on the streaming events.