.agents/skills/adk-architecture/references/architecture-node-runner.md
NodeRunner is the per-node executor. It creates the child Context, drives
BaseNode.run(), opens the node's span, enriches and enqueues events, retries
on failure, and returns the child Context to the caller.
ctx. The orchestrator reads ctx after the
child finishes to decide what happens next.A node writes to ctx to talk to its parent. It yields Events to persist data
and stream to the user.
Orchestrator
│
├─ NodeRunner(node=child, parent_ctx=ctx)
│ │
│ ├─ _create_child_context() → child Context (attempt_count)
│ ├─ start_as_current_node_span() → ctx._telemetry_context
│ ├─ _execute_node() → iterate node.run()
│ │ ├─ _track_event_in_context() → write results to ctx
│ │ └─ _enqueue_event() → enrich + persist
│ ├─ _flush_output_and_deltas() → emit deferred output/route/deltas
│ └─ return child ctx
│
└─ reads ctx.output, ctx.route, ctx.interrupt_ids, ctx.error
Create child Context. Shares the InvocationContext, builds node_path
from the parent, assigns run_id, records attempt_count. If the session
already holds events for this node path, resolved responses are rehydrated
into ctx._resume_inputs before the node runs.
Open the span via node_tracing.start_as_current_node_span, storing the
resulting TelemetryContext on ctx. Opening it inside the try is
deliberate — exceptions are recorded on the span.
Iterate node.run(). For each yielded Event:
_track_event_in_context copies output, route and
long_running_tool_ids onto ctx, which is the source of truth. Route
and transfer_to_agent are only picked up from native events (no
author, or authored by this node), so a composite parent does not
re-bubble a decision its own sub-agent already handled. The event ID is
also registered on the node's span._enrich_event stamps author (ctx.event_author or the
node name), invocation_id, node_info.path, branch, and
isolation_scope. For an event carrying output it also sets
node_info.output_for to this node path plus its output ancestors.
node_info.run_id is not stamped separately — it is a property
derived from the last segment of node_info.path (wf@1/child@2).ctx.actions onto the event.ic._enqueue_event(event) puts it on the shared queue for
session persistence.Flush deferred output. If ctx.output or ctx.route were set directly
rather than yielded, _flush_output_and_deltas emits one final Event after
_run_impl returns, bundling any remaining deltas onto it.
Return the child ctx. The orchestrator reads output, route,
interrupt_ids and error.
node.timeout wraps the iteration in asyncio.wait_for; a timeout raises
NodeTimeoutError.error_code and
error_message, then consults node.retry_config. A retry sleeps for the
configured delay, increments attempt_count and rebuilds the child Context
from scratch. Retry count is not persisted, so it does not survive a
resume.ctx.error and
ctx.error_node_path and the Context is returned normally — NodeRunner does
not re-raise to the orchestrator.NodeInterruptedError from a dynamic child is swallowed here: the child's
interrupt IDs are already on ctx, so the caller just reads
ctx.interrupt_ids.use_as_output)When a child is scheduled with use_as_output=True, its output Event also
counts as the parent's output. NodeRunner sets ctx._output_delegated, drops
the output field from the parent's own event (keeping the event if it still
carries deltas), and stamps node_info.output_for with the ancestor paths.