@signalridge/pi-workflows 1.3.2 → 1.4.0

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.
package/src/runtime.ts CHANGED
@@ -24,6 +24,12 @@ import vm from "node:vm";
24
24
  import { parse } from "acorn";
25
25
  import { checkRuntimeBindings } from "./capabilities.js";
26
26
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
27
+ import {
28
+ executeOrchestration,
29
+ normalizeOrchestration,
30
+ type OrchestrationResult,
31
+ type OrchestrationTaskContext,
32
+ } from "./orchestrator.js";
27
33
  import { SharedStore } from "./shared-store.js";
28
34
 
29
35
  // ── Meta contract ────────────────────────────────────────────────────────────
@@ -59,6 +65,10 @@ export interface JournalEntry {
59
65
  hash: string;
60
66
  result: unknown;
61
67
  storeDelta?: Record<string, unknown>;
68
+ /** Generation for a nested workflow replay boundary. */
69
+ generation?: number;
70
+ /** Logical agents represented by a nested workflow replay boundary. */
71
+ agentCount?: number;
62
72
  }
63
73
 
64
74
  export interface AgentUsage {
@@ -72,11 +82,12 @@ export interface AgentUsage {
72
82
 
73
83
  export interface SharedRuntime {
74
84
  limiter: Limiter;
85
+ /** Logical calls, including replayed calls, used for reporting/labels. */
75
86
  agentCount: number;
87
+ /** Real dispatches only; replayed journal values do not consume maxAgents. */
88
+ physicalAgentCount: number;
76
89
  spent: number;
77
90
  tokenUsage: AgentUsage;
78
- depth: number;
79
- nestedCallSeq: number;
80
91
  runFatalController: AbortController;
81
92
  inFlight: Set<Promise<unknown>>;
82
93
  activeThreads: Set<string>;
@@ -102,6 +113,8 @@ export interface AgentRunOptions {
102
113
  cwd?: string;
103
114
  /** Named subagent type; resolved by pi-subagents at dispatch. */
104
115
  agentType?: string;
116
+ /** The runtime frame that owns this call (used to namespace nested workflows). */
117
+ runId?: string;
105
118
  /** Lexical call index — engine-side spawnKey identity (A4). */
106
119
  callIndex?: number;
107
120
  /** Stable hash of the call inputs — engine-side resume comparison (A4). */
@@ -145,7 +158,7 @@ export interface CheckpointOptions {
145
158
  }
146
159
 
147
160
  export interface WorkflowRuntimeEvent {
148
- type: "phase" | "workflow" | "quality" | "control-attempt";
161
+ type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
149
162
  [key: string]: unknown;
150
163
  }
151
164
 
@@ -154,6 +167,10 @@ export interface WorkflowRunOptions {
154
167
  agent?: WorkflowAgentRunner;
155
168
  /** The session's main model (provider/id), shown for default agents. */
156
169
  mainModel?: string;
170
+ /** Engine-owned execution nonce used to isolate stale nested managed calls. */
171
+ executionNonce?: string;
172
+ /** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
173
+ workflowDepth?: number;
157
174
  cwd?: string;
158
175
  concurrency?: number;
159
176
  agentRetries?: number;
@@ -167,6 +184,8 @@ export interface WorkflowRunOptions {
167
184
  toolset?: string;
168
185
  excludeTools?: string[];
169
186
  onAgentJournal?: (entry: JournalEntry) => void;
187
+ /** Journal one completed nested workflow as a stable parent call. */
188
+ onWorkflowJournal?: (entry: JournalEntry) => void;
170
189
  onRetrySpend?: (tokens: number) => void;
171
190
  sharedRuntime?: SharedRuntime;
172
191
  initialTokenUsage?: AgentUsage;
@@ -212,10 +231,16 @@ export interface RuntimeState {
212
231
  currentPhase?: string;
213
232
  phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
214
233
  callSeq: number;
234
+ /** Live generation counters for nested workflow replay boundaries. */
235
+ workflowGenerations: Map<number, number>;
215
236
  firstMiss: number;
216
237
  }
217
238
 
218
239
  export const MAX_AGENTS_PER_RUN = 1000;
240
+ /** Maximum journal-compatible lexical calls in one workflow frame (indices 0..999). */
241
+ export const MAX_CALLS_PER_WORKFLOW = 1_000;
242
+ /** Per-call item cap, matching the bounded batch contract used by reference engines. */
243
+ export const MAX_FANOUT_ITEMS = 4_096;
219
244
  export const MAX_CONCURRENCY = 16;
220
245
  export const MAX_AGENT_RETRIES = 3;
221
246
  export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
@@ -223,7 +248,55 @@ export const MAX_SCRIPT_SYNC_MS = 1_000;
223
248
 
224
249
  // ── Determinism guardrail ────────────────────────────────────────────────────
225
250
 
226
- const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
251
+ interface AstRecord {
252
+ readonly [key: string]: unknown;
253
+ }
254
+
255
+ function isAstRecord(value: unknown): value is AstRecord {
256
+ return typeof value === "object" && value !== null;
257
+ }
258
+
259
+ /** Detect actual nondeterministic expressions, not words inside user prompt strings. */
260
+ function containsNondeterminism(value: unknown, seen = new WeakSet<object>()): boolean {
261
+ if (Array.isArray(value)) return value.some((item) => containsNondeterminism(item, seen));
262
+ if (!isAstRecord(value)) return false;
263
+ if (seen.has(value)) return false;
264
+ seen.add(value);
265
+
266
+ const type = value.type;
267
+ if (type === "MemberExpression") {
268
+ const object = value.object;
269
+ const property = value.property;
270
+ const objectName =
271
+ isAstRecord(object) && object.type === "Identifier" && typeof object.name === "string" ? object.name : undefined;
272
+ // Leave computed properties to the in-realm stubs so the runtime still
273
+ // guards aliases such as Math["random"] while this precheck ignores text
274
+ // embedded in prompt literals.
275
+ const propertyName =
276
+ isAstRecord(property) && property.type === "Identifier" && typeof property.name === "string"
277
+ ? property.name
278
+ : undefined;
279
+ if ((objectName === "Date" && propertyName === "now") || (objectName === "Math" && propertyName === "random")) {
280
+ return true;
281
+ }
282
+ }
283
+ if (
284
+ type === "NewExpression" &&
285
+ isAstRecord(value.callee) &&
286
+ value.callee.type === "Identifier" &&
287
+ value.callee.name === "Date" &&
288
+ Array.isArray(value.arguments) &&
289
+ value.arguments.length === 0
290
+ ) {
291
+ return true;
292
+ }
293
+
294
+ for (const [key, child] of Object.entries(value)) {
295
+ if (key === "loc" || key === "start" || key === "end") continue;
296
+ if (containsNondeterminism(child, seen)) return true;
297
+ }
298
+ return false;
299
+ }
227
300
 
228
301
  /**
229
302
  * Run inside the vm realm BEFORE the user script. Neutered builtins would break
@@ -264,14 +337,6 @@ const DETERMINISM_PRELUDE = [
264
337
  * is rejected so the meta is fully static and deterministic.
265
338
  */
266
339
  export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body: string } {
267
- if (DETERMINISM_BLOCKLIST.test(script)) {
268
- throw new WorkflowError(
269
- "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
270
- WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
271
- { recoverable: false },
272
- );
273
- }
274
-
275
340
  const ast = parse(script, {
276
341
  ecmaVersion: "latest",
277
342
  sourceType: "module",
@@ -279,6 +344,13 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body:
279
344
  allowReturnOutsideFunction: true,
280
345
  ranges: false,
281
346
  });
347
+ if (containsNondeterminism(ast)) {
348
+ throw new WorkflowError(
349
+ "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
350
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
351
+ { recoverable: false },
352
+ );
353
+ }
282
354
 
283
355
  const first = ast.body?.[0] as { type?: string } | undefined;
284
356
  if (first?.type !== "ExportNamedDeclaration") {
@@ -477,6 +549,8 @@ function hashAgentCall(
477
549
  isolation: options.isolation ?? null,
478
550
  toolset: options.toolset ?? null,
479
551
  excludeTools: options.excludeTools ?? null,
552
+ timeoutMs: options.timeoutMs ?? null,
553
+ retries: options.retries ?? null,
480
554
  });
481
555
  }
482
556
 
@@ -526,30 +600,30 @@ function estimateTokens(value: unknown): number {
526
600
  return Math.ceil(JSON.stringify(value ?? "").length / 4);
527
601
  }
528
602
 
529
- function normalizeConcurrency(value: unknown): number {
603
+ export function normalizeConcurrency(value: unknown): number {
530
604
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
531
605
  return Math.min(MAX_CONCURRENCY, Math.floor(value));
532
606
  }
533
607
 
534
- function normalizeMaxAgents(value: unknown): number {
608
+ export function normalizeMaxAgents(value: unknown): number {
535
609
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return MAX_AGENTS_PER_RUN;
536
610
  return Math.min(MAX_AGENTS_PER_RUN, Math.floor(value));
537
611
  }
538
612
 
539
- function normalizeAgentTimeout(value: unknown): number | null {
613
+ export function normalizeAgentTimeout(value: unknown): number | null {
540
614
  if (value === null) return null;
541
615
  if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
542
616
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
543
617
  return Math.floor(value);
544
618
  }
545
619
 
546
- function normalizeTokenBudget(value: unknown): number | null {
620
+ export function normalizeTokenBudget(value: unknown): number | null {
547
621
  if (value === null || value === undefined) return null;
548
622
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return null;
549
623
  return Math.floor(value);
550
624
  }
551
625
 
552
- function normalizeAgentRetries(value: unknown): number {
626
+ export function normalizeAgentRetries(value: unknown): number {
553
627
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
554
628
  return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
555
629
  }
@@ -802,6 +876,7 @@ export interface WorkflowRuntimeBindings {
802
876
  items: unknown[],
803
877
  ...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
804
878
  ) => Promise<Array<unknown | null>>;
879
+ orchestrate: (tasks: unknown, options?: unknown) => Promise<OrchestrationResult>;
805
880
  workflow: (savedName: string, childArgs?: unknown) => Promise<unknown>;
806
881
  verify: (
807
882
  item: unknown,
@@ -840,6 +915,8 @@ export interface WorkflowRuntimeBindings {
840
915
 
841
916
  /** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
842
917
  const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
918
+ /** Task-local phase attribution for named orchestration callbacks. */
919
+ const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
843
920
 
844
921
  // ── The run ──────────────────────────────────────────────────────────────────
845
922
 
@@ -863,6 +940,7 @@ export async function runWorkflow<T = unknown>(
863
940
  currentPhase: meta.phases?.[0]?.title,
864
941
  phaseBudgets: new Map(),
865
942
  callSeq: 0,
943
+ workflowGenerations: new Map(),
866
944
  firstMiss: Number.POSITIVE_INFINITY,
867
945
  };
868
946
 
@@ -879,12 +957,11 @@ export async function runWorkflow<T = unknown>(
879
957
  const shared: SharedRuntime = options.sharedRuntime ?? {
880
958
  limiter: createLimiter(concurrency),
881
959
  agentCount: 0,
960
+ physicalAgentCount: 0,
882
961
  spent: options.initialTokenUsage?.total ?? 0,
883
962
  tokenUsage: options.initialTokenUsage
884
963
  ? { ...options.initialTokenUsage }
885
964
  : { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
886
- depth: 0,
887
- nestedCallSeq: 0,
888
965
  runFatalController: new AbortController(),
889
966
  inFlight: new Set<Promise<unknown>>(),
890
967
  activeThreads: new Set<string>(),
@@ -892,6 +969,9 @@ export async function runWorkflow<T = unknown>(
892
969
  const limiter = shared.limiter;
893
970
  const isTopLevelRun = !options.sharedRuntime;
894
971
  const store: SharedStore = options.sharedStore ?? new SharedStore();
972
+ let frameAgentCount = 0;
973
+ /** agent() promises owned by this runWorkflow frame only. */
974
+ const frameInFlight = new Set<Promise<unknown>>();
895
975
 
896
976
  const log = (message: unknown) => {
897
977
  const text = String(message);
@@ -899,6 +979,14 @@ export async function runWorkflow<T = unknown>(
899
979
  options.onLog?.(text);
900
980
  };
901
981
 
982
+ const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
983
+ try {
984
+ options.onRuntimeEvent?.(event);
985
+ } catch {
986
+ // Runtime observers are diagnostic surfaces and must not change execution.
987
+ }
988
+ };
989
+
902
990
  const phase = (title: string, phaseOptions?: { budget?: number }) => {
903
991
  state.currentPhase = title;
904
992
  if (!state.phases.includes(title)) state.phases.push(title);
@@ -908,7 +996,7 @@ export async function runWorkflow<T = unknown>(
908
996
  state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
909
997
  }
910
998
  options.onPhase?.(title);
911
- options.onRuntimeEvent?.({
999
+ emitRuntimeEvent({
912
1000
  type: "phase",
913
1001
  title,
914
1002
  budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
@@ -923,7 +1011,7 @@ export async function runWorkflow<T = unknown>(
923
1011
 
924
1012
  const agentLimitError = () =>
925
1013
  new WorkflowError(
926
- `Agent limit exceeded (${shared.agentCount}/${maxAgents}). Re-call workflow with resumeFromRunId="${runId}", the same script, and maxAgents: N (N>${maxAgents}) — journaled prefix replays free. /workflows resume alone cannot raise the cap.`,
1014
+ `Agent limit exceeded (${shared.physicalAgentCount}/${maxAgents} real dispatches). Re-call workflow with resumeFromRunId="${runId}", the same script, and maxAgents: N (N>${maxAgents}) — journaled prefix replays free. /workflows resume alone cannot raise the cap.`,
927
1015
  WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
928
1016
  { recoverable: false },
929
1017
  );
@@ -936,6 +1024,17 @@ export async function runWorkflow<T = unknown>(
936
1024
  }
937
1025
  };
938
1026
 
1027
+ const nextCallIndex = (): number => {
1028
+ if (state.callSeq >= MAX_CALLS_PER_WORKFLOW) {
1029
+ throw new WorkflowError(
1030
+ `Workflow call limit exceeded (${MAX_CALLS_PER_WORKFLOW} agent/workflow/checkpoint calls)`,
1031
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1032
+ { recoverable: false },
1033
+ );
1034
+ }
1035
+ return state.callSeq++;
1036
+ };
1037
+
939
1038
  const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
940
1039
  const rawThread = agentOptions.thread;
941
1040
  const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
@@ -962,11 +1061,16 @@ export async function runWorkflow<T = unknown>(
962
1061
  const normalizedOptions = thread && thread !== rawThread ? { ...agentOptions, thread } : agentOptions;
963
1062
  let call = agentImpl(promptText, normalizedOptions);
964
1063
  if (thread) call = call.finally(() => shared.activeThreads.delete(thread));
965
- // Track every call (awaited or not) so the top-level run can drain
966
- // outstanding calls before completing — a forgotten `await` cannot let an
967
- // agent mutate state after the run is torn down.
1064
+ // Track every call (awaited or not) both globally and in its owning frame.
1065
+ // A nested workflow drains only its own forgotten calls before exposing its
1066
+ // result/journal boundary; the top-level frame still drains all shared work.
968
1067
  shared.inFlight.add(call);
969
- call.catch(() => {}).finally(() => shared.inFlight.delete(call));
1068
+ frameInFlight.add(call);
1069
+ const removeSettledCall = () => {
1070
+ shared.inFlight.delete(call);
1071
+ frameInFlight.delete(call);
1072
+ };
1073
+ void call.then(removeSettledCall, removeSettledCall);
970
1074
  return call;
971
1075
  };
972
1076
 
@@ -977,40 +1081,8 @@ export async function runWorkflow<T = unknown>(
977
1081
  // synchronously, before suspending on the limiter below.
978
1082
  const batch = fanoutScope.getStore();
979
1083
 
980
- // Check agent limit. A fan-out that overshoots the cap has already reserved
981
- // and queued up to `maxAgents` agents; the breaching call throws here, and
982
- // parallel()/pipeline() mark their own batch cancelled so the already-queued
983
- // agents short-circuit before their real API call (see the limiter body).
984
- if (shared.agentCount >= maxAgents) {
985
- throw agentLimitError();
986
- }
987
-
988
- if (budget.total !== null && budget.remaining() <= 0) {
989
- throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
990
- recoverable: false,
991
- });
992
- }
993
-
994
- const assignedPhase = agentOptions.phase ?? state.currentPhase;
995
-
996
- // Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
997
- if (assignedPhase) {
998
- const pb = state.phaseBudgets.get(assignedPhase);
999
- if (pb) {
1000
- const phaseSpent = shared.spent - pb.startSpent;
1001
- if (phaseSpent >= pb.budget) {
1002
- throw new WorkflowError(
1003
- `phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
1004
- WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
1005
- { recoverable: false },
1006
- );
1007
- }
1008
- if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
1009
- pb.warned = true;
1010
- log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
1011
- }
1012
- }
1013
- }
1084
+ const taskScope = orchestrationScope.getStore();
1085
+ const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
1014
1086
 
1015
1087
  const requestedLabel = agentOptions.label?.trim();
1016
1088
  // agentType resolution lives in pi-subagents; the name is the observable key.
@@ -1026,26 +1098,25 @@ export async function runWorkflow<T = unknown>(
1026
1098
  const modelSpec = agentOptions.model ?? (agentOptions.tier === undefined ? phaseModel : undefined);
1027
1099
 
1028
1100
  // Deterministic resume key: assigned at lexical call time, before the limiter.
1029
- const callIndex = state.callSeq++;
1101
+ const callIndex = nextCallIndex();
1030
1102
  const callHash = hashAgentCall(promptText, modelSpec, assignedPhase, agentOptions, agentOptions.agentType ?? null);
1031
1103
  // Store delta key: runId-namespaced so a nested workflow() call's
1032
1104
  // callIndex-0 can never collide with the parent's (see SharedStore doc).
1033
1105
  const deltaKey = `${runId}:${callIndex}`;
1034
1106
 
1035
- // Reserve the agent slot synchronously — atomic with the limit/budget gate
1036
- // above (no await in between) so a parallel() fan-out cannot all observe the
1037
- // same agentCount and overshoot maxAgents.
1038
- shared.agentCount++;
1039
- const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount);
1107
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
1040
1108
 
1041
1109
  // Longest-unchanged-prefix resume: replay a cached result only while the
1042
1110
  // prefix is still intact. Once any call misses, it AND everything after it
1043
1111
  // run live, so an edited upstream call never leaves stale downstream
1044
- // results served from the journal.
1112
+ // results served from the journal. Replay is logical work, not a real
1113
+ // dispatch: it consumes neither the agent cap nor token/phase budget.
1045
1114
  const cached = agentOptions.thread ? undefined : options.resumeJournal?.get(deltaKey);
1046
1115
  const hashMatches = cached != null && cached.hash === callHash;
1047
1116
  const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
1048
1117
  if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
1118
+ shared.agentCount++;
1119
+ frameAgentCount++;
1049
1120
  options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
1050
1121
  options.onAgentEnd?.({
1051
1122
  id: deltaKey,
@@ -1060,6 +1131,36 @@ export async function runWorkflow<T = unknown>(
1060
1131
  if (!hashMatches || cachedEmptyOutput || agentOptions.thread)
1061
1132
  state.firstMiss = Math.min(state.firstMiss, callIndex);
1062
1133
 
1134
+ // Reserve a real dispatch synchronously — no await occurs between this
1135
+ // check and the increments, so a parallel() burst cannot overshoot the cap.
1136
+ if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
1137
+ if (budget.total !== null && budget.remaining() <= 0) {
1138
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
1139
+ recoverable: false,
1140
+ });
1141
+ }
1142
+ // Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
1143
+ if (assignedPhase) {
1144
+ const pb = state.phaseBudgets.get(assignedPhase);
1145
+ if (pb) {
1146
+ const phaseSpent = shared.spent - pb.startSpent;
1147
+ if (phaseSpent >= pb.budget) {
1148
+ throw new WorkflowError(
1149
+ `phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
1150
+ WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
1151
+ { recoverable: false },
1152
+ );
1153
+ }
1154
+ if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
1155
+ pb.warned = true;
1156
+ log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
1157
+ }
1158
+ }
1159
+ }
1160
+ shared.agentCount++;
1161
+ frameAgentCount++;
1162
+ shared.physicalAgentCount++;
1163
+
1063
1164
  return limiter(async () => {
1064
1165
  const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
1065
1166
  const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
@@ -1097,6 +1198,7 @@ export async function runWorkflow<T = unknown>(
1097
1198
  schema !== undefined
1098
1199
  ? `\n\nReply with a single raw JSON value (object or array) matching exactly this JSON Schema:\n${JSON.stringify(schema)}\nDo not wrap it in prose or markdown fences.`
1099
1200
  : "";
1201
+ let repairFeedback = "";
1100
1202
 
1101
1203
  try {
1102
1204
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -1128,7 +1230,7 @@ export async function runWorkflow<T = unknown>(
1128
1230
  );
1129
1231
  }
1130
1232
 
1131
- const runPromise = agentRunner.run(promptText + schemaInstruction, {
1233
+ const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
1132
1234
  label,
1133
1235
  sessionName: agentOptions.thread
1134
1236
  ? `workflow:${runId} thread:${agentOptions.thread}`
@@ -1142,6 +1244,7 @@ export async function runWorkflow<T = unknown>(
1142
1244
  isolation: resolvedIsolation,
1143
1245
  toolset: options.toolset ?? agentOptions.toolset,
1144
1246
  excludeTools: options.excludeTools ?? agentOptions.excludeTools,
1247
+ runId,
1145
1248
  callIndex,
1146
1249
  callHash,
1147
1250
  attempt,
@@ -1186,7 +1289,9 @@ export async function runWorkflow<T = unknown>(
1186
1289
  if (errors.length > 0) {
1187
1290
  const detail = errors.join("; ");
1188
1291
  if (attempt < maxAttempts) {
1189
- // Repair: re-dispatch with the validation errors as feedback.
1292
+ // Repair: re-dispatch with the concrete validation errors so
1293
+ // the next model attempt can correct the actual failure.
1294
+ repairFeedback = `\n\nThe previous reply was invalid. Correct these validation errors and return only the corrected JSON value:\n${detail.slice(0, 4_000)}`;
1190
1295
  throw new WorkflowError(
1191
1296
  `Subagent reply did not match the schema: ${detail}`,
1192
1297
  WorkflowErrorCode.AGENT_EXECUTION_ERROR,
@@ -1275,31 +1380,45 @@ export async function runWorkflow<T = unknown>(
1275
1380
  const parallel = async (thunks: Array<() => Promise<unknown>>) => {
1276
1381
  throwIfAborted();
1277
1382
  if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
1383
+ if (thunks.length > MAX_FANOUT_ITEMS) {
1384
+ throw new WorkflowError(
1385
+ `parallel() accepts at most ${MAX_FANOUT_ITEMS} items`,
1386
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1387
+ { recoverable: false },
1388
+ );
1389
+ }
1278
1390
  if (thunks.some((thunk) => typeof thunk !== "function")) {
1279
1391
  throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
1280
1392
  }
1281
1393
  // Batch-scoped cancellation: a breach in THIS fan-out flips `cancelled` so
1282
1394
  // its own still-queued agents bail, without touching a sibling or enclosing
1283
- // fan-out.
1395
+ // fan-out. Await all wrappers before surfacing a fatal error so the barrier
1396
+ // never returns while a sibling is still mutating the run.
1284
1397
  const batch = { cancelled: false };
1285
- return fanoutScope.run(batch, () =>
1398
+ const outcomes = await fanoutScope.run(batch, () =>
1286
1399
  Promise.all(
1287
- thunks.map(async (thunk, index) => {
1400
+ thunks.map(async (thunk, index): Promise<{ value: unknown } | { fatal: unknown }> => {
1288
1401
  try {
1289
- return await thunk();
1402
+ return { value: await thunk() };
1290
1403
  } catch (error) {
1291
- if (isAborted()) throw error;
1292
1404
  const workflowError = wrapError(error);
1293
- if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1405
+ if (
1406
+ isAborted() ||
1407
+ !workflowError.recoverable ||
1408
+ workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
1409
+ ) {
1294
1410
  if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
1295
- throw workflowError;
1411
+ return { fatal: workflowError };
1296
1412
  }
1297
1413
  log(`parallel[${index}] failed: ${workflowError.message}`);
1298
- return null;
1414
+ return { value: null };
1299
1415
  }
1300
1416
  }),
1301
1417
  ),
1302
1418
  );
1419
+ const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
1420
+ if (fatal) throw fatal.fatal;
1421
+ return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
1303
1422
  };
1304
1423
 
1305
1424
  const pipeline = async (
@@ -1308,13 +1427,20 @@ export async function runWorkflow<T = unknown>(
1308
1427
  ) => {
1309
1428
  throwIfAborted();
1310
1429
  if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
1430
+ if (items.length > MAX_FANOUT_ITEMS) {
1431
+ throw new WorkflowError(
1432
+ `pipeline() accepts at most ${MAX_FANOUT_ITEMS} items`,
1433
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1434
+ { recoverable: false },
1435
+ );
1436
+ }
1311
1437
  if (stages.some((stage) => typeof stage !== "function")) {
1312
1438
  throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
1313
1439
  }
1314
1440
  const batch = { cancelled: false };
1315
- return fanoutScope.run(batch, () =>
1441
+ const outcomes = await fanoutScope.run(batch, () =>
1316
1442
  Promise.all(
1317
- items.map(async (item, index) => {
1443
+ items.map(async (item, index): Promise<{ value: unknown } | { fatal: unknown }> => {
1318
1444
  let value: unknown = item;
1319
1445
  for (const stage of stages) {
1320
1446
  try {
@@ -1322,58 +1448,138 @@ export async function runWorkflow<T = unknown>(
1322
1448
  value = await stage(value, item, index);
1323
1449
  throwIfAborted();
1324
1450
  } catch (error) {
1325
- if (isAborted()) throw error;
1326
1451
  const workflowError = wrapError(error);
1327
- if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1452
+ if (
1453
+ isAborted() ||
1454
+ !workflowError.recoverable ||
1455
+ workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
1456
+ ) {
1328
1457
  if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
1329
- throw workflowError;
1458
+ return { fatal: workflowError };
1330
1459
  }
1331
1460
  log(`pipeline[${index}] failed: ${workflowError.message}`);
1332
- return null;
1461
+ return { value: null };
1333
1462
  }
1334
1463
  }
1335
- return value;
1464
+ return { value };
1336
1465
  }),
1337
1466
  ),
1338
1467
  );
1468
+ const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
1469
+ if (fatal) throw fatal.fatal;
1470
+ return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
1471
+ };
1472
+
1473
+ /**
1474
+ * Run a named dependency graph in deterministic declaration-order layers.
1475
+ * The graph is deliberately a runtime primitive rather than a second top-level
1476
+ * engine: agent/session policy still belongs to the enclosing workflow and
1477
+ * pi-subagents, while this helper owns only task dependencies and results.
1478
+ */
1479
+ const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
1480
+ throwIfAborted();
1481
+ const normalized = normalizeOrchestration(rawTasks, rawOptions);
1482
+ const tasks = normalized.tasks.map((task) => ({
1483
+ ...task,
1484
+ run: (context: OrchestrationTaskContext) =>
1485
+ orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
1486
+ task.run(context),
1487
+ ),
1488
+ }));
1489
+ return executeOrchestration(tasks, normalized.options, {
1490
+ throwIfAborted,
1491
+ isFatal: (error: unknown) => {
1492
+ if (isAborted()) return true;
1493
+ const workflowError = wrapError(error);
1494
+ return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
1495
+ },
1496
+ formatError: (error: unknown) => wrapError(error).message,
1497
+ onEvent: emitRuntimeEvent,
1498
+ });
1339
1499
  };
1340
1500
 
1341
1501
  // Nested workflow(): run a saved workflow (or raw script) inline, sharing this
1342
- // run's limiter/counters/budget. One level deep only.
1502
+ // run's limiter/counters/budget. One level deep only. The whole child result is
1503
+ // journaled as one stable parent call so resume does not silently re-spend every
1504
+ // nested agent when the parent prefix is unchanged.
1343
1505
  const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
1344
1506
  throwIfAborted();
1345
- if (shared.depth >= 1) {
1507
+ const workflowDepth = options.workflowDepth ?? 0;
1508
+ if (workflowDepth >= 1) {
1346
1509
  throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
1347
1510
  recoverable: false,
1348
1511
  });
1349
1512
  }
1513
+ const callIndex = nextCallIndex();
1350
1514
  const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
1351
1515
  const childScript = resolved ?? String(nameOrScript);
1352
1516
  const workflowName = String(nameOrScript);
1353
- options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
1354
- shared.depth++;
1517
+ const argsPresent = childArgs !== undefined;
1518
+ const callHash = hashIdentity({
1519
+ kind: "workflow",
1520
+ name: workflowName,
1521
+ script: childScript,
1522
+ argsPresent,
1523
+ args: argsPresent ? childArgs : null,
1524
+ });
1525
+ const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
1526
+ if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
1527
+ state.workflowGenerations.set(callIndex, cached.generation ?? 1);
1528
+ if (cached.storeDelta) store.applyDelta(cached.storeDelta);
1529
+ const replayedAgentCount = cached.agentCount ?? 0;
1530
+ shared.agentCount += replayedAgentCount;
1531
+ frameAgentCount += replayedAgentCount;
1532
+ return cached.result;
1533
+ }
1534
+ if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
1535
+ const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
1536
+ state.workflowGenerations.set(callIndex, generation);
1537
+ emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
1355
1538
  try {
1356
- // Propagate the resume journal into the child frame ONLY while the
1357
- // parent's own prefix is still intact (state.firstMiss === Infinity).
1358
- // SharedStore content is not part of any call's hash, so a cached child
1359
- // result computed against OLD store state would be stale once an upstream
1360
- // parent call misses and re-runs live with different store writes.
1361
- const prefixIntact = state.firstMiss === Number.POSITIVE_INFINITY;
1539
+ const childDeltas = new Map<number, Record<string, unknown>>();
1362
1540
  const child = await runWorkflow(childScript, {
1363
1541
  ...options,
1364
1542
  args: childArgs,
1365
1543
  sharedRuntime: shared,
1366
1544
  sharedStore: store,
1367
- resumeJournal: prefixIntact ? options.resumeJournal : undefined,
1545
+ workflowDepth: workflowDepth + 1,
1546
+ // The parent call caches the complete child value. Replaying child
1547
+ // call results here would duplicate nested calls and make the synthetic
1548
+ // parent journal entry ambiguous.
1549
+ resumeJournal: undefined,
1368
1550
  resumeFromRunId: undefined,
1369
- // shared.nestedCallSeq, not depth: depth returns to 0 between sequential
1370
- // sibling calls, which would otherwise mint colliding child runIds.
1371
- runId: `${runId}-nested${++shared.nestedCallSeq}`,
1551
+ onWorkflowJournal: undefined,
1552
+ onAgentJournal: (entry) => {
1553
+ if (entry.storeDelta) childDeltas.set(entry.index, entry.storeDelta);
1554
+ // Preserve nested lifecycle visibility for direct runtime consumers;
1555
+ // the engine's top-level agent journal intentionally ignores child ids.
1556
+ options.onAgentJournal?.(entry);
1557
+ },
1558
+ // The call index + generation keeps changed nested calls distinct across
1559
+ // resume. The engine nonce additionally fences an interrupted child whose
1560
+ // result was never durable before a process/branch restart.
1561
+ runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
1372
1562
  });
1373
- return child.result;
1563
+ const normalizedChildResult = child.result === undefined ? null : child.result;
1564
+ const storeDelta: Record<string, unknown> = {};
1565
+ for (const delta of [...childDeltas.entries()].sort(([a], [b]) => a - b).map(([, value]) => value)) {
1566
+ Object.assign(storeDelta, delta);
1567
+ }
1568
+ // Do not append a nested result after pause/stop has claimed the parent;
1569
+ // terminal recovery must remain the last durable fact for that execution.
1570
+ throwIfAborted();
1571
+ options.onWorkflowJournal?.({
1572
+ index: callIndex,
1573
+ runId,
1574
+ hash: callHash,
1575
+ result: normalizedChildResult,
1576
+ generation,
1577
+ agentCount: child.agentCount,
1578
+ ...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
1579
+ });
1580
+ return normalizedChildResult;
1374
1581
  } finally {
1375
- shared.depth--;
1376
- options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
1582
+ emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
1377
1583
  }
1378
1584
  };
1379
1585
 
@@ -1393,7 +1599,7 @@ export async function runWorkflow<T = unknown>(
1393
1599
  total: number;
1394
1600
  votes: Array<{ real: boolean; reason?: string }>;
1395
1601
  }> => {
1396
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
1602
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
1397
1603
  const reviewers = Math.max(1, opts.reviewers ?? 2);
1398
1604
  const threshold = opts.threshold ?? 0.5;
1399
1605
  const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
@@ -1430,9 +1636,9 @@ export async function runWorkflow<T = unknown>(
1430
1636
  ): Promise<
1431
1637
  { index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
1432
1638
  > => {
1433
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
1639
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
1434
1640
  if (!Array.isArray(attempts) || attempts.length === 0) {
1435
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1641
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1436
1642
  return undefined;
1437
1643
  }
1438
1644
  const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
@@ -1473,12 +1679,12 @@ export async function runWorkflow<T = unknown>(
1473
1679
  judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
1474
1680
  }));
1475
1681
  if (normalized.length === 0) {
1476
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1682
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1477
1683
  return undefined;
1478
1684
  }
1479
1685
  let best = normalized[0];
1480
1686
  for (const s of normalized) if (s.score > best.score || (s.score === best.score && s.index < best.index)) best = s;
1481
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1687
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1482
1688
  return best;
1483
1689
  };
1484
1690
 
@@ -1530,12 +1736,12 @@ export async function runWorkflow<T = unknown>(
1530
1736
  taskArgs: unknown,
1531
1737
  results: unknown,
1532
1738
  ): Promise<{ complete: boolean; missing?: string[] } | null> => {
1533
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
1739
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
1534
1740
  const verdict = await agent(
1535
1741
  `Given the task and the results gathered so far, list what is still MISSING (modalities not covered, claims unverified, gaps). Be specific and concise.\n\nTask:\n${JSON.stringify(taskArgs)}\n\nResults so far:\n${JSON.stringify(results).slice(0, 4000)}`,
1536
1742
  { label: "completeness critic", schema: COMPLETENESS_SCHEMA },
1537
1743
  );
1538
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
1744
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
1539
1745
  return verdict as { complete: boolean; missing?: string[] } | null;
1540
1746
  };
1541
1747
 
@@ -1549,7 +1755,7 @@ export async function runWorkflow<T = unknown>(
1549
1755
  throwIfAborted();
1550
1756
  last = await thunk(i);
1551
1757
  const accepted = !opts.until || opts.until(last);
1552
- options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
1758
+ emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
1553
1759
  if (accepted) return last;
1554
1760
  }
1555
1761
  return last;
@@ -1568,7 +1774,7 @@ export async function runWorkflow<T = unknown>(
1568
1774
  last = await thunk(feedback, i);
1569
1775
  const verdict = await validator(last);
1570
1776
  const accepted = Boolean(verdict?.ok);
1571
- options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
1777
+ emitRuntimeEvent({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
1572
1778
  if (accepted) return { ok: true, value: last, attempts: i + 1 };
1573
1779
  feedback = verdict?.feedback;
1574
1780
  }
@@ -1581,19 +1787,20 @@ export async function runWorkflow<T = unknown>(
1581
1787
  const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
1582
1788
  throwIfAborted();
1583
1789
  if (typeof promptText !== "string") throw new TypeError("checkpoint(promptText, options?) needs a prompt string");
1584
- if (shared.agentCount >= maxAgents) {
1585
- throw agentLimitError();
1586
- }
1587
- const callIndex = state.callSeq++;
1790
+ const callIndex = nextCallIndex();
1588
1791
  const callHash = hashCheckpoint(promptText, checkpointOptions);
1589
1792
  const journalKey = `${runId}:${callIndex}`;
1590
1793
  const cached = options.resumeJournal?.get(journalKey);
1591
1794
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
1592
1795
  shared.agentCount++;
1593
- return cached.result; // replay the journaled human reply
1796
+ frameAgentCount++;
1797
+ return cached.result; // replay the journaled human reply without spending a slot
1594
1798
  }
1595
1799
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
1800
+ if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
1596
1801
  shared.agentCount++;
1802
+ frameAgentCount++;
1803
+ shared.physicalAgentCount++;
1597
1804
 
1598
1805
  let reply: unknown;
1599
1806
  if (options.confirm) {
@@ -1616,6 +1823,7 @@ export async function runWorkflow<T = unknown>(
1616
1823
  agent,
1617
1824
  parallel,
1618
1825
  pipeline,
1826
+ orchestrate,
1619
1827
  workflow: workflowFn,
1620
1828
  verify,
1621
1829
  judgePanel,
@@ -1681,7 +1889,7 @@ export async function runWorkflow<T = unknown>(
1681
1889
  result: result as T,
1682
1890
  logs: state.logs,
1683
1891
  phases: state.phases,
1684
- agentCount: shared.agentCount,
1892
+ agentCount: isTopLevelRun ? shared.agentCount : frameAgentCount,
1685
1893
  durationMs: Date.now() - started,
1686
1894
  runId,
1687
1895
  tokenUsage: shared.tokenUsage,
@@ -1695,14 +1903,15 @@ export async function runWorkflow<T = unknown>(
1695
1903
  if (isTopLevelRun) shared.runFatalController.abort();
1696
1904
  throw error;
1697
1905
  } finally {
1698
- if (isTopLevelRun) {
1699
- if (shared.inFlight.size > 0) {
1700
- log(`waiting for ${shared.inFlight.size} outstanding agent() call(s) to settle before this run completes`);
1701
- }
1702
- while (shared.inFlight.size > 0) {
1703
- await Promise.allSettled(Array.from(shared.inFlight));
1704
- }
1705
- store.dispose();
1906
+ const ownedInFlight = isTopLevelRun ? shared.inFlight : frameInFlight;
1907
+ if (ownedInFlight.size > 0) {
1908
+ log(
1909
+ `waiting for ${ownedInFlight.size} outstanding agent() call(s) to settle before this workflow frame completes`,
1910
+ );
1911
+ }
1912
+ while (ownedInFlight.size > 0) {
1913
+ await Promise.allSettled(Array.from(ownedInFlight));
1706
1914
  }
1915
+ if (isTopLevelRun) store.dispose();
1707
1916
  }
1708
1917
  }