@signalridge/pi-workflows 1.3.2 → 1.5.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
@@ -21,9 +21,17 @@
21
21
  import { AsyncLocalStorage } from "node:async_hooks";
22
22
  import { createHash } from "node:crypto";
23
23
  import vm from "node:vm";
24
+ import type { ManagedRoutingPolicy } from "@signalridge/pi-subagents-protocol";
25
+ import { agentTierPolicyIdentity, isManagedAgentTier } from "@signalridge/pi-subagents-protocol";
24
26
  import { parse } from "acorn";
25
27
  import { checkRuntimeBindings } from "./capabilities.js";
26
28
  import { WorkflowError, WorkflowErrorCode, wrapError } from "./errors.js";
29
+ import {
30
+ executeOrchestration,
31
+ normalizeOrchestration,
32
+ type OrchestrationResult,
33
+ type OrchestrationTaskContext,
34
+ } from "./orchestrator.js";
27
35
  import { SharedStore } from "./shared-store.js";
28
36
 
29
37
  // ── Meta contract ────────────────────────────────────────────────────────────
@@ -31,24 +39,12 @@ import { SharedStore } from "./shared-store.js";
31
39
  export interface WorkflowMetaPhase {
32
40
  title: string;
33
41
  detail?: string;
34
- /**
35
- * Phase-level model preference — currently validated but not routed.
36
- * Per-call model routing requires a protocol extension (see README
37
- * "Intentional divergences"); use agent({ tier }) instead.
38
- */
39
- model?: string;
40
42
  }
41
43
 
42
44
  export interface WorkflowMeta {
43
45
  name: string;
44
46
  description: string;
45
47
  phases?: WorkflowMetaPhase[];
46
- /**
47
- * Run-level model preference — currently validated but not routed.
48
- * Per-call model routing requires a protocol extension (see README
49
- * "Intentional divergences"); use agent({ tier }) instead.
50
- */
51
- model?: string;
52
48
  }
53
49
 
54
50
  /** A resolved, journaled agent() call. */
@@ -59,6 +55,12 @@ export interface JournalEntry {
59
55
  hash: string;
60
56
  result: unknown;
61
57
  storeDelta?: Record<string, unknown>;
58
+ /** Agent tier this call resolved to, as the host reported it. */
59
+ tier?: string;
60
+ /** Generation for a nested workflow replay boundary. */
61
+ generation?: number;
62
+ /** Logical agents represented by a nested workflow replay boundary. */
63
+ agentCount?: number;
62
64
  }
63
65
 
