Skip to content
2 min read · 490 words

ADK-side facts and helpers vs persistence

The runner-side constraints on the executor surface, and the deliberate decoupling between streaming helpers and the persistence layer.

The executor seam covers the callback contract and what the runner puts on ctx.

ADK-side facts that constrain the surface

A handful of ADK-side facts constrain the executor's surface. They are not about how the executor should be written; they are about what the runner does around it.

Stores queue as deltas

ctx.store* / ctx.mutate* / ctx.delete* calls during an iteration queue as deltas. They flush to the parent TurnContext at the end of the iteration on the derived path, or drain locally on the standalone path. Abort or DispatchContext.nack mid-iteration discards the queue — partial writes never reach the parent. Successful iterations always flush before iterationEnd.

Reporting after a stream is sealed throws

Once helpers.reportMessage(id, …, { isComplete: true }) — or the equivalent for thoughts or tool calls — has been called for an id, any further report* for that id throws a bare Error. Uncaught, it surfaces as E_LLM_EXECUTION_EXECUTOR_ERROR and nacks the dispatch. Pick stable ids and complete each one exactly once.

ctx.abortSignal is the abort surface

Wire DispatchContext.abortSignal (or a signal linked to it) into whatever long-running operation the executor performs. The turn-level abort fires this signal; if the executor doesn't observe it, the request continues until it completes on its own.

Nack vs throw is the executor's call

The runner treats them differently. A throw is wrapped as E_LLM_EXECUTION_EXECUTOR_ERROR and reported on the error bus. ctx.nack(error) reports the original error unwrapped. Use nack for expected failure modes you have a stable error code for; let throws surface bugs.

The executor must not assume what middleware did. Read DispatchContext.turnMessages, DispatchContext.turnRetrievables, and friends if you need to know what is in scope; do not capture middleware-local state in closures.

Helpers vs persistence

Helpers emit on the event bus; persistence writes to your store. They are deliberately decoupled.

  • Helpers stream the wire shape — enough for a UI to render a partial response and enough for downstream consumers to consume chunks.
  • Persistence stores the canonical record — including fields (identity, SpooledArtifact results, Thought.replayCompatibility) that the wire shape does not carry.

Two function calls, not one — emit is not persistence

Calling helpers.reportMessage without later calling ctx.storeMessage creates a ghost message: the UI saw it, storage did not, and the next turn has no record it happened. The wire payload and the persisted record are not the same data, and an emit-only message is invisible to every later turn. Emit-only messages are allowed only when you deliberately want a ghost — intentionally ephemeral progress chatter, "thinking..." placeholders, throwaway debug surfacing. The ADK cannot tell the difference and will not flag it. Audit every report* without a matching store*.