Skip to content

Commit 4e44725

Browse files
committed
Merge Workflow Supervisor PR 5: workflows as agents
# Conflicts: # packages/execution/src/session.ts
2 parents 3ba3234 + 5ed0099 commit 4e44725

23 files changed

Lines changed: 1189 additions & 19 deletions

docs/workflow-supervisor-implementation-plan.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,40 @@ bundle, and the HTTP debug endpoint cannot drift.
116116
- Supervisor cost attributed through the existing cost tracking so `nodetool costs` sees it (PRD open question 3 resolved: attributed to the run, tagged `supervisor`).
117117
- Docs: CLI section in root `CLAUDE.md` + `docs/cli.md`.
118118

119-
### PR 5 — Workflows as agents ∥ (with PR 4)
119+
### PR 5 — Workflows as agents ∥ (with PR 4)**shipped**
120120

121121
Depends on PR 3.
122122

123+
Three things the plan did not anticipate, all forced by the websocket half.
124+
125+
A `supervise` flag alone cannot start a supervisor: something has to name the
126+
model. The run request therefore carries an optional `supervisor`
127+
(`SupervisorRunOptions`: provider, model, the three bounds, cost cap) next to
128+
the flag, falling back to the connection's configured default model and then to
129+
`NODETOOL_SUPERVISOR_PROVIDER` / `NODETOOL_SUPERVISOR_MODEL` — the last exists
130+
because trigger-driven headless runs have no connection defaults at all. A
131+
request that asks for supervision it cannot get runs **unsupervised** rather
132+
than failing, which is the same fail-closed rule every other supervisor failure
133+
follows.
134+
135+
The trigger flag is a real column (`trigger_registrations.supervise`, default
136+
`0`, migration `20260801_000001`), read by the dispatcher into the headless run.
137+
Registration sync mutates existing rows in place, so re-syncing a workflow never
138+
resets it.
139+
140+
The supervisor gets a **dedicated provider instance** (`getProvider`, not the
141+
context's cached one) and a listener-free context copy: per-turn spend is
142+
reconciled from the provider's own running cost, so a second caller on the same
143+
instance would corrupt the dollar cap, and the decision's own provider traffic
144+
is not the run's message stream. The escalation and the verdict still cross the
145+
websocket — they are emitted by the kernel on the run's context.
146+
147+
`ExecutionSessionOptions.supervisor` is PR 4's deliverable and landed here
148+
because PR 5 needs it; the two branches carry the same three-line change.
149+
123150
- `AgentOptions.graph?: GraphData | { workflowId: string }`; fourth branch in `Agent._executeImpl`: hydrate, run through `ExecutionSession` with self as supervisor, forward messages, `getResults()` returns run outputs. The branch adopts the common `AgentPolicy` object (`packages/agents/src/agent-policy.ts`); it does not add a fifth ad-hoc policy.
124151
- Websocket: `supervise` flag on run requests; forward `supervisor_*` messages to clients. Trigger rows carry the flag but it **defaults to off** — the flip to default-on belongs to PR 8's gate, nowhere earlier.
125-
- Tests: `Agent({graph})` returns identical outputs to a bare runner on a clean graph; interventions surface in the message stream.
152+
- Tests: `Agent({graph})` returns identical outputs to a bare runner on a clean graph; interventions surface in the message stream. Plus what makes the branch trustworthy: a scripted `skip` completes a run that otherwise fails and a scripted `fail` does not, a clean supervised run emits no `supervisor_*` message at all, an aborted signal cancels the run, and `createRunSupervisor` returns no handle without an explicit flag and a resolvable model.
126153

127154
## Phase C — the product surface
128155

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/agents/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"@llamaindex/liteparse": "^1.5.2",
2121
"@nodetool-ai/app-runtime": "*",
2222
"@nodetool-ai/config": "*",
23+
"@nodetool-ai/execution": "*",
2324
"@nodetool-ai/kernel": "*",
2425
"@nodetool-ai/models": "*",
2526
"@nodetool-ai/node-sdk": "*",

packages/agents/src/agent.ts

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@ import type {
2626
Chunk
2727
} from "@nodetool-ai/protocol";
2828
import { TaskUpdateEvent } from "@nodetool-ai/protocol";
29+
import { BoundedHandle, type SupervisorBounds } from "@nodetool-ai/kernel";
2930
import { TaskPlanner } from "./task-planner.js";
31+
import {
32+
resolveAgentGraph,
33+
runWorkflowAsAgent,
34+
type AgentGraphSource
35+
} from "./workflow-agent.js";
36+
import { SupervisorAgent } from "./supervisor/supervisor-agent.js";
3037
import { TaskExecutor } from "./task-executor.js";
3138
import { ParallelTaskExecutor } from "./parallel-task-executor.js";
3239
import { CompilerAgent } from "./compiler-agent.js";
3340
import { GraphPlanner } from "./graph-planner.js";
34-
import { AgentWorkflowRunner } from "./agent-workflow-runner.js";
41+
import { AgentWorkflowRunner, applyRunPolicy } from "./agent-workflow-runner.js";
3542
import { ScriptPlanner } from "./script-planner.js";
3643
import { ScriptRunner } from "./script-runner.js";
3744
import type { Tool } from "./tools/base-tool.js";
@@ -285,6 +292,28 @@ export interface AgentOptions {
285292
* script API.
286293
*/
287294
script?: string;
295+
/**
296+
* Run an existing workflow as this agent: an inline graph, or
297+
* `{ workflowId }` to hydrate one from the workflow table. There is no
298+
* planning phase — the graph is the plan — and the agent supervises the run
299+
* instead of authoring it, so `getResults()` returns the run's outputs.
300+
* Requires {@link registry}. Takes precedence over every planning mode.
301+
*
302+
* Supervision is opt-in: without {@link supervise} the run is an ordinary
303+
* kernel run that never constructs an escalation.
304+
*/
305+
graph?: AgentGraphSource;
306+
/**
307+
* Supervise the {@link graph} run: a failing node invocation escalates to
308+
* this agent, which answers with a verdict (retry / substitute / skip /
309+
* end_stream / fail). Default `false` — the flip to default-on is gated on
310+
* the eval suite, not on this option.
311+
*/
312+
supervise?: boolean;
313+
/** Decision, retry, and timeout ceilings for {@link supervise}. */
314+
supervisorBounds?: SupervisorBounds;
315+
/** Dollar ceiling on supervision for the whole run. */
316+
maxSupervisorCostUsd?: number;
288317
/** Script mode: concurrent `agent()` calls beyond this queue. Default 8. */
289318
maxConcurrentAgents?: number;
290319
/** Script mode: lifetime `agent()` call cap per run. Default 100. */
@@ -374,6 +403,10 @@ export class Agent {
374403
private readonly useGraphPlanner: boolean;
375404
private readonly useScriptPlanner: boolean;
376405
private readonly script?: string;
406+
private readonly graphSource?: AgentGraphSource;
407+
private readonly supervise: boolean;
408+
private readonly supervisorBounds?: SupervisorBounds;
409+
private readonly maxSupervisorCostUsd?: number;
377410
private readonly registry?: NodeRegistry;
378411
private readonly providers?: Record<string, BaseProvider>;
379412
private readonly securityMonitorEnabled: boolean;
@@ -416,6 +449,10 @@ export class Agent {
416449
this.useGraphPlanner = opts.useGraphPlanner === true;
417450
this.useScriptPlanner = opts.useScriptPlanner === true;
418451
this.script = opts.script;
452+
this.graphSource = opts.graph;
453+
this.supervise = opts.supervise === true;
454+
this.supervisorBounds = opts.supervisorBounds;
455+
this.maxSupervisorCostUsd = opts.maxSupervisorCostUsd;
419456
this.registry = opts.registry;
420457
this.providers = opts.providers;
421458
this.securityMonitorEnabled = opts.securityMonitor?.enabled === true;
@@ -687,6 +724,13 @@ export class Agent {
687724
path.join(os.homedir(), "nodetool_workspace", Date.now().toString());
688725
await fs.mkdir(workspacePath, { recursive: true });
689726

727+
// A supplied graph is already the plan: no planner runs, and the agent's
728+
// job is to supervise the run rather than author it.
729+
if (this.graphSource) {
730+
yield* this.executeSuppliedGraph(context, mergedSystemPrompt);
731+
return;
732+
}
733+
690734
if (this.initialTask) {
691735
yield* this.executeSingleTask(
692736
context,
@@ -1263,6 +1307,100 @@ export class Agent {
12631307
}
12641308
}
12651309

1310+
/**
1311+
* Workflows as agents: run a supplied graph on the kernel with this agent as
1312+
* the run's supervisor. No planning phase — the graph is the plan — so the
1313+
* only judgment the model supplies is a verdict on a broken invocation.
1314+
*
1315+
* The run obeys the same {@link AgentPolicy} as every other mode: the policy's
1316+
* turn and token bounds are stamped onto model-less Agent nodes exactly as
1317+
* the graph-planner branch stamps them, and nothing here invents a second set
1318+
* of numbers. Supervision's own ceilings (decisions, retries, dollars) are the
1319+
* supervisor's, shared with every other surface that configures one.
1320+
*/
1321+
private async *executeSuppliedGraph(
1322+
context: ProcessingContext,
1323+
systemPrompt: string | undefined
1324+
): AsyncGenerator<ProcessingMessage> {
1325+
if (!this.registry) {
1326+
throw new Error(
1327+
"Agent({ graph }) requires a NodeRegistry to resolve node executors."
1328+
);
1329+
}
1330+
1331+
const graph = applyRunPolicy(
1332+
await resolveAgentGraph(this.graphSource!, context),
1333+
{
1334+
providerId: this.provider.provider,
1335+
modelId: this.model,
1336+
...(systemPrompt ? { systemPrompt } : {}),
1337+
maxStepIterations: this.policy.maxStepIterations,
1338+
...(this.policy.maxTokens !== undefined
1339+
? { maxTokens: this.policy.maxTokens }
1340+
: {})
1341+
}
1342+
);
1343+
1344+
yield {
1345+
type: "log_update",
1346+
node_id: "workflow_executor",
1347+
node_name: this.name,
1348+
content: `Running workflow: ${graph.nodes.length} nodes, ${graph.edges.length} edges${
1349+
this.supervise ? " (supervised)" : ""
1350+
}...`,
1351+
severity: "info"
1352+
} satisfies LogUpdate;
1353+
1354+
const supervisor = this.supervise
1355+
? new BoundedHandle(
1356+
new SupervisorAgent({
1357+
provider: this.provider,
1358+
model: this.reasoningModel,
1359+
// The supervisor reads and writes the run's memory (`supervisor:`
1360+
// keys) but must not push its own provider chatter into the run's
1361+
// message stream, so it gets a listener-free copy.
1362+
context: context.copy({
1363+
shareMemory: true,
1364+
inheritMessageListeners: false
1365+
}),
1366+
...(this.maxSupervisorCostUsd !== undefined
1367+
? { maxCostUsd: this.maxSupervisorCostUsd }
1368+
: {})
1369+
}),
1370+
this.supervisorBounds ?? {}
1371+
)
1372+
: undefined;
1373+
1374+
const run = runWorkflowAsAgent({
1375+
graph,
1376+
registry: this.registry,
1377+
context,
1378+
params: this.inputs,
1379+
...(supervisor ? { supervisor } : {}),
1380+
...(this.signal ? { signal: this.signal } : {})
1381+
});
1382+
1383+
let next = await run.next();
1384+
while (!next.done) {
1385+
yield next.value;
1386+
next = await run.next();
1387+
}
1388+
const result = next.value;
1389+
1390+
if (result.status === "failed") {
1391+
throw new Error(result.error ?? "Workflow run failed");
1392+
}
1393+
1394+
this.results = result.outputs ?? {};
1395+
1396+
log.info("Agent completed", {
1397+
name: this.name,
1398+
status: result.status,
1399+
interventions: result.interventions?.length ?? 0
1400+
});
1401+
this.persistAgentRunMemory();
1402+
}
1403+
12661404
/**
12671405
* Execute a single pre-defined task (legacy path for backward compatibility).
12681406
*/

packages/agents/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,11 @@ export type {
684684
AgentWorkflowRunnerOptions,
685685
RunPolicy
686686
} from "./agent-workflow-runner.js";
687+
export { resolveAgentGraph, runWorkflowAsAgent } from "./workflow-agent.js";
688+
export type {
689+
AgentGraphSource,
690+
WorkflowAgentRunOptions
691+
} from "./workflow-agent.js";
687692
export {
688693
SupervisorAgent,
689694
DEFAULT_MAX_SUPERVISOR_COST_USD,
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/**
2+
* Workflows as agents — the run half of `Agent`'s graph branch.
3+
*
4+
* A saved workflow is already a plan, so this path has no planning phase: the
5+
* graph runs on the kernel through `ExecutionSession`, and the agent supplies
6+
* judgment only where a node breaks, as the run's `SupervisorHandle`. That is
7+
* the whole difference from `AgentWorkflowRunner`, which executes a graph the
8+
* planner just wrote and cannot supervise it.
9+
*
10+
* See docs/workflow-supervisor-design.md §7 entry point 3.
11+
*/
12+
13+
import { randomUUID } from "node:crypto";
14+
import { createLogger } from "@nodetool-ai/config";
15+
import { ExecutionSession, type RawGraphInput } from "@nodetool-ai/execution";
16+
import type { RunResult, SupervisorHandle } from "@nodetool-ai/kernel";
17+
import type { NodeRegistry } from "@nodetool-ai/node-sdk";
18+
import type { ProcessingContext } from "@nodetool-ai/runtime";
19+
import type { GraphData, ProcessingMessage } from "@nodetool-ai/protocol";
20+
21+
const log = createLogger("nodetool.agents.workflow-agent");
22+
23+
/** Either an inline graph or a saved workflow to hydrate one from. */
24+
export type AgentGraphSource = GraphData | { workflowId: string };
25+
26+
export interface WorkflowAgentRunOptions {
27+
graph: GraphData;
28+
/** Resolves every node's executor. Hydration reads flags off it too. */
29+
registry: NodeRegistry;
30+
/** The run's context. A child copy is made so listeners stay separated. */
31+
context: ProcessingContext;
32+
/** Start params, keyed by input-node name. */
33+
params?: Record<string, unknown>;
34+
/**
35+
* The run's supervisor, already wrapped in the kernel's `BoundedHandle`.
36+
* Omitted, the run behaves exactly as an unsupervised one.
37+
*/
38+
supervisor?: SupervisorHandle;
39+
/** External cancellation; aborting it cancels the run. */
40+
signal?: AbortSignal;
41+
}
42+
43+
/**
44+
* Hydrate a graph source into a graph. A `{ workflowId }` source is read from
45+
* the workflow table under the context's user, so a workflow the caller may
46+
* not read stays unreadable here too.
47+
*/
48+
export async function resolveAgentGraph(
49+
source: AgentGraphSource,
50+
context: ProcessingContext
51+
): Promise<GraphData> {
52+
if (!("workflowId" in source)) return source;
53+
const { Workflow } = await import("@nodetool-ai/models");
54+
const workflow = await Workflow.find(context.userId, source.workflowId);
55+
if (!workflow) {
56+
throw new Error(`Workflow not found: ${source.workflowId}`);
57+
}
58+
return workflow.getGraph() as unknown as GraphData;
59+
}
60+
61+
/**
62+
* Run a graph and yield the kernel's messages live; the terminal `RunResult`
63+
* is the generator's return value.
64+
*
65+
* Messages are forwarded to the caller's context as well as yielded, matching
66+
* `AgentWorkflowRunner`: a host that only reads the shared context's queue (the
67+
* websocket runner, when an Agent node runs inside a workflow) must still see
68+
* the inner run.
69+
*/
70+
export async function* runWorkflowAsAgent(
71+
options: WorkflowAgentRunOptions
72+
): AsyncGenerator<ProcessingMessage, RunResult> {
73+
const { graph, registry, context, supervisor, signal } = options;
74+
const jobId = randomUUID();
75+
76+
// A child context keeps the inner run's listeners off the caller's, the same
77+
// separation `AgentWorkflowRunner` makes; memory is shared so sub-agents
78+
// inside the graph — and the supervisor's `supervisor:` keys — land in the
79+
// run's one memory.
80+
const runContext = context.copy({
81+
shareMemory: true,
82+
inheritMessageListeners: false
83+
});
84+
const removeListener = runContext.addMessageListener((message) => {
85+
context.emit(message);
86+
});
87+
88+
const session = await ExecutionSession.create({
89+
graph: graph as unknown as RawGraphInput,
90+
registry,
91+
jobId,
92+
context: runContext,
93+
params: options.params ?? {},
94+
captureMessages: true,
95+
...(supervisor ? { supervisor } : {})
96+
});
97+
98+
const onAbort = (): void => session.cancel("cancelled");
99+
if (signal?.aborted) session.cancel("cancelled");
100+
else signal?.addEventListener("abort", onAbort, { once: true });
101+
102+
log.info("Running workflow as agent", {
103+
jobId,
104+
nodes: graph.nodes.length,
105+
edges: graph.edges.length,
106+
supervised: Boolean(supervisor)
107+
});
108+
109+
let drained = false;
110+
try {
111+
for await (const message of session.messages) {
112+
yield message;
113+
}
114+
drained = true;
115+
} finally {
116+
signal?.removeEventListener("abort", onAbort);
117+
removeListener();
118+
// A consumer that stops early (a `break`, or a throw upstream) leaves the
119+
// run executing and billing. The stream only closes when the run settles,
120+
// so anything short of a full drain means the caller walked away.
121+
if (!drained) session.cancel("cancelled");
122+
}
123+
124+
const result = await session.result;
125+
log.info("Workflow-as-agent run finished", {
126+
jobId,
127+
status: result.status,
128+
interventions: result.interventions?.length ?? 0
129+
});
130+
return result;
131+
}

0 commit comments

Comments
 (0)