Skip to content
7 min read · 1,374 words

Turn Runner

TurnRunner is the entry point for one turn. It validates its config at construction, validates the raw turn input at run() time, builds a TurnContext, and threads that context through input middleware → LLM dispatch → output middleware. Anything you need to see leaves through the event buses.

run() returns Promise<void>. The return value is a completion signal, not a result — if you wrote code that reaches into a .then(result => …) looking for the model's reply, you are reading a different library.

"But I just want the final assistant message" — here is the short answer

Three questions land here on first contact. The answers are not buried; they are spread across pages, so here they are in one place.

Q. run() returns void. How do I get the final assistant message?

You subscribe before you call run(). The message event fires for each streaming delta and once more with isComplete: true carrying the full assembled text:

ts
let final = ''
runner.on('message', (event) => {
  if (event.isComplete) final = event.full   // final assistant text for this stream
})
await runner.run(rawCtx)
// `final` now holds the assistant's reply.
// Alternatively, read `ctx.turnMessages` from a piece of output middleware:
// the last `assistant`-identity Message in that Set is the final reply,
// and the record carries identity, payload, and provenance — not just text.

event.aDelta is the incremental chunk for token-level rendering; event.full is the accumulated text so far; event.isComplete: true marks the last emission for a given id. There is no runner.getFinalMessage() because run() is not a result API. By the time you would call it, the data has already left through the bus, and one turn can produce multiple assistant streams. Subscribe before run(), or read canonical records from middleware. Waiting until after run() and asking the runner for "the answer" is the wrong library.

Q. Which event is guaranteed to fire?

turnEnd — always. Clean exit, dispatch failure, output failure, even abort. If you need to know when a turn is over, observe turnEnd. If you need to know whether it failed, observe error (which fires before turnEnd for any non-fatal pipeline failure — see Failure).

Q. Do I have to render streaming deltas to use this?

No. on('message', …) fires for every chunk, but you can ignore everything except the isComplete: true emission and treat it as a "message arrived" callback. If you only care about the persisted record (identity, payload, provenance), read ctx.turnMessages from output middleware — the message event is a wire-shape stream, the Set on ctx is the canonical record.

The rule that unifies all three: the buses are the only egress. run() is a control-flow signal. See Events for the full grid.

Construction is validation

ts
const runner = new TurnRunner(config)

The constructor runs turnRunnerConfigSchema against config and throws E_INVALID_TURN_RUNNER_CONFIG on failure. No async, no lazy init, no "we'll figure it out on the first turn." If new returned, the runner is complete.

A misconfigured runner does not exist

Every required callback is present, every middleware array is iterable, every schema is parsed — or construction throws. There is no third state. The first turn never starts because the configuration was wrong; it starts because the configuration was right.

The required surface is the twenty-five storage callbacks on TurnRunnerConfig — seven retrieval and eighteen persistence, covering messages, memories, thoughts, tool calls, retrievables, tools, and standing instructions — plus the required TurnRunnerConfig.executorCallback that drives the model dispatch. The middleware arrays (TurnRunnerConfig.turnInputPipeline, turnOutputPipeline, dispatchInputPipeline, dispatchOutputPipeline) and tools are optional and default to an empty array, normalised at construction so internal access can assume present, iterable values.

You can opt out. You cannot omit.

A no-op storeMessage is fine. A fetchMemories that returns [] is fine. The ADK will run, and the agent will behave exactly as you wired it — without persistence, without history, without recall.

What the ADK refuses is the missing entry. The runner has no default to fall back to, because the only safe default for the boundary between an agent and its store is the one you chose. Persistence is not the kind of thing that gets deferred — it is the kind of thing that gets missed, and the cost shows up in production as an agent that loses every conversation or wakes up amnesiac every turn.

So you write the callback. If you mean "no memory," return [] and move on. If you mean "memory later," throw E_NOT_IMPLEMENTED so the first turn fails loudly. Either is a declaration. Nothing is not.