64
66
  export interface AgentUsage {
@@ -72,11 +74,12 @@ export interface AgentUsage {
72
74
 
73
75
  export interface SharedRuntime {
74
76
  limiter: Limiter;
77
+ /** Logical calls, including replayed calls, used for reporting/labels. */
75
78
  agentCount: number;
79
+ /** Real dispatches only; replayed journal values do not consume maxAgents. */
80
+ physicalAgentCount: number;
76
81
  spent: number;
77
82
  tokenUsage: AgentUsage;
78
- depth: number;
79
- nestedCallSeq: number;
80
83
  runFatalController: AbortController;
81
84
  inFlight: Set<Promise<unknown>>;
82
85
  activeThreads: Set<string>;
@@ -97,11 +100,12 @@ export interface AgentRunOptions {
97
100
  schema?: unknown;
98
101
  signal?: AbortSignal;
99
102
  instructions?: string;
100
- model?: string;
101
103
  tier?: string;
102
104
  cwd?: string;
103
105
  /** Named subagent type; resolved by pi-subagents at dispatch. */
104
106
  agentType?: string;
107
+ /** The runtime frame that owns this call (used to namespace nested workflows). */
108
+ runId?: string;
105
109
  /** Lexical call index — engine-side spawnKey identity (A4). */
106
110
  callIndex?: number;
107
111
  /** Stable hash of the call inputs — engine-side resume comparison (A4). */
@@ -116,7 +120,14 @@ export interface AgentRunOptions {
116
120
  /** Additional tool names denied for this call. */
117
121
  excludeTools?: string[];
118
122
  onModelResolved?: (id: string) => void;
119
- onModelFallback?: (info: { tier: string; requestedSpec: string }) => void;
123
+ /**
124
+ * The tier the host actually selected, which may be one this call did not
125
+ * name — an agent's own tier, or the host's default. A callback rather than a
126
+ * mutation of `tier`: the dispatcher does not own this object, and reading a
127
+ * reply back out of an input field would only work while every layer happened
128
+ * to preserve its identity.
129
+ */
130
+ onTierResolved?: (tier: string) => void;
120
131
  onUsage?: (usage: AgentUsage) => void;
121
132
  onHistory?: (history: unknown[]) => void;
122
133
  }
@@ -125,7 +136,12 @@ export interface AgentOptions {
125
136
  label?: string;
126
137
  phase?: string;
127
138
  schema?: unknown;
128
- model?: string;
139
+ /**
140
+ * Agent tier: a key from the host's `agentTiers` catalogue. This is the only
141
+ * model control a workflow has — there is no per-call model or thinking, so
142
+ * a script cannot pin a vendor the host did not choose. Omitting it uses the
143
+ * agent's own tier, else the host's configured default.
144
+ */
129
145
  tier?: string;
130
146
  isolation?: "worktree";
131
147
  agentType?: string;
@@ -145,7 +161,7 @@ export interface CheckpointOptions {
145
161
  }
146
162
 
147
163
  export interface WorkflowRuntimeEvent {
148
- type: "phase" | "workflow" | "quality" | "control-attempt";
164
+ type: "phase" | "workflow" | "quality" | "control-attempt" | "task";
149
165
  [key: string]: unknown;
150
166
  }
151
167
 
@@ -154,6 +170,31 @@ export interface WorkflowRunOptions {
154
170
  agent?: WorkflowAgentRunner;
155
171
  /** The session's main model (provider/id), shown for default agents. */
156
172
  mainModel?: string;
173
+ /** The host's Agent-tier catalogue, captured fresh for this start/resume. */
174
+ routingPolicy?: ManagedRoutingPolicy;
175
+ /**
176
+ * This script ships with the package rather than being written here.
177
+ *
178
+ * The tier catalogue belongs to the user, names included. A script we ship
179
+ * cannot assert that `low` exists on someone whose tiers are called
180
+ * `cheap`/`deep`, so its tier is a preference: an undefined one falls back to
181
+ * the host's default with a warning. A script the user wrote names tiers from
182
+ * the user's own catalogue, so an undefined one there is a typo and fails
183
+ * closed rather than quietly running a different model.
184
+ */
185
+ shippedScript?: boolean;
186
+ /**
187
+ * Whole-catalogue identity, used wherever a per-tier one would be dishonest:
188
+ * a nested `workflow()` boundary, where the parent caches one value for a
189
+ * child whose per-call tiers it cannot see, and an `agent()` call that names
190
+ * no tier, whose real tier may come from the agent's frontmatter. A call that
191
+ * names its tier keys on that tier's policy instead.
192
+ */
193
+ routingPolicyFingerprint?: string;
194
+ /** Engine-owned execution nonce used to isolate stale nested managed calls. */
195
+ executionNonce?: string;
196
+ /** Runtime frame depth; siblings inherit the same depth and may run concurrently. */
197
+ workflowDepth?: number;
157
198
  cwd?: string;
158
199
  concurrency?: number;
159
200
  agentRetries?: number;
@@ -167,20 +208,35 @@ export interface WorkflowRunOptions {
167
208
  toolset?: string;
168
209
  excludeTools?: string[];
169
210
  onAgentJournal?: (entry: JournalEntry) => void;
211
+ /** Journal one completed nested workflow as a stable parent call. */
212
+ onWorkflowJournal?: (entry: JournalEntry) => void;
170
213
  onRetrySpend?: (tokens: number) => void;
171
214
  sharedRuntime?: SharedRuntime;
172
215
  initialTokenUsage?: AgentUsage;
173
216
  sharedStore?: SharedStore;
174
- loadSavedWorkflow?: (name: string) => string | undefined;
217
+ /**
218
+ * Resolve a nested `workflow(name)` reference, reporting whether the script
219
+ * it returned ships with this package. Shipped-ness travels with the script,
220
+ * not with the frame that called it — see `shippedScript`.
221
+ */
222
+ loadSavedWorkflow?: (name: string) => { script: string; shippedScript?: boolean } | undefined;
175
223
  confirm?: (promptText: string, options: CheckpointOptions) => Promise<unknown>;
176
224
  onLog?: (message: string) => void;
177
225
  onPhase?: (title: string) => void;
178
226
  onRuntimeEvent?: (event: WorkflowRuntimeEvent) => void;
179
- onAgentStart?: (event: { id: string; label: string; phase?: string; prompt: string; model?: string }) => void;
227
+ onAgentStart?: (event: {
228
+ id: string;
229
+ label: string;
230
+ phase?: string;
231
+ prompt: string;
232
+ model?: string;
233
+ tier?: string;
234
+ }) => void;
180
235
  onAgentEnd?: (event: {
181
236
  id: string;
182
237
  label: string;
183
238
  phase?: string;
239
+ tier?: string;
184
240
  result: unknown;
185
241
  tokens?: number;
186
242
  tokenUsage?: AgentUsage;
@@ -212,10 +268,16 @@ export interface RuntimeState {
212
268
  currentPhase?: string;
213
269
  phaseBudgets: Map<string, { budget: number; startSpent: number; warned: boolean }>;
214
270
  callSeq: number;
271
+ /** Live generation counters for nested workflow replay boundaries. */
272
+ workflowGenerations: Map<number, number>;
215
273
  firstMiss: number;
216
274
  }
217
275
 
218
276
  export const MAX_AGENTS_PER_RUN = 1000;
277
+ /** Maximum journal-compatible lexical calls in one workflow frame (indices 0..999). */
278
+ export const MAX_CALLS_PER_WORKFLOW = 1_000;
279
+ /** Per-call item cap, matching the bounded batch contract used by reference engines. */
280
+ export const MAX_FANOUT_ITEMS = 4_096;
219
281
  export const MAX_CONCURRENCY = 16;
220
282
  export const MAX_AGENT_RETRIES = 3;
221
283
  export const DEFAULT_AGENT_TIMEOUT_MS: number | null = 300_000;
@@ -223,7 +285,55 @@ export const MAX_SCRIPT_SYNC_MS = 1_000;
223
285
 
224
286
  // ── Determinism guardrail ────────────────────────────────────────────────────
225
287
 
226
- const DETERMINISM_BLOCKLIST = /\bDate\s*\.\s*now\b|\bMath\s*\.\s*random\b|\bnew\s+Date\s*\(\s*\)/;
288
+ interface AstRecord {
289
+ readonly [key: string]: unknown;
290
+ }
291
+
292
+ function isAstRecord(value: unknown): value is AstRecord {
293
+ return typeof value === "object" && value !== null;
294
+ }
295
+
296
+ /** Detect actual nondeterministic expressions, not words inside user prompt strings. */
297
+ function containsNondeterminism(value: unknown, seen = new WeakSet<object>()): boolean {
298
+ if (Array.isArray(value)) return value.some((item) => containsNondeterminism(item, seen));
299
+ if (!isAstRecord(value)) return false;
300
+ if (seen.has(value)) return false;
301
+ seen.add(value);
302
+
303
+ const type = value.type;
304
+ if (type === "MemberExpression") {
305
+ const object = value.object;
306
+ const property = value.property;
307
+ const objectName =
308
+ isAstRecord(object) && object.type === "Identifier" && typeof object.name === "string" ? object.name : undefined;
309
+ // Leave computed properties to the in-realm stubs so the runtime still
310
+ // guards aliases such as Math["random"] while this precheck ignores text
311
+ // embedded in prompt literals.
312
+ const propertyName =
313
+ isAstRecord(property) && property.type === "Identifier" && typeof property.name === "string"
314
+ ? property.name
315
+ : undefined;
316
+ if ((objectName === "Date" && propertyName === "now") || (objectName === "Math" && propertyName === "random")) {
317
+ return true;
318
+ }
319
+ }
320
+ if (
321
+ type === "NewExpression" &&
322
+ isAstRecord(value.callee) &&
323
+ value.callee.type === "Identifier" &&
324
+ value.callee.name === "Date" &&
325
+ Array.isArray(value.arguments) &&
326
+ value.arguments.length === 0
327
+ ) {
328
+ return true;
329
+ }
330
+
331
+ for (const [key, child] of Object.entries(value)) {
332
+ if (key === "loc" || key === "start" || key === "end") continue;
333
+ if (containsNondeterminism(child, seen)) return true;
334
+ }
335
+ return false;
336
+ }
227
337
 
228
338
  /**
229
339
  * Run inside the vm realm BEFORE the user script. Neutered builtins would break
@@ -264,14 +374,6 @@ const DETERMINISM_PRELUDE = [
264
374
  * is rejected so the meta is fully static and deterministic.
265
375
  */
266
376
  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
377
  const ast = parse(script, {
276
378
  ecmaVersion: "latest",
277
379
  sourceType: "module",
@@ -279,6 +381,13 @@ export function parseWorkflowScript(script: string): { meta: WorkflowMeta; body:
279
381
  allowReturnOutsideFunction: true,
280
382
  ranges: false,
281
383
  });
384
+ if (containsNondeterminism(ast)) {
385
+ throw new WorkflowError(
386
+ "Workflow scripts must be deterministic: Date.now()/Math.random()/new Date() are unavailable",
387
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
388
+ { recoverable: false },
389
+ );
390
+ }
282
391
 
283
392
  const first = ast.body?.[0] as { type?: string } | undefined;
284
393
  if (first?.type !== "ExportNamedDeclaration") {
@@ -401,26 +510,29 @@ function propertyKey(node: unknown, path: string): string {
401
510
 
402
511
  function validateMeta(meta: unknown): asserts meta is WorkflowMeta {
403
512
  if (!meta || typeof meta !== "object") throw new Error("meta must be an object");
404
- const value = meta as WorkflowMeta;
513
+ const value = meta as WorkflowMeta & Record<string, unknown>;
405
514
  if (typeof value.name !== "string" || !value.name.trim() || value.name.length > 512)
406
515
  throw new Error("meta.name must be a non-empty string of at most 512 characters");
407
516
  if (typeof value.description !== "string" || !value.description.trim() || value.description.length > 100_000)
408
517
  throw new Error("meta.description must be a non-empty string of at most 100000 characters");
409
- if (value.model !== undefined && typeof value.model !== "string") throw new Error("meta.model must be a string");
518
+ if (Object.hasOwn(value, "model") || Object.hasOwn(value, "thinking")) {
519
+ throw new Error("workflow meta model/thinking fields are unsupported; use agent({ tier })");
520
+ }
410
521
  if (value.phases !== undefined) {
411
522
  if (!Array.isArray(value.phases)) throw new Error("meta.phases must be an array");
412
523
  for (const phase of value.phases) {
413
524
  if (
414
525
  !phase ||
415
526
  typeof phase !== "object" ||
416
- typeof phase.title !== "string" ||
417
- !phase.title.trim() ||
418
- phase.title.length > 512
527
+ typeof (phase as { title?: unknown }).title !== "string" ||
528
+ !(phase as { title: string }).title.trim() ||
529
+ (phase as { title: string }).title.length > 512
419
530
  ) {
420
531
  throw new Error("each meta phase must have a title string");
421
532
  }
422
- if ((phase as WorkflowMetaPhase).model !== undefined && typeof (phase as WorkflowMetaPhase).model !== "string") {
423
- throw new Error("each meta phase model must be a string when present");
533
+ const phaseRecord = phase as unknown as Record<string, unknown>;
534
+ if (Object.hasOwn(phaseRecord, "model") || Object.hasOwn(phaseRecord, "thinking")) {
535
+ throw new Error("workflow phase model/thinking fields are unsupported; use agent({ tier })");
424
536
  }
425
537
  }
426
538
  }
@@ -455,28 +567,48 @@ function hashIdentity(value: unknown): string {
455
567
  /**
456
568
  * Stable identity hash for an agent() call. Must cover every input that can
457
569
  * change the outcome so a cache-miss is never served on resume. The agent
458
- * definition is represented by the agentType name (the definition itself lives
459
- * in pi-subagents; a name-only key is the honest observable).
570
+ * definition is represented by the agentType name — the definition itself lives
571
+ * in pi-subagents, so a name-only key is the honest observable, and there is no
572
+ * second agent-definition field to hash beside it.
460
573
  */
461
574
  function hashAgentCall(
462
575
  prompt: string,
463
- model: string | undefined,
464
576
  phase: string | undefined,
465
577
  options: AgentOptions,
466
- agentDefKey: string | null,
578
+ routingPolicy: ManagedRoutingPolicy | undefined,
579
+ routingPolicyFingerprint: string | undefined,
467
580
  ): string {
581
+ // A call that names its tier is keyed on that tier's policy alone, so
582
+ // defining or editing an unrelated tier cannot invalidate it.
583
+ //
584
+ // A call that names none cannot be keyed that way, and keying it on
585
+ // `defaultTier` would be worse than coarse — it would be wrong. The host
586
+ // resolves an unnamed tier against the agent's own frontmatter tier first and
587
+ // only then the default, and frontmatter is invisible here: the agent
588
+ // definition lives in pi-subagents and reaches this side as a name. Keyed on
589
+ // the default, editing the tier the agent actually declares would replay
590
+ // stale work, while editing the default would invalidate work that never
591
+ // touched it. The whole-catalogue fingerprint is the coarse answer that is at
592
+ // least sound, and it is stable across the original run and its resume, which
593
+ // the tier the host selected is not — that one is only known after dispatch,
594
+ // and this hash is computed before it.
595
+ const tierPolicy =
596
+ options.tier === undefined
597
+ ? (routingPolicyFingerprint ?? null)
598
+ : agentTierPolicyIdentity(routingPolicy, options.tier);
468
599
  return hashIdentity({
469
600
  prompt,
470
- model: model ?? null,
471
601
  tier: options.tier ?? null,
602
+ tierPolicy,
472
603
  phase: phase ?? null,
473
604
  agentType: options.agentType ?? null,
474
605
  thread: options.thread ?? null,
475
- agentDef: agentDefKey,
476
606
  schema: options.schema ?? null,
477
607
  isolation: options.isolation ?? null,
478
608
  toolset: options.toolset ?? null,
479
609
  excludeTools: options.excludeTools ?? null,
610
+ timeoutMs: options.timeoutMs ?? null,
611
+ retries: options.retries ?? null,
480
612
  });
481
613
  }
482
614
 
@@ -526,30 +658,30 @@ function estimateTokens(value: unknown): number {
526
658
  return Math.ceil(JSON.stringify(value ?? "").length / 4);
527
659
  }
528
660
 
529
- function normalizeConcurrency(value: unknown): number {
661
+ export function normalizeConcurrency(value: unknown): number {
530
662
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1;
531
663
  return Math.min(MAX_CONCURRENCY, Math.floor(value));
532
664
  }
533
665
 
534
- function normalizeMaxAgents(value: unknown): number {
666
+ export function normalizeMaxAgents(value: unknown): number {
535
667
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return MAX_AGENTS_PER_RUN;
536
668
  return Math.min(MAX_AGENTS_PER_RUN, Math.floor(value));
537
669
  }
538
670
 
539
- function normalizeAgentTimeout(value: unknown): number | null {
671
+ export function normalizeAgentTimeout(value: unknown): number | null {
540
672
  if (value === null) return null;
541
673
  if (value === undefined) return DEFAULT_AGENT_TIMEOUT_MS;
542
674
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return DEFAULT_AGENT_TIMEOUT_MS;
543
675
  return Math.floor(value);
544
676
  }
545
677
 
546
- function normalizeTokenBudget(value: unknown): number | null {
678
+ export function normalizeTokenBudget(value: unknown): number | null {
547
679
  if (value === null || value === undefined) return null;
548
680
  if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return null;
549
681
  return Math.floor(value);
550
682
  }
551
683
 
552
- function normalizeAgentRetries(value: unknown): number {
684
+ export function normalizeAgentRetries(value: unknown): number {
553
685
  if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return 0;
554
686
  return Math.min(MAX_AGENT_RETRIES, Math.floor(value));
555
687
  }
@@ -802,6 +934,7 @@ export interface WorkflowRuntimeBindings {
802
934
  items: unknown[],
803
935
  ...stages: Array<(prev: unknown, original: unknown, index: number) => unknown>
804
936
  ) => Promise<Array<unknown | null>>;
937
+ orchestrate: (tasks: unknown, options?: unknown) => Promise<OrchestrationResult>;
805
938
  workflow: (savedName: string, childArgs?: unknown) => Promise<unknown>;
806
939
  verify: (
807
940
  item: unknown,
@@ -840,6 +973,8 @@ export interface WorkflowRuntimeBindings {
840
973
 
841
974
  /** Fan-out cancellation scope: parallel()/pipeline() batches mark themselves. */
842
975
  const fanoutScope = new AsyncLocalStorage<{ cancelled: boolean }>();
976
+ /** Task-local phase attribution for named orchestration callbacks. */
977
+ const orchestrationScope = new AsyncLocalStorage<{ taskId: string; phase?: string }>();
843
978
 
844
979
  // ── The run ──────────────────────────────────────────────────────────────────
845
980
 
@@ -853,6 +988,11 @@ export async function runWorkflow<T = unknown>(
853
988
  const agentTimeoutMs = normalizeAgentTimeout(options.agentTimeoutMs);
854
989
  const runId = options.runId ?? `run-${started.toString(36)}`;
855
990
  const baseCwd = options.cwd ?? process.cwd();
991
+ // The host's catalogue, known before the first call. A tier name that is not
992
+ // in it is a typo, and catching it here beats discovering it on dispatch.
993
+ // Undefined when no policy was supplied (in-process tests, nested frames that
994
+ // inherit the parent's checks) — then only the key shape is enforced.
995
+ const knownTiers = options.routingPolicy ? new Set(Object.keys(options.routingPolicy.profiles)) : undefined;
856
996
 
857
997
  const state: RuntimeState = {
858
998
  logs: [],
@@ -863,6 +1003,7 @@ export async function runWorkflow<T = unknown>(
863
1003
  currentPhase: meta.phases?.[0]?.title,
864
1004
  phaseBudgets: new Map(),
865
1005
  callSeq: 0,
1006
+ workflowGenerations: new Map(),
866
1007
  firstMiss: Number.POSITIVE_INFINITY,
867
1008
  };
868
1009
 
@@ -879,12 +1020,11 @@ export async function runWorkflow<T = unknown>(
879
1020
  const shared: SharedRuntime = options.sharedRuntime ?? {
880
1021
  limiter: createLimiter(concurrency),
881
1022
  agentCount: 0,
1023
+ physicalAgentCount: 0,
882
1024
  spent: options.initialTokenUsage?.total ?? 0,
883
1025
  tokenUsage: options.initialTokenUsage
884
1026
  ? { ...options.initialTokenUsage }
885
1027
  : { input: 0, output: 0, total: 0, cost: 0, cacheRead: 0, cacheWrite: 0 },
886
- depth: 0,
887
- nestedCallSeq: 0,
888
1028
  runFatalController: new AbortController(),
889
1029
  inFlight: new Set<Promise<unknown>>(),
890
1030
  activeThreads: new Set<string>(),
@@ -892,6 +1032,9 @@ export async function runWorkflow<T = unknown>(
892
1032
  const limiter = shared.limiter;
893
1033
  const isTopLevelRun = !options.sharedRuntime;
894
1034
  const store: SharedStore = options.sharedStore ?? new SharedStore();
1035
+ let frameAgentCount = 0;
1036
+ /** agent() promises owned by this runWorkflow frame only. */
1037
+ const frameInFlight = new Set<Promise<unknown>>();
895
1038
 
896
1039
  const log = (message: unknown) => {
897
1040
  const text = String(message);
@@ -899,6 +1042,14 @@ export async function runWorkflow<T = unknown>(
899
1042
  options.onLog?.(text);
900
1043
  };
901
1044
 
1045
+ const emitRuntimeEvent = (event: WorkflowRuntimeEvent): void => {
1046
+ try {
1047
+ options.onRuntimeEvent?.(event);
1048
+ } catch {
1049
+ // Runtime observers are diagnostic surfaces and must not change execution.
1050
+ }
1051
+ };
1052
+
902
1053
  const phase = (title: string, phaseOptions?: { budget?: number }) => {
903
1054
  state.currentPhase = title;
904
1055
  if (!state.phases.includes(title)) state.phases.push(title);
@@ -908,7 +1059,7 @@ export async function runWorkflow<T = unknown>(
908
1059
  state.phaseBudgets.set(title, { budget: phaseOptions.budget, startSpent: shared.spent, warned: false });
909
1060
  }
910
1061
  options.onPhase?.(title);
911
- options.onRuntimeEvent?.({
1062
+ emitRuntimeEvent({
912
1063
  type: "phase",
913
1064
  title,
914
1065
  budget: typeof phaseOptions?.budget === "number" && phaseOptions.budget > 0 ? phaseOptions.budget : null,
@@ -923,7 +1074,7 @@ export async function runWorkflow<T = unknown>(
923
1074
 
924
1075
  const agentLimitError = () =>
925
1076
  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.`,
1077
+ `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
1078
  WorkflowErrorCode.AGENT_LIMIT_EXCEEDED,
928
1079
  { recoverable: false },
929
1080
  );
@@ -936,6 +1087,17 @@ export async function runWorkflow<T = unknown>(
936
1087
  }
937
1088
  };
938
1089
 
1090
+ const nextCallIndex = (): number => {
1091
+ if (state.callSeq >= MAX_CALLS_PER_WORKFLOW) {
1092
+ throw new WorkflowError(
1093
+ `Workflow call limit exceeded (${MAX_CALLS_PER_WORKFLOW} agent/workflow/checkpoint calls)`,
1094
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1095
+ { recoverable: false },
1096
+ );
1097
+ }
1098
+ return state.callSeq++;
1099
+ };
1100
+
939
1101
  const agent = (promptText: string, agentOptions: AgentOptions = {}): Promise<unknown> => {
940
1102
  const rawThread = agentOptions.thread;
941
1103
  const thread = rawThread === undefined ? undefined : typeof rawThread === "string" ? rawThread.trim() : "";
@@ -962,11 +1124,16 @@ export async function runWorkflow<T = unknown>(
962
1124
  const normalizedOptions = thread && thread !== rawThread ? { ...agentOptions, thread } : agentOptions;
963
1125
  let call = agentImpl(promptText, normalizedOptions);
964
1126
  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.
1127
+ // Track every call (awaited or not) both globally and in its owning frame.
1128
+ // A nested workflow drains only its own forgotten calls before exposing its
1129
+ // result/journal boundary; the top-level frame still drains all shared work.
968
1130
  shared.inFlight.add(call);
969
- call.catch(() => {}).finally(() => shared.inFlight.delete(call));
1131
+ frameInFlight.add(call);
1132
+ const removeSettledCall = () => {
1133
+ shared.inFlight.delete(call);
1134
+ frameInFlight.delete(call);
1135
+ };
1136
+ void call.then(removeSettledCall, removeSettledCall);
970
1137
  return call;
971
1138
  };
972
1139
 
@@ -977,40 +1144,8 @@ export async function runWorkflow<T = unknown>(
977
1144
  // synchronously, before suspending on the limiter below.
978
1145
  const batch = fanoutScope.getStore();
979
1146
 
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
- }
1147
+ const taskScope = orchestrationScope.getStore();
1148
+ const assignedPhase = agentOptions.phase ?? taskScope?.phase ?? state.currentPhase;
1014
1149
 
1015
1150
  const requestedLabel = agentOptions.label?.trim();
1016
1151
  // agentType resolution lives in pi-subagents; the name is the observable key.
@@ -1018,39 +1153,85 @@ export async function runWorkflow<T = unknown>(
1018
1153
  log(`agentType "${agentOptions.agentType}" resolves in pi-subagents at dispatch`);
1019
1154
  }
1020
1155
 
1021
- // Model precedence: explicit per-call model > phase/run model. A tier is
1022
- // deliberately left to pi-subagents so its configured profile wins over the
1023
- // phase default. The model string may carry a `:thinking` suffix understood by
1024
- // the managed protocol.
1025
- const phaseModel = meta.phases?.find((candidate) => candidate.title === assignedPhase)?.model ?? meta.model;
1026
- const modelSpec = agentOptions.model ?? (agentOptions.tier === undefined ? phaseModel : undefined);
1156
+ // A tier is a key in the host's catalogue, so the only shape check this
1157
+ // side can make is the key shape. An unknown key is the host's call to
1158
+ // refuse — it is the one that knows what is defined, and its error names
1159
+ // the available tiers.
1160
+ if (agentOptions.tier !== undefined && !isManagedAgentTier(agentOptions.tier)) {
1161
+ throw new WorkflowError(
1162
+ `agent tier must be a non-empty, whitespace-free key (received ${JSON.stringify(agentOptions.tier)})`,
1163
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1164
+ { recoverable: false },
1165
+ );
1166
+ }
1167
+ let effectiveTier = agentOptions.tier;
1168
+ if (effectiveTier !== undefined && knownTiers && !knownTiers.has(effectiveTier)) {
1169
+ if (options.shippedScript) {
1170
+ // Our own script naming a tier this host does not define. Drop it and
1171
+ // let the host's default decide, which is the user's policy either way.
1172
+ log(`agent tier ${JSON.stringify(effectiveTier)} is not defined here; using the host default`);
1173
+ effectiveTier = undefined;
1174
+ } else {
1175
+ // A typo would otherwise surface only when this call reaches dispatch,
1176
+ // possibly deep into a run. The catalogue is known at start; check now.
1177
+ throw new WorkflowError(
1178
+ `unknown agent tier ${JSON.stringify(effectiveTier)}; this host defines: ${
1179
+ [...knownTiers].sort().join(", ") || "(none)"
1180
+ }`,
1181
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1182
+ { recoverable: false },
1183
+ );
1184
+ }
1185
+ }
1186
+ // Everything downstream — the resume hash, the dispatch, the journal —
1187
+ // works from the tier that will actually be requested, not the one the
1188
+ // script wrote, so a dropped name cannot make a cached call look different
1189
+ // from the live one.
1190
+ const tieredOptions: AgentOptions =
1191
+ effectiveTier === agentOptions.tier ? agentOptions : { ...agentOptions, tier: effectiveTier };
1027
1192
 
1028
1193
  // Deterministic resume key: assigned at lexical call time, before the limiter.
1029
- const callIndex = state.callSeq++;
1030
- const callHash = hashAgentCall(promptText, modelSpec, assignedPhase, agentOptions, agentOptions.agentType ?? null);
1194
+ const callIndex = nextCallIndex();
1195
+ const callHash = hashAgentCall(
1196
+ promptText,
1197
+ assignedPhase,
1198
+ tieredOptions,
1199
+ options.routingPolicy,
1200
+ options.routingPolicyFingerprint,
1201
+ );
1031
1202
  // Store delta key: runId-namespaced so a nested workflow() call's
1032
1203
  // callIndex-0 can never collide with the parent's (see SharedStore doc).
1033
1204
  const deltaKey = `${runId}:${callIndex}`;
1034
1205
 
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);
1206
+ const label = requestedLabel || defaultAgentLabel(assignedPhase, shared.agentCount + 1);
1040
1207
 
1041
1208
  // Longest-unchanged-prefix resume: replay a cached result only while the
1042
1209
  // prefix is still intact. Once any call misses, it AND everything after it
1043
1210
  // run live, so an edited upstream call never leaves stale downstream
1044
- // results served from the journal.
1211
+ // results served from the journal. Replay is logical work, not a real
1212
+ // dispatch: it consumes neither the agent cap nor token/phase budget.
1045
1213
  const cached = agentOptions.thread ? undefined : options.resumeJournal?.get(deltaKey);
1046
1214
  const hashMatches = cached != null && cached.hash === callHash;
1047
1215
  const cachedEmptyOutput = hashMatches && isEmptyTextAgentResult(cached.result, agentOptions.schema);
1048
1216
  if (hashMatches && !cachedEmptyOutput && callIndex < state.firstMiss) {
1049
- options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
1217
+ shared.agentCount++;
1218
+ frameAgentCount++;
1219
+ // The journal holds the tier the host actually selected, which may be a
1220
+ // default the script never named. Replay reports that durable identity
1221
+ // rather than re-deriving one from the script.
1222
+ const replayTier = cached.tier ?? effectiveTier;
1223
+ options.onAgentStart?.({
1224
+ id: deltaKey,
1225
+ label,
1226
+ phase: assignedPhase,
1227
+ prompt: promptText,
1228
+ ...(replayTier === undefined ? {} : { tier: replayTier }),
1229
+ });
1050
1230
  options.onAgentEnd?.({
1051
1231
  id: deltaKey,
1052
1232
  label,
1053
1233
  phase: assignedPhase,
1234
+ tier: replayTier,
1054
1235
  result: cached.result,
1055
1236
  tokens: 0,
1056
1237
  });
@@ -1060,12 +1241,48 @@ export async function runWorkflow<T = unknown>(
1060
1241
  if (!hashMatches || cachedEmptyOutput || agentOptions.thread)
1061
1242
  state.firstMiss = Math.min(state.firstMiss, callIndex);
1062
1243
 
1244
+ // Reserve a real dispatch synchronously — no await occurs between this
1245
+ // check and the increments, so a parallel() burst cannot overshoot the cap.
1246
+ if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
1247
+ if (budget.total !== null && budget.remaining() <= 0) {
1248
+ throw new WorkflowError("workflow token budget exhausted", WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED, {
1249
+ recoverable: false,
1250
+ });
1251
+ }
1252
+ // Per-phase soft sub-budget gate: warns once at ~80%, throws at 100%.
1253
+ if (assignedPhase) {
1254
+ const pb = state.phaseBudgets.get(assignedPhase);
1255
+ if (pb) {
1256
+ const phaseSpent = shared.spent - pb.startSpent;
1257
+ if (phaseSpent >= pb.budget) {
1258
+ throw new WorkflowError(
1259
+ `phase "${assignedPhase}" token sub-budget exhausted (${pb.budget})`,
1260
+ WorkflowErrorCode.TOKEN_BUDGET_EXHAUSTED,
1261
+ { recoverable: false },
1262
+ );
1263
+ }
1264
+ if (!pb.warned && phaseSpent >= pb.budget * 0.8) {
1265
+ pb.warned = true;
1266
+ log(`phase "${assignedPhase}" at ${Math.round((phaseSpent / pb.budget) * 100)}% of its token sub-budget`);
1267
+ }
1268
+ }
1269
+ }
1270
+ shared.agentCount++;
1271
+ frameAgentCount++;
1272
+ shared.physicalAgentCount++;
1273
+
1063
1274
  return limiter(async () => {
1064
1275
  const timeout = agentOptions.timeoutMs !== undefined ? agentOptions.timeoutMs : agentTimeoutMs;
1065
1276
  const retryAttempts = normalizeAgentRetries(agentOptions.retries ?? options.agentRetries ?? 0);
1066
1277
  const maxAttempts = retryAttempts + 1;
1067
1278
 
1068
- options.onAgentStart?.({ id: deltaKey, label, phase: assignedPhase, prompt: promptText });
1279
+ options.onAgentStart?.({
1280
+ id: deltaKey,
1281
+ label,
1282
+ phase: assignedPhase,
1283
+ prompt: promptText,
1284
+ ...(effectiveTier === undefined ? {} : { tier: effectiveTier }),
1285
+ });
1069
1286
 
1070
1287
  // Worktree creation and cleanup remain owned by pi-subagents; the request
1071
1288
  // carries the explicit per-call policy without touching the host filesystem here.
@@ -1097,6 +1314,7 @@ export async function runWorkflow<T = unknown>(
1097
1314
  schema !== undefined
1098
1315
  ? `\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
1316
  : "";
1317
+ let repairFeedback = "";
1100
1318
 
1101
1319
  try {
1102
1320
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
@@ -1104,6 +1322,9 @@ export async function runWorkflow<T = unknown>(
1104
1322
  const externalSignal = options.signal;
1105
1323
  let onExternalAbort: (() => void) | undefined;
1106
1324
  let onRunFatal: (() => void) | undefined;
1325
+ // Filled in from the host's reply, which may name a tier this call
1326
+ // did not: an agent's own tier, or the configured default.
1327
+ let resolvedTier = effectiveTier;
1107
1328
  try {
1108
1329
  throwIfAborted();
1109
1330
  if (batch?.cancelled) throw agentLimitError();
@@ -1128,7 +1349,7 @@ export async function runWorkflow<T = unknown>(
1128
1349
  );
1129
1350
  }
1130
1351
 
1131
- const runPromise = agentRunner.run(promptText + schemaInstruction, {
1352
+ const runPromise = agentRunner.run(promptText + schemaInstruction + repairFeedback, {
1132
1353
  label,
1133
1354
  sessionName: agentOptions.thread
1134
1355
  ? `workflow:${runId} thread:${agentOptions.thread}`
@@ -1136,12 +1357,12 @@ export async function runWorkflow<T = unknown>(
1136
1357
  schema,
1137
1358
  signal: agentController.signal,
1138
1359
  instructions: buildAgentInstructions(assignedPhase, agentOptions, resolvedIsolation),
1139
- model: modelSpec,
1140
- tier: agentOptions.tier,
1360
+ tier: effectiveTier,
1141
1361
  agentType: agentOptions.agentType,
1142
1362
  isolation: resolvedIsolation,
1143
1363
  toolset: options.toolset ?? agentOptions.toolset,
1144
1364
  excludeTools: options.excludeTools ?? agentOptions.excludeTools,
1365
+ runId,
1145
1366
  callIndex,
1146
1367
  callHash,
1147
1368
  attempt,
@@ -1149,6 +1370,9 @@ export async function runWorkflow<T = unknown>(
1149
1370
  onModelResolved: (id: string) => {
1150
1371
  void id; // display model tracking is engine-side
1151
1372
  },
1373
+ onTierResolved: (tier: string) => {
1374
+ resolvedTier = tier;
1375
+ },
1152
1376
  onUsage: (u: AgentUsage) => {
1153
1377
  usage = u;
1154
1378
  },
@@ -1186,7 +1410,9 @@ export async function runWorkflow<T = unknown>(
1186
1410
  if (errors.length > 0) {
1187
1411
  const detail = errors.join("; ");
1188
1412
  if (attempt < maxAttempts) {
1189
- // Repair: re-dispatch with the validation errors as feedback.
1413
+ // Repair: re-dispatch with the concrete validation errors so
1414
+ // the next model attempt can correct the actual failure.
1415
+ 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
1416
  throw new WorkflowError(
1191
1417
  `Subagent reply did not match the schema: ${detail}`,
1192
1418
  WorkflowErrorCode.AGENT_EXECUTION_ERROR,
@@ -1208,6 +1434,7 @@ export async function runWorkflow<T = unknown>(
1208
1434
  runId,
1209
1435
  hash: callHash,
1210
1436
  result,
1437
+ ...(resolvedTier === undefined ? {} : { tier: resolvedTier }),
1211
1438
  storeDelta: store.commitDelta(deltaKey),
1212
1439
  });
1213
1440
  options.onAgentEnd?.({
@@ -1215,9 +1442,9 @@ export async function runWorkflow<T = unknown>(
1215
1442
  label,
1216
1443
  phase: assignedPhase,
1217
1444
  result,
1445
+ tier: resolvedTier,
1218
1446
  tokens,
1219
1447
  tokenUsage: usage,
1220
- model: modelSpec,
1221
1448
  });
1222
1449
  return result;
1223
1450
  } catch (error) {
@@ -1245,9 +1472,9 @@ export async function runWorkflow<T = unknown>(
1245
1472
  label,
1246
1473
  phase: assignedPhase,
1247
1474
  result: null,
1475
+ tier: resolvedTier,
1248
1476
  tokens,
1249
1477
  tokenUsage: usage,
1250
- model: modelSpec,
1251
1478
  error: workflowError.message,
1252
1479
  errorCode: workflowError.code,
1253
1480
  recoverable: workflowError.recoverable,
@@ -1275,31 +1502,45 @@ export async function runWorkflow<T = unknown>(
1275
1502
  const parallel = async (thunks: Array<() => Promise<unknown>>) => {
1276
1503
  throwIfAborted();
1277
1504
  if (!Array.isArray(thunks)) throw new TypeError("parallel() expects an array of functions");
1505
+ if (thunks.length > MAX_FANOUT_ITEMS) {
1506
+ throw new WorkflowError(
1507
+ `parallel() accepts at most ${MAX_FANOUT_ITEMS} items`,
1508
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1509
+ { recoverable: false },
1510
+ );
1511
+ }
1278
1512
  if (thunks.some((thunk) => typeof thunk !== "function")) {
1279
1513
  throw new TypeError("parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)");
1280
1514
  }
1281
1515
  // Batch-scoped cancellation: a breach in THIS fan-out flips `cancelled` so
1282
1516
  // its own still-queued agents bail, without touching a sibling or enclosing
1283
- // fan-out.
1517
+ // fan-out. Await all wrappers before surfacing a fatal error so the barrier
1518
+ // never returns while a sibling is still mutating the run.
1284
1519
  const batch = { cancelled: false };
1285
- return fanoutScope.run(batch, () =>
1520
+ const outcomes = await fanoutScope.run(batch, () =>
1286
1521
  Promise.all(
1287
- thunks.map(async (thunk, index) => {
1522
+ thunks.map(async (thunk, index): Promise<{ value: unknown } | { fatal: unknown }> => {
1288
1523
  try {
1289
- return await thunk();
1524
+ return { value: await thunk() };
1290
1525
  } catch (error) {
1291
- if (isAborted()) throw error;
1292
1526
  const workflowError = wrapError(error);
1293
- if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1527
+ if (
1528
+ isAborted() ||
1529
+ !workflowError.recoverable ||
1530
+ workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
1531
+ ) {
1294
1532
  if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
1295
- throw workflowError;
1533
+ return { fatal: workflowError };
1296
1534
  }
1297
1535
  log(`parallel[${index}] failed: ${workflowError.message}`);
1298
- return null;
1536
+ return { value: null };
1299
1537
  }
1300
1538
  }),
1301
1539
  ),
1302
1540
  );
1541
+ const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
1542
+ if (fatal) throw fatal.fatal;
1543
+ return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
1303
1544
  };
1304
1545
 
1305
1546
  const pipeline = async (
@@ -1308,13 +1549,20 @@ export async function runWorkflow<T = unknown>(
1308
1549
  ) => {
1309
1550
  throwIfAborted();
1310
1551
  if (!Array.isArray(items)) throw new TypeError("pipeline() expects an array as the first argument");
1552
+ if (items.length > MAX_FANOUT_ITEMS) {
1553
+ throw new WorkflowError(
1554
+ `pipeline() accepts at most ${MAX_FANOUT_ITEMS} items`,
1555
+ WorkflowErrorCode.SCRIPT_VALIDATION_ERROR,
1556
+ { recoverable: false },
1557
+ );
1558
+ }
1311
1559
  if (stages.some((stage) => typeof stage !== "function")) {
1312
1560
  throw new TypeError("pipeline() stages must be functions: pipeline(items, item => ..., result => ...)");
1313
1561
  }
1314
1562
  const batch = { cancelled: false };
1315
- return fanoutScope.run(batch, () =>
1563
+ const outcomes = await fanoutScope.run(batch, () =>
1316
1564
  Promise.all(
1317
- items.map(async (item, index) => {
1565
+ items.map(async (item, index): Promise<{ value: unknown } | { fatal: unknown }> => {
1318
1566
  let value: unknown = item;
1319
1567
  for (const stage of stages) {
1320
1568
  try {
@@ -1322,58 +1570,148 @@ export async function runWorkflow<T = unknown>(
1322
1570
  value = await stage(value, item, index);
1323
1571
  throwIfAborted();
1324
1572
  } catch (error) {
1325
- if (isAborted()) throw error;
1326
1573
  const workflowError = wrapError(error);
1327
- if (!workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT) {
1574
+ if (
1575
+ isAborted() ||
1576
+ !workflowError.recoverable ||
1577
+ workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT
1578
+ ) {
1328
1579
  if (workflowError.code === WorkflowErrorCode.AGENT_LIMIT_EXCEEDED) batch.cancelled = true;
1329
- throw workflowError;
1580
+ return { fatal: workflowError };
1330
1581
  }
1331
1582
  log(`pipeline[${index}] failed: ${workflowError.message}`);
1332
- return null;
1583
+ return { value: null };
1333
1584
  }
1334
1585
  }
1335
- return value;
1586
+ return { value };
1336
1587
  }),
1337
1588
  ),
1338
1589
  );
1590
+ const fatal = outcomes.find((outcome): outcome is { fatal: unknown } => "fatal" in outcome);
1591
+ if (fatal) throw fatal.fatal;
1592
+ return outcomes.map((outcome) => ("value" in outcome ? outcome.value : null));
1593
+ };
1594
+
1595
+ /**
1596
+ * Run a named dependency graph in deterministic declaration-order layers.
1597
+ * The graph is deliberately a runtime primitive rather than a second top-level
1598
+ * engine: agent/session policy still belongs to the enclosing workflow and
1599
+ * pi-subagents, while this helper owns only task dependencies and results.
1600
+ */
1601
+ const orchestrate = async (rawTasks: unknown, rawOptions?: unknown): Promise<OrchestrationResult> => {
1602
+ throwIfAborted();
1603
+ const normalized = normalizeOrchestration(rawTasks, rawOptions);
1604
+ const tasks = normalized.tasks.map((task) => ({
1605
+ ...task,
1606
+ run: (context: OrchestrationTaskContext) =>
1607
+ orchestrationScope.run({ taskId: task.id, ...(task.phase === undefined ? {} : { phase: task.phase }) }, () =>
1608
+ task.run(context),
1609
+ ),
1610
+ }));
1611
+ return executeOrchestration(tasks, normalized.options, {
1612
+ throwIfAborted,
1613
+ isFatal: (error: unknown) => {
1614
+ if (isAborted()) return true;
1615
+ const workflowError = wrapError(error);
1616
+ return !workflowError.recoverable || workflowError.code === WorkflowErrorCode.PROVIDER_USAGE_LIMIT;
1617
+ },
1618
+ formatError: (error: unknown) => wrapError(error).message,
1619
+ onEvent: emitRuntimeEvent,
1620
+ });
1339
1621
  };
1340
1622
 
1341
1623
  // Nested workflow(): run a saved workflow (or raw script) inline, sharing this
1342
- // run's limiter/counters/budget. One level deep only.
1624
+ // run's limiter/counters/budget. One level deep only. The whole child result is
1625
+ // journaled as one stable parent call so resume does not silently re-spend every
1626
+ // nested agent when the parent prefix is unchanged.
1343
1627
  const workflowFn = async (nameOrScript: string, childArgs?: unknown) => {
1344
1628
  throwIfAborted();
1345
- if (shared.depth >= 1) {
1629
+ const workflowDepth = options.workflowDepth ?? 0;
1630
+ if (workflowDepth >= 1) {
1346
1631
  throw new WorkflowError("workflow() can nest only one level deep", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, {
1347
1632
  recoverable: false,
1348
1633
  });
1349
1634
  }
1635
+ const callIndex = nextCallIndex();
1350
1636
  const resolved = options.loadSavedWorkflow?.(String(nameOrScript));
1351
- const childScript = resolved ?? String(nameOrScript);
1637
+ const childScript = resolved?.script ?? String(nameOrScript);
1638
+ // A named script carries its own shipped-ness; a raw script literal is the
1639
+ // caller's own text and shares the caller's. Inheriting the frame's flag for
1640
+ // a resolved script would let a user script run a built-in under the strict
1641
+ // tier check — a hard failure on any host that renamed the shipped tiers.
1642
+ const childShippedScript = resolved ? resolved.shippedScript === true : options.shippedScript === true;
1352
1643
  const workflowName = String(nameOrScript);
1353
- options.onRuntimeEvent?.({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
1354
- shared.depth++;
1644
+ const argsPresent = childArgs !== undefined;
1645
+ const callHash = hashIdentity({
1646
+ kind: "workflow",
1647
+ name: workflowName,
1648
+ script: childScript,
1649
+ argsPresent,
1650
+ args: argsPresent ? childArgs : null,
1651
+ // Part of the identity because it decides how the child treats a tier
1652
+ // name the host does not define: dropped, or fatal.
1653
+ shippedScript: childShippedScript,
1654
+ routingPolicyFingerprint: options.routingPolicyFingerprint ?? null,
1655
+ });
1656
+ const cached = options.resumeJournal?.get(`${runId}:${callIndex}`);
1657
+ if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
1658
+ state.workflowGenerations.set(callIndex, cached.generation ?? 1);
1659
+ if (cached.storeDelta) store.applyDelta(cached.storeDelta);
1660
+ const replayedAgentCount = cached.agentCount ?? 0;
1661
+ shared.agentCount += replayedAgentCount;
1662
+ frameAgentCount += replayedAgentCount;
1663
+ return cached.result;
1664
+ }
1665
+ if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
1666
+ const generation = (state.workflowGenerations.get(callIndex) ?? cached?.generation ?? 0) + 1;
1667
+ state.workflowGenerations.set(callIndex, generation);
1668
+ emitRuntimeEvent({ type: "workflow", stage: "start", name: workflowName, args: childArgs });
1355
1669
  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;
1670
+ const childDeltas = new Map<number, Record<string, unknown>>();
1362
1671
  const child = await runWorkflow(childScript, {
1363
1672
  ...options,
1364
1673
  args: childArgs,
1674
+ shippedScript: childShippedScript,
1365
1675
  sharedRuntime: shared,
1366
1676
  sharedStore: store,
1367
- resumeJournal: prefixIntact ? options.resumeJournal : undefined,
1677
+ workflowDepth: workflowDepth + 1,
1678
+ // The parent call caches the complete child value. Replaying child
1679
+ // call results here would duplicate nested calls and make the synthetic
1680
+ // parent journal entry ambiguous.
1681
+ resumeJournal: undefined,
1368
1682
  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}`,
1683
+ onWorkflowJournal: undefined,
1684
+ onAgentJournal: (entry) => {
1685
+ if (entry.storeDelta) childDeltas.set(entry.index, entry.storeDelta);
1686
+ // Preserve nested lifecycle visibility for direct runtime consumers;
1687
+ // the engine's top-level agent journal intentionally ignores child ids.
1688
+ options.onAgentJournal?.(entry);
1689
+ },
1690
+ // The call index + generation keeps changed nested calls distinct across
1691
+ // resume. The engine nonce additionally fences an interrupted child whose
1692
+ // result was never durable before a process/branch restart.
1693
+ runId: `${runId}-nested-call-${callIndex}-g${generation}${options.executionNonce ? `-${options.executionNonce}` : ""}`,
1694
+ });
1695
+ const normalizedChildResult = child.result === undefined ? null : child.result;
1696
+ const storeDelta: Record<string, unknown> = {};
1697
+ for (const delta of [...childDeltas.entries()].sort(([a], [b]) => a - b).map(([, value]) => value)) {
1698
+ Object.assign(storeDelta, delta);
1699
+ }
1700
+ // Do not append a nested result after pause/stop has claimed the parent;
1701
+ // terminal recovery must remain the last durable fact for that execution.
1702
+ throwIfAborted();
1703
+ options.onWorkflowJournal?.({
1704
+ index: callIndex,
1705
+ runId,
1706
+ hash: callHash,
1707
+ result: normalizedChildResult,
1708
+ generation,
1709
+ agentCount: child.agentCount,
1710
+ ...(Object.keys(storeDelta).length > 0 ? { storeDelta } : {}),
1372
1711
  });
1373
- return child.result;
1712
+ return normalizedChildResult;
1374
1713
  } finally {
1375
- shared.depth--;
1376
- options.onRuntimeEvent?.({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
1714
+ emitRuntimeEvent({ type: "workflow", stage: "end", name: workflowName, args: childArgs });
1377
1715
  }
1378
1716
  };
1379
1717
 
@@ -1393,7 +1731,7 @@ export async function runWorkflow<T = unknown>(
1393
1731
  total: number;
1394
1732
  votes: Array<{ real: boolean; reason?: string }>;
1395
1733
  }> => {
1396
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "verify" });
1734
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "verify" });
1397
1735
  const reviewers = Math.max(1, opts.reviewers ?? 2);
1398
1736
  const threshold = opts.threshold ?? 0.5;
1399
1737
  const lenses = opts.lens ? (Array.isArray(opts.lens) ? opts.lens : [opts.lens]) : [];
@@ -1430,9 +1768,9 @@ export async function runWorkflow<T = unknown>(
1430
1768
  ): Promise<
1431
1769
  { index: number; attempt: unknown; score: number; judgments: Array<{ score: number; reason?: string }> } | undefined
1432
1770
  > => {
1433
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "judgePanel" });
1771
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "judgePanel" });
1434
1772
  if (!Array.isArray(attempts) || attempts.length === 0) {
1435
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1773
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1436
1774
  return undefined;
1437
1775
  }
1438
1776
  const judges = Math.min(16, Math.max(1, Math.floor(opts.judges ?? 3)));
@@ -1473,12 +1811,12 @@ export async function runWorkflow<T = unknown>(
1473
1811
  judgments: s.judgments.map((j) => ({ score: Number(j?.score) || 0, reason: j?.reason })),
1474
1812
  }));
1475
1813
  if (normalized.length === 0) {
1476
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "judgePanel" });
1814
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1477
1815
  return undefined;
1478
1816
  }
1479
1817
  let best = normalized[0];
1480
1818
  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" });
1819
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "judgePanel" });
1482
1820
  return best;
1483
1821
  };
1484
1822
 
@@ -1530,12 +1868,12 @@ export async function runWorkflow<T = unknown>(
1530
1868
  taskArgs: unknown,
1531
1869
  results: unknown,
1532
1870
  ): Promise<{ complete: boolean; missing?: string[] } | null> => {
1533
- options.onRuntimeEvent?.({ type: "quality", stage: "start", helper: "completenessCheck" });
1871
+ emitRuntimeEvent({ type: "quality", stage: "start", helper: "completenessCheck" });
1534
1872
  const verdict = await agent(
1535
1873
  `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
1874
  { label: "completeness critic", schema: COMPLETENESS_SCHEMA },
1537
1875
  );
1538
- options.onRuntimeEvent?.({ type: "quality", stage: "end", helper: "completenessCheck" });
1876
+ emitRuntimeEvent({ type: "quality", stage: "end", helper: "completenessCheck" });
1539
1877
  return verdict as { complete: boolean; missing?: string[] } | null;
1540
1878
  };
1541
1879
 
@@ -1549,7 +1887,7 @@ export async function runWorkflow<T = unknown>(
1549
1887
  throwIfAborted();
1550
1888
  last = await thunk(i);
1551
1889
  const accepted = !opts.until || opts.until(last);
1552
- options.onRuntimeEvent?.({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
1890
+ emitRuntimeEvent({ type: "control-attempt", helper: "retry", attempt: i + 1, accepted });
1553
1891
  if (accepted) return last;
1554
1892
  }
1555
1893
  return last;
@@ -1568,7 +1906,7 @@ export async function runWorkflow<T = unknown>(
1568
1906
  last = await thunk(feedback, i);
1569
1907
  const verdict = await validator(last);
1570
1908
  const accepted = Boolean(verdict?.ok);
1571
- options.onRuntimeEvent?.({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
1909
+ emitRuntimeEvent({ type: "control-attempt", helper: "gate", attempt: i + 1, accepted });
1572
1910
  if (accepted) return { ok: true, value: last, attempts: i + 1 };
1573
1911
  feedback = verdict?.feedback;
1574
1912
  }
@@ -1581,19 +1919,20 @@ export async function runWorkflow<T = unknown>(
1581
1919
  const checkpoint = async (promptText: string, checkpointOptions: CheckpointOptions = {}) => {
1582
1920
  throwIfAborted();
1583
1921
  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++;
1922
+ const callIndex = nextCallIndex();
1588
1923
  const callHash = hashCheckpoint(promptText, checkpointOptions);
1589
1924
  const journalKey = `${runId}:${callIndex}`;
1590
1925
  const cached = options.resumeJournal?.get(journalKey);
1591
1926
  if (cached != null && cached.hash === callHash && callIndex < state.firstMiss) {
1592
1927
  shared.agentCount++;
1593
- return cached.result; // replay the journaled human reply
1928
+ frameAgentCount++;
1929
+ return cached.result; // replay the journaled human reply without spending a slot
1594
1930
  }
1595
1931
  if (cached == null || cached.hash !== callHash) state.firstMiss = Math.min(state.firstMiss, callIndex);
1932
+ if (shared.physicalAgentCount >= maxAgents) throw agentLimitError();
1596
1933
  shared.agentCount++;
1934
+ frameAgentCount++;
1935
+ shared.physicalAgentCount++;
1597
1936
 
1598
1937
  let reply: unknown;
1599
1938
  if (options.confirm) {
@@ -1616,6 +1955,7 @@ export async function runWorkflow<T = unknown>(
1616
1955
  agent,
1617
1956
  parallel,
1618
1957
  pipeline,
1958
+ orchestrate,
1619
1959
  workflow: workflowFn,
1620
1960
  verify,
1621
1961
  judgePanel,
@@ -1681,7 +2021,7 @@ export async function runWorkflow<T = unknown>(
1681
2021
  result: result as T,
1682
2022
  logs: state.logs,
1683
2023
  phases: state.phases,
1684
- agentCount: shared.agentCount,
2024
+ agentCount: isTopLevelRun ? shared.agentCount : frameAgentCount,
1685
2025
  durationMs: Date.now() - started,
1686
2026
  runId,
1687
2027
  tokenUsage: shared.tokenUsage,
@@ -1695,14 +2035,15 @@ export async function runWorkflow<T = unknown>(
1695
2035
  if (isTopLevelRun) shared.runFatalController.abort();
1696
2036
  throw error;
1697
2037
  } 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();
2038
+ const ownedInFlight = isTopLevelRun ? shared.inFlight : frameInFlight;
2039
+ if (ownedInFlight.size > 0) {
2040
+ log(
2041
+ `waiting for ${ownedInFlight.size} outstanding agent() call(s) to settle before this workflow frame completes`,
2042
+ );
2043
+ }
2044
+ while (ownedInFlight.size > 0) {
2045
+ await Promise.allSettled(Array.from(ownedInFlight));
1706
2046
  }
2047
+ if (isTopLevelRun) store.dispose();
1707
2048
  }
1708
2049
  }