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()callsdispatch()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 ofdispatch(). 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 fullTurnRunnerwould 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.
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 */ },
})sourceis aTurnContext. 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'sSets at the end of every iteration. This is the path theTurnRunneruses.rawis 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(seeDispatchContext.isSignalled) after the input pipeline, after the executor, and at the top of every iteration. The first seam to callctx.ack()(seeDispatchContext.ack) orctx.nack(error)(seeDispatchContext.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(seeDispatchContext.iteration),ctx.toolCallCount(checksum)(seeDispatchContext.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(...)andif (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.
- Read
ctx.systemPrompt,ctx.standingInstructions,ctx.turnMessages,ctx.turnMemories,ctx.turnRetrievables, andctx.tools(seeTurnContext.systemPrompt,TurnContext.standingInstructions,TurnContext.turnMessages,TurnContext.turnMemories,TurnContext.turnRetrievables,TurnContext.tools). - Translate those primitives into your provider's request shape.
- Stream model output back through
helpers.reportMessage(...)orhelpers.reportThought(...). - Report tool calls through
helpers.reportToolCall(...)if your model asks for tools. - Persist complete
Message,Thought, andToolCallrecords throughctx.store*methods. - Call
ctx.ack()(seeDispatchContext.ack) when the dispatch is done, orctx.nack(error)(seeDispatchContext.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