Implementation Plan: Workflow Supervisor
Author: Matti Georgi Status: Draft — for review Last updated: 2026-08-01 PRD: workflow-supervisor-prd.md Design: workflow-supervisor-design.md
PRs marked ∥ can proceed in parallel once their listed dependency lands.
0. Ground rules
- Every PR passes
npm run checkand lands behavior-preserving unless its description says otherwise. PR 1 and PR 2 must be invisible to existing runs. - The kernel never imports from
@nodetool-ai/agents. TheSupervisorHandleinterface lives in kernel; implementations live above it. - No UI work before the intervention record is stable in
--jsonoutput (PR 4) — the web surface consumes the record, it does not define it. - Verdict semantics change only via the design doc, in the same PR.
Phase A — the mechanism (kernel, no LLM) — shipped
PR 1 and PR 2 landed together: PR 1’s allowed-set enforcement is only as good as
PR 2’s invocation-scoped cost tracking, so shipping the first without the second
would have meant a window where retry was offered after a billed call.
PR 1 — Escalation types and the actor hook
The seam, fail-closed. Zero behavior change.
packages/protocol/src/supervisor.ts:Escalation(incl. optionalfailureSignature,candidateOutput,retrySafe),Verdict, message schemassupervisor_escalation/supervisor_decision; add to theProcessingMessageunion.packages/kernel/src/supervisor.ts:SupervisorHandleinterface (decide(e, signal)),FailClosedHandledefault.- Resolved-secret registry on
ProcessingContext(design §6.1): record every value the secret resolver hands out for the run’s lifetime. Prerequisite for masking — today the context resolves one key and forgets. - Redaction at escalation construction (design §6.1): the kernel-side constructor masks registry values and drops sensitive-named fields before the
Escalationexists as a record —Escalationis public (messages,RunResult, websocket), so no unredacted instance may be constructed. Test: a planted secret in a node input never appears in any emitted message orRunResult. - Kernel-side allowed-action enforcement (design §5.1): the actor computes the allowed set per escalation, sends it on the
Escalation, and rejects (→fail) any verdict outside it. Test: a hostile scripted handle returningretryfor an unsafe node is rejected without the schema layer’s help. - Recovery catch wraps
invoke()only (design §5.1): output routing stays outside; a routing failure after partial delivery fails the node without escalation. Test: edge-2-throws-after-edge-1-delivered never re-invokes. - Scope-closure plumbing for skip (design §5.2): per-parent
lineage_scope_closedemission for scopes the skipped invocation would have opened —drop()alone signals onlylineage_done. Test: skipping one iterator invocation feeding an aggregate still lets the aggregate collapse. - node-sdk:
RecoverableNodeError(carries the malformed candidate output, redacted at construction like everything else) and theretrySafenode-metadata opt-in; declare it across the pure-compute and read-only node categories in shipped packages.WorkflowNode/SubgraphNodestay unsafe. Unknown ⇒ no retry. packages/kernel/src/runner.ts:WorkflowRunnerOptions.supervisor, wireescalateonto the execution context,interventionsonRunResult.packages/kernel/src/actor.ts: wrap invocation execution per design §5.1 — buffered/correlated path first, controlled path second.WorkflowSuspendedErrorpasses through untouched. Skip =drop()per output slot (design §5.2), never a bare return.- Audit while in here: provider-level transient retry in
packages/runtime/src/providers/(design §10.3). Done — workflow-supervisor-provider-retry-audit.md. Gemini and Ollama have no backoff at all and would flood the supervisor with escalations a 2-second wait would fix; the fixes land in runtime, not here. - Tests (
packages/kernel/tests/): scriptedSupervisorHandledoubles driving every verdict against fan-out, controlled, and single-fire nodes; retry re-invokes with identical inputs and properties; skip on a correlated key prunes a downstream join whose other input already buffered (the lineage_done case) plus the iterator→aggregate scope-closure case; no-supervisor runs byte-identical to before (message-stream snapshot).
PR 2 — Bounds, retry safety, streaming carve-out
Still no LLM. Depends on PR 1.
- Invocation-scoped cost/asset tracking in
ProcessingContext— viaAsyncLocalStoragearoundinvoke()or an explicit per-invocation accounting object, not a stack on the shared context: actors complete out of order and push/pop would cross-attribute (design §5.3). New plumbing — today cost aggregates per run in one slot and no asset-created flag exists. Test: two concurrent invocations completing in reverse order attribute correctly. BoundedHandlewrapper in kernel:maxDecisions,maxRetriesPerNode, per-decision timeout; everydecide()gets a derivedAbortSignal(run signal + timeout);decidedBy: "bounds" | "default"stamping. (Dollar reservation is agent-side, PR 3 — the kernel handle cannot see individual provider turns.)- Allowed-set computation (consumed by PR 1’s enforcement):
retryonly whenretrySafe∧ cost-free ∧ asset-free (design §5.3);substituteonly withcandidateOutput∧ full validator coverage; streaming sets per design §5.4 —run()nodes getend_stream·failonly. - Runtime substitute validator (design §5.2): per-output-type value rules — strict primitives, structural lists/objects, reference types must be well-formed refs that resolve against run storage; async, exposed for PR 3’s acceptance hook.
- Sticky-verdict cache keyed by
(nodeId, failureSignature)(skip/failonly),decidedBy: "sticky"; signatures come from a registry of error-shape recognizers (HTTP status, provider codes, validation paths) — a plainErroryields no signature and no stickiness. - Tests: bounds degrade to deterministic fail; cancel during a pending decision resolves instantly; an undeclared node’s allowed set has no retry; a plain-
Errorescalation has no substitute; arun()node’s set is exactly {end_stream, fail}; a fabricated{type:"image"}ref with an unresolvable uri fails validation; sticky skip resolves the 7-of-200 case with onedecide()call while a plain-Errorfailure on the same node escalates every time.
Phase B — the agent and the CLI
PR 3 — SupervisorAgent — shipped
Depends on PR 2.
Two additions the plan did not name, both forced by what the agent has to see:
Escalation.declaredOutputs (a repair cannot be typed, or accepted, without
the node’s declared output types) and a kernel-side RunStateReader handed
to the handle via SupervisorHandle.attach() — the tools are pull-based over
runner state, and a handle is configured before the runner exists. Output
recording for read_node_output is bounded per node and happens only on a
supervised run.
Host-side schema validation checks the schema as the caller wrote it, not
the sanitized copy sent to the provider: the sanitizer injects
additionalProperties: false for strict structured-output modes, and enforcing
that would reject results no author ever forbade.
packages/agents/src/supervisor/supervisor-agent.ts:SupervisorHandlevia oneStepExecutorper decision, verdictoutputSchema(mirroring the escalation’s allowed set), serialized queue; thedecide()signal threads throughStepExecutorintoprovider.generateLoop({signal})so abort kills the in-flight LLM request.TurnBudgetin the runtime provider layer (design §6):reserve/commitobject accepted viagenerateLoopoptions, honored before every model turn by the base loop and by native loop overrides (Claude Agent SDK provider explicitly). Worst-case reservation from the pricing catalog with explicitsupervisorMaxOutputTokens(default 2048, never unset); no pricing entry ⇒ fail closed. This is apackages/runtimechange with its own provider-contract test, not a wrapper in the agents package — a wrapper aroundgenerateLoop()cannot see individual turns.StepExecutorextensions (design §6): full host-side JSON-schema validation forfinish_step(enums, nested fields,additionalProperties, discriminated unions — today it checks top-level type and required keys only) and an injectable async acceptance callback so asubstitutebecomes terminal only after the runtime validator (PR 2, storage resolution included) resolves; failures return as tool errors into the still-open conversation,MAX_REPAIR_ROUNDS(3) thenfail. Both extensions are generalStepExecutorfeatures with their own tests.- Tool-result redaction:
read_node_outputreturns are the one supervisor input not derived from an already-redactedEscalation(that layer landed in PR 1, kernel-side); apply the same layer here. packages/agents/src/supervisor/tools.ts:get_run_state,read_node_output(read-only, redacted, truncated, lineage-scoped — reads resolve through the escalation’scorrelation_lineageand refuse anything outside it).packages/agents/src/supervisor/prompt.ts: verb semantics, skip-distrust, one-line rationale requirement.supervisor:memory keys for decisions + rationales.- Tests: mock provider returning scripted tool-calls/verdicts; malformed verdict exercises the
finish_stepbounce loop; an unresolvable substitute ref bounces back into the open conversation; supervisor exceptions and timeouts resolve asfail; abort mid-decision cancels the provider call; a secret value planted in aread_node_outputresult never appears in the outbound prompt; in a fan-out,read_node_outputrefuses a sibling item’s output; reservation blocks the $0.49+$0.30 overshoot mid-decision.
PR 4 — CLI surface ∥ (with PR 5) — shipped
Depends on PR 3.
Two deviations the plan did not anticipate.
nodetool run is a DSL command, not a session command. `nodetool run