Skip to content

Commit e4e8f65

Browse files
authored
Workflow Supervisor PR 3: the SupervisorAgent (#4634)
1 parent afb971c commit e4e8f65

27 files changed

Lines changed: 2693 additions & 102 deletions

docs/workflow-supervisor-design.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export interface Escalation {
6464
failureSignature?: string; // stable categorical code; absent ⇒ stickiness disabled
6565
candidateOutput?: unknown; // from RecoverableNodeError — the malformed value, redacted+truncated
6666
inputs: Record<string, unknown>; // the invocation's input values, redacted+truncated
67+
declaredOutputs: Record<string, string>; // the node's declared output types, per slot
6768
attempt: number; // 1-based, per (nodeId, invocationKey)
6869
spentCostUsd: number; // provider cost recorded by this invocation
6970
createdAssets: boolean;
@@ -87,6 +88,13 @@ export type Verdict =
8788

8889
`applyTo: "signature"` is the sticky form: the handle caches the verdict keyed by `(nodeId, failureSignature)` and resolves later escalations that match without waking the agent (PRD scenario 2 — 7 identical failures, 1 LLM call). Keying by signature rather than node keeps one login-required error from silently skipping later, unrelated timeouts on the same node. A signature exists only when the error carries a **stable categorical code** — HTTP status, provider error code, validation path — extracted by a small registry of error-shape recognizers. A plain `Error` gets **no** signature (error class alone is not one; every generic throw would collide), and with no signature stickiness is simply off: each such failure escalates individually. Signatures never derive from message text with embedded values. Only `skip` and `fail` may stick; a sticky `retry` or `substitute` would blindly replay a decision made against different inputs.
8990

91+
`declaredOutputs` is what makes `substitute` authorable at all: a repair has to
92+
be typed per slot, and the escalation is the only thing the supervisor sees.
93+
It is also what the agent-side acceptance hook validates against before the
94+
verdict is allowed to become terminal, so the model learns its repair was the
95+
wrong shape while its conversation is still open. Type metadata, so nothing to
96+
redact.
97+
9098
`RecoverableNodeError` (node-sdk) is how a node hands the supervisor the thing that needs repairing: a parser that throws on broken JSON attaches the raw response as `candidateOutput` instead of losing it. **No `candidateOutput`, no `substitute`** — without the broken value in hand, "repair" is fabrication: the model would invent a structurally valid output the runtime validator cannot semantically vet. A plain thrown error offers retry/skip/fail only.
9199

92100
New `ProcessingMessage` variants: `supervisor_escalation` and `supervisor_decision` (escalation + verdict + `decidedBy: "agent" | "sticky" | "bounds" | "default"` + cost). Both flow through the existing `_emit` path.

docs/workflow-supervisor-implementation-plan.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,23 @@ Still no LLM. Depends on PR 1.
5555

5656
## Phase B — the agent and the CLI
5757

58-
### PR 3 — SupervisorAgent
58+
### PR 3 — SupervisorAgent**shipped**
5959

6060
Depends on PR 2.
6161

62+
Two additions the plan did not name, both forced by what the agent has to see:
63+
`Escalation.declaredOutputs` (a repair cannot be typed, or accepted, without
64+
the node's declared output types) and a kernel-side **`RunStateReader`** handed
65+
to the handle via `SupervisorHandle.attach()` — the tools are pull-based over
66+
runner state, and a handle is configured before the runner exists. Output
67+
recording for `read_node_output` is bounded per node and happens only on a
68+
supervised run.
69+
70+
Host-side schema validation checks the schema **as the caller wrote it**, not
71+
the sanitized copy sent to the provider: the sanitizer injects
72+
`additionalProperties: false` for strict structured-output modes, and enforcing
73+
that would reject results no author ever forbade.
74+
6275
- `packages/agents/src/supervisor/supervisor-agent.ts`: `SupervisorHandle` via one `StepExecutor` per decision, verdict `outputSchema` (mirroring the escalation's allowed set), serialized queue; the `decide()` signal threads through `StepExecutor` into `provider.generateLoop({signal})` so abort kills the in-flight LLM request.
6376
- **`TurnBudget` in the runtime provider layer** (design §6): `reserve/commit` object accepted via `generateLoop` options, 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 explicit `supervisorMaxOutputTokens` (default 2048, never unset); no pricing entry ⇒ fail closed. This is a `packages/runtime` change with its own provider-contract test, not a wrapper in the agents package — a wrapper around `generateLoop()` cannot see individual turns.
6477
- **`StepExecutor` extensions** (design §6): full host-side JSON-schema validation for `finish_step` (enums, nested fields, `additionalProperties`, discriminated unions — today it checks top-level type and required keys only) and an injectable **async acceptance callback** so a `substitute` becomes 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) then `fail`. Both extensions are general `StepExecutor` features with their own tests.

packages/agents/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,3 +684,21 @@ export type {
684684
AgentWorkflowRunnerOptions,
685685
RunPolicy
686686
} from "./agent-workflow-runner.js";
687+
export {
688+
SupervisorAgent,
689+
DEFAULT_MAX_SUPERVISOR_COST_USD,
690+
DEFAULT_SUPERVISOR_MAX_OUTPUT_TOKENS
691+
} from "./supervisor/supervisor-agent.js";
692+
export type { SupervisorAgentOptions } from "./supervisor/supervisor-agent.js";
693+
export { buildVerdictSchema } from "./supervisor/verdict-schema.js";
694+
export { buildSupervisorPrompt } from "./supervisor/prompt.js";
695+
export {
696+
createSupervisorTools,
697+
GetRunStateTool,
698+
ReadNodeOutputTool
699+
} from "./supervisor/tools.js";
700+
export {
701+
validateAgainstSchema,
702+
formatViolations
703+
} from "./utils/json-schema-validate.js";
704+
export type { SchemaViolation } from "./utils/json-schema-validate.js";

packages/agents/src/step-executor.ts

Lines changed: 95 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
ToolCall,
2020
ProviderStreamItem
2121
} from "@nodetool-ai/runtime";
22+
import type { TurnBudget } from "@nodetool-ai/runtime";
2223
import { memoryKeys, withAgentSpanGen } from "@nodetool-ai/runtime";
2324
import { linkAbort } from "./utils/link-abort.js";
2425
import { createLogger } from "@nodetool-ai/config";
@@ -40,6 +41,10 @@ import { ControlNodeTool } from "./tools/control-tool.js";
4041
import { FinishStepTool } from "./tools/finish-step-tool.js";
4142
import { getMemoryTools } from "./tools/memory-tools.js";
4243
import { truncateToolResult } from "./constants.js";
44+
import {
45+
formatViolations,
46+
validateAgainstSchema
47+
} from "./utils/json-schema-validate.js";
4348

4449
const log = createLogger("nodetool.agents.step-executor");
4550

@@ -242,6 +247,19 @@ function validateAndSanitizeSchema(
242247
return cleanSchemaRecursive(result) as Record<string, unknown>;
243248
}
244249

250+
/**
251+
* The authored schema, deep-copied and given a `type` when it omits one, so
252+
* host validation reads exactly what the caller declared and nothing more.
253+
*/
254+
function normalizeDeclaredSchema(
255+
raw: unknown
256+
): Record<string, unknown> | null {
257+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
258+
const copy = JSON.parse(JSON.stringify(raw)) as Record<string, unknown>;
259+
if (!("type" in copy) && "properties" in copy) copy["type"] = "object";
260+
return copy;
261+
}
262+
245263
// ---------------------------------------------------------------------------
246264
// Think-tag removal
247265
// ---------------------------------------------------------------------------
@@ -334,8 +352,35 @@ export interface StepExecutorOptions {
334352
upstreamMemoryKeys?: string[];
335353
/** External cancellation. Aborts the provider loop mid-flight. */
336354
signal?: AbortSignal;
355+
/**
356+
* Final say on a schema-valid result, for contracts whose acceptance can
357+
* only be decided asynchronously — a repaired value that must resolve
358+
* against run storage, say. A rejection is not a failure: it returns to the
359+
* model as a tool error inside the still-open conversation, so the next
360+
* attempt has the reason. After {@link MAX_REPAIR_ROUNDS} rejections the
361+
* step fails rather than looping on an answer the model cannot produce.
362+
*/
363+
acceptResult?: (result: unknown) => Promise<StepResultAcceptance>;
364+
/**
365+
* Spend admission consulted before every provider turn. Forwarded to
366+
* `generateLoop`; a refusal ends the loop, which surfaces here as a step
367+
* that never completed.
368+
*/
369+
turnBudget?: TurnBudget;
337370
}
338371

372+
/** Answer from {@link StepExecutorOptions.acceptResult}. */
373+
export type StepResultAcceptance =
374+
| { accepted: true }
375+
| { accepted: false; reason: string };
376+
377+
/**
378+
* Rejections by the acceptance callback before the step gives up. Three is
379+
* enough for the model to read the reason and correct course; more is a model
380+
* that cannot satisfy the contract, and looping costs real money.
381+
*/
382+
export const MAX_REPAIR_ROUNDS = 3;
383+
339384
export class StepExecutor {
340385
private history: Message[] = [];
341386
private step: Step;
@@ -352,6 +397,8 @@ export class StepExecutor {
352397
private result: unknown = null;
353398
private finishStepTool: FinishStepTool | null = null;
354399
private resultSchema: Record<string, unknown> | null = null;
400+
/** The schema as the caller wrote it — what host validation checks. */
401+
private declaredSchema: Record<string, unknown> | null = null;
355402
private iterations = 0;
356403
private generationFailures = 0;
357404
private sources: string[] = [];
@@ -362,6 +409,11 @@ export class StepExecutor {
362409
}> = [];
363410
private threadId?: string;
364411
private upstreamMemoryKeys: string[];
412+
private acceptResult?: (result: unknown) => Promise<StepResultAcceptance>;
413+
private turnBudget?: TurnBudget;
414+
private repairRounds = 0;
415+
/** Set when acceptance rejected the result once too often. */
416+
private acceptanceError: string | null = null;
365417

366418
constructor(opts: StepExecutorOptions) {
367419
this.task = opts.task;
@@ -376,6 +428,8 @@ export class StepExecutor {
376428
this.threadId = opts.threadId;
377429
this.upstreamMemoryKeys = opts.upstreamMemoryKeys ?? [];
378430
this.signal = opts.signal;
431+
this.acceptResult = opts.acceptResult;
432+
this.turnBudget = opts.turnBudget;
379433

380434
this.resultSchema = this.loadResultSchema();
381435

@@ -410,9 +464,17 @@ export class StepExecutor {
410464
typeof this.step.outputSchema === "string"
411465
? JSON.parse(this.step.outputSchema)
412466
: this.step.outputSchema;
467+
// Two schemas, on purpose. The sanitized one is what the provider is
468+
// shown — it injects `additionalProperties: false` because strict
469+
// structured-output modes demand it. The authored one is the contract,
470+
// and it is what the host validates against: an author who wrote
471+
// `properties: {}` meant "an object", not "an empty object", and
472+
// enforcing a transport artifact would reject a result nobody forbade.
473+
this.declaredSchema = normalizeDeclaredSchema(raw);
413474
return validateAndSanitizeSchema(raw, defaultDescription);
414475
} catch {
415476
// Fallback: permissive object schema
477+
this.declaredSchema = null;
416478
return { type: "object", description: defaultDescription };
417479
}
418480
}
@@ -462,41 +524,15 @@ export class StepExecutor {
462524
return [true, null, normalized];
463525
}
464526

465-
// Basic structural validation: check required keys from schema
466-
if (
467-
this.resultSchema["type"] === "object" &&
468-
typeof normalized === "object" &&
469-
normalized !== null
470-
) {
471-
const requiredKeys = this.resultSchema["required"];
472-
if (Array.isArray(requiredKeys)) {
473-
const obj = normalized as Record<string, unknown>;
474-
for (const key of requiredKeys) {
475-
// `requiredKeys` is LLM/planner-authored; use Object.hasOwn so a
476-
// required key like "toString"/"constructor" can't be satisfied by
477-
// the prototype chain when the model never produced it.
478-
if (!Object.hasOwn(obj, key as string)) {
479-
return [false, `Missing required key: ${key}`, normalized];
480-
}
481-
}
482-
}
483-
}
484-
485-
// Type check
486-
const expectedType = this.resultSchema["type"];
487-
if (expectedType === "string" && typeof normalized !== "string") {
488-
return [false, `Expected string, got ${typeof normalized}`, normalized];
489-
}
490-
if (
491-
expectedType === "object" &&
492-
(typeof normalized !== "object" ||
493-
normalized === null ||
494-
Array.isArray(normalized))
495-
) {
496-
return [false, `Expected object, got ${typeof normalized}`, normalized];
497-
}
498-
if (expectedType === "array" && !Array.isArray(normalized)) {
499-
return [false, `Expected array, got ${typeof normalized}`, normalized];
527+
// The whole declared schema, not its outline. Backends vary in how much of
528+
// a tool-parameter schema they enforce — enums and unions are routinely
529+
// dropped — so a contract holds only where the host checks it.
530+
const violations = validateAgainstSchema(
531+
normalized,
532+
this.declaredSchema ?? this.resultSchema
533+
);
534+
if (violations.length > 0) {
535+
return [false, formatViolations(violations), normalized];
500536
}
501537

502538
return [true, null, normalized];
@@ -939,6 +975,24 @@ export class StepExecutor {
939975
normalizedResult !== null &&
940976
normalizedResult !== undefined
941977
) {
978+
if (this.acceptResult) {
979+
const acceptance = await this.acceptResult(normalizedResult);
980+
if (!acceptance.accepted) {
981+
this.repairRounds++;
982+
if (this.repairRounds >= MAX_REPAIR_ROUNDS) {
983+
// Out of rounds. Stop the loop rather than pay for another
984+
// attempt at a contract this model is not meeting.
985+
this.acceptanceError = acceptance.reason;
986+
abort.abort();
987+
return JSON.stringify({
988+
error: `Result rejected: ${acceptance.reason}`
989+
});
990+
}
991+
return JSON.stringify({
992+
error: `Result rejected: ${acceptance.reason}. Call finish_step again with a corrected result.`
993+
});
994+
}
995+
}
942996
emitCompletion(normalizedResult);
943997
abort.abort();
944998
return '{"status": "completed"}';
@@ -1031,6 +1085,7 @@ export class StepExecutor {
10311085
maxIterations: this.maxIterations,
10321086
maxTokens: this.maxTokens,
10331087
sequentialTools: true,
1088+
turnBudget: this.turnBudget,
10341089
signal: abort.signal
10351090
});
10361091

@@ -1104,9 +1159,11 @@ export class StepExecutor {
11041159
if (!this.step.completed) {
11051160
this.step.endTime = Date.now();
11061161

1107-
const message = generationError
1108-
? `Step failed: ${generationError.message}`
1109-
: `Step failed: exceeded ${this.maxIterations} iterations without completion`;
1162+
const message = this.acceptanceError
1163+
? `Step failed: result rejected after ${MAX_REPAIR_ROUNDS} attempts — ${this.acceptanceError}`
1164+
: generationError
1165+
? `Step failed: ${generationError.message}`
1166+
: `Step failed: exceeded ${this.maxIterations} iterations without completion`;
11101167
this.step.failed = true;
11111168
this.step.error = message;
11121169

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* The supervisor's system prompt.
3+
*
4+
* A preamble over the standard execution contract, per the `StepExecutor`
5+
* rule — it says what the verbs mean and what the job is, not how to finish a
6+
* step.
7+
*/
8+
9+
import type { Escalation, VerdictAction } from "@nodetool-ai/protocol";
10+
11+
const VERB_SEMANTICS: Record<VerdictAction, string> = {
12+
retry: "Run this invocation again, unchanged. Only useful when the failure was transient — a timeout, a rate limit, a 5xx. Offered only when the invocation spent nothing and wrote nothing, so it is never a way to pay twice.",
13+
substitute:
14+
"Replace the node's output with a corrected value you supply. For a node that produced something almost right — malformed JSON, a near-miss shape. The value is type-checked against the node's declared outputs before it enters the graph; a repair that fails the check is not a repair.",
15+
skip: "Retire this invocation. Downstream nodes see it as producing nothing — an item drops out of the batch. Correct when one item is genuinely unprocessable and the rest of the run is still worth having.",
16+
end_stream:
17+
"End this streaming node's output where it stopped. What it already emitted stands; nothing more comes. The only recovery available once a node has emitted.",
18+
fail: "Let the run fail, as it would without a supervisor. The right answer whenever recovery would be a guess."
19+
};
20+
21+
export const SUPERVISOR_PROMPT_PREAMBLE = `# Role
22+
You supervise a running workflow. A node invocation has failed. Decide what happens to it.
23+
24+
# What you are deciding
25+
One invocation, not the run. Everything else in the graph is still executing. Your verdict applies to this invocation alone.
26+
27+
# Duties
28+
- Diagnose before you decide. \`get_run_state\` shows how the run is going; \`read_node_output\` shows what a node upstream of this failure produced. Read them when the error alone does not tell you what happened.
29+
- Give a one-line rationale with every verdict. It goes in the run report, and it is what a person reads later to understand why an item is missing.
30+
- Distrust \`skip\`. Skipping is data loss, and it is silent — the run reports success with a smaller result. Use it when an item is genuinely unprocessable, not when a failure is merely inconvenient. If you skip, name the item in your rationale.
31+
- \`fail\` is not a defeat. A guessed repair that passes validation and is wrong is worse than a failed run: the run reports success and the wrong answer flows downstream.
32+
33+
# What the verbs mean`;
34+
35+
/** The prompt for one decision: verbs the escalation actually allows, then it. */
36+
export function buildSupervisorPrompt(escalation: Escalation): string {
37+
const verbs = escalation.allowedActions
38+
.map((action) => `- \`${action}\` — ${VERB_SEMANTICS[action]}`)
39+
.join("\n");
40+
const omitted = (Object.keys(VERB_SEMANTICS) as VerdictAction[]).filter(
41+
(action) => !escalation.allowedActions.includes(action)
42+
);
43+
const unavailable =
44+
omitted.length > 0
45+
? `\n\nNot available for this failure: ${omitted.map((a) => `\`${a}\``).join(", ")}. The kernel computed that from what this node is and what this invocation already did; asking for one of them gets you \`fail\`.`
46+
: "";
47+
48+
return `${SUPERVISOR_PROMPT_PREAMBLE}
49+
${verbs}${unavailable}
50+
51+
# The failure
52+
Node \`${escalation.nodeId}\` (\`${escalation.nodeType}\`), attempt ${escalation.attempt}${
53+
escalation.invocationKey ? `, invocation \`${escalation.invocationKey}\`` : ""
54+
}.
55+
56+
Error: ${escalation.detail}
57+
58+
Inputs: ${JSON.stringify(escalation.inputs)}${
59+
escalation.candidateOutput === undefined
60+
? ""
61+
: `\n\nThe node produced this, and it was rejected: ${JSON.stringify(escalation.candidateOutput)}`
62+
}${
63+
escalation.emitted
64+
? "\n\nThis node already emitted output downstream. Nothing can undo that."
65+
: ""
66+
}`;
67+
}

0 commit comments

Comments
 (0)