Skip to content

Commit d746c8f

Browse files
committed
Interactive escalations for the agent-facing run/debug tools
The supervisor put an LLM on a run's failure path; this puts the calling agent there. `run_workflow`/`debug_workflow` accept `interactive: true`: a failing node invocation parks the run and the tool returns the escalation record, the agent answers with a verdict via the new `resolve_workflow_escalation` tool, and the final report arrives once the run settles. - `packages/websocket/src/debug-sessions.ts`: `InteractiveEscalationHandle` (a `SupervisorHandle` whose `decide()` waits for an HTTP verdict) plus a user-owned session registry with a post-run TTL. - `http-api.ts`: `interactive` on `POST /api/workflows/:id/run|debug` (responds with the final report or `status: "escalated"`), new `GET/POST /api/debug/sessions/:id[/verdict|/cancel]` routes. The handle is wrapped in the same `BoundedHandle` (caps, sticky verdicts, fail-closed timeout — sized at 10 min for a tool round trip), and the kernel still enforces `allowedActions`; a disallowed or malformed verdict 400s without deciding the escalation, so the agent can correct itself. - Agent tools: `interactive` flag on run/debug, new `resolve_workflow_escalation`, bridged onto the MCP server. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNZApg6rgnpV9aqs5BTyX7
1 parent c1ae45c commit d746c8f

10 files changed

Lines changed: 1021 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,20 @@ tool. It posts to `POST /api/workflows/:id/debug`, which runs the workflow and
352352
returns the same execution summary and verdict the CLI harness computes —
353353
per-node status and errors, logs, LLM calls, outputs — plus the job record and
354354
the graph overview. The summary reducer and triage live in
355-
`@nodetool-ai/execution/debug`, so CLI and agent surfaces cannot drift. The browser surface is exposed in `web/` as
355+
`@nodetool-ai/execution/debug`, so CLI and agent surfaces cannot drift.
356+
357+
With `interactive: true`, `run_workflow` and `debug_workflow` put the calling
358+
agent on the failure path the way `--supervise` puts an LLM supervisor there:
359+
a failing node invocation parks the run and the tool returns the escalation
360+
(`status: "escalated"` with the supervisor's `Escalation` record — redacted
361+
inputs, error detail, `allowedActions`). The agent answers via
362+
**`resolve_workflow_escalation`** — retry, substitute, skip, end_stream, or
363+
fail, kernel-enforced against the allowed set — and gets back either the next
364+
escalation or the run's final report. HTTP surface:
365+
`POST /api/workflows/:id/run|debug {interactive: true}` plus
366+
`GET/POST /api/debug/sessions/:id[/verdict|/cancel]`
367+
(`packages/websocket/src/debug-sessions.ts`). Escalations the agent leaves
368+
unanswered fail closed on the decision timeout (default 10 min). The browser surface is exposed in `web/` as
356369
`npm run test:debug-harness` (env: `NODETOOL_DEBUG_GRAPH`, `NODETOOL_DEBUG_OUT`,
357370
`NODETOOL_DEBUG_PARAMS`).
358371

docs/workflow-supervisor-design.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ Surfaces do not construct `WorkflowRunner` directly anymore — CLI, debug, head
239239
2. **CLI**`nodetool run --supervise [--max-decisions N] [--max-retries N] [--supervisor-cost-cap USD] [--supervisor-model id]` and the same flags on `nodetool debug`. Interventions print inline (`` lines) and appear in `--json` reports.
240240
3. **`Agent({ graph })`** — a fourth branch in `Agent._executeImpl` alongside `executeScriptPlan`/`executeGraphPlan`: hydrate the graph, start a `WorkflowRunner` with itself as supervisor, forward the runner's message stream, return run outputs from `getResults()`. No planning phase — the graph is the plan.
241241
4. **API/web**`supervise: true` on the run request; the websocket runner constructs the handle and forwards `supervisor_*` messages to the client for the intervention feed. Trigger rows carry the flag, **off by default** until the plan's PR 8 gate passes; the eventual flip covers newly created triggers only (§6.1 consent).
242+
5. **Interactive (agent tools)**`interactive: true` on `POST /api/workflows/:id/run|debug` makes the *calling* agent the supervisor: `InteractiveEscalationHandle` (`packages/websocket/src/debug-sessions.ts`) parks each `decide()` until a verdict arrives on `POST /api/debug/sessions/:id/verdict`, and the escalated HTTP response carries the `Escalation` record itself. The agent-facing `run_workflow`/`debug_workflow` tools expose the flag and `resolve_workflow_escalation` answers; the handle is wrapped in the same `BoundedHandle` (caps, sticky verdicts, a timeout sized for a tool round trip — 10 min — that fails closed), and the kernel still enforces `allowedActions` whatever arrives.
242243

