Skip to content
7 min read · 1,344 words

LLM Dispatch

A dispatch is one LLM execution cycle. DispatchRunner constructs a single-use DispatchContext, runs the iteration loop, and resolves when the executor signals completion or the abort signal fires.

The loop is bounded by signals, not heuristics

There is no maxIterations, no checksum-repeat detector, no termination policy. What the ADK does give you is the information you need to make those decisions yourself: ctx.iteration (see DispatchContext.iteration) for how many cycles you've spent, ctx.toolCallCount(checksum) (see DispatchContext.toolCallCount) for how often the model has asked for the same call, and ctx.tools (see DispatchContext.tools) for what is currently available to it. The decision of when to stop is yours — the primitives that let you make it are ours.

What a dispatch is

A dispatch is the bounded loop around one act of "calling the model" — including any iterations required to chase tool calls to completion. DispatchRunner.dispatch() is designed to run either inside a turn or standalone, with the same core behaviour in both scenarios. What changes between the two paths is the wiring, not the loop.

  • Inside a turn (source:). TurnRunner.run() calls dispatch() between the input and output middleware pipelines. The dispatch inherits the turn's collections, callbacks, abort signal, and event buses; mutations flow back to the turn at the end of each iteration; functional and observability events bubble up to the runner's listeners.
  • Standalone (raw:). The caller assembles the context directly. There is no parent — collections, callbacks, hooks, and observers are wired by the caller of dispatch(). This is the entry point for planning agents proposing a sub-step, specialist agents handling a single tightly scoped reasoning task, summarisers running over a finished conversation, evaluators replaying a recorded trace, and anywhere else a full TurnRunner would be too much machinery for the job at hand.

Both paths run the same loop, signal the same way, surface the same errors. The only differences are the wiring that connects the dispatch to its environment.

ts
await DispatchRunner.dispatch({
  source: turnContext,         // OR raw: { ... } — exactly one
  executor: executorCallback,
  turnInputPipeline: [/* per-iteration input */],
  turnOutputPipeline: [/* per-iteration output */],
  hooks: { /* functional forwarders */ },
  observers: { /* observability forwarders */ },
})
  • source is a TurnContext. The dispatch context inherits the turn's collections, callbacks, abort signal, and event wiring. Mutations made during dispatch are queued as deltas and flushed back to the turn's Sets at the end of every iteration. This is the path the TurnRunner uses.
  • raw is the standalone path. Supply the raw fields directly. There is no parent context; nothing bubbles up, and the delta queue is drained but never applied to a parent.

Exactly one of source or raw

Supplying both, or neither, throws E_INVALID_LLM_DISPATCH_INPUT from dispatch() synchronously. When TurnRunner is the caller this surfaces as a wrapped error on the runner's error bus; when called standalone, the promise rejects.

Single-use by construction

The runner is constructed inside dispatch(), runs the loop, and is garbage-collected. Per-id stream state on DispatchExecutorHelpers lives on closure-captured Maps and dies with the runner — it cannot leak across dispatches.

The iteration loop

The loop does not bound itself

If the executor never signals and never throws, the loop runs forever. Not "until the framework notices." Forever. Your cap is middleware — ctx.iteration (see DispatchContext.iteration), ctx.toolCallCount(checksum) (see DispatchContext.toolCallCount) — or your outage is the cap.

Tool handlers are conventionally invoked by the executor — inside the iteration that proposed them

When the model returns a tool call on iteration N, the executor invokes tool.executor(ctx)(args), persists the completed ToolCall record via ctx.storeToolCall(...), and only then returns. Move that work after the loop and the model never sees the result. Have middleware also invoke it and side effects fire twice. Iteration N+1's model call then sees the result in ctx.turnToolCalls and can reason about it. That two-iteration round trip — model proposes → executor invokes handler → executor persists → next iteration sees result — is the convention that makes the dispatch loop useful. Tool handlers should not be re-invoked in turnOutputPipeline, dispatchOutputPipeline, or other pipeline middleware; after the loop, the model never saw the result, and if the executor already ran the handler, middleware would double-fire side effects. See The executor seam → Invoking tools.

