Signalling and bounds
The three terminal states a dispatch can settle in, the primitives the dispatch context exposes for bounding the loop, and how events forward when the dispatch is sourced from a turn versus standalone.
LLM Dispatch covers the dispatch contract; The executor seam covers how the executor calls these signals.
Signalling: ack, nack, aborted
A dispatch ends in exactly one of three terminal states. nack is one of those states — it is not a synonym for "anything bad." A nack happens because a seam explicitly called ctx.nack(error), or because a throw was caught and converted into one. Either way, the dispatch reaches the same terminal state with dispatchEnd.error carrying the cause.
dispatchEnd.status | Cause |
|---|---|
'ack' | DispatchContext.ack was called and no nackError was set. |
'nack' | DispatchContext.nack was called. A non-abort throw from the executor or from input/output middleware is wrapped and converted into a nack — same terminal state, just reached implicitly instead of by explicit signal. dispatchEnd.error carries the cause. |
'aborted' | The abort signal fired before any signal was set. The pending delta queue is discarded. |
Signalling is not silently idempotent
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 no-op. If multiple seams in your pipeline may try to signal (e.g. an output middleware that completes on "no further tool calls" and an executor that also tries to ack), guard with if (!ctx.isSignalled) ctx.ack(). Read DispatchContext.isSignalled, DispatchContext.isAcked, and DispatchContext.nackError to inspect signal state without provoking the exception.
Local DispatchContext Sets and persistence callbacks are written immediately as store / mutate / delete are called. The parent TurnContext Set mirror is a separate delta queue that flushes at the iteration boundary: on a successful iteration the queue flushes before iterationEnd, on a mid-iteration ack it also flushes before exit, and on nack or abort the queue is discarded so the parent turn does not see partial mirror writes.
ctx.iteration, ctx.toolCallCount, ctx.onAck
The dispatch context exposes the primitives needed to build behavior on top of the loop.
DispatchContext.iteration— 0-based index of the current iteration. Use this to bound retries (if (ctx.iteration >= 10) ctx.nack(new Error('too many iterations'))).ctx.toolCallCount(checksum)(seeDispatchContext.toolCallCount) — count of tool calls with this checksum stored in this dispatch (onlyctx.storeToolCalland the initialtoolCallsseed bump the count;helpers.reportToolCallemits to the bus but does not). Use this to detect models stuck in a loop calling the same tool with the same args.ctx.onAck(handler)(seeDispatchContext.onAck) — register a handler that runs synchronously whenDispatchContext.ackfires. Does not run onnack. Returns an unsubscribe function. This is the lifecycle hook thatToolRegistry.bindContext(ctx)uses to prune ephemeral tools.
onAck handler errors are swallowed
Handlers registered via onAck are invoked synchronously in registration order. If one throws, the exception is caught and dropped so that one misbehaving subscriber cannot prevent the others from running. The ack itself has already succeeded — there is no place to surface the error. If you need to observe failures, log inside the handler. That is the only chance you get.
The runner does not implement bounds, retries, or de-duplication on your behalf. These primitives are what you build them out of.
Forwarded events
Functional events emitted by the dispatch context (message, thought, toolCall) forward through the runner's hooks and back up to the TurnRunner's functional bus (see TurnRunner.on) when the dispatch is sourced from a TurnContext. Same for observability events (toolExecutionStart, toolExecutionEnd, plus the dispatch-level dispatchStart / dispatchEnd / iterationStart / iterationEnd / log / error). See Events for the full payload shapes.
In the standalone path (raw: instead of source:), nothing bubbles up — the caller of dispatch() is the only listener, and they wire hooks / observers directly into the dispatch input.