The runner is stateless across turns. Call run() repeatedly, concurrently, or both. The runner stores no cross-turn state, and multiple runners with overlapping config share nothing.

TurnContext

TurnContext is built fresh per turn from the RawTurnContext passed to run(). Validation (turnContextSchema) throws E_INVALID_TURN_CONTEXT on failure. There is one TurnContext per call to run() — it never leaks, it never gets reused, and there is no global "current turn."

Every context the runner builds carries:

FieldWhat it isOwned by
TurnContext.idUUIDv6 for correlation across all events emitted during the turn.Runner.
turnAbortController, TurnContext.systemPrompt, TurnContext.standingInstructions, TurnContext.stashThe raw fields you supplied on RawTurnContext.Consumer.
ctx.fetch* / ctx.store* / ctx.mutate* / ctx.delete*The runner's storage callbacks, bound.Runner-bound, consumer-implemented.
TurnContext.turnMessages, TurnContext.turnMemories, TurnContext.turnRetrievables, TurnContext.turnThoughts, TurnContext.turnToolCallsPer-turn Sets that start empty. Earlier middleware fills them; later middleware reads them.Middleware.
TurnContext.toolsFresh ToolRegistry seeded from config.tools. Per-turn register / unregister / merge mutate this turn only.Middleware.
emit*Wired into the runner's event buses.Runner.
openGate, TurnContext.waitForThe gate machinery.Runner.

The boundary is reached through ctx. Middleware calls ctx.fetchMessages(), not the bare fetchMessagesCallback from config. The same goes for storage, emission, and gates. Closure capture of the raw callbacks is a code smell — ctx exists so the runner can bind, count, and observe every crossing.

In-place mutation only

The per-turn collections are read-only references to mutable Sets. You add to them (ctx.turnMemories.add(m)); you do not replace them (ctx.turnMemories = new Set() will fail). The shape of stash is yours, but the slot is the slot — middleware patches stash in place. There is no API for swapping whole references, because there is no point in the loop where doing so would be safe.

Inside one turn

The full pipeline — RawTurnContext validation → TurnContext construction → turnStart → input middleware → dispatch → output middleware → turnEnd — plus the four invariants that govern error paths and the two event-bus contract.

→ Continue reading: Inside one turn

Two event buses

TurnRunner exposes a functional bus (TurnRunner.on / TurnRunner.off / TurnRunner.once) for events that participate in product behavior — message, thought, toolCall — and an observability bus (TurnRunner.observe / TurnRunner.unobserve / TurnRunner.observeOnce) for everything else. The rule: if removing the listener would change agent behavior, it belongs on the functional bus.

→ Continue reading: Two event buses

Gates and ctx.waitFor

ctx.waitFor(gate) opens a TurnGate — the cooperative suspension primitive that every safety, authorization, and human-oversight feature attaches to. The runner owns the lifecycle; you own who can resolve and how the gate is surfaced.

→ Continue reading: Gates and ctx.waitFor

What run() does not do

run() does not retry, bound iterations, impose policy, interpret Message.role, decide when to call tools, or trim context. Those are behaviors — and behaviors are yours.

→ Continue reading: What run() does not do

Wiring real storage

Every fetch and mutation callback is explicit because storage is not a side quest. It is where your product's guarantees live.

For a prototype, noop callbacks are fine. For production, point them at your stores:

  • storeMessageCallback / fetchMessagesCallback — your conversation history table
  • storeMemoryCallback / fetchMemoriesCallback — your memory or profile store
  • storeRetrievableCallback / fetchRetrievablesCallback — your RAG index
  • storeToolCallCallback / fetchToolCallsCallback — your audit or event log
  • storeStandingInstructionCallback / refreshStandingInstructionsCallback — your tenant, user, or deployment configuration

The runner injects those callbacks into each turn context, so middleware can fetch, mutate, and persist through ctx without knowing which database, cache, or service backs the operation. The callback is the boundary. What is on the other side is your choice.