Any seam with access to ctx can signal — the executor, an input middleware, or an output middleware. Three things to know:

  • The signal is what ends the dispatch. The loop checks ctx.isSignalled (see DispatchContext.isSignalled) after the input pipeline, after the executor, and at the top of every iteration. The first seam to call ctx.ack() (see DispatchContext.ack) or ctx.nack(error) (see DispatchContext.nack) wins; the loop breaks at the next check.
  • The executor sees one iteration at a time. It has the raw provider response in hand and that is the basis on which it can decide to ack (the response is final) or nack (the API call failed). It does not, by itself, have visibility into what happened in earlier iterations beyond what is in ctx.
  • Middleware sees the whole dispatch. ctx.iteration (see DispatchContext.iteration), ctx.toolCallCount(checksum) (see DispatchContext.toolCallCount), and the turn-scoped collections (ctx.turnMessages, ctx.turnToolCalls, etc.) accumulate across iterations and are available to input and output middleware. Bounds, repetition detection, and any "we are done now" decision based on what has already happened belong here. if (ctx.iteration >= 10) ctx.nack(...) and if (ctx.toolCallCount(checksum) >= 3) ctx.nack(...) are the canonical shapes; an output middleware that inspects the latest tool call and acks when the dispatch's goal is met is the same idea.

Subsequent signals after the first throw E_LLM_EXECUTION_ALREADY_SIGNALLED. When more than one seam may try to signal, read DispatchContext.isSignalled first.

The executor seam

The executor is one callback. The runner invokes it once per iteration with the dispatch context ctx (DispatchContext) and the streaming helpers helpers (DispatchExecutorHelpers). Helpers stream the wire shape; persistence (ctx.storeMessage / ctx.storeThought / ctx.storeToolCall) stores the canonical record. The executor's return signals — ctx.ack() (see DispatchContext.ack), ctx.nack(error) (see DispatchContext.nack), or returning without signalling — are how the runner decides what to do next.

→ Continue reading: The executor seam

Wiring a real provider

The quickstart uses a scripted executor. Replace TurnRunnerConfig.executorCallback with your provider call when you are ready. The runner stays; only the body changes.

  1. Read ctx.systemPrompt, ctx.standingInstructions, ctx.turnMessages, ctx.turnMemories, ctx.turnRetrievables, and ctx.tools (see TurnContext.systemPrompt, TurnContext.standingInstructions, TurnContext.turnMessages, TurnContext.turnMemories, TurnContext.turnRetrievables, TurnContext.tools).
  2. Translate those primitives into your provider's request shape.
  3. Stream model output back through helpers.reportMessage(...) or helpers.reportThought(...).
  4. Report tool calls through helpers.reportToolCall(...) if your model asks for tools.
  5. Persist complete Message, Thought, and ToolCall records through ctx.store* methods.
  6. Call ctx.ack() (see DispatchContext.ack) when the dispatch is done, or ctx.nack(error) (see DispatchContext.nack) when it should fail.

ADK does not pick a provider, retry strategy, prompt format, or tool-calling protocol. That is your architecture. ADK keeps the turn loop deterministic while you own the rest.

Signalling: ack, nack, aborted

A dispatch ends in exactly one of three terminal states. The first call to DispatchContext.ack or DispatchContext.nack sets the signal; a second call throws E_LLM_EXECUTION_ALREADY_SIGNALLED — it is not a silent no-op.

→ Continue reading: Signalling: ack, nack, aborted

ctx.iteration, ctx.toolCallCount, ctx.onAck

The dispatch context exposes three primitives for bounding the loop: a running iteration count (see DispatchContext.iteration), a per-checksum tool call count (see DispatchContext.toolCallCount), and a synchronous lifecycle hook that fires only on ack (see DispatchContext.onAck).

→ Continue reading: ctx.iteration, ctx.toolCallCount, ctx.onAck

Forwarded events

Functional events (message, thought, toolCall) and observability events forward through the runner's hooks and back up to the TurnRunner's buses when the dispatch is sourced from a TurnContext. In the standalone path nothing bubbles up.

→ Continue reading: Forwarded events

Errors during dispatch

Executor throws, middleware throws, abort, and ctx.nack(error) each settle the dispatch into one of the terminal states. Where the errors surface depends on whether the dispatch was called by TurnRunner.run() or standalone.

→ Continue reading: Errors during dispatch