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.
TurnStreamableContentis{ 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/reportThoughtcomputefull = full + aDeltaand throw on any chunk afterisComplete: true. Bypass the helpers (callctx.emitMessagedirectly) and you maintain that contract yourself — subscribers assume it. - Persist
full. Render withaDelta. 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 callId — sha256 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.turnEndcarriesdurationMsand fires even on abort.dispatchStart/dispatchEnd— one dispatch inside the turn.dispatchEnd.statusis'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— aTurnGateopened and settled.opencarries the live gate (render your approval UI);closedcarries theTurnGateClosedEvent.result. Join ongateIdto measure operator response time.toolExecutionStart/toolExecutionEnd— wraps the tool handler. The observabilitycallIdis sha256 of{ tool, args }over the raw, pre-validation arguments — the same value that lands on the correspondingToolCall.checksum. The functional-bustoolCallevent carries the streamid(the persistedToolCall.id, distinct fromcallId). Correlate the two buses viaToolCall.checksum— not by assumingcallId === id. The two ids serve different purposes:callIddeduplicates identical invocations across observability spans;ididentifies the single persisted record.log— structured executor diagnostics. Level, kind, message, optional payload.error— aBaseExceptionwrapping 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 case | Bus |
|---|---|
| Render streaming model output | functional (message, thought) |
| Surface tool calls and results to the user | functional (toolCall) |
| OpenTelemetry spans / traces | observability (turnStart, dispatchStart, …) |
| Metrics — turn count, latency, tool error rate | observability (turnEnd, toolExecutionEnd) |
| Structured executor diagnostics | observability (log) |
| Centralized error reporting (Sentry / Bugsnag) | observability (error) |
| Render UI for a human-approval gate | observability (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
errorsurfaces.