@deepstrike/sdk 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,8 @@ export interface HarnessOutcome {
26
26
  overallScore?: number;
27
27
  feedback?: string;
28
28
  details?: CriterionResult[];
29
+ /** R3-1: nodes the agent submitted via `submit_workflow_nodes` while running under the harness. */
30
+ submittedNodes?: import("../types/agent.js").WorkflowNodeSpec[];
29
31
  }
30
32
  export interface Verdict {
31
33
  passed: boolean;
@@ -55,6 +57,9 @@ export type HarnessEvent = {
55
57
  callId: string;
56
58
  content: string;
57
59
  isError: boolean;
60
+ } | {
61
+ type: "workflow_nodes_submitted";
62
+ nodes: import("../types/agent.js").WorkflowNodeSpec[];
58
63
  } | {
59
64
  type: "supervising";
60
65
  } | {
@@ -61,8 +61,14 @@ export class HarnessLoop {
61
61
  }
62
62
  async run(request) {
63
63
  let last;
64
- for await (const evt of this.stream(request))
64
+ // R3-1: collect nodes the agent submitted while running under the harness, so dynamic fan-out
65
+ // works in harness mode too (not just the plain streaming path).
66
+ const submittedNodes = [];
67
+ for await (const evt of this.stream(request)) {
65
68
  last = evt;
69
+ if (evt.type === "workflow_nodes_submitted")
70
+ submittedNodes.push(...evt.nodes);
71
+ }
66
72
  const done = last?.type === "done" ? last : undefined;
67
73
  return {
68
74
  result: "",
@@ -73,6 +79,7 @@ export class HarnessLoop {
73
79
  overallScore: done?.verdict.overallScore,
74
80
  feedback: done?.verdict.feedback,
75
81
  details: done?.verdict.details,
82
+ ...(submittedNodes.length ? { submittedNodes } : {}),
76
83
  };
77
84
  }
78
85
  async *stream(request) {
@@ -107,6 +114,10 @@ export class HarnessLoop {
107
114
  const tr = evt;
108
115
  yield { type: "tool_result", callId: tr.callId, content: tr.content, isError: tr.isError };
109
116
  }
117
+ else if (evt.type === "workflow_nodes_submitted") {
118
+ const ws = evt;
119
+ yield { type: "workflow_nodes_submitted", nodes: ws.nodes };
120
+ }
110
121
  else if (evt.type === "done") {
111
122
  const d = evt;
112
123
  lastIterations = d.iterations;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { RuntimeRunner, collectText } from "./runtime/runner.js";
2
2
  export type { RuntimeOptions, SchedulerBudget } from "./runtime/runner.js";
3
+ export { builtinReducers, resolveReducer } from "./runtime/reducers.js";
4
+ export type { Reducer, ReducerRegistry, ReducerInput } from "./runtime/reducers.js";
3
5
  export type { MemoryPolicy, MemoryWriteRateLimit, ResourceQuota } from "./kernel.js";
4
6
  export { KernelPrimitivesDashboard } from "./runtime/kernel-primitives-dashboard.js";
5
7
  export { FilteredExecutionPlane } from "./runtime/filtered-plane.js";
@@ -61,7 +63,7 @@ export { SinglePassHarness, EvalLoopHarness, HarnessLoop } from "./harness/harne
61
63
  export type { HarnessRequest, HarnessOutcome, HarnessLoopOptions, QualityGate } from "./harness/harness.js";
62
64
  export type { Message, ToolCall, ToolResult, ToolSchema, ContentPart, TextPart, ImagePart, AudioPart, StreamEvent, TextDelta, ThinkingDelta, ToolCallEvent, ToolChunk, ToolDeltaEvent, ToolSuspendEvent, ToolResultEvent, DoneEvent, ErrorEvent, PermissionRequestEvent, PermissionResolvedEvent, PermissionResponse, LLMProvider, RetryConfig, TokenUsage, ProviderToolSpec, ProviderRunState, ProviderReplay, RenderedContext, ReplayabilityAssessment, } from "./types.js";
63
65
  export type { AgentCapabilityFilter, AgentIdentity, AgentIsolation, AgentRunSpec, AgentProcessChangedObservation, ContextInheritance, KernelAgentRole, LoopResult, MilestoneCheckResult, MilestoneContract, MilestonePhase, MilestonePolicy, SubAgentResult, TerminationReason, WorkflowSpec, WorkflowNodeSpec, WorkflowTaskSpec, WorkflowSpawnInfo, } from "./types/agent.js";
64
- export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, fanoutSynthesize, generateAndFilter, verifyRules, } from "./types/agent.js";
66
+ export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, workflowNodeSpecToKernel, submitWorkflowNodesToKernel, submitWorkflowNodesTool, fanoutSynthesize, generateAndFilter, verifyRules, } from "./types/agent.js";
65
67
  export type { AcceptanceCriterion, VerificationContract, ContractCheckResult, } from "./collaboration/contract.js";
66
68
  export { ContractBuilder, formatContractForSystemPrompt, contractToCriteriaStrings, } from "./collaboration/contract.js";
67
69
  export { AgentPool } from "./collaboration/pool.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // ── Runtime (Layer 1.5) ────────────────────────────────────────────────────
2
2
  export { RuntimeRunner, collectText } from "./runtime/runner.js";
3
+ export { builtinReducers, resolveReducer } from "./runtime/reducers.js";
3
4
  export { KernelPrimitivesDashboard } from "./runtime/kernel-primitives-dashboard.js";
4
5
  export { FilteredExecutionPlane } from "./runtime/filtered-plane.js";
5
6
  export { SubAgentOrchestrator, defaultSubAgentOrchestrator, spawnStandalone } from "./runtime/sub-agent-orchestrator.js";
@@ -41,7 +42,7 @@ export { PermissionManager, PermissionMode } from "./safety/permissions.js";
41
42
  export { Governance, governancePolicyToKernelEvent } from "./governance.js";
42
43
  // ── Harness ────────────────────────────────────────────────────────────────
43
44
  export { SinglePassHarness, EvalLoopHarness, HarnessLoop } from "./harness/harness.js";
44
- export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, fanoutSynthesize, generateAndFilter, verifyRules, } from "./types/agent.js";
45
+ export { agentIdentitySub, agentRunSpecToKernel, milestoneCheckFail, milestoneCheckPass, milestoneCheckResultToKernel, subAgentResultToKernel, workflowSpecToKernel, workflowNodeSpecToKernel, submitWorkflowNodesToKernel, submitWorkflowNodesTool, fanoutSynthesize, generateAndFilter, verifyRules, } from "./types/agent.js";
45
46
  export { ContractBuilder, formatContractForSystemPrompt, contractToCriteriaStrings, } from "./collaboration/contract.js";
46
47
  export { AgentPool } from "./collaboration/pool.js";
47
48
  export { KERNEL_ROLE_MAP } from "./collaboration/pool.js";
@@ -0,0 +1,14 @@
1
+ export interface SchemaValidation {
2
+ ok: boolean;
3
+ errors: string[];
4
+ }
5
+ type JsonSchema = Record<string, unknown>;
6
+ /** Validate `value` against `schema` (the supported subset). `path` is for error messages. */
7
+ export declare function validateAgainstSchema(value: unknown, schema: JsonSchema, path?: string): SchemaValidation;
8
+ /** The instruction appended to a node's goal so its agent produces schema-conforming JSON. */
9
+ export declare function schemaInstruction(schema: JsonSchema): string;
10
+ /** A stronger re-prompt for a retry after a validation failure. */
11
+ export declare function schemaRetryInstruction(schema: JsonSchema, errors: string[]): string;
12
+ /** Best-effort extraction of a JSON value from agent output (raw, fenced, or embedded). */
13
+ export declare function extractJsonValue(text: string): unknown;
14
+ export {};
@@ -0,0 +1,113 @@
1
+ // G3 structured output: a small, dependency-free JSON-Schema subset validator + helpers used by the
2
+ // workflow runner to enforce a node's `output_schema`. The kernel carries the schema verbatim (it is
3
+ // zero-I/O and never validates); enforcement lives here, SDK-side, where the agent output exists.
4
+ //
5
+ // Supported keywords (the common structured-output subset): `type` (object | array | string |
6
+ // number | integer | boolean | null), `required`, `properties` (recursive), `items` (recursive),
7
+ // `enum`. Unknown keywords are ignored rather than rejected — a permissive superset of these specs
8
+ // still validates, matching "instruct the model, then check the shape" rather than full JSON Schema.
9
+ function typeOfValue(v) {
10
+ if (v === null)
11
+ return "null";
12
+ if (Array.isArray(v))
13
+ return "array";
14
+ return typeof v; // "object" | "string" | "number" | "boolean"
15
+ }
16
+ function matchesType(v, t) {
17
+ switch (t) {
18
+ case "integer":
19
+ return typeof v === "number" && Number.isInteger(v);
20
+ case "number":
21
+ return typeof v === "number";
22
+ case "object":
23
+ return typeOfValue(v) === "object";
24
+ default:
25
+ return typeOfValue(v) === t;
26
+ }
27
+ }
28
+ /** Validate `value` against `schema` (the supported subset). `path` is for error messages. */
29
+ export function validateAgainstSchema(value, schema, path = "$") {
30
+ const errors = [];
31
+ const type = schema.type;
32
+ if (typeof type === "string" && !matchesType(value, type)) {
33
+ errors.push(`${path}: expected ${type}, got ${typeOfValue(value)}`);
34
+ return { ok: false, errors }; // type mismatch ⇒ stop; deeper checks are meaningless
35
+ }
36
+ if (Array.isArray(type) && !type.some(t => typeof t === "string" && matchesType(value, t))) {
37
+ errors.push(`${path}: expected one of [${type.join(", ")}], got ${typeOfValue(value)}`);
38
+ return { ok: false, errors };
39
+ }
40
+ if (Array.isArray(schema.enum) && !schema.enum.some(e => e === value)) {
41
+ errors.push(`${path}: value not in enum`);
42
+ }
43
+ if (typeOfValue(value) === "object") {
44
+ const obj = value;
45
+ const required = Array.isArray(schema.required) ? schema.required : [];
46
+ for (const key of required) {
47
+ if (!(key in obj))
48
+ errors.push(`${path}.${key}: required property missing`);
49
+ }
50
+ const properties = schema.properties ?? {};
51
+ for (const [key, sub] of Object.entries(properties)) {
52
+ if (key in obj) {
53
+ const r = validateAgainstSchema(obj[key], sub, `${path}.${key}`);
54
+ if (!r.ok)
55
+ errors.push(...r.errors);
56
+ }
57
+ }
58
+ }
59
+ if (typeOfValue(value) === "array" && schema.items && typeof schema.items === "object") {
60
+ const items = schema.items;
61
+ value.forEach((el, i) => {
62
+ const r = validateAgainstSchema(el, items, `${path}[${i}]`);
63
+ if (!r.ok)
64
+ errors.push(...r.errors);
65
+ });
66
+ }
67
+ return { ok: errors.length === 0, errors };
68
+ }
69
+ /** The instruction appended to a node's goal so its agent produces schema-conforming JSON. */
70
+ export function schemaInstruction(schema) {
71
+ return ("You MUST return ONLY a single JSON value that conforms to this JSON Schema, with no prose, " +
72
+ "no markdown, and no code fences:\n" +
73
+ JSON.stringify(schema));
74
+ }
75
+ /** A stronger re-prompt for a retry after a validation failure. */
76
+ export function schemaRetryInstruction(schema, errors) {
77
+ return (`${schemaInstruction(schema)}\n\nYour previous output did NOT conform: ${errors.join("; ")}. ` +
78
+ "Return ONLY the corrected JSON value.");
79
+ }
80
+ /** Best-effort extraction of a JSON value from agent output (raw, fenced, or embedded). */
81
+ export function extractJsonValue(text) {
82
+ const trimmed = (text ?? "").trim();
83
+ if (!trimmed)
84
+ return undefined;
85
+ const tryParse = (s) => {
86
+ try {
87
+ return JSON.parse(s);
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ };
93
+ const whole = tryParse(trimmed);
94
+ if (whole !== undefined)
95
+ return whole;
96
+ const fence = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
97
+ if (fence) {
98
+ const fenced = tryParse(fence[1].trim());
99
+ if (fenced !== undefined)
100
+ return fenced;
101
+ }
102
+ // Fall back to the first balanced {...} or [...] slice.
103
+ for (const [open, close] of [["{", "}"], ["[", "]"]]) {
104
+ const start = trimmed.indexOf(open);
105
+ const end = trimmed.lastIndexOf(close);
106
+ if (start !== -1 && end > start) {
107
+ const slice = tryParse(trimmed.slice(start, end + 1));
108
+ if (slice !== undefined)
109
+ return slice;
110
+ }
111
+ }
112
+ return undefined;
113
+ }
@@ -0,0 +1,15 @@
1
+ /** One dependency's contribution to a reduce: the producing node's agent id and its output text. */
2
+ export interface ReducerInput {
3
+ agentId: string;
4
+ output: string;
5
+ }
6
+ /** A pure function over a reduce node's dependency outputs → the reduce node's output string. */
7
+ export type Reducer = (inputs: ReducerInput[]) => string;
8
+ export type ReducerRegistry = Record<string, Reducer>;
9
+ /**
10
+ * Built-in reducers, available to every workflow without registration. A user-supplied registry is
11
+ * merged over these (so a custom reducer can shadow a built-in of the same name).
12
+ */
13
+ export declare const builtinReducers: ReducerRegistry;
14
+ /** Resolve a reducer by name from the built-ins overlaid with a user registry. */
15
+ export declare function resolveReducer(name: string, user?: ReducerRegistry): Reducer | undefined;
@@ -0,0 +1,57 @@
1
+ // G2 deterministic compute: the host-side reducer registry. A `NodeKind::Reduce` workflow node runs
2
+ // no LLM agent — the kernel hands the SDK a reducer name + its dependency outputs, and the SDK runs
3
+ // the named pure function here. This is the "ordinary code between stages" (dedupe / filter / merge /
4
+ // early-exit) of the code-orchestration model, expressed deterministically as a DAG node.
5
+ import { extractJsonValue } from "./output-schema.js";
6
+ /** Non-empty, trimmed lines of a string. */
7
+ function lines(s) {
8
+ return s
9
+ .split("\n")
10
+ .map(l => l.trim())
11
+ .filter(l => l.length > 0);
12
+ }
13
+ /**
14
+ * Built-in reducers, available to every workflow without registration. A user-supplied registry is
15
+ * merged over these (so a custom reducer can shadow a built-in of the same name).
16
+ */
17
+ export const builtinReducers = {
18
+ /** Concatenate every input's output, separated by blank lines, in dependency order. */
19
+ concat: inputs => inputs.map(i => i.output).join("\n\n"),
20
+ /** Union of non-empty lines across all inputs, first-seen order preserved (dedupe a fan-out). */
21
+ dedupe_lines: inputs => {
22
+ const seen = new Set();
23
+ const out = [];
24
+ for (const i of inputs) {
25
+ for (const line of lines(i.output)) {
26
+ if (!seen.has(line)) {
27
+ seen.add(line);
28
+ out.push(line);
29
+ }
30
+ }
31
+ }
32
+ return out.join("\n");
33
+ },
34
+ /** Parse each input as a JSON array, concatenate, dedupe by canonical JSON → a JSON array string. */
35
+ merge_json_arrays: inputs => {
36
+ const seen = new Set();
37
+ const merged = [];
38
+ for (const i of inputs) {
39
+ const v = extractJsonValue(i.output);
40
+ const arr = Array.isArray(v) ? v : v !== undefined ? [v] : [];
41
+ for (const el of arr) {
42
+ const key = JSON.stringify(el);
43
+ if (!seen.has(key)) {
44
+ seen.add(key);
45
+ merged.push(el);
46
+ }
47
+ }
48
+ }
49
+ return JSON.stringify(merged);
50
+ },
51
+ /** The number of inputs that produced any non-empty output — handy for early-exit/branch gates. */
52
+ count: inputs => String(inputs.filter(i => i.output.trim().length > 0).length),
53
+ };
54
+ /** Resolve a reducer by name from the built-ins overlaid with a user registry. */
55
+ export function resolveReducer(name, user) {
56
+ return user?.[name] ?? builtinReducers[name];
57
+ }
@@ -8,6 +8,7 @@ import type { ExecutionPlane } from "./execution-plane.js";
8
8
  import { type MemoryPolicy, type ResourceQuota } from "../kernel.js";
9
9
  import type { AgentRunSpec, MilestoneCheckResult, MilestoneContract, MilestonePolicy, WorkflowSpec } from "../types/agent.js";
10
10
  import { type SubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
+ import { type ReducerRegistry } from "./reducers.js";
11
12
  import { type GovernancePolicy } from "../governance.js";
12
13
  import { type NativeOsProfile, type OsProfileId } from "./os-profile.js";
13
14
  import { LargeResultSpool } from "./large-result-spool.js";
@@ -97,6 +98,9 @@ export interface RuntimeOptions {
97
98
  evalProvider: LLMProvider;
98
99
  maxAttempts?: number;
99
100
  };
101
+ /** G2: custom reducers for `NodeKind::Reduce` workflow nodes, merged over the built-ins
102
+ * (`concat` / `dedupe_lines` / `merge_json_arrays` / `count`). A reduce node runs no LLM. */
103
+ reducers?: ReducerRegistry;
100
104
  /** Optional system prompt injected into the dream synthesis call. */
101
105
  dreamSystemPrompt?: string;
102
106
  /** Custom LLM provider used for background memory consolidation (dream loop). */
@@ -159,6 +163,22 @@ export declare class RuntimeRunner {
159
163
  * Requires an active parent run (`run()` / `wake()` in progress or paused at milestone).
160
164
  */
161
165
  spawnSubAgent(spec: AgentRunSpec): AsyncIterable<StreamEvent>;
166
+ /**
167
+ * G3: run one workflow node, enforcing its `output_schema` (if any). Without a schema this is a
168
+ * plain `orchestrator.run`. With one, the node's agent is instructed to emit conforming JSON, its
169
+ * output is validated (the supported JSON-Schema subset), and on mismatch the node is re-run once
170
+ * with the validation errors fed back. If it still does not conform, the node is failed with the
171
+ * validation reason — a node that cannot meet its declared output contract starves its dependents,
172
+ * exactly as a denied spawn does.
173
+ */
174
+ private runWorkflowNode;
175
+ /**
176
+ * G2: execute a deterministic reduce node. Looks up the named reducer (built-ins overlaid with
177
+ * `opts.reducers`), runs it over the node's dependency outputs (gathered from `outputs`), and
178
+ * returns a synthetic completion carrying the reducer's output — no LLM, zero tokens. An unknown
179
+ * reducer or a thrown reducer fails the node (`Error` termination → the kernel starves dependents).
180
+ */
181
+ private runReduceNode;
162
182
  /**
163
183
  * W0-ABI: run a declarative workflow DAG. The kernel owns the DAG and gates every node spawn
164
184
  * through the syscall trap; this driver runs each kernel-emitted batch of nodes in parallel,
@@ -167,6 +187,7 @@ export declare class RuntimeRunner {
167
187
  */
168
188
  runWorkflow(spec: WorkflowSpec, opts?: {
169
189
  resumedCompleted?: string[];
190
+ resumedSubmissions?: Record<string, unknown>[][];
170
191
  }): Promise<{
171
192
  completed: string[];
172
193
  failed: string[];
@@ -3,11 +3,13 @@ import { resolvePermissionRequest } from "./execution-plane.js";
3
3
  import { getKernel } from "../kernel.js";
4
4
  import { peekProviderReplay, seedProviderReplayFromEvents } from "./provider-replay.js";
5
5
  import { sanitizeReplayText } from "./replay-sanitize.js";
6
- import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, recoverCompletedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
6
+ import { buildLlmCompletedEvent, buildRunTerminalEvent, buildWorkflowNodeCompletedEvent, buildWorkflowNodesSubmittedEvent, recoverCompletedWorkflowNodes, recoverSubmittedWorkflowNodes, repairEventsForRecovery, } from "./session-repair.js";
7
7
  import { KernelPrimitivesDashboard } from "./kernel-primitives-dashboard.js";
8
8
  import { capabilityMarker, capabilitySkill, capabilityTool, capabilityCommandMount, capabilityCommandUnmount, kernelAction, kernelApply, kernelMaybeAction, forceCompact, messageToKernelMessage, skillMetadataToKernel, taskUpdateToKernel, toolResultToKernel, toolSchemaToKernel, } from "./kernel-step.js";
9
- import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
9
+ import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
10
10
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
+ import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
12
+ import { resolveReducer } from "./reducers.js";
11
13
  import { governancePolicyToKernelEvent } from "../governance.js";
12
14
  import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
13
15
  import { assertNativeProfile } from "./os-profile.js";
@@ -256,6 +258,86 @@ export class RuntimeRunner {
256
258
  });
257
259
  yield { type: "done", iterations: result.result.turnsUsed, totalTokens: result.result.totalTokensUsed, status: result.result.termination };
258
260
  }
261
+ /**
262
+ * G3: run one workflow node, enforcing its `output_schema` (if any). Without a schema this is a
263
+ * plain `orchestrator.run`. With one, the node's agent is instructed to emit conforming JSON, its
264
+ * output is validated (the supported JSON-Schema subset), and on mismatch the node is re-run once
265
+ * with the validation errors fed back. If it still does not conform, the node is failed with the
266
+ * validation reason — a node that cannot meet its declared output contract starves its dependents,
267
+ * exactly as a denied spawn does.
268
+ */
269
+ async runWorkflowNode(node, parentSessionId, orchestrator, budget, outputs) {
270
+ // G2: a reduce node runs no LLM — execute the registered pure function over its dependency
271
+ // outputs and feed the result back as an ordinary completion. Deterministic; no agent burned.
272
+ if (node.reducer) {
273
+ return this.runReduceNode(node, outputs ?? new Map());
274
+ }
275
+ const baseSpec = workflowNodeToSpec(node, parentSessionId);
276
+ const manifest = workflowNodeToManifest(node, parentSessionId);
277
+ // G4: surface the workflow's remaining budget to the node's agent so a coordinator can size its
278
+ // `submit_workflow_nodes` batch to what is available (empty string ⇒ unbounded, no note).
279
+ const budgetNote = workflowBudgetNote(budget);
280
+ const withBudget = (goal) => (budgetNote ? `${goal}\n\n${budgetNote}` : goal);
281
+ const mkCtx = (goal) => ({
282
+ parentOpts: this.opts,
283
+ parentSessionId,
284
+ spec: { ...baseSpec, goal: withBudget(goal) },
285
+ manifest,
286
+ sessionLog: this.opts.sessionLog,
287
+ ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
288
+ });
289
+ const schema = node.output_schema;
290
+ if (!schema)
291
+ return orchestrator.run(mkCtx(baseSpec.goal));
292
+ const MAX_ATTEMPTS = 2;
293
+ let last;
294
+ let lastErrors = [];
295
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
296
+ const goal = attempt === 1
297
+ ? `${baseSpec.goal}\n\n${schemaInstruction(schema)}`
298
+ : `${baseSpec.goal}\n\n${schemaRetryInstruction(schema, lastErrors)}`;
299
+ const result = await orchestrator.run(mkCtx(goal));
300
+ const content = result.result.finalMessage?.content;
301
+ const text = typeof content === "string" ? content : content != null ? JSON.stringify(content) : "";
302
+ const v = validateAgainstSchema(extractJsonValue(text), schema);
303
+ if (v.ok)
304
+ return result;
305
+ last = result;
306
+ lastErrors = v.errors;
307
+ }
308
+ const reason = `output_schema validation failed after ${MAX_ATTEMPTS} attempts: ${lastErrors.join("; ")}`;
309
+ const fallback = last;
310
+ return {
311
+ ...fallback,
312
+ result: {
313
+ ...fallback.result,
314
+ termination: "error",
315
+ finalMessage: { role: "assistant", content: reason, toolCalls: [] },
316
+ },
317
+ };
318
+ }
319
+ /**
320
+ * G2: execute a deterministic reduce node. Looks up the named reducer (built-ins overlaid with
321
+ * `opts.reducers`), runs it over the node's dependency outputs (gathered from `outputs`), and
322
+ * returns a synthetic completion carrying the reducer's output — no LLM, zero tokens. An unknown
323
+ * reducer or a thrown reducer fails the node (`Error` termination → the kernel starves dependents).
324
+ */
325
+ runReduceNode(node, outputs) {
326
+ const ok = (content, termination) => ({
327
+ agentId: node.agent_id,
328
+ result: { termination, finalMessage: { role: "assistant", content, toolCalls: [] }, turnsUsed: 0, totalTokensUsed: 0 },
329
+ });
330
+ const reducer = resolveReducer(node.reducer, this.opts.reducers);
331
+ if (!reducer)
332
+ return ok(`unknown reducer "${node.reducer}"`, "error");
333
+ const inputs = (node.input_agent_ids ?? []).map(agentId => ({ agentId, output: outputs.get(agentId) ?? "" }));
334
+ try {
335
+ return ok(reducer(inputs), "completed");
336
+ }
337
+ catch (err) {
338
+ return ok(`reducer "${node.reducer}" threw: ${err instanceof Error ? err.message : String(err)}`, "error");
339
+ }
340
+ }
259
341
  /**
260
342
  * W0-ABI: run a declarative workflow DAG. The kernel owns the DAG and gates every node spawn
261
343
  * through the syscall trap; this driver runs each kernel-emitted batch of nodes in parallel,
@@ -275,26 +357,30 @@ export class RuntimeRunner {
275
357
  parent_session_id: parentSessionId,
276
358
  // W0-ABI resume: skip nodes already completed before an interruption.
277
359
  ...(opts?.resumedCompleted?.length ? { resumed_completed: opts.resumedCompleted } : {}),
360
+ // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
361
+ ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
278
362
  });
279
363
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
280
364
  ?.nodes ?? [];
365
+ // G4: the batch observation also carries the workflow's remaining budget; track the latest so a
366
+ // coordinator node's prompt reflects current headroom when it decides how much to submit.
367
+ const collectBudget = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")?.budget;
281
368
  const findDone = (obs) => obs.find(o => o.kind === "workflow_completed");
282
369
  let done = findDone(observations);
283
370
  if (done)
284
371
  return { completed: done.completed ?? [], failed: done.failed ?? [] };
285
372
  let nodes = collectNodes(observations);
373
+ let budget = collectBudget(observations);
374
+ // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
375
+ // outputs from here. Deps always complete in an earlier round than the reduce node that needs
376
+ // them (the kernel keeps the reduce node un-ready until its deps finish), so this is populated.
377
+ const outputs = new Map();
286
378
  for (;;) {
287
379
  if (nodes.length === 0)
288
380
  return { completed: [], failed: [] }; // nothing to run (e.g. all gated)
289
381
  // Run the currently-runnable nodes in parallel — each is independent within a round.
290
- const results = await Promise.all(nodes.map(node => orchestrator.run({
291
- parentOpts: this.opts,
292
- parentSessionId,
293
- spec: workflowNodeToSpec(node, parentSessionId),
294
- manifest: workflowNodeToManifest(node, parentSessionId),
295
- sessionLog: this.opts.sessionLog,
296
- ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
297
- })));
382
+ const roundBudget = budget;
383
+ const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs)));
298
384
  // Feed completions back one at a time. The kernel's run-queue executor may spawn a node's
299
385
  // dependents the moment *that* node completes (per-node unblock), so each feed can emit its
300
386
  // own `workflow_batch_spawned`; ACCUMULATE them across the round rather than keeping only the
@@ -304,11 +390,32 @@ export class RuntimeRunner {
304
390
  const nextNodes = [];
305
391
  done = undefined;
306
392
  for (const result of results) {
393
+ // G2: record this node's output so a downstream reduce node can consume it.
394
+ const outContent = result.result.finalMessage?.content;
395
+ outputs.set(result.agentId, typeof outContent === "string" ? outContent : outContent != null ? JSON.stringify(outContent) : "");
396
+ // R3-1: if this node's agent submitted more nodes, append them to the parent DAG BEFORE
397
+ // reporting the node's completion — the workflow is still active (the kernel hasn't seen this
398
+ // node finish), so even a submission from the last running node keeps the DAG alive. The
399
+ // appended nodes' `workflow_batch_spawned` is collected into this round like any other.
400
+ if (result.submittedNodes?.length) {
401
+ // G1: stamp the submitting node's agent id so the kernel can coerce a quarantined
402
+ // submitter's nodes to quarantined (no topological privilege escalation).
403
+ const submitEvent = submitWorkflowNodesToKernel(result.submittedNodes, result.agentId);
404
+ const subObs = kernelApply(runtime, this.pendingObservations, submitEvent);
405
+ nextNodes.push(...collectNodes(subObs));
406
+ budget = collectBudget(subObs) ?? budget;
407
+ // R3-1: persist the submission (kernel-shape nodes) so resume can re-apply it.
408
+ await this.opts.sessionLog.append(parentSessionId, buildWorkflowNodesSubmittedEvent({
409
+ turn: runtime.turn(),
410
+ nodes: submitEvent.nodes ?? [],
411
+ }));
412
+ }
307
413
  const obs = kernelApply(runtime, this.pendingObservations, {
308
414
  kind: "sub_agent_completed",
309
415
  result: subAgentResultToKernel(result),
310
416
  });
311
417
  nextNodes.push(...collectNodes(obs));
418
+ budget = collectBudget(obs) ?? budget;
312
419
  const d = findDone(obs);
313
420
  if (d)
314
421
  done = d;
@@ -336,7 +443,8 @@ export class RuntimeRunner {
336
443
  }
337
444
  const events = await this.opts.sessionLog.read(this.currentSessionId);
338
445
  const resumedCompleted = recoverCompletedWorkflowNodes(events);
339
- return this.runWorkflow(spec, { resumedCompleted });
446
+ const resumedSubmissions = recoverSubmittedWorkflowNodes(events);
447
+ return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions });
340
448
  }
341
449
  interrupt() { this.interrupted = true; }
342
450
  async *run(req) {
@@ -827,8 +935,9 @@ export class RuntimeRunner {
827
935
  resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
828
936
  };
829
937
  const toolResults = [];
830
- const normalCalls = allCalls.filter(c => c.name !== "update_plan");
938
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes");
831
939
  const planCalls = allCalls.filter(c => c.name === "update_plan");
940
+ const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes");
832
941
  for (const call of planCalls) {
833
942
  const update = parseUpdatePlanArgs(call.arguments);
834
943
  kernelApply(runtime, this.pendingObservations, {
@@ -839,6 +948,18 @@ export class RuntimeRunner {
839
948
  toolResults.push(result);
840
949
  yield { type: "tool_result", callId: call.id, content: "success", isError: false };
841
950
  }
951
+ // R3-1: `submit_workflow_nodes` cannot be applied to this runner's kernel — when this runner
952
+ // is a workflow node, the workflow lives in the *parent* kernel. Surface the requested nodes
953
+ // as a stream event; the orchestrator collects them onto the node's result and `runWorkflow`
954
+ // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
955
+ // simply unconsumed — a no-op.)
956
+ for (const call of submitCalls) {
957
+ const nodes = parseSubmitWorkflowNodesArgs(call.arguments);
958
+ yield { type: "workflow_nodes_submitted", nodes };
959
+ const result = { callId: call.id, output: "submitted", isError: false };
960
+ toolResults.push(result);
961
+ yield { type: "tool_result", callId: call.id, content: "submitted", isError: false };
962
+ }
842
963
  if (normalCalls.length > 0) {
843
964
  for await (const evt of this.opts.executionPlane.executeAll(normalCalls, runCtx)) {
844
965
  yield evt;
@@ -1317,3 +1438,16 @@ function parseUpdatePlanArgs(argsStr) {
1317
1438
  : parsed.blocked_on,
1318
1439
  };
1319
1440
  }
1441
+ /** R3-1: parse the `submit_workflow_nodes` tool arguments (`{ nodes: WorkflowNodeSpec[] }`). Node
1442
+ * shapes are trusted structurally here; the kernel validates them (dep range, quarantine, quota) on
1443
+ * append. A malformed payload yields no nodes rather than throwing. */
1444
+ function parseSubmitWorkflowNodesArgs(argsStr) {
1445
+ let parsed = {};
1446
+ try {
1447
+ parsed = JSON.parse(argsStr);
1448
+ }
1449
+ catch {
1450
+ // Ignore parse error → no nodes submitted.
1451
+ }
1452
+ return Array.isArray(parsed.nodes) ? parsed.nodes : [];
1453
+ }
@@ -243,6 +243,13 @@ export type SessionEvent = {
243
243
  primitive?: KernelPrimitive;
244
244
  agent_id: string;
245
245
  termination: string;
246
+ } | {
247
+ kind: "workflow_nodes_submitted";
248
+ turn: number;
249
+ category?: KernelEventCategory;
250
+ primitive?: KernelPrimitive;
251
+ /** Kernel-shape (snake_case) submitted node specs — persisted so resume can re-apply them. */
252
+ nodes: Record<string, unknown>[];
246
253
  } | {
247
254
  kind: "workflow_batch_spawned";
248
255
  turn: number;
@@ -58,3 +58,17 @@ export declare function recoverCompletedWorkflowNodes(events: Array<{
58
58
  seq: number;
59
59
  event: SessionEvent;
60
60
  }>): string[];
61
+ /** R3-1: build workflow_nodes_submitted for persistence after a runtime submission, so resume can
62
+ * re-apply it. `nodes` is the kernel-shape (snake_case) submitted node array. */
63
+ export declare function buildWorkflowNodesSubmittedEvent(input: {
64
+ turn: number;
65
+ nodes: Record<string, unknown>[];
66
+ }): Extract<SessionEvent, {
67
+ kind: "workflow_nodes_submitted";
68
+ }>;
69
+ /** R3-1: recover the runtime submission batches (in order) from a session event stream, to rebuild
70
+ * `resumed_submissions` for resumeWorkflow so dynamically-appended nodes are reconstructed. */
71
+ export declare function recoverSubmittedWorkflowNodes(events: Array<{
72
+ seq: number;
73
+ event: SessionEvent;
74
+ }>): Record<string, unknown>[][];
@@ -76,3 +76,18 @@ export function recoverCompletedWorkflowNodes(events) {
76
76
  }
77
77
  return completed;
78
78
  }
79
+ /** R3-1: build workflow_nodes_submitted for persistence after a runtime submission, so resume can
80
+ * re-apply it. `nodes` is the kernel-shape (snake_case) submitted node array. */
81
+ export function buildWorkflowNodesSubmittedEvent(input) {
82
+ return { kind: "workflow_nodes_submitted", turn: input.turn, nodes: input.nodes };
83
+ }
84
+ /** R3-1: recover the runtime submission batches (in order) from a session event stream, to rebuild
85
+ * `resumed_submissions` for resumeWorkflow so dynamically-appended nodes are reconstructed. */
86
+ export function recoverSubmittedWorkflowNodes(events) {
87
+ const submissions = [];
88
+ for (const { event } of events) {
89
+ if (event.kind === "workflow_nodes_submitted")
90
+ submissions.push(event.nodes);
91
+ }
92
+ return submissions;
93
+ }
@@ -74,15 +74,24 @@ export class SubAgentOrchestrator {
74
74
  totalTokensUsed: outcome.totalTokens,
75
75
  ...(outcome.result ? { finalMessage: { role: "assistant", content: outcome.result, toolCalls: [] } } : {}),
76
76
  },
77
+ // R3-1: surface nodes the agent submitted under the harness so `runWorkflow` appends them.
78
+ ...(outcome.submittedNodes?.length ? { submittedNodes: outcome.submittedNodes } : {}),
77
79
  };
78
80
  }
79
81
  let done;
80
82
  let finalText = "";
83
+ // R3-1: collect any nodes this node's agent submitted via the `submit_workflow_nodes` tool (the
84
+ // runner surfaces them as `workflow_nodes_submitted` because the workflow lives in the parent
85
+ // kernel, not this child's). `runWorkflow` sends them to the parent kernel.
86
+ const submittedNodes = [];
81
87
  for await (const evt of this.stream(ctx)) {
82
88
  if (evt.type === "text_delta")
83
89
  finalText += evt.delta;
84
90
  if (evt.type === "done")
85
91
  done = evt;
92
+ if (evt.type === "workflow_nodes_submitted") {
93
+ submittedNodes.push(...evt.nodes);
94
+ }
86
95
  }
87
96
  const loopResult = {
88
97
  termination: terminationFromStatus(done?.status ?? "error"),
@@ -90,7 +99,11 @@ export class SubAgentOrchestrator {
90
99
  totalTokensUsed: done?.totalTokens ?? 0,
91
100
  ...(finalText ? { finalMessage: { role: "assistant", content: finalText, toolCalls: [] } } : {}),
92
101
  };
93
- return { agentId: ctx.spec.identity.agentId, result: loopResult };
102
+ return {
103
+ agentId: ctx.spec.identity.agentId,
104
+ result: loopResult,
105
+ ...(submittedNodes.length ? { submittedNodes } : {}),
106
+ };
94
107
  }
95
108
  }
