@deepstrike/sdk 0.2.16 → 0.2.18

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.
@@ -6,10 +6,11 @@ import { sanitizeReplayText } from "./replay-sanitize.js";
6
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, submitWorkflowNodesToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
9
+ import { agentRunSpecToKernel, findSpawnProcessObservation, milestoneCheckPass, milestoneCheckResultToKernel, spawnObservationToManifest, subAgentResultToKernel, submitWorkflowNodesToKernel, submitWorkflowToKernel, workflowBudgetNote, workflowNodeToManifest, workflowNodeToSpec, workflowSpecToKernel, } from "../types/agent.js";
10
10
  import { defaultSubAgentOrchestrator } from "./sub-agent-orchestrator.js";
11
11
  import { extractJsonValue, schemaInstruction, schemaRetryInstruction, validateAgainstSchema, } from "./output-schema.js";
12
12
  import { resolveReducer } from "./reducers.js";
13
+ import { loopInstruction, classifyInstruction, judgeGoal, extractLoopContinue, extractClassifyBranch, extractJudgeWinner, } from "./workflow-control-flow.js";
13
14
  import { governancePolicyToKernelEvent } from "../governance.js";
14
15
  import { kernelObservationToSessionEvent, withCategory } from "./kernel-event-log.js";
15
16
  import { assertNativeProfile } from "./os-profile.js";
