Node context & runner threading — how a run's runner + scratchpad reach every subagent
Status: DESIGN / PLAN (LOCKED) (2026-07-03). Companion to
docs/store-family.md. This doc owns the two tracks that are not Stores but are what let canvas-workstation’swith-persistence.tsdissolve: #116 (per-node runner policy) and #99 (scratchpad + deps propagation across the agent-as-tool seam). Plus the deferred AgentNode ↔ Node-world consolidation. All claims below were verified against the runtime source and the downstream consumer (~/retrieval-agent-2.0/canvas-workstation) on 2026-07-03.
Background: what a run threads down the Node tree
Every Node receives a NodeRunContext (workflows/node.ts:28):
interface NodeRunContext { readonly runner: RunnerProtocol; // the LLM executor readonly toolExecutor?: ToolExecutor; readonly scratchpad?: Scratchpad; // run-scoped shared state (slot.ts) readonly deps?: DepReader; // injected clients/resolvers (deps.ts) readonly traceId?: string; readonly parentSpanId?: string;}Composites (Sequential/Parallel/FanOut/CoordinatorStep) propagate it to children via { ...ctx }.
Two fields are the subject of this doc: runner (#116) and scratchpad (#99). Both are
ambient today — threaded by reference — and both hit the same wall: they can’t cross the
runner → tool boundary, where an agent-as-tool subagent actually executes.
Where our Runner sits (the ADK split)
Our Runner is not the ADK agent — it’s the executor half of ADK’s fat Runner. ADK’s one
Runner does three jobs; we split them:
ADK Runner does… | …in our world |
|---|---|
| run the LLM + tool loop for one agent | AgentRunner (RunnerProtocol.run / runStructured) |
| carry the services (session/memory/artifact) into that run | NodeRunContext ({ runner, scratchpad, deps, … }) threaded down the call-tree |
| orchestrate multiple agents | the composites (Sequential/Parallel/CoordinatorStep) or AgencyRuntime |
AgentRunner only turns (agent, message) → response. It holds no stores and orchestrates no one.
That narrowness is what lets the same runner power a Node graph, a coordinator, or an AgentNode.
The Role layer (Role = Persona + Judgments + Capabilities + Responsibilities) has no ADK
equivalent — it’s our reusable template below Agent, and it’s the thing that keeps sibling
subagents from drifting (persona/judgments tuned in one place). It is the “clean clear consistency
between subagents” mechanism.
#116 — Per-node runner / execution policy
The problem (verified)
Per-node model override already exists: AgentStepSpec.model → applyStepModel (base.ts:169).
But its own comment (base.ts:168) is explicit: “a per-step agent view with a different declared
model, NOT a per-step runner.” The runner is ambient — one ctx.runner, threaded by reference
(node.ts:29). There is no way to declare “this node runs on a different runner.”
The consequence is canvas-workstation’s runner closure hack (with-persistence.ts:124-133):
to force one subtree onto a direct-Google LanguageModelV2 (because ambient AP_GATEWAY_* flips
createRunner into resolver mode → structured output routes through a gateway that rejects
json-schema responseFormat → classify fails 100%), the code builds its own runner and overwrites
ctx.runner in a hand-written decorator:
baseRunner = (await createRunner({ model: google(opts.model), … })).runner;const { runner } = countingRunner(baseRunner);const childCtx: NodeRunContext = { ...ctx, runner, hooks }; // ← the hackconst res = await node.run(input, childCtx);The node’s real requirement (“I need native structured output”) is invisible in its declaration and lives out-of-band in a wrapper. That is exactly the inconsistency the north star rejects.
The fix
Make runner a declarative per-node property, and provide a blessed subtree override that formalizes the hack:
- Add
runner?: RunnerProtocoltoAgentStepSpec(agent-step.ts:44) andCoordinatorStepSpec(coordinator-step.ts:69). - In
AgentStep.run, resolveconst runner = this.spec.runner ?? ctx.runner;and route therun/runStructuredcalls through it (agent-step.ts:114,117,127). - Add a
withRunner(node, runner)combinator (new,workflows/) that returns aNoderunninginner.run(input, { ...ctx, runner })— the declared version of the closure hack, for when a whole subtree (e.g. the four pipeline organs) needs one runner.
Result: canvas-workstation’s override becomes withRunner(pipelineNode, directGoogleRunner) declared
at assembly, visible in review — not a decorator that rewrites context.
Open design choice (decide during impl)
- v1 (recommended): the override is a raw
RunnerProtocolinstance — pragmatic, minimal, and a 1:1 replacement for what the hack already builds. - future: a capability/policy requirement (
requires: { structuredOutput: 'native' }) that the runtime resolves to a satisfying runner. More elegant, more machinery — defer until a second case appears.
Interaction: a per-node runner override and the existing per-node model override are orthogonal. A concrete runner override also sidesteps the “model override only bites with a resolver-backed runner” caveat for that node.
#99 — Scratchpad + deps propagation across the agent-as-tool seam
The problem (verified)
The Scratchpad (workflows/slot.ts) is the run-scoped shared-context primitive — run-scope shared
across the tree, branch-scope forked per Parallel/FanOut branch and merged back deterministically.
It is the mechanism by which delegated subagents share context. But it is not workflow-only:
a coordinator handing to truly-delegated subagents needs it too, and it currently doesn’t get it.
Why: an agent-as-tool subagent doesn’t run in the Node call-tree — it runs inside the runner’s
tool loop, and the only object that crosses that runner → tool boundary is core’s
ToolExecutionContext (core/molecules/toolbox.ts:29), which carries emit / runId / traceId / parentToolCallId. The runner builds it per tool-call in buildToolCtx (agent-runner.ts:207).
nodeTool already reads the trace fields from it (node-tool.ts:60-62, the #102 work) — but takes
scratchpad and deps from a construction-time closure, not from the live context:
// node-tool.ts:55 — the re-rootspec.node.run(input, { runner, scratchpad: scratchpad ?? createScratchpad(), // ← closure / fresh, NOT the caller's ✗ deps, // ← closure, NOT the caller's ✗ traceId: ctx?.traceId, // ← from the boundary ctx ✓ parentSpanId: ctx?.parentToolCallId, // ← from the boundary ctx ✓});Its own comment names this: “Automatic parent→child propagation across the agent-as-tool seam is scoped to #99/#102.” Trace already rides the rail; scratchpad/deps don’t yet. This is #99.
The fix — Design A (chosen): ride the same rail via an opaque passthrough
The scratchpad must travel through the runner. Put it on the one object that already crosses the
boundary, as an opaque passthrough field — the exact philosophy emit uses (core declares a
slot; the host wires the meaning). Core stays dependency-clean (the field is unknown; core never
imports runtime’s Scratchpad/DepReader).
AgentStep.run(ctx) has ctx.scratchpad ✓ └─ runner.runStructured(agent, msg, { …opts, host:{scratchpad,deps} }) ← (2) carry on RunOptions └─ [LLM emits tool call] └─ buildToolCtx() → ToolExecutionContext{ …trace, host } ← (3) copy one field └─ NodeToolbox tool.execute(args, ctx) └─ nodeTool → spec.node.run(input, { scratchpad: ctx.host.scratchpad.fork() ?? fresh, ← (4) inherit, fork deps: ctx.host.deps ?? deps, })Four small, additive edits:
- core
ToolExecutionContext(toolbox.ts:29) — addreadonly host?: unknown;(opaque; doc it likeemit: “host-declared passthrough; core never interprets it”). - runtime
RunOptions(runner/types.ts:113) — addhost?: unknown;. AgentStep.run(agent-step.ts:105) — sethost: { scratchpad: ctx.scratchpad, deps: ctx.deps }in theRunOptionsit passes to the runner. (CoordinatorStep inherits this viaAgentStep.)- runner
buildToolCtx(agent-runner.ts:207) — copyoptions.hostonto the returnedToolExecutionContext(threadoptions.hostintobuildToolCtx’s args — impl detail; there are three dispatch sites the doc-comment already notes converge here). nodeTool(node-tool.ts:57) — readconst host = ctx?.host as { scratchpad?: Scratchpad; deps?: DepReader } | undefined;and preferhost?.scratchpad(forked — see below) over the closure/fresh, andhost?.depsover the closure.
The one semantic decision: fork, don’t alias
A coordinator’s LLM can fire tool calls in parallel (agent-runner.ts:118). Sharing one mutable
scratchpad by reference across parallel subagent calls reintroduces the exact race the run/branch
split was built to prevent. So the seam passes parentScratchpad.fork() (slot.ts:127), not the
raw instance:
- run-scoped slots → shared by reference through the fork → the subagent reads/writes the same shared state (the ask).
- branch-scoped slots → fresh per subagent call → concurrency-safe.
Merge-back (join()) is off for v1, matching “a branch with no merge discards its scratch” —
call-and-return delegation has no clean index order to merge on. Revisit only if a use case needs it.
What it unlocks (the mental model)
The scratchpad is per-run state (in NodeRunContext), not a build-time field on the agent.
With #99 in place, you run the top node with a scratchpad and it flows to every subagent with no
wiring:
const coordinator = delegateTo(runner, [dataAgent, dealResolver, answerAgent]); // subagents=[…]await coordinator.run(input, { runner, scratchpad }); // ← scratchpad now reaches every subagentSince #124 this is ambient: run the top node with a scratchpad and it flows to every subagent with
no hand-threading. The #269 delegateTo regression suite pins that behavior on both the MockRunner
and live AgentRunner rails.
Tests to add
Implemented in packages/agent-runtime/src/workflows/__tests__/host-propagation.test.ts (#124) and
packages/agent-runtime/src/workflows/__tests__/delegate-pad-sharing.test.ts (#269):
- Coordinator writes a run-scoped slot, delegates to a subagent that reads it → subagent sees the value.
- Two subagent tool-calls in parallel each write a branch-scoped slot → no clobber (isolated forks).
- Deps declared at the root are readable inside a delegated subagent (same rail).
toolExecutor at the same seam — derive, don’t forward
toolExecutor is the third NodeRunContext field that meets the runner → tool boundary
(node.ts:32), and it exposed the same class of bug as #99: a delegated subagent got no executor
for its OWN tools. The coordinator’s LLM routes to the subagent fine (the team executor
CoordinatorStep derives covers the team tools one level up), but when the subagent’s own LLM then
calls one of its tools, every call returned { error: "No tool executor configured" } and the
subagent answered “data unavailable”. Root cause: nodeTool re-roots the sub-run ctx without a
toolExecutor (correctly — see below), and AgentStep only forwarded ctx.toolExecutor, never
deriving one from the agent it runs.
The resolution is the opposite of #99/#102’s. Trace, scratchpad, and deps are parent run state
that must be forwarded across the seam because they can’t be reconstructed. A toolExecutor is
not parent state — it is a pure function of the agent (createToolboxExecutor(agent)), and each
agent needs its own: forwarding the coordinator’s executor to a subagent would wrongly expose the
coordinator’s tools instead of the subagent’s. That is exactly why nodeTool does not forward
toolExecutor — and the fix is not to start forwarding it, but to derive it at the leaf:
// agent-step.ts — AgentStep.runconst toolExecutor = ctx.toolExecutor ?? deriveToolboxExecutor(agent);- Explicit wins. An executor already in
ctx.toolExecutor(e.g.CoordinatorStep’s team executor, covering team + the coordinator’s direct tools) is used verbatim —deriveToolboxExecutornever runs.CoordinatorStepis unchanged and byte-identical. - Capability-less is byte-identical.
deriveToolboxExecutorreturnsundefinedwhen the agent has no capabilities (a structural, non-instanceofcheck — the repo’s dual-core makesinstanceof Agentunreliable across the package boundary), soRunOptions.toolExecutorstays unset exactly as before. A tool-less agent can’t emit a tool call anyway. - General, not seam-local. Because the derivation lives in
AgentStep(the one node that holds a concrete agent), it fixes every AgentStep leaf — not justdelegateTo, but any bareSequential/Parallel/Loopof agents that carry their own capabilities. Behavioral note: tool calls in such bare pipelines that were silently no-ops now execute — intended.
This is the last of the three boundary fields to be reconciled: trace rides the rail (#102), scratchpad/deps ride the rail (#99), and the executor is derived per-agent at the leaf.
Deferred-with-intent: AgentNode ↔ Node-world consolidation
We have two agent-orchestration paradigms, and they overlap at the continuity layer:
| Node world (AgentStep / CoordinatorStep / Sequential / Parallel / NodeToolbox) | Agency world (AgentNode / AgencyRuntime) | |
|---|---|---|
| shape | typed in-process call-tree — run(input, ctx) → typed output | event-driven actor mesh — long-lived mailbox workers |
| coordination | deterministic (Seq/Par) or model-driven routing (Coordinator) | async peer messaging over SandboxEventBus |
| data flow | typed returns + Scratchpad in NodeRunContext | messages on the bus |
| continuity | AgentStep + (future) ConversationStore | hand-rolled _conversation: CanonicalMessage[] (agent-node.ts:86,279) |
AgentNode is not a duplicate of AgentRunner — it uses one (agent-node.ts:263). The real
overlap is orchestration + continuity: AgentNode re-implements by hand (its worker loop:
format → runner.run → append request/response → continue) what the Node world gets from AgentStep
- a
ConversationStore+Scratchpad.
Stance: the Node world is the spine for “a unified conversational agent around sequential/parallel
nodes.” The Agency actor-mesh is a separate, heavier capability (autonomous peers). Not in scope
this session. The consolidation — re-base AgentNode’s continuity onto ConversationStore and its
run onto AgentStep/runner, so there’s one execution+continuity core with two entry paradigms
(call-tree vs mailbox) — is a filed follow-up. It depends on #118 (ConversationStore) landing
first, which is a tidy argument that we’re sequencing correctly.