243244
## 8. Replay
244245

packages/agents/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export {
108108
CreateWorkflowTool,
109109
RunWorkflowTool,
110110
DebugWorkflowTool,
111+
ResolveWorkflowEscalationTool,
111112
ValidateWorkflowTool,
112113
PlanWorkflowGraphTool,
113114
GetExampleWorkflowTool,

packages/agents/src/tools/mcp-tools.ts

Lines changed: 142 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -592,10 +592,31 @@ export class CreateWorkflowTool extends Tool {
592592
}
593593
}
594594

595+
/**
596+
* Escalated run payloads name the follow-up tool, so the model driving the
597+
* loop knows how to answer without reading endpoint docs.
598+
*/
599+
function annotateEscalatedRun(run: unknown): unknown {
600+
if (!run || typeof run !== "object") return run;
601+
const record = run as Record<string, unknown>;
602+
if (record["status"] !== "escalated") return run;
603+
return {
604+
...record,
605+
next_tool:
606+
"A node invocation failed and the run is parked awaiting your verdict. " +
607+
"Call resolve_workflow_escalation with this session_id and " +
608+
"escalation_id and one of the escalation's allowedActions."
609+
};
610+
}
611+
595612
export class RunWorkflowTool extends Tool {
596613
readonly name = "run_workflow";
597614
readonly description =
598-
"Execute a workflow with given parameters and return results.";
615+
"Execute a workflow with given parameters and return results. With " +
616+
"interactive=true a failing node invocation pauses the run and returns " +
617+
"an escalation (status \"escalated\") for you to answer via " +
618+
"resolve_workflow_escalation — retry, substitute, skip, or fail — " +
619+
"instead of the whole run failing outright.";
599620
readonly jsonSchema = {
600621
type: "object" as const,
601622
properties: {
@@ -606,6 +627,12 @@ export class RunWorkflowTool extends Tool {
606627
params: {
607628
type: "object" as const,
608629
description: "Dictionary of input parameters for the workflow"
630+
},
631+
interactive: {
632+
type: "boolean" as const,
633+
description:
634+
"Bubble node failures up as escalations you answer, instead of " +
635+
"failing the run (default false)"
609636
}
610637
},
611638
required: ["workflow_id"]
@@ -615,9 +642,15 @@ export class RunWorkflowTool extends Tool {
615642
context: ProcessingContext,
616643
params: Record<string, unknown>
617644
): Promise<unknown> {
618-
return apiPost(context, `/api/workflows/${params["workflow_id"]}/run`, {
619-
params: params["params"] ?? {}
620-
});
645+
const run = await apiPost(
646+
context,
647+
`/api/workflows/${params["workflow_id"]}/run`,
648+
{
649+
params: params["params"] ?? {},
650+
...(params["interactive"] === true ? { interactive: true } : {})
651+
}
652+
);
653+
return annotateEscalatedRun(run);
621654
}
622655

623656
userMessage(params: Record<string, unknown>): string {
@@ -653,7 +686,10 @@ export class DebugWorkflowTool extends Tool {
653686
"Run a workflow end-to-end and return a consolidated debug report: a " +
654687
"pass/fail verdict with the issues behind it, per-node status and errors, " +
655688
"logs, LLM calls, outputs, job record, and the workflow graph overview. " +
656-
"Use this to troubleshoot a failing or misbehaving workflow and iterate.";
689+
"Use this to troubleshoot a failing or misbehaving workflow and iterate. " +
690+
"With interactive=true a failing node invocation pauses the run and " +
691+
"returns an escalation (status \"escalated\") for you to answer via " +
692+
"resolve_workflow_escalation before the report is produced.";
657693
readonly jsonSchema = {
658694
type: "object" as const,
659695
properties: {
@@ -665,6 +701,12 @@ export class DebugWorkflowTool extends Tool {
665701
type: "object" as const,
666702
description: "Input parameters keyed by input-node name"
667703
},
704+
interactive: {
705+
type: "boolean" as const,
706+
description:
707+
"Bubble node failures up as escalations you answer mid-run, " +
708+
"instead of only reading them post-mortem (default false)"
709+
},
668710
include_graph: {
669711
type: "boolean" as const,
670712
description:
@@ -697,7 +739,10 @@ export class DebugWorkflowTool extends Tool {
697739
const debugRun = await apiPostWithStatus(
698740
context,
699741
`/api/workflows/${workflowId}/debug`,
700-
{ params: params["params"] ?? {} }
742+
{
743+
params: params["params"] ?? {},
744+
...(params["interactive"] === true ? { interactive: true } : {})
745+
}
701746
);
702747
const endpointMissing =
703748
!debugRun.ok && (debugRun.status === 404 || debugRun.status === 405);
@@ -708,6 +753,17 @@ export class DebugWorkflowTool extends Tool {
708753
});
709754
}
710755

756+
// An escalated run has produced no report yet — the job is parked on the
757+
// failing node. Hand the escalation back for a verdict; the final report
758+
// arrives from resolve_workflow_escalation once the run settles.
759+
if (
760+
run &&
761+
typeof run === "object" &&
762+
(run as Record<string, unknown>)["status"] === "escalated"
763+
) {
764+
return { workflow_id: workflowId, run: annotateEscalatedRun(run) };
765+
}
766+
711767
const report: Record<string, unknown> = { workflow_id: workflowId, run };
712768
if (endpointMissing) {
713769
report["note"] =
@@ -732,6 +788,85 @@ export class DebugWorkflowTool extends Tool {
732788
}
733789
}
734790

791+
export class ResolveWorkflowEscalationTool extends Tool {
792+
readonly name = "resolve_workflow_escalation";
793+
readonly description =
794+
"Answer an escalation raised by an interactive run_workflow/debug_workflow " +
795+
"run. The run is parked on the failing node until you decide: retry the " +
796+
"invocation, substitute repaired outputs (only when the escalation carries " +
797+
"a candidateOutput), skip the invocation, end the stream (streaming nodes), " +
798+
"or fail the node. Only the escalation's allowedActions are accepted; the " +
799+
"kernel enforces the same set. Returns the next escalation (answer it the " +
800+
"same way) or the run's final report.";
801+
readonly jsonSchema = {
802+
type: "object" as const,
803+
properties: {
804+
session_id: {
805+
type: "string" as const,
806+
description: "The debug session id from the escalated response"
807+
},
808+
escalation_id: {
809+
type: "string" as const,
810+
description: "The escalation id being answered"
811+
},
812+
action: {
813+
type: "string" as const,
814+
enum: ["retry", "substitute", "skip", "end_stream", "fail"],
815+
description: "The verdict — must be one of the escalation's allowedActions"
816+
},
817+
outputs: {
818+
type: "object" as const,
819+
description:
820+
"For substitute: repaired output values keyed by the node's " +
821+
"declared output slots"
822+
},
823+
reason: {
824+
type: "string" as const,
825+
description:
826+
"For fail: a one-sentence reason surfaced as the run's error summary"
827+
},
828+
apply_to: {
829+
type: "string" as const,
830+
enum: ["invocation", "signature"],
831+
description:
832+
"For skip/fail: \"signature\" also resolves later failures with the " +
833+
"same failureSignature without asking again (default \"invocation\")"
834+
}
835+
},
836+
required: ["session_id", "escalation_id", "action"]
837+
};
838+
839+
async process(
840+
context: ProcessingContext,
841+
params: Record<string, unknown>
842+
): Promise<unknown> {
843+
const action = String(params["action"]);
844+
const verdict: Record<string, unknown> = { action };
845+
if (action === "substitute" && params["outputs"] !== undefined) {
846+
verdict["outputs"] = params["outputs"];
847+
}
848+
if (action === "fail" && typeof params["reason"] === "string") {
849+
verdict["reason"] = params["reason"];
850+
}
851+
if (
852+
(action === "skip" || action === "fail") &&
853+
typeof params["apply_to"] === "string"
854+
) {
855+
verdict["applyTo"] = params["apply_to"];
856+
}
857+
const result = await apiPost(
858+
context,
859+
`/api/debug/sessions/${params["session_id"]}/verdict`,
860+
{ escalation_id: params["escalation_id"], verdict }
861+
);
862+
return annotateEscalatedRun(result);
863+
}
864+
865+
userMessage(params: Record<string, unknown>): string {
866+
return `Resolving workflow escalation with "${params["action"]}"`;
867+
}
868+
}
869+
735870
export class ValidateWorkflowTool extends Tool {
736871
readonly name = "validate_workflow";
737872
readonly description =
@@ -1441,6 +1576,7 @@ export function getAllMcpTools(options: GetAllMcpToolsOptions = {}): Tool[] {
14411576
new CreateWorkflowTool(),
14421577
new RunWorkflowTool(),
14431578
new DebugWorkflowTool(),
1579+
new ResolveWorkflowEscalationTool(),
14441580
new ValidateWorkflowTool(options.registry),
14451581
new GetExampleWorkflowTool(),
14461582
new ExportWorkflowDigraphTool(),

packages/agents/tests/mcp-tools.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
CreateWorkflowTool,
99
RunWorkflowTool,
1010
DebugWorkflowTool,
11+
ResolveWorkflowEscalationTool,
1112
ValidateWorkflowTool,
1213
GetExampleWorkflowTool,
1314
ExportWorkflowDigraphTool,
@@ -335,6 +336,29 @@ describe("RunWorkflowTool", () => {
335336
expect(lastFetchUrl()).toContain("/api/workflows/wf-456/run");
336337
expect(lastFetchOpts().method).toBe("POST");
337338
});
339+
340+
it("forwards interactive and annotates an escalated response", async () => {
341+
fetchSpy.mockResolvedValue({
342+
ok: true,
343+
json: async () => ({
344+
status: "escalated",
345+
session_id: "sess-1",
346+
escalation_id: "esc-1",
347+
escalation: { nodeId: "n1", allowedActions: ["skip", "fail"] }
348+
}),
349+
text: async () => ""
350+
});
351+
352+
const result = (await tool.process(ctx, {
353+
workflow_id: "wf-456",
354+
interactive: true
355+
})) as Record<string, unknown>;
356+
357+
const body = JSON.parse(lastFetchOpts().body as string);
358+
expect(body.interactive).toBe(true);
359+
expect(result.status).toBe("escalated");
360+
expect(String(result.next_tool)).toContain("resolve_workflow_escalation");
361+
});
338362
});
339363

340364
describe("DebugWorkflowTool", () => {
@@ -393,6 +417,98 @@ describe("DebugWorkflowTool", () => {
393417
const urls = fetchSpy.mock.calls.map((c) => String(c[0]));
394418
expect(urls.some((u) => u.endsWith("/run"))).toBe(true);
395419
});
420+
421+
it("returns the escalation directly when the interactive run parks", async () => {
422+
respondTo({
423+
"/debug": {
424+
body: {
425+
status: "escalated",
426+
session_id: "sess-9",
427+
escalation_id: "esc-1",
428+
escalation: { nodeId: "n1", allowedActions: ["skip", "fail"] }
429+
}
430+
}
431+
});
432+
433+
const report = (await tool.process(ctx, {
434+
workflow_id: "wf-1",
435+
interactive: true
436+
})) as Record<string, unknown>;
437+
438+
const body = JSON.parse(lastFetchOpts().body as string);
439+
expect(body.interactive).toBe(true);
440+
const run = report.run as Record<string, unknown>;
441+
expect(run.status).toBe("escalated");
442+
expect(String(run.next_tool)).toContain("resolve_workflow_escalation");
443+
// No report exists yet, so neither the job nor the graph is fetched.
444+
expect(fetchSpy.mock.calls).toHaveLength(1);
445+
});
446+
});
447+
448+
describe("ResolveWorkflowEscalationTool", () => {
449+
const tool = new ResolveWorkflowEscalationTool();
450+
451+
it("posts the verdict to the session endpoint", async () => {
452+
await tool.process(ctx, {
453+
session_id: "sess-9",
454+
escalation_id: "esc-1",
455+
action: "skip",
456+
apply_to: "signature"
457+
});
458+
459+
expect(lastFetchUrl()).toContain("/api/debug/sessions/sess-9/verdict");
460+
const body = JSON.parse(lastFetchOpts().body as string);
461+
expect(body.escalation_id).toBe("esc-1");
462+
expect(body.verdict).toEqual({ action: "skip", applyTo: "signature" });
463+
});
464+
465+
it("carries substitute outputs and fail reasons, nothing else", async () => {
466+
await tool.process(ctx, {
467+
session_id: "s",
468+
escalation_id: "e",
469+
action: "substitute",
470+
outputs: { value: "repaired" },
471+
reason: "ignored for substitute"
472+
});
473+
const body = JSON.parse(lastFetchOpts().body as string);
474+
expect(body.verdict).toEqual({
475+
action: "substitute",
476+
outputs: { value: "repaired" }
477+
});
478+
479+
fetchSpy.mockClear();
480+
await tool.process(ctx, {
481+
session_id: "s",
482+
escalation_id: "e",
483+
action: "fail",
484+
reason: "upstream data is unusable"
485+
});
486+
const failBody = JSON.parse(lastFetchOpts().body as string);
487+
expect(failBody.verdict).toEqual({
488+
action: "fail",
489+
reason: "upstream data is unusable"
490+
});
491+
});
492+
493+
it("annotates a follow-up escalation so the loop continues", async () => {
494+
fetchSpy.mockResolvedValue({
495+
ok: true,
496+
json: async () => ({
497+
status: "escalated",
498+
session_id: "sess-9",
499+
escalation_id: "esc-2",
500+
escalation: { nodeId: "n2", allowedActions: ["skip", "fail"] }
501+
}),
502+
text: async () => ""
503+
});
504+
505+
const result = (await tool.process(ctx, {
506+
session_id: "sess-9",
507+
escalation_id: "esc-1",
508+
action: "skip"
509+
})) as Record<string, unknown>;
510+
expect(String(result.next_tool)).toContain("resolve_workflow_escalation");
511+
});
396512
});
397513

398514
describe("ValidateWorkflowTool", () => {

0 commit comments

Comments
 (0)