@@ -17,6 +18,9 @@ import { LargeResultSpool } from "./large-result-spool.js";
17
18
  export class RuntimeRunner {
18
19
  opts;
19
20
  interrupted = false;
21
+ /** #2-B-ii: aborts the in-flight provider stream when the run is interrupted/preempted. Recreated
22
+ * per `execute`; `interrupt()` fires it so a Critical `InterruptNow` cancels the live LLM call. */
23
+ abortController = null;
20
24
  activeKernel = null;
21
25
  pendingObservations = [];
22
26
  currentSessionId = null;
@@ -25,6 +29,9 @@ export class RuntimeRunner {
25
29
  pendingSpoolOutputs = new Map();
26
30
  /** Local cache of paged-out/archived messages for priority memory retrieval. */
27
31
  localPageOutCache = [];
32
+ /** M5 v2.1: sub-workflow specs a top-level agent authored via `start_workflow`, awaiting auto-drive
33
+ * at the next safe point (after the tool turn resolves, kernel back in Reason — not suspended). */
34
+ pendingAuthoredWorkflows = [];
28
35
  dashboard = null;
29
36
  constructor(opts) {
30
37
  this.opts = opts;
@@ -126,6 +133,8 @@ export class RuntimeRunner {
126
133
  return new KernelRuntime({
127
134
  maxTokens: this.opts.maxTokens,
128
135
  maxTurns: this.opts.maxTurns,
136
+ // M4/G5: per-node token cap → child run's cumulative token budget.
137
+ maxTotalTokens: this.opts.maxTotalTokens !== undefined ? BigInt(this.opts.maxTotalTokens) : undefined,
129
138
  timeoutMs: this.opts.timeoutMs !== undefined ? BigInt(this.opts.timeoutMs) : undefined,
130
139
  });
131
140
  }
@@ -266,7 +275,7 @@ export class RuntimeRunner {
266
275
  * validation reason — a node that cannot meet its declared output contract starves its dependents,
267
276
  * exactly as a denied spawn does.
268
277
  */
269
- async runWorkflowNode(node, parentSessionId, orchestrator, budget, outputs) {
278
+ async runWorkflowNode(node, parentSessionId, orchestrator, budget, outputs, abortSignal) {
270
279
  // G2: a reduce node runs no LLM — execute the registered pure function over its dependency
271
280
  // outputs and feed the result back as an ordinary completion. Deterministic; no agent burned.
272
281
  if (node.reducer) {
@@ -284,8 +293,45 @@ export class RuntimeRunner {
284
293
  spec: { ...baseSpec, goal: withBudget(goal) },
285
294
  manifest,
286
295
  sessionLog: this.opts.sessionLog,
296
+ // M5 v2.1: this child IS a workflow node — its `start_workflow` flattens to this kernel (the
297
+ // workflow it would author joins the running DAG) rather than bootstrapping a nested pivot.
298
+ isWorkflowNode: true,
299
+ // #2-B-ii: the per-node abort signal the driver fires when the kernel preempts this node.
300
+ ...(abortSignal ? { abortSignal } : {}),
287
301
  ...(this.opts.subAgentHarness ? { harness: this.opts.subAgentHarness } : {}),
288
302
  });
303
+ const textOf = (r) => {
304
+ const c = r.result.finalMessage?.content;
305
+ return typeof c === "string" ? c : c != null ? JSON.stringify(c) : "";
306
+ };
307
+ const withSignal = (r, patch) => ({ ...r, result: { ...r.result, ...patch } });
308
+ // A#2 tournament judge: this node compares two entrants' produced outputs rather than running its
309
+ // own goal. Look up both candidates, run a judge over the controller's criterion, and report the
310
+ // winning entrant's agent id as `tournamentWinner` (the kernel advances the bracket with it).
311
+ if (node.judge_match) {
312
+ const out = outputs ?? new Map();
313
+ const left = out.get(node.judge_match.left) ?? "";
314
+ const right = out.get(node.judge_match.right) ?? "";
315
+ const result = await orchestrator.run(mkCtx(judgeGoal(baseSpec.goal, left, right)));
316
+ const winner = extractJudgeWinner(textOf(result));
317
+ const winnerId = winner === "right" ? node.judge_match.right : node.judge_match.left;
318
+ return withSignal(result, { tournamentWinner: winnerId });
319
+ }
320
+ // A#2 v2 loop iteration: run the increment, then extract a stop signal so the kernel can end the
321
+ // loop early (`loopContinue: false`). No signal ⇒ run to `max_iters`.
322
+ if (node.loop_max_iters != null) {
323
+ const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${loopInstruction(node.loop_max_iters)}`));
324
+ const cont = extractLoopContinue(textOf(result));
325
+ return cont === undefined ? result : withSignal(result, { loopContinue: cont });
326
+ }
327
+ // A#2 classify: run the classifier, then extract the chosen branch label; the kernel runs that
328
+ // branch and prunes the rest. No recognizable choice ⇒ leave unset (kernel prunes all branches).
329
+ if (node.classify_labels && node.classify_labels.length) {
330
+ const labels = node.classify_labels;
331
+ const result = await orchestrator.run(mkCtx(`${baseSpec.goal}\n\n${classifyInstruction(labels)}`));
332
+ const branch = extractClassifyBranch(textOf(result), labels);
333
+ return branch === undefined ? result : withSignal(result, { classifyBranch: branch });
334
+ }
289
335
  const schema = node.output_schema;
290
336
  if (!schema)
291
337
  return orchestrator.run(mkCtx(baseSpec.goal));
@@ -350,8 +396,7 @@ export class RuntimeRunner {
350
396
  }
351
397
  const parentSessionId = this.currentSessionId;
352
398
  const runtime = this.activeKernel;
353
- const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
354
- let observations = kernelApply(runtime, this.pendingObservations, {
399
+ const observations = kernelApply(runtime, this.pendingObservations, {
355
400
  kind: "load_workflow",
356
401
  spec: workflowSpecToKernel(spec),
357
402
  parent_session_id: parentSessionId,
@@ -360,6 +405,87 @@ export class RuntimeRunner {
360
405
  // R3-1: re-apply recorded runtime submissions so dynamically-appended nodes are reconstructed.
361
406
  ...(opts?.resumedSubmissions?.length ? { resumed_submissions: opts.resumedSubmissions } : {}),
362
407
  });
408
+ return this.driveWorkflow(observations, parentSessionId, runtime);
409
+ }
410
+ /**
411
+ * M5/G1: bootstrap an **agent-authored** workflow ("the model writes its own harness"). Unlike
412
+ * `runWorkflow` (the host fires the privileged `load_workflow`), this routes the spec through the
413
+ * agent-reachable `Syscall::LoadWorkflow` (the `submit_workflow` event): with no workflow active the
414
+ * kernel **bootstraps** the DAG; if one is already active it **flattens** the spec's nodes onto it
415
+ * (bootstrap-or-flatten — one kernel, one quota, never a workflow stack). Gated by the same
416
+ * `max_workflow_nodes` backstop as runtime submission, so an authored harness can't overgrow the run.
417
+ * The resulting batches are driven by the same shared driver as `runWorkflow`.
418
+ */
419
+ async bootstrapWorkflow(spec, opts) {
420
+ if (!this.activeKernel || !this.currentSessionId) {
421
+ throw new Error("bootstrapWorkflow requires an active parent run");
422
+ }
423
+ const parentSessionId = this.currentSessionId;
424
+ const runtime = this.activeKernel;
425
+ const observations = kernelApply(runtime, this.pendingObservations, submitWorkflowToKernel(spec, parentSessionId, opts?.submitterAgentId));
426
+ return this.driveWorkflow(observations, parentSessionId, runtime);
427
+ }
428
+ /**
429
+ * M5 v2.1: drive the sub-workflow(s) a top-level agent authored via `start_workflow`. Called at the
430
+ * verified-safe point (right after the tool turn resolved to `call_provider` — kernel in Reason, not
431
+ * suspended). For each authored spec: `bootstrapWorkflow` runs it in THIS kernel (the kernel resumes
432
+ * the agent reason loop on `workflow_completed` — `finish_workflow` sets phase=Reason), then the
433
+ * outcome is injected as a user message so the agent's next turn sees the result. Returns a fresh
434
+ * `call_provider` synthesized from the updated context (the workflow drive consumed its own kernel
435
+ * actions, so we re-render — the same pattern as the reactive-compact retry path).
436
+ */
437
+ async driveAuthoredWorkflows(runtime, action) {
438
+ const specs = this.pendingAuthoredWorkflows;
439
+ this.pendingAuthoredWorkflows = [];
440
+ for (const spec of specs) {
441
+ const outcome = await this.bootstrapWorkflow(spec);
442
+ kernelApply(runtime, this.pendingObservations, {
443
+ kind: "add_history_message",
444
+ message: messageToKernelMessage({ role: "user", content: authoredWorkflowOutcomeNote(outcome) }),
445
+ });
446
+ }
447
+ return { kind: "call_provider", context: runtime.render(), tools: action.tools };
448
+ }
449
+ /**
450
+ * #2-B-ii: while a workflow batch is in flight, poll the signal source. A Critical `InterruptNow`
451
+ * routes through the kernel (which, with the root suspended in `SubAgentAwait`, preempts — marks the
452
+ * running nodes `UserAbort`, tears the `WorkflowRun` down, emits `AgentPreempted`); we then abort the
453
+ * matching children's in-flight LLM calls (via their per-node `AbortController` → `interrupt()`).
454
+ * Returns the torn-down workflow's outcome on preemption, else `null`. No-op (null) without a signal
455
+ * source. Non-preempting signals (queue/observe/soft-interrupt) are still applied as they arrive.
456
+ */
457
+ async monitorWorkflowPreemption(runtime, controllers, batchState) {
458
+ const source = this.opts.signalSource;
459
+ if (!source)
460
+ return null;
461
+ while (!batchState.settled) {
462
+ const sig = await source.nextSignal();
463
+ if (batchState.settled)
464
+ break;
465
+ if (!sig) {
466
+ await new Promise(resolve => setTimeout(resolve, 5));
467
+ continue;
468
+ }
469
+ const obs = kernelApply(runtime, this.pendingObservations, signalToKernelEvent(sig));
470
+ const preempted = obs.find(o => o.kind === "agent_preempted");
471
+ if (preempted) {
472
+ for (const id of preempted.agent_ids ?? [])
473
+ controllers.get(id)?.abort();
474
+ const wc = obs.find(o => o.kind === "workflow_completed");
475
+ return { completed: wc?.completed ?? [], failed: wc?.failed ?? [] };
476
+ }
477
+ }
478
+ return null;
479
+ }
480
+ /**
481
+ * Shared workflow driver for `runWorkflow` (host `load_workflow`) and `bootstrapWorkflow` (agent
482
+ * `submit_workflow`): given the observations from the initial load/bootstrap, run each kernel-emitted
483
+ * batch in parallel, feed completions back (appending any agent-submitted nodes first), and loop
484
+ * until the kernel reports the workflow complete. Returns the completed / failed node agent-ids.
485
+ */
486
+ async driveWorkflow(initial, parentSessionId, runtime) {
487
+ let observations = initial;
488
+ const orchestrator = this.opts.subAgentOrchestrator ?? defaultSubAgentOrchestrator;
363
489
  const collectNodes = (obs) => obs.find(o => o.kind === "workflow_batch_spawned")
364
490
  ?.nodes ?? [];
365
491
  // G4: the batch observation also carries the workflow's remaining budget; track the latest so a
@@ -368,7 +494,7 @@ export class RuntimeRunner {
368
494
  const findDone = (obs) => obs.find(o => o.kind === "workflow_completed");
369
495
  let done = findDone(observations);
370
496
  if (done)
371
- return { completed: done.completed ?? [], failed: done.failed ?? [] };
497
+ return { completed: done.completed ?? [], failed: done.failed ?? [], outputs: {} };
372
498
  let nodes = collectNodes(observations);
373
499
  let budget = collectBudget(observations);
374
500
  // G2: each completed node's output, keyed by agent id — a reduce node reads its dependencies'
@@ -377,10 +503,21 @@ export class RuntimeRunner {
377
503
  const outputs = new Map();
378
504
  for (;;) {
379
505
  if (nodes.length === 0)
380
- return { completed: [], failed: [] }; // nothing to run (e.g. all gated)
506
+ return { completed: [], failed: [], outputs: Object.fromEntries(outputs) }; // nothing to run (e.g. all gated)
381
507
  // Run the currently-runnable nodes in parallel — each is independent within a round.
382
508
  const roundBudget = budget;
383
- const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs)));
509
+ // #2-B-ii: per-node abort controllers + a concurrent preemption monitor. While the batch is in
510
+ // flight the monitor polls the signal source; a Critical `InterruptNow` routes through the kernel
511
+ // (which preempts → `AgentPreempted` + tears the workflow down) and we abort the matching child's
512
+ // in-flight LLM call. If the kernel preempted, stop driving and return the torn-down outcome.
513
+ const controllers = new Map(nodes.map(n => [n.agent_id, new AbortController()]));
514
+ const batchState = { settled: false };
515
+ const monitor = this.monitorWorkflowPreemption(runtime, controllers, batchState);
516
+ const results = await Promise.all(nodes.map(node => this.runWorkflowNode(node, parentSessionId, orchestrator, roundBudget, outputs, controllers.get(node.agent_id)?.signal)));
517
+ batchState.settled = true;
518
+ const preempted = await monitor;
519
+ if (preempted)
520
+ return { ...preempted, outputs: Object.fromEntries(outputs) };
384
521
  // Feed completions back one at a time. The kernel's run-queue executor may spawn a node's
385
522
  // dependents the moment *that* node completes (per-node unblock), so each feed can emit its
386
523
  // own `workflow_batch_spawned`; ACCUMULATE them across the round rather than keeping only the
@@ -427,7 +564,7 @@ export class RuntimeRunner {
427
564
  }));
428
565
  }
429
566
  if (done && nextNodes.length === 0) {
430
- return { completed: done.completed ?? [], failed: done.failed ?? [] };
567
+ return { completed: done.completed ?? [], failed: done.failed ?? [], outputs: Object.fromEntries(outputs) };
431
568
  }
432
569
  nodes = nextNodes;
433
570
  }
@@ -446,7 +583,7 @@ export class RuntimeRunner {
446
583
  const resumedSubmissions = recoverSubmittedWorkflowNodes(events);
447
584
  return this.runWorkflow(spec, { resumedCompleted, resumedSubmissions });
448
585
  }
449
- interrupt() { this.interrupted = true; }
586
+ interrupt() { this.interrupted = true; this.abortController?.abort(); }
450
587
  async *run(req) {
451
588
  const prior = req.inheritEvents ?? await this.opts.sessionLog.read(req.sessionId);
452
589
  const midRun = isMidRun(prior);
@@ -626,6 +763,7 @@ export class RuntimeRunner {
626
763
  }
627
764
  async *execute(sessionId, goal, criteria, extensions, priorEvents, resumeMidRun = false, attachments) {
628
765
  this.interrupted = false;
766
+ this.abortController = new AbortController();
629
767
  this.pendingObservations = [];
630
768
  this.pendingSpoolOutputs.clear();
631
769
  this.currentSessionId = sessionId;
@@ -820,28 +958,10 @@ export class RuntimeRunner {
820
958
  if (this.opts.signalSource) {
821
959
  const sig = await this.opts.signalSource.nextSignal();
822
960
  if (sig) {
823
- const id = crypto.randomUUID();
824
- const source = sig.source ?? "custom";
825
- const signalType = sig.signalType ?? "event";
826
- const urgency = sig.urgency ?? "normal";
827
- const summary = String(sig.payload?.goal ?? sig.kind ?? "signal");
828
- // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt)
829
- // and emits `signal_disposed`. An actionable disposition yields a new
830
- // action to adopt; queued/observed/ignored yields none (kernel buffers).
831
- // Wire shape is snake_case RuntimeSignal with an object payload.
832
- const sigAction = kernelMaybeAction(runtime, this.pendingObservations, {
833
- kind: "signal",
834
- signal: {
835
- id,
836
- source,
837
- signal_type: signalType,
838
- urgency,
839
- summary,
840
- payload: sig.payload ?? {},
841
- ...(sig.dedupeKey ? { dedupe_key: sig.dedupeKey } : {}),
842
- timestamp_ms: Date.now(),
843
- },
844
- });
961
+ // Kernel-routed: the kernel decides disposition (dedup/queue/interrupt) and emits
962
+ // `signal_disposed`. An actionable disposition yields a new action to adopt; queued/observed/
963
+ // ignored yields none (kernel buffers).
964
+ const sigAction = kernelMaybeAction(runtime, this.pendingObservations, signalToKernelEvent(sig));
845
965
  if (sigAction)
846
966
  action = sigAction;
847
967
  }
@@ -849,6 +969,15 @@ export class RuntimeRunner {
849
969
  if (runtime.isTerminal())
850
970
  break;
851
971
  if (action.kind === "call_provider") {
972
+ // M5 v2.1: top-level auto-pivot at the safe point. If the agent authored sub-workflow(s) via
973
+ // `start_workflow`, drive each in THIS kernel now (the kernel is in Reason / `call_provider`,
974
+ // NOT suspended — driving mid-suspend would clobber the single-slot suspend state), inject the
975
+ // outcome into context, and re-render. Loop-top placement (vs only after `tool_results`) catches
976
+ // EVERY path to `call_provider` — including resuming after an approval gate — so a queued spec
977
+ // is never stranded. Drains the queue; fires once per authored batch.
978
+ if (this.pendingAuthoredWorkflows.length > 0) {
979
+ action = await this.driveAuthoredWorkflows(runtime, action);
980
+ }
852
981
  const finalToolCalls = [];
853
982
  let finalText = "";
854
983
  const context = action.context;
@@ -857,8 +986,14 @@ export class RuntimeRunner {
857
986
  let turnInputTokens = 0;
858
987
  let turnOutputTokens = 0;
859
988
  let shouldRetry = false;
989
+ const abortSignal = this.abortController?.signal;
860
990
  try {
861
- for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState)) {
991
+ for await (const evt of this.opts.provider.stream(context, tools, Object.keys(ext).length ? ext : undefined, providerState, abortSignal)) {
992
+ // #2-B-ii: a preempting `interrupt()` fires `abortController` — stop consuming the live
993
+ // stream immediately (providers that forward `signal` also abort the socket; the rest at
994
+ // least stop here at the next event). The loop-top `interrupted` check then ends the run.
995
+ if (abortSignal?.aborted)
996
+ break;
862
997
  if (evt.type === "usage") {
863
998
  const usageEvt = evt;
864
999
  turnTokens = usageEvt.totalTokens;
@@ -876,6 +1011,11 @@ export class RuntimeRunner {
876
1011
  }
877
1012
  }
878
1013
  catch (err) {
1014
+ // #2-B-ii: an aborted in-flight request surfaces as an AbortError — treat it as an interrupt
1015
+ // (the loop-top `interrupted` check converts it to a clean `timeout`/UserAbort), not a crash.
1016
+ if (abortSignal?.aborted) {
1017
+ this.interrupted = true;
1018
+ }
879
1019
  const errMsg = String(err).toLowerCase();
880
1020
  if ((errMsg.includes("413") || errMsg.includes("too long") || errMsg.includes("context length exceeded") || errMsg.includes("context_length_exceeded")) &&
881
1021
  !hasAttemptedReactiveCompact) {
@@ -891,6 +1031,13 @@ export class RuntimeRunner {
891
1031
  break;
892
1032
  }
893
1033
  }
1034
+ // #2-B-ii: stream aborted (preempt/interrupt) via the break path (provider yielded no error) —
1035
+ // end the turn now with a timeout so the kernel terminates the run, rather than feeding the
1036
+ // partial assistant output as a normal turn.
1037
+ if (abortSignal?.aborted) {
1038
+ action = kernelAction(runtime, this.pendingObservations, { kind: "timeout" });
1039
+ break;
1040
+ }
894
1041
  if (shouldRetry) {
895
1042
  action = {
896
1043
  kind: "call_provider",
@@ -946,9 +1093,11 @@ export class RuntimeRunner {
946
1093
  resultSpool: this.opts.resultSpool ?? new LargeResultSpool(),
947
1094
  };
948
1095
  const toolResults = [];
949
- const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes");
1096
+ const normalCalls = allCalls.filter(c => c.name !== "update_plan" && c.name !== "submit_workflow_nodes" && c.name !== "start_workflow");
950
1097
  const planCalls = allCalls.filter(c => c.name === "update_plan");
951
- const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes");
1098
+ // M5 v1: `start_workflow` (author a sub-workflow) flattens to the same append path as
1099
+ // `submit_workflow_nodes` — a `WorkflowSpec` is a node batch. (v2 adds top-level bootstrap.)
1100
+ const submitCalls = allCalls.filter(c => c.name === "submit_workflow_nodes" || c.name === "start_workflow");
952
1101
  for (const call of planCalls) {
953
1102
  const update = parseUpdatePlanArgs(call.arguments);
954
1103
  kernelApply(runtime, this.pendingObservations, {
@@ -965,7 +1114,24 @@ export class RuntimeRunner {
965
1114
  // sends `submit_workflow_nodes` to the parent kernel. (When not a workflow node, the event is
966
1115
  // simply unconsumed — a no-op.)
967
1116
  for (const call of submitCalls) {
968
- const nodes = parseSubmitWorkflowNodesArgs(call.arguments);
1117
+ // M5 v2.1: a TOP-LEVEL agent authoring a whole sub-workflow via `start_workflow` — record the
1118
+ // full spec and AUTO-PIVOT once this tool turn resolves (the loop drives it in this kernel and
1119
+ // injects the outcome). A workflow-NODE's `start_workflow` (and every `submit_workflow_nodes`)
1120
+ // instead FLATTENS: the batch is surfaced for the parent `runWorkflow` to append.
1121
+ if (call.name === "start_workflow" && !this.opts.isWorkflowNode) {
1122
+ const spec = parseStartWorkflowSpec(call.arguments);
1123
+ if (spec) {
1124
+ this.pendingAuthoredWorkflows.push(spec);
1125
+ const out = "workflow authored; executing now";
1126
+ toolResults.push({ callId: call.id, output: out, isError: false });
1127
+ yield { type: "tool_result", callId: call.id, content: out, isError: false };
1128
+ continue;
1129
+ }
1130
+ }
1131
+ // `start_workflow` wraps the batch as `{ spec: { nodes } }`; `submit_workflow_nodes` is `{ nodes }`.
1132
+ const nodes = call.name === "start_workflow"
1133
+ ? parseStartWorkflowArgs(call.arguments)
1134
+ : parseSubmitWorkflowNodesArgs(call.arguments);
969
1135
  yield { type: "workflow_nodes_submitted", nodes };
970
1136
  const result = { callId: call.id, output: "submitted", isError: false };
971
1137
  toolResults.push(result);
@@ -1487,3 +1653,63 @@ function parseSubmitWorkflowNodesArgs(argsStr) {
1487
1653
  }
1488
1654
  return Array.isArray(parsed.nodes) ? parsed.nodes : [];
1489
1655
  }
1656
+ /** M5 v1: parse the `start_workflow` tool arguments (`{ spec: { nodes: WorkflowNodeSpec[] } }`) into
1657
+ * the spec's node batch — flattened onto the running workflow via the same append path. A malformed
1658
+ * payload yields no nodes rather than throwing. */
1659
+ function parseStartWorkflowArgs(argsStr) {
1660
+ let parsed = {};
1661
+ try {
1662
+ parsed = JSON.parse(argsStr);
1663
+ }
1664
+ catch {
1665
+ // Ignore parse error → no nodes.
1666
+ }
1667
+ const spec = parsed.spec;
1668
+ return Array.isArray(spec?.nodes) ? spec.nodes : [];
1669
+ }
1670
+ /** M5 v2.1: parse the full `WorkflowSpec` from a top-level `start_workflow` call, for auto-pivot drive
1671
+ * (vs `parseStartWorkflowArgs`, which returns only the node batch for the flatten path). Returns
1672
+ * `undefined` on a malformed / empty payload so the caller falls back to the flatten path. */
1673
+ function parseStartWorkflowSpec(argsStr) {
1674
+ try {
1675
+ const parsed = JSON.parse(argsStr);
1676
+ if (Array.isArray(parsed.spec?.nodes) && parsed.spec.nodes.length > 0) {
1677
+ return { nodes: parsed.spec.nodes };
1678
+ }
1679
+ }
1680
+ catch {
1681
+ // Ignore parse error → undefined (fall back to flatten).
1682
+ }
1683
+ return undefined;
1684
+ }
1685
+ /** M5 v2.1: render an authored-workflow outcome into a user-message note injected back into the
1686
+ * agent's context, so the agent's next turn continues with the sub-workflow's results in view. */
1687
+ function authoredWorkflowOutcomeNote(outcome) {
1688
+ const lines = [
1689
+ `[authored workflow result] ${outcome.completed.length} node(s) completed` +
1690
+ (outcome.failed.length ? `, ${outcome.failed.length} failed` : "") + ".",
1691
+ ];
1692
+ for (const id of outcome.completed) {
1693
+ const out = outcome.outputs[id];
1694
+ if (out)
1695
+ lines.push(`- ${id}: ${out.length > 500 ? out.slice(0, 500) + "…" : out}`);
1696
+ }
1697
+ return lines.join("\n");
1698
+ }
1699
+ /** Lower a host `RuntimeSignal` to the kernel's snake_case `signal` input event. Shared by the main
1700
+ * loop's per-turn poll and #2-B-ii's workflow-batch preemption monitor (so the two never drift). */
1701
+ function signalToKernelEvent(sig) {
1702
+ return {
1703
+ kind: "signal",
1704
+ signal: {
1705
+ id: crypto.randomUUID(),
1706
+ source: sig.source ?? "custom",
1707
+ signal_type: sig.signalType ?? "event",
1708
+ urgency: sig.urgency ?? "normal",
1709
+ summary: String(sig.payload?.goal ?? sig.kind ?? "signal"),
1710
+ payload: sig.payload ?? {},
1711
+ ...(sig.dedupeKey ? { dedupe_key: sig.dedupeKey } : {}),
1712
+ timestamp_ms: Date.now(),
1713
+ },
1714
+ };
1715
+ }
@@ -12,7 +12,18 @@ export interface SubAgentRunContext {
12
12
  evalProvider: import("../types.js").LLMProvider;
13
13
  maxAttempts?: number;
14
14
  };
15
+ /** M5 v2.1: set when this child is a workflow node (spawned by the workflow driver). Propagated to
16
+ * the child runner so a nested `start_workflow` FLATTENS to the parent kernel rather than
17
+ * auto-pivoting into its own bootstrap (which would fragment the one-kernel/one-quota governance). */
18
+ isWorkflowNode?: boolean;
19
+ /** #2-B-ii: parent-controlled abort. When this fires (the kernel preempted this node via
20
+ * `InterruptNow` → `AgentPreempted`), the orchestrator interrupts the child runner, cancelling its
21
+ * in-flight LLM call. */
22
+ abortSignal?: AbortSignal;
15
23
  }
24
+ /** M1/G3 intelligence routing: resolve the provider for a sub-agent from its spec's `modelHint`.
25
+ * Falls back to the parent provider when there is no hint or no `providerFor` hook resolves it. */
26
+ export declare function resolveProvider(opts: RuntimeOptions, modelHint?: string): RuntimeOptions["provider"];
16
27
  /** Host-side driver for kernel-isolated sub-agent runs. */
17
28
  export declare class SubAgentOrchestrator {
18
29
  stream(ctx: SubAgentRunContext): AsyncIterable<StreamEvent>;
@@ -1,5 +1,6 @@
1
1
  import { agentRunSpecToKernel, findSpawnProcessObservation, spawnObservationToManifest } from "../types/agent.js";
2
2
  import { FilteredExecutionPlane } from "./filtered-plane.js";
3
+ import { WorktreeExecutionPlane } from "./worktree-plane.js";
3
4
  import { kernelApply } from "./kernel-step.js";
4
5
  function terminationFromStatus(status) {
5
6
  const normalized = status.toLowerCase();
@@ -14,6 +15,38 @@ function terminationFromStatus(status) {
14
15
  }
15
16
  return status;
16
17
  }
18
+ /** M3/G4: if this sub-agent is an `isolation: "worktree"` node and a worktree manager is configured,
19
+ * wrap its plane in a `WorktreeExecutionPlane` (creates a git worktree, injects it as `cwd`, removes
20
+ * it on cleanup). Returns the plane to use plus a cleanup hook the caller must run when the sub-agent
21
+ * finishes. Without a manager (or for non-worktree nodes) this is a pass-through with a no-op cleanup. */
22
+ function withWorktree(ctx, plane) {
23
+ if (ctx.manifest.isolation === "worktree" && ctx.parentOpts.worktreeManager) {
24
+ const wt = new WorktreeExecutionPlane(plane, ctx.parentOpts.worktreeManager, ctx.spec.identity.agentId);
25
+ return { plane: wt, cleanup: () => wt.cleanup() };
26
+ }
27
+ return { plane, cleanup: async () => { } };
28
+ }
29
+ /** M1/G3 intelligence routing: resolve the provider for a sub-agent from its spec's `modelHint`.
30
+ * Falls back to the parent provider when there is no hint or no `providerFor` hook resolves it. */
31
+ export function resolveProvider(opts, modelHint) {
32
+ if (modelHint && opts.providerFor) {
33
+ const routed = opts.providerFor(modelHint);
34
+ if (routed)
35
+ return routed;
36
+ }
37
+ return opts.provider;
38
+ }
39
+ /** #2-B-ii: bridge a parent-controlled AbortSignal to a child runner's `interrupt()` — fires now if
40
+ * the signal is already aborted (creation race), else once when it aborts. */
41
+ function linkAbort(signal, runner) {
42
+ if (!signal)
43
+ return;
44
+ if (signal.aborted) {
45
+ runner.interrupt();
46
+ return;
47
+ }
48
+ signal.addEventListener("abort", () => runner.interrupt(), { once: true });
49
+ }
17
50
  /** Derive which meta-tools a child runner should expose based on permitted IDs and available sources. */
18
51
  function deriveMetaTools(permitted, opts) {
19
52
  const metaTools = new Set();
@@ -33,6 +66,8 @@ export class SubAgentOrchestrator {
33
66
  const permitted = new Set(ctx.manifest.permitted_capability_ids ?? []);
34
67
  const metaTools = deriveMetaTools(permitted, ctx.parentOpts);
35
68
  const filteredPlane = new FilteredExecutionPlane(ctx.parentOpts.executionPlane, permitted, metaTools);
69
+ // M3/G4: a worktree node runs inside its own git worktree (created here, removed in `finally`).
70
+ const { plane: execPlane, cleanup: cleanupWorktree } = withWorktree(ctx, filteredPlane);
36
71
  let systemPrompt = ctx.parentOpts.systemPrompt;
37
72
  let inheritEvents;
38
73
  if (ctx.manifest.context_inheritance === "full") {
@@ -48,7 +83,11 @@ export class SubAgentOrchestrator {
48
83
  const { RuntimeRunner } = await import("./runner.js");
49
84
  const childRunner = new RuntimeRunner({
50
85
  ...ctx.parentOpts,
51
- executionPlane: filteredPlane,
86
+ // M1/G3: route to the node's hinted model (falls back to the parent provider).
87
+ provider: resolveProvider(ctx.parentOpts, ctx.spec.modelHint),
88
+ // M4/G5: cap the child run at the node's token budget (falls back to the inherited cap).
89
+ maxTotalTokens: ctx.spec.tokenBudget ?? ctx.parentOpts.maxTotalTokens,
90
+ executionPlane: execPlane,
52
91
  agentId: ctx.spec.identity.agentId,
53
92
  systemPrompt,
54
93
  sessionLog: ctx.sessionLog,
@@ -56,12 +95,22 @@ export class SubAgentOrchestrator {
56
95
  dreamStore: metaTools.has("memory") ? ctx.parentOpts.dreamStore : undefined,
57
96
  knowledgeSource: metaTools.has("knowledge") ? ctx.parentOpts.knowledgeSource : undefined,
58
97
  enablePlanTool: metaTools.has("update_plan") ? ctx.parentOpts.enablePlanTool : undefined,
98
+ // M5 v2.1: a workflow node's `start_workflow` flattens to the parent kernel (no nested pivot).
99
+ isWorkflowNode: ctx.isWorkflowNode,
59
100
  });
60
- yield* childRunner.run({
61
- sessionId: ctx.spec.identity.sessionId,
62
- goal: ctx.spec.goal,
63
- inheritEvents,
64
- });
101
+ // #2-B-ii: when the parent preempts this node (kernel `AgentPreempted`), interrupt the child —
102
+ // cancelling its in-flight LLM call. Handle an already-aborted signal too (creation race).
103
+ linkAbort(ctx.abortSignal, childRunner);
104
+ try {
105
+ yield* childRunner.run({
106
+ sessionId: ctx.spec.identity.sessionId,
107
+ goal: ctx.spec.goal,
108
+ inheritEvents,
109
+ });
110
+ }
111
+ finally {
112
+ await cleanupWorktree();
113
+ }
65
114
  }
66
115
  async run(ctx) {
67
116
  if (ctx.harness) {
@@ -70,25 +119,41 @@ export class SubAgentOrchestrator {
70
119
  const permitted = new Set(ctx.manifest.permitted_capability_ids ?? []);
71
120
  const metaTools = deriveMetaTools(permitted, ctx.parentOpts);
72
121
  const filteredPlane = new FilteredExecutionPlane(ctx.parentOpts.executionPlane, permitted, metaTools);
122
+ // M3/G4: worktree isolation for a worktree node (cleaned up in `finally` below).
123
+ const { plane: execPlane, cleanup: cleanupWorktree } = withWorktree(ctx, filteredPlane);
73
124
  const childRunner = new RuntimeRunner({
74
125
  ...ctx.parentOpts,
75
- executionPlane: filteredPlane,
126
+ // M1/G3: route to the node's hinted model (falls back to the parent provider).
127
+ provider: resolveProvider(ctx.parentOpts, ctx.spec.modelHint),
128
+ // M4/G5: cap the child run at the node's token budget (falls back to the inherited cap).
129
+ maxTotalTokens: ctx.spec.tokenBudget ?? ctx.parentOpts.maxTotalTokens,
130
+ executionPlane: execPlane,
76
131
  agentId: ctx.spec.identity.agentId,
77
132
  sessionLog: ctx.sessionLog,
78
133
  skillDir: metaTools.has("skill") ? ctx.parentOpts.skillDir : undefined,
79
134
  dreamStore: metaTools.has("memory") ? ctx.parentOpts.dreamStore : undefined,
80
135
  knowledgeSource: metaTools.has("knowledge") ? ctx.parentOpts.knowledgeSource : undefined,
81
136
  enablePlanTool: metaTools.has("update_plan") ? ctx.parentOpts.enablePlanTool : undefined,
137
+ // M5 v2.1: a workflow node's `start_workflow` flattens to the parent kernel (no nested pivot).
138
+ isWorkflowNode: ctx.isWorkflowNode,
82
139
  });
140
+ // #2-B-ii: parent preempt → interrupt the child (cancels its in-flight LLM call).
141
+ linkAbort(ctx.abortSignal, childRunner);
83
142
  const loop = new HarnessLoop(childRunner, ctx.harness.evalProvider, {
84
143
  maxAttempts: ctx.harness.maxAttempts ?? 3,
85
144
  });
86
- const outcome = await loop.run({
87
- goal: ctx.spec.goal,
88
- criteria: (ctx.spec.milestones?.phases.flatMap(p => p.criteria) ?? [])
89
- .filter((t) => typeof t === "string")
90
- .map(text => ({ text, required: true })),
91
- });
145
+ let outcome;
146
+ try {
147
+ outcome = await loop.run({
148
+ goal: ctx.spec.goal,
149
+ criteria: (ctx.spec.milestones?.phases.flatMap(p => p.criteria) ?? [])
150
+ .filter((t) => typeof t === "string")
151
+ .map(text => ({ text, required: true })),
152
+ });
153
+ }
154
+ finally {
155
+ await cleanupWorktree();
156
+ }
92
157
  return {
93
158
  agentId: ctx.spec.identity.agentId,
94
159
  result: {
@@ -0,0 +1,17 @@
1
+ /** Instruction appended to a loop node's goal: do the next increment, and signal when done. */
2
+ export declare function loopInstruction(maxIters: number): string;
3
+ /** Instruction appended to a classify node's goal: pick exactly one of the kernel's branch labels. */
4
+ export declare function classifyInstruction(labels: string[]): string;
5
+ /** Build a tournament judge's goal: the controller's criterion + the two candidates to compare. */
6
+ export declare function judgeGoal(criterion: string, leftOutput: string, rightOutput: string): string;
7
+ /** Extract a loop stop signal from a loop iteration's output. Returns the `loopContinue` value, or
8
+ * `undefined` when the agent gave no clear signal (⇒ the kernel runs the loop to `max_iters`).
9
+ * Accepts `{loop_continue: bool}` or, leniently, `{done: bool}` (continue = !done). */
10
+ export declare function extractLoopContinue(text: string): boolean | undefined;
11
+ /** Extract the chosen branch label from a classifier's output. Prefers `{branch: "..."}`; falls back
12
+ * to a bare label string that exactly matches one of the valid labels. Returns `undefined` when no
13
+ * recognizable choice was made (the kernel then prunes every branch — a safe "none matched"). */
14
+ export declare function extractClassifyBranch(text: string, labels: string[]): string | undefined;
15
+ /** Extract a tournament judge's verdict ("left" or "right"). Defaults to "left" when the verdict is
16
+ * unparseable, so the bracket always advances to a champion rather than stalling with no winner. */
17
+ export declare function extractJudgeWinner(text: string): "left" | "right";