96
109
  export const defaultSubAgentOrchestrator = new SubAgentOrchestrator();
@@ -1,4 +1,4 @@
1
- import type { Message } from "../types.js";
1
+ import type { Message, ToolSchema } from "../types.js";
2
2
  export type KernelAgentRole = "explore" | "plan" | "implement" | "verify" | "custom";
3
3
  export type AgentIsolation = "shared" | "read_only" | "worktree" | "remote";
4
4
  export type ContextInheritance = "none" | "system_only" | "full";
@@ -52,6 +52,11 @@ export interface LoopResult {
52
52
  export interface SubAgentResult {
53
53
  agentId: string;
54
54
  result: LoopResult;
55
+ /** R3-1: nodes this node's agent asked to append to the parent workflow DAG (via the
56
+ * `submit_workflow_nodes` tool). Surfaced by the orchestrator; the `runWorkflow` driver sends
57
+ * them to the parent kernel before this node's completion. SDK-internal — not sent over the wire
58
+ * on the kernel `SubAgentResult` (see `subAgentResultToKernel`). */
59
+ submittedNodes?: WorkflowNodeSpec[];
55
60
  }
56
61
  export interface MilestoneCheckResult {
57
62
  phaseId: string;
@@ -92,6 +97,12 @@ export interface WorkflowNodeSpec {
92
97
  modelHint?: string;
93
98
  /** W3: `quarantined` nodes read untrusted content and must run without privileges. */
94
99
  trust?: NodeTrust;
100
+ /** G3: JSON Schema the node's output must conform to. The kernel carries it to the spawn
101
+ * descriptor; the runner instructs the agent and validates + retries once on mismatch. */
102
+ outputSchema?: Record<string, unknown>;
103
+ /** G2: make this a deterministic *reduce* node — it runs no LLM agent. The runner routes it to the
104
+ * registered reducer of this name, over its `dependsOn` nodes' outputs (dedupe / filter / merge). */
105
+ reducer?: string;
95
106
  /** Indices of nodes this node depends on. */
96
107
  dependsOn?: number[];
97
108
  }
@@ -109,9 +120,40 @@ export interface WorkflowSpawnInfo {
109
120
  model_hint?: string;
110
121
  /** W3 trust level: `"trusted"` | `"quarantined"`. */
111
122
  trust?: string;
123
+ /** G3: JSON Schema the node's output must conform to (carried verbatim from the spec). */
124
+ output_schema?: Record<string, unknown>;
125
+ /** G2: for a reduce node, the name of the registered host function to run (no LLM). */
126
+ reducer?: string;
127
+ /** G2: the dependency agent ids whose outputs a reduce node consumes. */
128
+ input_agent_ids?: string[];
112
129
  }
130
+ /** G4 budget-as-signal: the workflow's remaining headroom under the active quota, carried on the
131
+ * `workflow_batch_spawned` observation so a coordinator node can scale its next submission. */
132
+ export interface WorkflowBudget {
133
+ nodes_used: number;
134
+ nodes_max?: number;
135
+ nodes_remaining?: number;
136
+ running_subagents: number;
137
+ max_concurrent_subagents?: number;
138
+ concurrency_remaining?: number;
139
+ }
140
+ /** G4: a concise, human-readable budget note appended to a coordinator node's goal, so its agent can
141
+ * size a `submit_workflow_nodes` batch to what is actually available. Returns "" when nothing is
142
+ * bounded (no quota ⇒ no signal). */
143
+ export declare function workflowBudgetNote(budget: WorkflowBudget | undefined): string;
144
+ /** Map one host `WorkflowNodeSpec` to its snake_case kernel JSON. Shared by `load_workflow` (the
145
+ * whole spec) and `submit_workflow_nodes` (R3-1 runtime append) so the two encodings never drift. */
146
+ export declare function workflowNodeSpecToKernel(n: WorkflowNodeSpec): Record<string, unknown>;
113
147
  /** Map a host `WorkflowSpec` to the snake_case kernel JSON (`load_workflow.spec`). */
114
148
  export declare function workflowSpecToKernel(spec: WorkflowSpec): Record<string, unknown>;
149
+ /** R3-1: map a batch of host nodes to the `submit_workflow_nodes` kernel event body. G1: pass
150
+ * `submitterAgentId` (the node that requested the append) so the kernel can enforce no-privilege-
151
+ * escalation — a quarantined submitter's nodes are coerced to quarantined. Omitted ⇒ no coercion. */
152
+ export declare function submitWorkflowNodesToKernel(nodes: WorkflowNodeSpec[], submitterAgentId?: string): Record<string, unknown>;
153
+ /** R3-1: the tool a workflow-coordinator node's agent calls to append work to the running DAG
154
+ * (true loop-until-done / dynamic fan-out). Give it to nodes meant to fan out; the runner intercepts
155
+ * the call and routes the nodes to the parent kernel (the child's own kernel holds no workflow). */
156
+ export declare const submitWorkflowNodesTool: ToolSchema;
115
157
  /** Build a sub-agent run spec for a kernel-generated workflow node. */
116
158
  export declare function workflowNodeToSpec(node: WorkflowSpawnInfo, parentSessionId: string): AgentRunSpec;
117
159
  /** Build the host manifest for a kernel-generated workflow node. */
@@ -94,29 +94,116 @@ export function milestoneCheckPass(phaseId) {
94
94
  export function milestoneCheckFail(phaseId, reason) {
95
95
  return { phaseId, passed: false, reason };
96
96
  }
97
+ /** G4: a concise, human-readable budget note appended to a coordinator node's goal, so its agent can
98
+ * size a `submit_workflow_nodes` batch to what is actually available. Returns "" when nothing is
99
+ * bounded (no quota ⇒ no signal). */
100
+ export function workflowBudgetNote(budget) {
101
+ if (!budget)
102
+ return "";
103
+ const parts = [];
104
+ if (budget.nodes_remaining != null && budget.nodes_max != null) {
105
+ parts.push(`nodes ${budget.nodes_used}/${budget.nodes_max} used, ${budget.nodes_remaining} remaining`);
106
+ }
107
+ if (budget.concurrency_remaining != null && budget.max_concurrent_subagents != null) {
108
+ parts.push(`concurrency ${budget.running_subagents}/${budget.max_concurrent_subagents} running, ${budget.concurrency_remaining} free`);
109
+ }
110
+ if (parts.length === 0)
111
+ return "";
112
+ return (`[workflow budget] ${parts.join(" · ")}. ` +
113
+ "If you submit more workflow nodes, keep the batch within the remaining node budget.");
114
+ }
115
+ /** Map one host `WorkflowNodeSpec` to its snake_case kernel JSON. Shared by `load_workflow` (the
116
+ * whole spec) and `submit_workflow_nodes` (R3-1 runtime append) so the two encodings never drift. */
117
+ export function workflowNodeSpecToKernel(n) {
118
+ const task = typeof n.task === "string" ? { goal: n.task } : n.task;
119
+ return {
120
+ task: {
121
+ goal: task.goal,
122
+ // `criteria` is required by the kernel's RuntimeTask serde (no default).
123
+ criteria: task.criteria ?? [],
124
+ ...(task.lane ? { lane: task.lane } : {}),
125
+ },
126
+ role: n.role,
127
+ // role/isolation/context_inheritance have no serde default in the kernel — always emit.
128
+ isolation: n.isolation ?? "shared",
129
+ context_inheritance: n.contextInheritance ?? "none",
130
+ ...(n.modelHint ? { model_hint: n.modelHint } : {}),
131
+ ...(n.trust && n.trust !== "trusted" ? { trust: n.trust } : {}),
132
+ ...(n.outputSchema ? { output_schema: n.outputSchema } : {}),
133
+ // G2: a reducer name lowers to the kernel's `NodeKind::Reduce` (serde-tagged by `type`).
134
+ ...(n.reducer ? { kind: { type: "reduce", reducer: n.reducer } } : {}),
135
+ ...(n.dependsOn && n.dependsOn.length ? { depends_on: n.dependsOn } : {}),
136
+ };
137
+ }
97
138
  /** Map a host `WorkflowSpec` to the snake_case kernel JSON (`load_workflow.spec`). */
98
139
  export function workflowSpecToKernel(spec) {
140
+ return { nodes: spec.nodes.map(workflowNodeSpecToKernel) };
141
+ }
142
+ /** R3-1: map a batch of host nodes to the `submit_workflow_nodes` kernel event body. G1: pass
143
+ * `submitterAgentId` (the node that requested the append) so the kernel can enforce no-privilege-
144
+ * escalation — a quarantined submitter's nodes are coerced to quarantined. Omitted ⇒ no coercion. */
145
+ export function submitWorkflowNodesToKernel(nodes, submitterAgentId) {
99
146
  return {
100
- nodes: spec.nodes.map(n => {
101
- const task = typeof n.task === "string" ? { goal: n.task } : n.task;
102
- return {
103
- task: {
104
- goal: task.goal,
105
- // `criteria` is required by the kernel's RuntimeTask serde (no default).
106
- criteria: task.criteria ?? [],
107
- ...(task.lane ? { lane: task.lane } : {}),
108
- },
109
- role: n.role,
110
- // role/isolation/context_inheritance have no serde default in the kernel — always emit.
111
- isolation: n.isolation ?? "shared",
112
- context_inheritance: n.contextInheritance ?? "none",
113
- ...(n.modelHint ? { model_hint: n.modelHint } : {}),
114
- ...(n.trust && n.trust !== "trusted" ? { trust: n.trust } : {}),
115
- ...(n.dependsOn && n.dependsOn.length ? { depends_on: n.dependsOn } : {}),
116
- };
117
- }),
147
+ kind: "submit_workflow_nodes",
148
+ nodes: nodes.map(workflowNodeSpecToKernel),
149
+ ...(submitterAgentId ? { submitter_agent_id: submitterAgentId } : {}),
118
150
  };
119
151
  }
152
+ /** R3-1: the tool a workflow-coordinator node's agent calls to append work to the running DAG
153
+ * (true loop-until-done / dynamic fan-out). Give it to nodes meant to fan out; the runner intercepts
154
+ * the call and routes the nodes to the parent kernel (the child's own kernel holds no workflow). */
155
+ export const submitWorkflowNodesTool = {
156
+ name: "submit_workflow_nodes",
157
+ description: "Append new nodes to the running workflow DAG (dynamic fan-out / loop-until-done). Each node " +
158
+ "spawns as a gated sub-agent. Use when you discover more work that should run as its own node.",
159
+ parameters: JSON.stringify({
160
+ type: "object",
161
+ properties: {
162
+ nodes: {
163
+ type: "array",
164
+ description: "Workflow nodes to append; each runs as a gated sub-agent.",
165
+ items: {
166
+ type: "object",
167
+ properties: {
168
+ task: {
169
+ description: "The node's goal: a string, or an object { goal, criteria?, lane? }.",
170
+ oneOf: [
171
+ { type: "string" },
172
+ {
173
+ type: "object",
174
+ properties: {
175
+ goal: { type: "string" },
176
+ criteria: { type: "array", items: { type: "string" } },
177
+ },
178
+ required: ["goal"],
179
+ },
180
+ ],
181
+ },
182
+ role: { type: "string", enum: ["explore", "plan", "implement", "verify", "custom"] },
183
+ isolation: { type: "string", enum: ["shared", "read_only", "worktree", "remote"] },
184
+ contextInheritance: { type: "string", enum: ["none", "system_only", "full"] },
185
+ trust: { type: "string", enum: ["trusted", "quarantined"] },
186
+ outputSchema: {
187
+ type: "object",
188
+ description: "Optional JSON Schema the node's output must conform to (validated + retried SDK-side).",
189
+ },
190
+ reducer: {
191
+ type: "string",
192
+ description: "Make this a deterministic reduce node (no LLM); names a registered reducer.",
193
+ },
194
+ dependsOn: {
195
+ type: "array",
196
+ items: { type: "integer" },
197
+ description: "Batch-relative, backward-only dependency indices within this submission.",
198
+ },
199
+ },
200
+ required: ["task", "role"],
201
+ },
202
+ },
203
+ },
204
+ required: ["nodes"],
205
+ }),
206
+ };
120
207
  /** Build a sub-agent run spec for a kernel-generated workflow node. */
121
208
  export function workflowNodeToSpec(node, parentSessionId) {
122
209
  return {
package/dist/types.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { WorkflowNodeSpec } from "./types/agent.js";
1
2
  export interface TextPart {
2
3
  type: "text";
3
4
  text: string;
@@ -116,6 +117,13 @@ export interface ToolResultEvent extends StreamEvent {
116
117
  isFatal?: boolean;
117
118
  errorKind?: ToolErrorKind;
118
119
  }
120
+ /** R3-1: a workflow node's agent called the `submit_workflow_nodes` tool. The runner intercepts it
121
+ * (it cannot apply to the child's own kernel — the workflow lives in the parent) and surfaces the
122
+ * requested nodes as this event; the `runWorkflow` driver sends them to the parent kernel. */
123
+ export interface WorkflowNodesSubmittedEvent extends StreamEvent {
124
+ type: "workflow_nodes_submitted";
125
+ nodes: WorkflowNodeSpec[];
126
+ }
119
127
  export interface DoneEvent extends StreamEvent {
120
128
  type: "done";
121
129
  iterations: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepstrike/sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "DeepStrike Node.js SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@anthropic-ai/sdk": "^0.99.0",
23
- "@deepstrike/core": "0.2.10",
23
+ "@deepstrike/core": "0.2.11",
24
24
  "@google/generative-ai": "^0.24.1",
25
25
  "openai": "^5.23.2"
26
26
  },