Skip to content
5 min read · 1,061 words

Events

TurnRunner has two event buses. They do two different jobs. Confuse them and observability code starts deciding agent behavior, or product code starts depending on telemetry being wired up.

The rule

WARNING

If removing the listener would change the agent's behavior, it's functional. Otherwise, it's observability.

Streaming a partial message to a UI is functional — the user sees nothing if no one listens. Recording an OpenTelemetry span is observability — the agent runs the same with or without it. Separate emitters enforce the split: a throwing observability listener cannot block a functional emission, and a missing functional listener does not silently drop telemetry.

The functional bus

Three events: message, thought, toolCall. The executor calls helpers.reportMessage(id, delta) / reportThought(...) / reportToolCall(id, partial) as chunks arrive; the helpers normalize each call into a streaming payload and the bus emits it. If nothing is subscribed, the agent still runs — the user just sees nothing.

message and thought carry TurnStreamableContent. One id per stream. full is the accumulated body to date. isComplete: true seals the stream. UIs append aDelta; persistence layers snapshot full.

The field is named aDelta — not delta — on purpose

The leading a stands for additive. The name is the contract: this is an append chunk, not a general-purpose diff.

  • The type has no diff payload, no patch operator, no retraction channel. TurnStreamableContent is { id, full, aDelta, isComplete, … } — there is nowhere to express a removal, a replacement, or a reorder. By design.
  • The stock helpers enforce additive accumulation. reportMessage / reportThought compute full = full + aDelta and throw on any chunk after isComplete: true. Bypass the helpers (call ctx.emitMessage directly) and you maintain that contract yourself — subscribers assume it.
  • Persist full. Render with aDelta. Deltas are for UIs that already painted the previous chunk; storage and audit need the canonical body.
  • Why the shape. LLMs are token generators. They emit forward and do not retract — there is no decoder operation that means "un-say the last sentence." A diff/patch/retraction channel would be machinery for a scenario the standard executor cannot produce. A non-LLM executor that can retract (a human typist, a programmatic editor) is your edge case to design around — emit a fresh stream with a new id.

If you find yourself wanting aDelta to behave like a generic delta, you are misreading the field. Read full.

toolCall carries TurnToolCallContent. The model's request and the eventual result share one envelope keyed by id. Arguments stream as partials; once the call settles, middleware writes the result back through the same id with isComplete: true. TurnToolCallContent.checksum is what DispatchContext.toolCallCount counts and what nonce-keyed adapter envelopes bind to.

Seal the stream

No further deltas with the same id after isComplete: true. Forget the seal and subscribers wait forever. The UI spinner is not "slow"; it is waiting for an isComplete: true that your early-return path never emitted.

The functional bus is the only place these streams surface. To count messages or tool calls in telemetry, aggregate on the functional bus or count toolExecutionEnd / iterationEnd on observability.

The observability bus

Telemetry. This bus is the flight recorder, not the steering wheel: turn lifecycle, dispatch lifecycle, gates, tool execution, structured logs, and errors. Every payload carries turnId. Dispatch-scoped events add dispatchId and iteration. Tool events add callIdsha256 of { tool, args } over the raw, pre-validation arguments. Full payload shapes are in the API reference (TurnGateClosedEvent is canonical; the rest follow the same convention).

  • turnStart / turnEnd — the outer envelope. turnEnd carries durationMs and fires even on abort.
  • dispatchStart / dispatchEnd — one dispatch inside the turn. dispatchEnd.status is 'ack' | 'nack' | 'aborted' — the signal the executor loop has stopped.
  • iterationStart / iterationEnd — one trip through the executor seam. Separates model latency from middleware latency.
  • turnGateOpen / turnGateClosed — a TurnGate opened and settled. open carries the live gate (render your approval UI); closed carries the TurnGateClosedEvent.result. Join on gateId to measure operator response time.
  • toolExecutionStart / toolExecutionEnd — wraps the tool handler. The observability callId is sha256 of { tool, args } over the raw, pre-validation arguments — the same value that lands on the corresponding ToolCall.checksum. The functional-bus toolCall event carries the stream id (the persisted ToolCall.id, distinct from callId). Correlate the two buses via ToolCall.checksumnot by assuming callId === id. The two ids serve different purposes: callId deduplicates identical invocations across observability spans; id identifies the single persisted record.
  • log — structured executor diagnostics. Level, kind, message, optional payload.
  • error — a BaseException wrapping whatever a pipeline stage threw.

Abort is not an error

When the turn aborts (or a stage throws AbortError), the pipeline short-circuits silently. turnEnd and dispatchEnd still fire. No error event. If you alert on error, abort traffic is invisible — watch dispatchEnd.status instead.

Pipeline errors (input middleware, dispatch, output middleware) and downstream tool errors land on observability as error. run() does not throw them. No fallback: if you do not subscribe, they are gone.

Forwarding from dispatch

Functional events and dispatch-scoped observability events originate in DispatchRunner.dispatch. The runner forwards them to the TurnRunner buses when dispatch is sourced from a TurnContext. In the raw: path nothing bubbles — subscribe at the DispatchRunner level instead.

What to do with what

Use caseBus
Render streaming model outputfunctional (message, thought)
Surface tool calls and results to the userfunctional (toolCall)
OpenTelemetry spans / tracesobservability (turnStart, dispatchStart, …)
Metrics — turn count, latency, tool error rateobservability (turnEnd, toolExecutionEnd)
Structured executor diagnosticsobservability (log)
Centralized error reporting (Sentry / Bugsnag)observability (error)
Render UI for a human-approval gateobservability (turnGateOpen, turnGateClosed)

Where to go next

  • Turn Runner — the runner that owns both buses.
  • LLM Dispatch — where forwarded events originate.
  • Gates — the source of turnGateOpen / turnGateClosed.
  • Failure — the exception taxonomy error surfaces.