pipeai 0.3.0 → 0.8.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/dist/index.cjs CHANGED
@@ -21,11 +21,19 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  Agent: () => Agent,
24
+ CHECKPOINT_STEP_ID: () => CHECKPOINT_STEP_ID,
25
+ CheckpointTimeoutError: () => CheckpointTimeoutError,
26
+ NestedGateUnsupportedError: () => NestedGateUnsupportedError,
27
+ SKIP: () => SKIP,
28
+ TOOL_PROVIDER_BRAND: () => TOOL_PROVIDER_BRAND,
29
+ ToolProvider: () => ToolProvider,
24
30
  Workflow: () => Workflow,
25
31
  WorkflowBranchError: () => WorkflowBranchError,
26
32
  WorkflowLoopError: () => WorkflowLoopError,
27
- WorkflowSuspended: () => WorkflowSuspended,
28
- defineTool: () => defineTool
33
+ defineTool: () => defineTool,
34
+ getActiveWriter: () => getActiveWriter,
35
+ isToolProvider: () => isToolProvider,
36
+ migrateSnapshot: () => migrateSnapshot
29
37
  });
30
38
  module.exports = __toCommonJS(index_exports);
31
39
 
@@ -37,6 +45,7 @@ var import_ai = require("ai");
37
45
 
38
46
  // src/utils.ts
39
47
  var import_node_async_hooks = require("async_hooks");
48
+ var import_node_crypto = require("crypto");
40
49
  var writerStorage = new import_node_async_hooks.AsyncLocalStorage();
41
50
  function runWithWriter(writer, fn) {
42
51
  return writerStorage.run(writer, fn);
@@ -50,35 +59,58 @@ function resolveValue(value, ctx, input) {
50
59
  }
51
60
  return value;
52
61
  }
53
- var Semaphore = class {
54
- available;
55
- waiters = [];
56
- constructor(permits) {
57
- if (!Number.isInteger(permits) || permits < 1) {
58
- throw new Error(`Semaphore: permits must be a positive integer, got ${permits}`);
59
- }
60
- this.available = permits;
61
- }
62
- async acquire() {
63
- if (this.available > 0) {
64
- this.available--;
65
- return;
66
- }
67
- await new Promise((resolve) => this.waiters.push(resolve));
68
- }
69
- release() {
70
- const next = this.waiters.shift();
71
- if (next) next();
72
- else this.available++;
73
- }
74
- };
75
- async function extractOutput(result, hasStructuredOutput) {
62
+ async function extractOutput(result, hasStructuredOutput, schema) {
76
63
  if (hasStructuredOutput) {
77
64
  const output = await result.output;
78
- if (output !== void 0) return output;
65
+ if (output === void 0) {
66
+ throw new Error(
67
+ "Agent: structured output was declared but the model returned none. This usually means the model produced text that did not match the declared schema, or the underlying SDK did not parse the structured output."
68
+ );
69
+ }
70
+ if (schema) {
71
+ return schema.parse(output);
72
+ }
73
+ return output;
79
74
  }
80
75
  return await result.text;
81
76
  }
77
+ function deepFreeze(value, seen = /* @__PURE__ */ new WeakSet()) {
78
+ if (value === null || typeof value !== "object" || seen.has(value)) return value;
79
+ if (Object.isFrozen(value)) return value;
80
+ seen.add(value);
81
+ Object.freeze(value);
82
+ for (const key of Reflect.ownKeys(value)) {
83
+ deepFreeze(value[key], seen);
84
+ }
85
+ return value;
86
+ }
87
+ function computeStepShapeHash(steps, getNested) {
88
+ return (0, import_node_crypto.createHash)("sha256").update(canonicalDescriptor(steps, getNested, /* @__PURE__ */ new WeakSet())).digest("hex");
89
+ }
90
+ function canonicalDescriptor(steps, getNested, path) {
91
+ return JSON.stringify(steps.map((s, i) => {
92
+ const triple = [i, s.type, s.id];
93
+ for (const inner of getNested(s)) {
94
+ if (path.has(inner)) {
95
+ triple.push(`<cycle:${inner.id ?? "anon"}>`);
96
+ continue;
97
+ }
98
+ path.add(inner);
99
+ try {
100
+ triple.push(canonicalDescriptor(inner.getStepsForShapeHash(), getNested, path));
101
+ } finally {
102
+ path.delete(inner);
103
+ }
104
+ }
105
+ return triple;
106
+ }));
107
+ }
108
+ var warnedOnceKeys = /* @__PURE__ */ new Set();
109
+ function warnOnce(key, message) {
110
+ if (warnedOnceKeys.has(key)) return;
111
+ warnedOnceKeys.add(key);
112
+ console.warn(message ?? key);
113
+ }
82
114
 
83
115
  // src/tool-provider.ts
84
116
  var TOOL_PROVIDER_BRAND = /* @__PURE__ */ Symbol.for("agent-workflow.ToolProvider");
@@ -109,10 +141,17 @@ var Agent = class {
109
141
  id;
110
142
  description;
111
143
  hasOutput;
144
+ /**
145
+ * Zod schema used to validate the agent's structured `output` after the AI
146
+ * SDK returns. Distinct from `tool.outputSchema` (which validates tool
147
+ * execution output). Exposed (readonly) so external runners — notably the
148
+ * workflow runtime — can pass it through to `extractOutput` without
149
+ * re-plumbing it.
150
+ */
151
+ validateOutput;
112
152
  config;
113
153
  _hasDynamicConfig;
114
154
  _resolvedStaticTools = null;
115
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
116
155
  _passthrough;
117
156
  _onStepFinish;
118
157
  _onFinish;
@@ -120,6 +159,7 @@ var Agent = class {
120
159
  this.id = config.id;
121
160
  this.description = config.description ?? "";
122
161
  this.hasOutput = config.output !== void 0;
162
+ this.validateOutput = config.validateOutput;
123
163
  this.config = config;
124
164
  this._hasDynamicConfig = [
125
165
  config.model,
@@ -128,8 +168,7 @@ var Agent = class {
128
168
  config.messages,
129
169
  config.tools,
130
170
  config.activeTools,
131
- config.toolChoice,
132
- config.stopWhen
171
+ config.toolChoice
133
172
  ].some((v) => typeof v === "function");
134
173
  if (!this._hasDynamicConfig) {
135
174
  const rawTools = config.tools ?? {};
@@ -143,6 +182,7 @@ var Agent = class {
143
182
  description: _desc,
144
183
  input: _inputSchema,
145
184
  output: _output,
185
+ validateOutput: _validateOutput,
146
186
  model: _m,
147
187
  system: _s,
148
188
  prompt: _p,
@@ -162,25 +202,13 @@ var Agent = class {
162
202
  }
163
203
  async generate(ctx, ...args) {
164
204
  const input = args[0];
165
- const resolved = await this.resolveConfig(ctx, input);
166
- const options = this.buildCallOptions(resolved, ctx, input);
167
- try {
168
- return await (0, import_ai2.generateText)(options);
169
- } catch (error) {
170
- if (this.config.onError) {
171
- await this.config.onError({ error, ctx, input, writer: getActiveWriter() });
172
- }
173
- throw error;
174
- }
205
+ const callOptions = args[1];
206
+ return this.generateWithOptions(ctx, input, callOptions ?? {});
175
207
  }
176
208
  async stream(ctx, ...args) {
177
209
  const input = args[0];
178
- const resolved = await this.resolveConfig(ctx, input);
179
- const options = this.buildCallOptions(resolved, ctx, input);
180
- return (0, import_ai2.streamText)({
181
- ...options,
182
- onError: this.config.onError ? ({ error }) => this.config.onError({ error, ctx, input, writer: getActiveWriter() }) : void 0
183
- });
210
+ const callOptions = args[1];
211
+ return this.streamWithOptions(ctx, input, callOptions ?? {});
184
212
  }
185
213
  asTool(ctx, options) {
186
214
  return this.createToolInstance(ctx, options);
@@ -201,24 +229,86 @@ var Agent = class {
201
229
  return (0, import_ai2.tool)({
202
230
  description: this.description,
203
231
  inputSchema: this.config.input,
204
- execute: async (toolInput) => {
232
+ // The AI SDK passes a `ToolExecutionOptions` argument that carries
233
+ // `abortSignal`, `toolCallId`, `messages`, etc. Forward `abortSignal` so
234
+ // a parent agent's abort cancels in-flight sub-agent calls instead of
235
+ // leaving them running and producing detached output.
236
+ execute: async (toolInput, execOptions) => {
237
+ const abortSignal = execOptions?.abortSignal;
205
238
  const writer = getActiveWriter();
206
239
  if (writer) {
207
- const result2 = await this.stream(ctx, toolInput);
240
+ const result2 = await this.streamWithOptions(ctx, toolInput, { abortSignal });
208
241
  writer.merge(result2.toUIMessageStream());
209
- if (options?.mapOutput) return options.mapOutput(result2);
210
- return extractOutput(result2, this.hasOutput);
242
+ if (options?.mapOutput) {
243
+ await result2.text;
244
+ return options.mapOutput(result2);
245
+ }
246
+ return extractOutput(result2, this.hasOutput, this.validateOutput);
211
247
  }
212
- const result = await this.generate(ctx, toolInput);
248
+ const result = await this.generateWithOptions(ctx, toolInput, { abortSignal });
213
249
  if (options?.mapOutput) return options.mapOutput(result);
214
- return extractOutput(result, this.hasOutput);
250
+ return extractOutput(result, this.hasOutput, this.validateOutput);
215
251
  }
216
252
  // TS cannot simplify the SDK's `NeverOptional<TOutput, ...>` conditional in a
217
253
  // generic context, so we cast through `unknown` instead of `any`.
218
254
  });
219
255
  }
220
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
256
+ // ── Internal: shared call helpers ─────────────────────────────
257
+ // `generate()` / `stream()` and the `asTool()` wrapper all funnel through
258
+ // these so the abortSignal-forwarding and onError-wrapping logic stays in
259
+ // one place. `extra` carries per-call overrides (currently `abortSignal`).
260
+ // If the user-supplied onError callback itself throws, attach the original
261
+ // model error as `.cause` on the new error and rethrow the wrapper. Without
262
+ // this, the original error is silently shadowed.
263
+ async invokeOnError(error, ctx, input) {
264
+ if (!this.config.onError) return;
265
+ try {
266
+ await this.config.onError({ error, ctx, input, writer: getActiveWriter() });
267
+ } catch (handlerError) {
268
+ if (handlerError instanceof Error) {
269
+ if (handlerError.cause === void 0) {
270
+ handlerError.cause = error;
271
+ }
272
+ throw handlerError;
273
+ }
274
+ const wrapped = new Error(
275
+ `Agent "${this.id}": onError handler threw a non-Error value (${typeof handlerError}): ${String(handlerError)}`
276
+ );
277
+ wrapped.cause = error;
278
+ throw wrapped;
279
+ }
280
+ }
281
+ async generateWithOptions(ctx, input, extra) {
282
+ const resolved = await this.resolveConfig(ctx, input);
283
+ const options = this.buildCallOptions(resolved, ctx, input);
284
+ try {
285
+ return await (0, import_ai2.generateText)({ ...options, ...extra });
286
+ } catch (error) {
287
+ await this.invokeOnError(error, ctx, input);
288
+ throw error;
289
+ }
290
+ }
291
+ async streamWithOptions(ctx, input, extra) {
292
+ const resolved = await this.resolveConfig(ctx, input);
293
+ const options = this.buildCallOptions(resolved, ctx, input);
294
+ try {
295
+ const onErrorOption = this.config.onError ? { onError: ({ error }) => this.invokeOnError(error, ctx, input) } : {};
296
+ return (0, import_ai2.streamText)({
297
+ ...options,
298
+ ...extra,
299
+ ...onErrorOption
300
+ });
301
+ } catch (error) {
302
+ await this.invokeOnError(error, ctx, input);
303
+ throw error;
304
+ }
305
+ }
221
306
  buildCallOptions(resolved, ctx, input) {
307
+ if (resolved.messages === void 0 && resolved.prompt === void 0) {
308
+ throw new Error(
309
+ `Agent "${this.id}": neither \`prompt\` nor \`messages\` was provided. Configure one of them, or supply a Resolvable that returns one based on input.`
310
+ );
311
+ }
222
312
  return {
223
313
  ...this._passthrough,
224
314
  model: resolved.model,
@@ -226,11 +316,16 @@ var Agent = class {
226
316
  activeTools: resolved.activeTools,
227
317
  toolChoice: resolved.toolChoice,
228
318
  stopWhen: resolved.stopWhen,
229
- ...resolved.messages ? { messages: resolved.messages } : { prompt: resolved.prompt ?? "" },
230
- ...resolved.system ? { system: resolved.system } : {},
319
+ ...resolved.messages !== void 0 ? { messages: resolved.messages } : { prompt: resolved.prompt },
320
+ // Use `!== undefined` rather than truthy so an intentional empty
321
+ // `system: ""` survives instead of being silently dropped.
322
+ ...resolved.system !== void 0 ? { system: resolved.system } : {},
231
323
  ...this.config.output ? { output: this.config.output } : {},
232
- onStepFinish: this._onStepFinish ? (event) => this._onStepFinish({ result: event, ctx, input, writer: getActiveWriter() }) : void 0,
233
- onFinish: this._onFinish ? (event) => this._onFinish({ result: event, ctx, input, writer: getActiveWriter() }) : void 0
324
+ // Only attach the callback when the user supplied one. Passing
325
+ // `undefined` explicitly can suppress default SDK rethrow behavior in
326
+ // some versions.
327
+ ...this._onStepFinish ? { onStepFinish: (event) => this._onStepFinish({ result: event, ctx, input, writer: getActiveWriter() }) } : {},
328
+ ...this._onFinish ? { onFinish: (event) => this._onFinish({ result: event, ctx, input, writer: getActiveWriter() }) } : {}
234
329
  };
235
330
  }
236
331
  resolveConfig(ctx, input) {
@@ -252,18 +347,27 @@ var Agent = class {
252
347
  return this.resolveConfigAsync(ctx, input);
253
348
  }
254
349
  async resolveConfigAsync(ctx, input) {
255
- const [model, prompt, system, messages, rawTools, activeTools, toolChoice, stopWhen] = await Promise.all([
350
+ const [model, prompt, system, messages, rawTools, activeTools, toolChoice] = await Promise.all([
256
351
  resolveValue(this.config.model, ctx, input),
257
352
  resolveValue(this.config.prompt, ctx, input),
258
353
  resolveValue(this.config.system, ctx, input),
259
354
  resolveValue(this.config.messages, ctx, input),
260
355
  resolveValue(this.config.tools, ctx, input),
261
356
  resolveValue(this.config.activeTools, ctx, input),
262
- resolveValue(this.config.toolChoice, ctx, input),
263
- resolveValue(this.config.stopWhen, ctx, input)
357
+ resolveValue(this.config.toolChoice, ctx, input)
264
358
  ]);
265
359
  const tools = this.resolveTools(rawTools ?? {}, ctx);
266
- return { model, prompt, system, messages, tools, activeTools, toolChoice, stopWhen };
360
+ return {
361
+ model,
362
+ prompt,
363
+ system,
364
+ messages,
365
+ tools,
366
+ activeTools,
367
+ toolChoice,
368
+ // `stopWhen` is always static — see field declaration for why.
369
+ stopWhen: this.config.stopWhen
370
+ };
267
371
  }
268
372
  resolveTools(tools, ctx) {
269
373
  const entries = Object.entries(tools);
@@ -299,37 +403,284 @@ var WorkflowLoopError = class extends Error {
299
403
  this.name = "WorkflowLoopError";
300
404
  }
301
405
  };
302
- var WorkflowSuspended = class extends Error {
303
- snapshot;
304
- constructor(snapshot) {
305
- super(`Workflow suspended at gate "${snapshot.gateId}"`);
306
- this.name = "WorkflowSuspended";
307
- this.snapshot = snapshot;
406
+ var NestedGateUnsupportedError = class extends Error {
407
+ gateId;
408
+ workflowId;
409
+ // Always present; non-gate rejections from concurrent foreach.
410
+ siblingErrors;
411
+ // Always present; OTHER suspending items in concurrent foreach.
412
+ siblingSuspensions;
413
+ constructor(gateId, workflowId, siblingErrors = [], siblingSuspensions = []) {
414
+ super(`Gate "${gateId}" hit inside nested workflow "${workflowId ?? "(anonymous)"}". Nested gates are not yet supported.`);
415
+ this.name = "NestedGateUnsupportedError";
416
+ this.gateId = gateId;
417
+ this.workflowId = workflowId;
418
+ this.siblingErrors = siblingErrors;
419
+ this.siblingSuspensions = siblingSuspensions;
308
420
  }
309
421
  };
310
- var SealedWorkflow = class {
422
+ var CHECKPOINT_STEP_ID = "::pipeai::onCheckpoint";
423
+ var CheckpointTimeoutError = class extends Error {
424
+ timeoutMs;
425
+ constructor(timeoutMs) {
426
+ super(`onCheckpoint exceeded ${timeoutMs}ms timeout`);
427
+ this.name = "CheckpointTimeoutError";
428
+ this.timeoutMs = timeoutMs;
429
+ }
430
+ };
431
+ function resolveFreezeSnapshots(state) {
432
+ return state.runOptions?.freezeSnapshots ? true : false;
433
+ }
434
+ function migrateSnapshot(legacy) {
435
+ if (legacy.version !== 1) {
436
+ throw new Error(`migrateSnapshot: expected v1 snapshot, got version ${legacy.version}`);
437
+ }
438
+ return {
439
+ version: 2,
440
+ kind: "gate",
441
+ resumeFromIndex: legacy.resumeFromIndex,
442
+ output: legacy.output,
443
+ gateId: legacy.gateId,
444
+ gatePayload: legacy.gatePayload
445
+ };
446
+ }
447
+ function getObservabilityType(node) {
448
+ if (node.type !== "step") return node.type;
449
+ return node.category ?? "step";
450
+ }
451
+ function getNestedWorkflows(node) {
452
+ switch (node.type) {
453
+ case "step":
454
+ return node.nestedWorkflow ? [node.nestedWorkflow] : [];
455
+ case "gate":
456
+ case "catch":
457
+ case "finally":
458
+ return [];
459
+ }
460
+ }
461
+ function pendingErrorSourceToStepType(source) {
462
+ switch (source) {
463
+ case "step":
464
+ return "step";
465
+ case "finally":
466
+ return "finally";
467
+ case "catch":
468
+ return "catch";
469
+ case "onCheckpoint":
470
+ return "step";
471
+ }
472
+ }
473
+ async function emitCheckpoint(state, opts, resumeFromIndex, stepShapeHash) {
474
+ if (!opts.onCheckpoint) return;
475
+ const snap = {
476
+ version: 2,
477
+ kind: "checkpoint",
478
+ resumeFromIndex,
479
+ output: state.output,
480
+ stepShapeHash
481
+ };
482
+ if (resolveFreezeSnapshots(state)) deepFreeze(snap);
483
+ const controller = new AbortController();
484
+ if (opts.checkpointTimeout !== void 0) {
485
+ const timeoutMs = opts.checkpointTimeout;
486
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
487
+ try {
488
+ const callPromise = Promise.resolve(opts.onCheckpoint(snap, { signal: controller.signal }));
489
+ const timeoutPromise = new Promise((_, reject) => {
490
+ controller.signal.addEventListener(
491
+ "abort",
492
+ () => reject(new CheckpointTimeoutError(timeoutMs)),
493
+ { once: true }
494
+ );
495
+ });
496
+ callPromise.catch(() => {
497
+ });
498
+ timeoutPromise.catch(() => {
499
+ });
500
+ await Promise.race([callPromise, timeoutPromise]);
501
+ } finally {
502
+ clearTimeout(timeoutId);
503
+ }
504
+ } else {
505
+ await opts.onCheckpoint(snap, { signal: controller.signal });
506
+ }
507
+ }
508
+ var warnedStreamOnErrorOnSuspend = false;
509
+ function pushWarning(state, source, stepId, error) {
510
+ (state.warnings ??= []).push({ source, stepId, error });
511
+ }
512
+ function demotePendingError(state, pe) {
513
+ pushWarning(state, pe.source, pe.stepId, pe.error);
514
+ }
515
+ function maybeWarnStreamOnErrorOnSuspend(result, options) {
516
+ if (result.status !== "suspended" || !options?.onError || warnedStreamOnErrorOnSuspend) return;
517
+ warnedStreamOnErrorOnSuspend = true;
518
+ console.warn(
519
+ "pipeai: stream() with options.onError suspended at a gate \u2014 onError will NOT be invoked for suspension. Discriminate via the resolved output Promise."
520
+ );
521
+ }
522
+ var SealedWorkflow = class _SealedWorkflow {
311
523
  id;
312
524
  steps;
313
- constructor(steps, id) {
525
+ observability;
526
+ // Memoized — see ensureDuplicateCheck().
527
+ duplicateCheckPassed = false;
528
+ // Memoized lazily per terminal instance — build pipelines once at module
529
+ // load and re-run via generate() to amortize.
530
+ _cachedExecutableStepCount;
531
+ _cachedStepShapeHash;
532
+ constructor(steps, id, observability) {
314
533
  this.steps = steps;
315
534
  this.id = id;
535
+ this.observability = observability;
536
+ }
537
+ // ── Construction-time validation (memoized per terminal instance) ────
538
+ /**
539
+ * Walk the step list once per terminal instance. Rejects:
540
+ * - Duplicate `(type, id)` pairs.
541
+ * - User step ids containing the reserved `::pipeai::` namespace
542
+ * (CHECKPOINT_STEP_ID lives there).
543
+ */
544
+ ensureDuplicateCheck() {
545
+ if (this.duplicateCheckPassed) return;
546
+ const seen = /* @__PURE__ */ new Map();
547
+ for (let i = 0; i < this.steps.length; i++) {
548
+ const node = this.steps[i];
549
+ if (node.id.includes("::pipeai::")) {
550
+ throw new Error(
551
+ `Workflow: step id "${node.id}" uses the reserved "::pipeai::" namespace at index ${i}.`
552
+ );
553
+ }
554
+ const key = `${node.type}:${node.id}`;
555
+ const prior = seen.get(key);
556
+ if (prior !== void 0) {
557
+ throw new Error(
558
+ `Workflow: duplicate (${node.type}, "${node.id}") at indices ${prior} and ${i}. Pass an explicit \`{ id }\` (e.g. for back-to-back \`branch(...)\` or \`foreach(agentX).foreach(agentX)\`) to disambiguate.`
559
+ );
560
+ }
561
+ seen.set(key, i);
562
+ }
563
+ this.duplicateCheckPassed = true;
564
+ }
565
+ // ── shape-hash + RunOptions validation ────────────────────────
566
+ /**
567
+ * Count of executable nodes — i.e. NOT `catch` or `finally`. Drives
568
+ * checkpoint auto-cadence so adding cleanup steps doesn't surprise users
569
+ * with extra fires. `branch`/`foreach`/`repeat`/`parallel`/`nested` are all
570
+ * `type: "step"` internally and count as executable.
571
+ */
572
+ get cachedExecutableStepCount() {
573
+ if (this._cachedExecutableStepCount !== void 0) return this._cachedExecutableStepCount;
574
+ let n = 0;
575
+ for (const s of this.steps) {
576
+ if (s.type !== "catch" && s.type !== "finally") n++;
577
+ }
578
+ this._cachedExecutableStepCount = n;
579
+ return n;
580
+ }
581
+ /** @internal — used by `computeStepShapeHash` to descend nested workflows. */
582
+ getStepsForShapeHash() {
583
+ return this.steps;
584
+ }
585
+ get cachedStepShapeHash() {
586
+ if (this._cachedStepShapeHash !== void 0) return this._cachedStepShapeHash;
587
+ const getNested = (node) => getNestedWorkflows(node);
588
+ this._cachedStepShapeHash = computeStepShapeHash(
589
+ this.steps,
590
+ getNested
591
+ );
592
+ return this._cachedStepShapeHash;
593
+ }
594
+ /**
595
+ * Validate user-provided RunOptions before a run begins. Throws on
596
+ * outright errors and on the loud-disaster combo (`freezeSnapshots: true
597
+ * + checkpointEvery: 1` on a workflow of 8+ steps). Warns once on the
598
+ * merely-suspicious combo (`freezeSnapshots: true + cadence <= 2`).
599
+ * Plan-of-record: catastrophic combo escape via the
600
+ * `"iAcceptThePerformanceCost"` literal.
601
+ */
602
+ validateRunOptions(opts) {
603
+ if (!opts) return;
604
+ if (!opts.onCheckpoint) return;
605
+ if (opts.checkpointEvery !== void 0 && opts.checkpointWhen !== void 0) {
606
+ throw new Error("RunOptions: checkpointEvery and checkpointWhen are mutually exclusive");
607
+ }
608
+ if (opts.checkpointEvery !== void 0 && (!Number.isInteger(opts.checkpointEvery) || opts.checkpointEvery < 1)) {
609
+ throw new Error(`RunOptions: checkpointEvery must be a positive integer, got ${opts.checkpointEvery}`);
610
+ }
611
+ if (opts.checkpointTimeout !== void 0 && (!Number.isFinite(opts.checkpointTimeout) || opts.checkpointTimeout < 1)) {
612
+ throw new Error(`RunOptions: checkpointTimeout must be a finite positive number (ms), got ${opts.checkpointTimeout}`);
613
+ }
614
+ const length = this.cachedExecutableStepCount;
615
+ const cadence = opts.checkpointEvery ?? Math.max(1, Math.ceil(length / 4));
616
+ if (opts.freezeSnapshots && opts.freezeSnapshots !== "iAcceptThePerformanceCost" && cadence === 1 && length >= 8) {
617
+ throw new Error(
618
+ `freezeSnapshots+checkpointEvery:1 on a ${length}-step workflow is reliably catastrophic. Set checkpointEvery >= 5, freezeSnapshots: false, or pass "iAcceptThePerformanceCost".`
619
+ );
620
+ }
621
+ if (opts.freezeSnapshots && cadence <= 2) {
622
+ warnOnce(
623
+ "pipeai:freezeSnapshots-low-cadence",
624
+ "pipeai: freezeSnapshots+checkpointEvery<=2 compounds graph-walk cost."
625
+ );
626
+ }
627
+ }
628
+ // ── Observability helpers ─────────────────────────────────────
629
+ /**
630
+ * Fire an observability hook safely. Returns `undefined` synchronously when
631
+ * no hook is registered — avoiding the promise wrapper + microtask that an
632
+ * async function would unconditionally allocate on every step boundary.
633
+ *
634
+ * On hook throw:
635
+ * - non-`onStepError` hooks: warning pushed + console.error.
636
+ * - `onStepError`: throw is propagated as a return value; the run loop
637
+ * attaches it as `cause` on the original step error.
638
+ *
639
+ * Returns the hook's thrown error if any; undefined otherwise. Callers
640
+ * `await` the result — `await undefined` is sync, so the no-hook path
641
+ * stays allocation-free.
642
+ */
643
+ fireHook(state, name, event) {
644
+ const hook = this.observability?.[name];
645
+ if (!hook) return void 0;
646
+ return this.fireHookSlow(state, name, event, hook);
647
+ }
648
+ async fireHookSlow(state, name, event, hook) {
649
+ try {
650
+ await hook(event);
651
+ return void 0;
652
+ } catch (e) {
653
+ if (name !== "onStepError") {
654
+ const stepId = event.stepId;
655
+ pushWarning(state, name, stepId, e);
656
+ console.error(`pipeai: ${name} hook threw for stepId "${stepId}":`, e);
657
+ }
658
+ return e;
659
+ }
316
660
  }
317
661
  // ── Execution ─────────────────────────────────────────────────
318
662
  async generate(ctx, ...args) {
663
+ this.ensureDuplicateCheck();
319
664
  const input = args[0];
665
+ const opts = args[1];
666
+ this.validateRunOptions(opts);
320
667
  const state = {
321
668
  ctx,
322
669
  output: input,
323
- mode: "generate"
324
- };
325
- await this.execute(state);
326
- return {
327
- output: state.output
670
+ mode: "generate",
671
+ runOptions: opts,
672
+ abortSignal: opts?.abortSignal
328
673
  };
674
+ await this.execute(state, 0, opts);
675
+ return this.buildResult(state);
329
676
  }
330
677
  stream(ctx, ...args) {
678
+ this.ensureDuplicateCheck();
331
679
  const input = args[0];
332
680
  const options = args[1];
681
+ const opts = args[2];
682
+ this.validateRunOptions(opts);
683
+ const abortSignal = opts?.abortSignal;
333
684
  let resolveOutput;
334
685
  let rejectOutput;
335
686
  const outputPromise = new Promise((res, rej) => {
@@ -344,11 +695,15 @@ var SealedWorkflow = class {
344
695
  ctx,
345
696
  output: input,
346
697
  mode: "stream",
347
- writer
698
+ writer,
699
+ runOptions: opts,
700
+ abortSignal
348
701
  };
349
702
  try {
350
- await this.execute(state);
351
- resolveOutput(state.output);
703
+ await this.execute(state, 0, opts);
704
+ const result = this.buildResult(state);
705
+ maybeWarnStreamOnErrorOnSuspend(result, options);
706
+ resolveOutput(result);
352
707
  } catch (error) {
353
708
  rejectOutput(error);
354
709
  throw error;
@@ -362,20 +717,78 @@ var SealedWorkflow = class {
362
717
  output: outputPromise
363
718
  };
364
719
  }
720
+ // Helper — converts terminal RuntimeState into a WorkflowResult; freezes
721
+ // snapshot + warnings if requested via runOptions.
722
+ buildResult(state) {
723
+ const warnings = state.warnings ?? [];
724
+ if (state.suspension && resolveFreezeSnapshots(state)) {
725
+ deepFreeze(warnings);
726
+ }
727
+ if (state.suspension) {
728
+ return { status: "suspended", snapshot: state.suspension, warnings };
729
+ }
730
+ return { status: "complete", output: state.output, warnings };
731
+ }
365
732
  // ── Internal: execute pipeline ────────────────────────────────
366
- async execute(state, startIndex = 0) {
733
+ async execute(state, startIndex = 0, opts, initialError = null) {
367
734
  if (this.steps.length === 0) {
368
735
  throw new Error("Workflow has no steps. Add at least one step before calling generate() or stream().");
369
736
  }
370
- let pendingError = null;
737
+ if (opts !== void 0 && state.runOptions === void 0) {
738
+ state.runOptions = opts;
739
+ }
740
+ const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedExecutableStepCount / 4)) : 0;
741
+ let pendingError = initialError;
742
+ let abortPromoted = false;
743
+ const makeAbortError = (signal) => ({
744
+ error: signal.reason ?? new Error("Workflow aborted"),
745
+ stepId: "abort",
746
+ source: "step"
747
+ });
371
748
  for (let i = startIndex; i < this.steps.length; i++) {
749
+ if (state.abortSignal?.aborted) {
750
+ if (!abortPromoted) {
751
+ abortPromoted = true;
752
+ state.suspension = void 0;
753
+ if (pendingError) demotePendingError(state, pendingError);
754
+ pendingError = makeAbortError(state.abortSignal);
755
+ } else if (!pendingError) {
756
+ pendingError = makeAbortError(state.abortSignal);
757
+ }
758
+ }
372
759
  const node = this.steps[i];
373
760
  if (node.type === "finally") {
374
- await node.execute(state);
761
+ const stepId2 = node.id;
762
+ const finStart = performance.now();
763
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "finally", ctx: state.ctx, input: state.output });
764
+ try {
765
+ await node.execute(state);
766
+ await this.fireHook(state, "onStepFinish", {
767
+ stepId: stepId2,
768
+ type: "finally",
769
+ ctx: state.ctx,
770
+ output: state.output,
771
+ durationMs: performance.now() - finStart,
772
+ suspended: false
773
+ });
774
+ } catch (e) {
775
+ await this.fireHook(state, "onStepError", {
776
+ stepId: stepId2,
777
+ type: "finally",
778
+ ctx: state.ctx,
779
+ error: e,
780
+ durationMs: performance.now() - finStart
781
+ });
782
+ if (pendingError) demotePendingError(state, pendingError);
783
+ pendingError = { error: e, stepId: stepId2, source: "finally" };
784
+ }
375
785
  continue;
376
786
  }
377
787
  if (node.type === "catch") {
378
- if (!pendingError) continue;
788
+ if (state.suspension || !pendingError || state.checkpointFailed) continue;
789
+ const stepId2 = node.id;
790
+ const cStart = performance.now();
791
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "catch", ctx: state.ctx, input: state.output });
379
792
  try {
380
793
  state.output = await node.catchFn({
381
794
  error: pendingError.error,
@@ -384,49 +797,183 @@ var SealedWorkflow = class {
384
797
  stepId: pendingError.stepId
385
798
  });
386
799
  pendingError = null;
387
- } catch (catchError) {
388
- pendingError = { error: catchError, stepId: node.id };
800
+ await this.fireHook(state, "onStepFinish", {
801
+ stepId: stepId2,
802
+ type: "catch",
803
+ ctx: state.ctx,
804
+ output: state.output,
805
+ durationMs: performance.now() - cStart,
806
+ suspended: false
807
+ });
808
+ } catch (e) {
809
+ await this.fireHook(state, "onStepError", {
810
+ stepId: stepId2,
811
+ type: "catch",
812
+ ctx: state.ctx,
813
+ error: e,
814
+ durationMs: performance.now() - cStart
815
+ });
816
+ if (pendingError) demotePendingError(state, pendingError);
817
+ pendingError = { error: e, stepId: stepId2, source: "catch" };
389
818
  }
390
819
  continue;
391
820
  }
821
+ if (state.suspension || pendingError) continue;
392
822
  if (node.type === "gate") {
393
- if (pendingError) continue;
394
- if (node.condition) {
395
- const shouldSuspend = await node.condition(state);
396
- if (!shouldSuspend) continue;
397
- }
398
- const gatePayload = await node.payload(state);
399
- throw new WorkflowSuspended({
400
- version: 1,
401
- resumeFromIndex: i,
823
+ const stepId2 = node.id;
824
+ const gStart = performance.now();
825
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "gate", ctx: state.ctx, input: state.output });
826
+ try {
827
+ if (node.condition && !await node.condition(state)) {
828
+ await this.fireHook(state, "onStepFinish", {
829
+ stepId: stepId2,
830
+ type: "gate",
831
+ ctx: state.ctx,
832
+ output: state.output,
833
+ durationMs: performance.now() - gStart,
834
+ suspended: false
835
+ });
836
+ continue;
837
+ }
838
+ const snapshot = {
839
+ version: 2,
840
+ kind: "gate",
841
+ resumeFromIndex: i,
842
+ output: state.output,
843
+ gateId: node.id,
844
+ gatePayload: await node.payload(state)
845
+ };
846
+ state.suspension = snapshot;
847
+ if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
848
+ await this.fireHook(state, "onStepFinish", {
849
+ stepId: stepId2,
850
+ type: "gate",
851
+ ctx: state.ctx,
852
+ output: state.output,
853
+ durationMs: performance.now() - gStart,
854
+ suspended: true
855
+ });
856
+ } catch (e) {
857
+ pendingError = { error: e, stepId: node.id, source: "step" };
858
+ }
859
+ continue;
860
+ }
861
+ const obsType = getObservabilityType(node);
862
+ const stepId = node.id;
863
+ const sStart = performance.now();
864
+ const stepInput = state.output;
865
+ await this.fireHook(state, "onStepStart", { stepId, type: obsType, ctx: state.ctx, input: stepInput });
866
+ try {
867
+ await node.execute(state);
868
+ await this.fireHook(state, "onStepFinish", {
869
+ stepId,
870
+ type: obsType,
871
+ ctx: state.ctx,
402
872
  output: state.output,
403
- gateId: node.id,
404
- gatePayload
873
+ durationMs: performance.now() - sStart,
874
+ suspended: false
875
+ });
876
+ } catch (e) {
877
+ pendingError = { error: e, stepId: node.id, source: "step" };
878
+ const obsError = await this.fireHook(state, "onStepError", {
879
+ stepId,
880
+ type: obsType,
881
+ ctx: state.ctx,
882
+ error: e,
883
+ durationMs: performance.now() - sStart
405
884
  });
885
+ if (obsError !== void 0 && typeof e === "object" && e !== null) {
886
+ try {
887
+ e.cause = obsError;
888
+ } catch {
889
+ }
890
+ }
891
+ }
892
+ const leaked = state.suspension;
893
+ if (leaked) {
894
+ state.suspension = void 0;
895
+ throw new Error(`internal: suspension bubbled from non-gate step "${node.id}" (gate "${leaked.gateId}").`);
896
+ }
897
+ if (!pendingError && !state.suspension && opts?.onCheckpoint) {
898
+ const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: i, stepId: node.id, ctx: state.ctx }) : (i + 1) % ckptCadence === 0;
899
+ if (shouldCheckpoint) {
900
+ const ckptStart = performance.now();
901
+ try {
902
+ await emitCheckpoint(
903
+ state,
904
+ opts,
905
+ i + 1,
906
+ this.cachedStepShapeHash
907
+ );
908
+ } catch (e) {
909
+ pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
910
+ state.checkpointFailed = true;
911
+ await this.fireHook(state, "onStepError", {
912
+ stepId: CHECKPOINT_STEP_ID,
913
+ type: "step",
914
+ ctx: state.ctx,
915
+ error: e,
916
+ durationMs: performance.now() - ckptStart
917
+ });
918
+ }
919
+ }
920
+ }
921
+ }
922
+ if (pendingError && !state.suspension) {
923
+ if (state.checkpointFailed) {
924
+ const warningsArr = state.warnings ?? [];
925
+ const checkpointError = pendingError.source === "onCheckpoint" ? pendingError.error : warningsArr.find((w) => w.source === "onCheckpoint")?.error;
926
+ const finallyErrors = warningsArr.filter((w) => w.source === "finally").map((w) => w.error);
927
+ const all = pendingError.source === "finally" ? [...finallyErrors, pendingError.error] : finallyErrors;
928
+ if (all.length > 0) {
929
+ console.warn(
930
+ `pipeai: ${all.length} .finally() error(s) suppressed by checkpoint-failure precedence:`,
931
+ all
932
+ );
933
+ }
934
+ throw checkpointError ?? pendingError.error;
935
+ }
936
+ const isFinallyPath = pendingError.source === "finally" || (state.warnings?.some((w) => w.source === "finally") ?? false);
937
+ if (isFinallyPath) {
938
+ const all = [...(state.warnings ?? []).map((w) => w.error), pendingError.error];
939
+ throw new AggregateError(all, `Workflow failed with ${all.length} error(s) from .finally() bodies`);
406
940
  }
407
- if (pendingError) continue;
941
+ throw pendingError.error;
942
+ } else if (pendingError && state.suspension) {
943
+ demotePendingError(state, pendingError);
408
944
  try {
409
- await node.execute(state);
410
- } catch (error) {
411
- if (error instanceof WorkflowSuspended) throw error;
412
- pendingError = { error, stepId: node.id };
945
+ await this.observability?.onStepError?.({
946
+ stepId: pendingError.stepId,
947
+ type: pendingErrorSourceToStepType(pendingError.source),
948
+ ctx: state.ctx,
949
+ error: pendingError.error,
950
+ durationMs: 0
951
+ });
952
+ } catch (obsError) {
953
+ pushWarning(state, "onStepError", pendingError.stepId, obsError);
413
954
  }
955
+ pendingError = null;
414
956
  }
415
- if (pendingError) throw pendingError.error;
416
957
  }
417
958
  // ── Internal: execute a nested workflow within a step/loop ─────
418
959
  // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
419
960
  // access rules only allow calling workflow.execute() from the same class.
961
+ //
962
+ // Contract: clears any inner suspension before re-throwing as
963
+ // NestedGateUnsupportedError. The outer execute() therefore never observes
964
+ // a leaked `state.suspension` from non-gate nodes (defensive invariant).
420
965
  async executeNestedWorkflow(state, workflow) {
966
+ const savedRunOptions = state.runOptions;
967
+ state.runOptions = void 0;
421
968
  try {
422
969
  await workflow.execute(state);
423
- } catch (error) {
424
- if (error instanceof WorkflowSuspended) {
425
- throw new Error(
426
- `Gates inside nested workflows are not yet supported. Gate "${error.snapshot.gateId}" was hit inside nested workflow "${workflow.id ?? "(anonymous)"}". Consider using a conditional gate with \`condition\` to skip when criteria are met, or restructure the workflow to use gates at the top level only.`
427
- );
428
- }
429
- throw error;
970
+ } finally {
971
+ state.runOptions = savedRunOptions;
972
+ }
973
+ if (state.suspension) {
974
+ const gateId = state.suspension.gateId;
975
+ state.suspension = void 0;
976
+ throw new NestedGateUnsupportedError(gateId, workflow.id);
430
977
  }
431
978
  }
432
979
  // ── Internal: execute an agent within a step/branch ───────────
@@ -436,59 +983,141 @@ var SealedWorkflow = class {
436
983
  async executeAgent(state, agent, ctx, options) {
437
984
  const input = state.output;
438
985
  const hasStructuredOutput = agent.hasOutput;
986
+ const abortSignal = state.abortSignal;
987
+ const agentCallOpts = abortSignal ? { abortSignal } : void 0;
439
988
  if (state.mode === "stream" && state.writer) {
440
989
  const writer = state.writer;
441
990
  await runWithWriter(writer, async () => {
442
- const result = await agent.stream(ctx, state.output);
991
+ const result = await agent.stream(ctx, state.output, agentCallOpts);
443
992
  if (options?.handleStream) {
444
993
  await options.handleStream({ result, writer, ctx });
445
994
  } else {
446
995
  writer.merge(result.toUIMessageStream());
447
996
  }
448
- if (options?.onStreamResult) {
449
- await options.onStreamResult({ result, ctx, input });
997
+ const hookParams = {
998
+ mode: "stream",
999
+ result,
1000
+ ctx,
1001
+ input
1002
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1003
+ };
1004
+ if (options?.onResult) {
1005
+ await options.onResult(hookParams);
450
1006
  }
451
- if (options?.mapStreamResult) {
452
- state.output = await options.mapStreamResult({ result, ctx, input });
1007
+ if (options?.mapResult) {
1008
+ state.output = await options.mapResult(hookParams);
453
1009
  } else {
454
- state.output = await extractOutput(result, hasStructuredOutput);
1010
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
455
1011
  }
456
1012
  });
457
1013
  } else {
458
- const result = await agent.generate(ctx, state.output);
459
- if (options?.onGenerateResult) {
460
- await options.onGenerateResult({ result, ctx, input });
1014
+ const result = await agent.generate(ctx, state.output, agentCallOpts);
1015
+ const hookParams = {
1016
+ mode: "generate",
1017
+ result,
1018
+ ctx,
1019
+ input
1020
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1021
+ };
1022
+ if (options?.onResult) {
1023
+ await options.onResult(hookParams);
461
1024
  }
462
- if (options?.mapGenerateResult) {
463
- state.output = await options.mapGenerateResult({ result, ctx, input });
1025
+ if (options?.mapResult) {
1026
+ state.output = await options.mapResult(hookParams);
464
1027
  } else {
465
- state.output = await extractOutput(result, hasStructuredOutput);
1028
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
466
1029
  }
467
1030
  }
468
1031
  }
469
1032
  // ── Gate: load persisted state for resumption ──────────────────
470
1033
  loadState(gateId, snapshot) {
471
- if (snapshot.gateId !== gateId) {
1034
+ if (snapshot.version === 2 && snapshot.kind === "checkpoint") {
1035
+ throw new Error(`loadState: received a checkpoint snapshot. Use resumeFrom() for checkpoint resume; loadState() is for gates.`);
1036
+ }
1037
+ const gateLike = snapshot;
1038
+ if (gateLike.gateId !== gateId) {
472
1039
  throw new Error(
473
- `loadState: gate ID mismatch \u2014 expected "${gateId}" but snapshot has "${snapshot.gateId}".`
1040
+ `loadState: gate ID mismatch \u2014 expected "${gateId}" but snapshot has "${gateLike.gateId}".`
474
1041
  );
475
1042
  }
476
- const gateIndex = this.findGateIndex(snapshot);
1043
+ this.ensureDuplicateCheck();
1044
+ const gateIndex = this.findGateIndex(gateLike);
477
1045
  const gateNode = this.steps[gateIndex];
478
- return new ResumedWorkflow(
479
- this.steps,
480
- gateIndex + 1,
481
- gateNode.schema,
482
- gateNode.merge,
483
- snapshot.output
484
- );
1046
+ return new ResumedWorkflow(this.steps, gateIndex + 1, {
1047
+ mode: "gate",
1048
+ schema: gateNode.schema,
1049
+ mergeFn: gateNode.merge,
1050
+ priorOutput: gateLike.output,
1051
+ snapshot: gateLike,
1052
+ observability: this.observability
1053
+ });
1054
+ }
1055
+ // ── Checkpoint resume ──────────────────────────────────────────
1056
+ /**
1057
+ * Resume from a checkpoint snapshot. Validates the step-shape hash unless
1058
+ * `{ skipShapeCheck: true }` is passed. Throws on:
1059
+ * - gate snapshots (use `loadState` instead)
1060
+ * - missing/corrupted `stepShapeHash`
1061
+ * - shape mismatch (unless skipped)
1062
+ * - out-of-bounds `resumeFromIndex`
1063
+ * - 0-step workflow (structural invariant)
1064
+ *
1065
+ * Returns a `CheckpointResumedWorkflow` whose `generate(ctx, opts?)` takes
1066
+ * NO response arg — the state is seeded from the snapshot's output. The
1067
+ * matching gate-resume path (`loadState`) keeps the `response` arg.
1068
+ */
1069
+ resumeFrom(snapshot, options) {
1070
+ const isGate = snapshot.version === 2 && snapshot.kind === "gate" || snapshot.version === 1 && snapshot.gateId !== void 0;
1071
+ if (isGate) {
1072
+ throw new Error(`resumeFrom: received a gate snapshot. Use loadState() for gate resume; resumeFrom() is for checkpoints.`);
1073
+ }
1074
+ if (this.steps.length === 0) {
1075
+ throw new Error("resumeFrom: workflow has no steps; snapshot is structurally invalid.");
1076
+ }
1077
+ const ckpt = snapshot;
1078
+ const idx = ckpt.resumeFromIndex;
1079
+ if (!Number.isInteger(idx) || idx < 0 || idx > this.steps.length) {
1080
+ throw new Error(`resumeFrom: resumeFromIndex (${idx}) out of bounds for ${this.steps.length}-step workflow.`);
1081
+ }
1082
+ if (!options?.skipShapeCheck) {
1083
+ if (!ckpt.stepShapeHash) {
1084
+ throw new Error("resumeFrom: snapshot missing stepShapeHash; corrupted or hand-crafted.");
1085
+ }
1086
+ this.ensureDuplicateCheck();
1087
+ if (this.cachedStepShapeHash !== ckpt.stepShapeHash) {
1088
+ throw new Error("resumeFrom: workflow shape mismatch; cannot safely resume. Pass { skipShapeCheck: true } to override.");
1089
+ }
1090
+ } else {
1091
+ this.ensureDuplicateCheck();
1092
+ }
1093
+ return new CheckpointResumedWorkflow(this.steps, idx, {
1094
+ mode: "checkpoint",
1095
+ priorOutput: ckpt.output,
1096
+ snapshot: ckpt,
1097
+ observability: this.observability
1098
+ });
1099
+ }
1100
+ /**
1101
+ * Append a `.finally()` body to a sealed workflow, returning another sealed
1102
+ * workflow. Allows multi-finally chains (`.finally().finally()`). A throwing
1103
+ * `.finally` body does NOT abort subsequent ones — they all run.
1104
+ */
1105
+ finally(id, fn) {
1106
+ const node = {
1107
+ type: "finally",
1108
+ id,
1109
+ execute: async (state) => {
1110
+ await fn({ ctx: state.ctx });
1111
+ }
1112
+ };
1113
+ return new _SealedWorkflow([...this.steps, node], this.id, this.observability);
485
1114
  }
486
1115
  findGateIndex(snapshot) {
487
- if (snapshot.version !== 1) {
1116
+ if (snapshot.version !== 1 && snapshot.version !== 2) {
488
1117
  throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
489
1118
  }
490
1119
  const hint = snapshot.resumeFromIndex;
491
- if (hint >= 0 && hint < this.steps.length) {
1120
+ if (Number.isInteger(hint) && hint >= 0 && hint < this.steps.length) {
492
1121
  const node = this.steps[hint];
493
1122
  if (node.type === "gate" && node.id === snapshot.gateId) {
494
1123
  return hint;
@@ -511,12 +1140,12 @@ var ResumedWorkflow = class extends SealedWorkflow {
511
1140
  mergeFn;
512
1141
  priorOutput;
513
1142
  /** @internal */
514
- constructor(steps, startIndex, schema, mergeFn, priorOutput) {
515
- super(steps);
1143
+ constructor(steps, startIndex, config) {
1144
+ super(steps, void 0, config.observability);
516
1145
  this.startIndex = startIndex;
517
- this.schema = schema;
518
- this.mergeFn = mergeFn;
519
- this.priorOutput = priorOutput;
1146
+ this.schema = config.schema;
1147
+ this.mergeFn = config.mergeFn;
1148
+ this.priorOutput = config.priorOutput;
520
1149
  }
521
1150
  validateResponse(response) {
522
1151
  if (this.schema) {
@@ -525,15 +1154,31 @@ var ResumedWorkflow = class extends SealedWorkflow {
525
1154
  return response;
526
1155
  }
527
1156
  async generate(ctx, ...args) {
528
- const response = this.validateResponse(args[0]);
529
- const output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
530
- const state = { ctx, output, mode: "generate" };
531
- await this.execute(state, this.startIndex);
532
- return { output: state.output };
1157
+ const rawResponse = args[0];
1158
+ const opts = args[1];
1159
+ let output = this.priorOutput;
1160
+ let initialError = null;
1161
+ try {
1162
+ const response = this.validateResponse(rawResponse);
1163
+ output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
1164
+ } catch (error) {
1165
+ initialError = { error, stepId: "gate:resume", source: "step" };
1166
+ }
1167
+ const state = {
1168
+ ctx,
1169
+ output,
1170
+ mode: "generate",
1171
+ runOptions: opts,
1172
+ abortSignal: opts?.abortSignal
1173
+ };
1174
+ await this.execute(state, this.startIndex, opts, initialError);
1175
+ return this.buildResult(state);
533
1176
  }
534
1177
  stream(ctx, ...args) {
535
- const response = this.validateResponse(args[0]);
1178
+ const rawResponse = args[0];
536
1179
  const options = args[1];
1180
+ const opts = args[2];
1181
+ const abortSignal = opts?.abortSignal;
537
1182
  let resolveOutput;
538
1183
  let rejectOutput;
539
1184
  const outputPromise = new Promise((res, rej) => {
@@ -544,18 +1189,92 @@ var ResumedWorkflow = class extends SealedWorkflow {
544
1189
  });
545
1190
  const mergeFn = this.mergeFn;
546
1191
  const priorOutput = this.priorOutput;
1192
+ const startIndex = this.startIndex;
547
1193
  const stream = (0, import_ai3.createUIMessageStream)({
548
1194
  execute: async ({ writer }) => {
549
- const output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
1195
+ let output = priorOutput;
1196
+ let initialError = null;
1197
+ try {
1198
+ const response = this.validateResponse(rawResponse);
1199
+ output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
1200
+ } catch (error) {
1201
+ initialError = { error, stepId: "gate:resume", source: "step" };
1202
+ }
550
1203
  const state = {
551
1204
  ctx,
552
1205
  output,
553
1206
  mode: "stream",
554
- writer
1207
+ writer,
1208
+ runOptions: opts,
1209
+ abortSignal
1210
+ };
1211
+ try {
1212
+ await this.execute(state, startIndex, opts, initialError);
1213
+ const result = this.buildResult(state);
1214
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1215
+ resolveOutput(result);
1216
+ } catch (error) {
1217
+ rejectOutput(error);
1218
+ throw error;
1219
+ }
1220
+ },
1221
+ ...options?.onError ? { onError: options.onError } : {},
1222
+ ...options?.onFinish ? { onFinish: options.onFinish } : {}
1223
+ });
1224
+ return { stream, output: outputPromise };
1225
+ }
1226
+ };
1227
+ var CheckpointResumedWorkflow = class extends SealedWorkflow {
1228
+ startIndex;
1229
+ priorOutput;
1230
+ /** @internal */
1231
+ constructor(steps, startIndex, config) {
1232
+ super(steps, void 0, config.observability);
1233
+ this.startIndex = startIndex;
1234
+ this.priorOutput = config.priorOutput;
1235
+ }
1236
+ // Override with widened arg list compatible with parent's `[input?, opts?]`.
1237
+ // Inputs are ignored — state is seeded from the snapshot's `output` field.
1238
+ async generate(ctx, ...args) {
1239
+ const opts = args[1];
1240
+ this.validateRunOptions(opts);
1241
+ const state = {
1242
+ ctx,
1243
+ output: this.priorOutput,
1244
+ mode: "generate",
1245
+ runOptions: opts
1246
+ };
1247
+ await this.execute(state, this.startIndex, opts);
1248
+ return this.buildResult(state);
1249
+ }
1250
+ stream(ctx, ...args) {
1251
+ const options = args[1];
1252
+ const opts = args[2];
1253
+ this.validateRunOptions(opts);
1254
+ let resolveOutput;
1255
+ let rejectOutput;
1256
+ const outputPromise = new Promise((res, rej) => {
1257
+ resolveOutput = res;
1258
+ rejectOutput = rej;
1259
+ });
1260
+ outputPromise.catch(() => {
1261
+ });
1262
+ const priorOutput = this.priorOutput;
1263
+ const startIndex = this.startIndex;
1264
+ const stream = (0, import_ai3.createUIMessageStream)({
1265
+ execute: async ({ writer }) => {
1266
+ const state = {
1267
+ ctx,
1268
+ output: priorOutput,
1269
+ mode: "stream",
1270
+ writer,
1271
+ runOptions: opts
555
1272
  };
556
1273
  try {
557
- await this.execute(state, this.startIndex);
558
- resolveOutput(state.output);
1274
+ await this.execute(state, startIndex, opts);
1275
+ const result = this.buildResult(state);
1276
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1277
+ resolveOutput(result);
559
1278
  } catch (error) {
560
1279
  rejectOutput(error);
561
1280
  throw error;
@@ -574,15 +1293,21 @@ var Workflow = class _Workflow extends SealedWorkflow {
574
1293
  * shortening it relative to the input array.
575
1294
  */
576
1295
  static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
577
- constructor(steps = [], id) {
578
- super(steps, id);
1296
+ constructor(steps = [], id, observability) {
1297
+ super(steps, id, observability);
579
1298
  }
580
1299
  static create(options) {
581
- return new _Workflow([], options?.id);
1300
+ return new _Workflow([], options?.id, options?.observability);
582
1301
  }
583
1302
  static from(agent, options) {
584
1303
  return new _Workflow([]).step(agent, options);
585
1304
  }
1305
+ // Builder helper — append a step and return a re-typed Workflow.
1306
+ // Centralizes the `[...steps, node] as any` + new Workflow + observability/id
1307
+ // forwarding pattern used by every combinator method.
1308
+ appendStep(node) {
1309
+ return new _Workflow([...this.steps, node], this.id, this.observability);
1310
+ }
586
1311
  // ── step: implementation ──────────────────────────────────────
587
1312
  step(target, optionsOrFn) {
588
1313
  if (target instanceof SealedWorkflow) {
@@ -590,11 +1315,15 @@ var Workflow = class _Workflow extends SealedWorkflow {
590
1315
  const node2 = {
591
1316
  type: "step",
592
1317
  id: workflow.id ?? "nested-workflow",
1318
+ nestedWorkflow: workflow,
1319
+ // Feeds the recursive stepShapeHash walk.
1320
+ category: "nested",
1321
+ // Observability event type.
593
1322
  execute: async (state) => {
594
1323
  await this.executeNestedWorkflow(state, workflow);
595
1324
  }
596
1325
  };
597
- return new _Workflow([...this.steps, node2], this.id);
1326
+ return this.appendStep(node2);
598
1327
  }
599
1328
  if (typeof target === "string") {
600
1329
  if (typeof optionsOrFn !== "function") {
@@ -611,19 +1340,19 @@ var Workflow = class _Workflow extends SealedWorkflow {
611
1340
  });
612
1341
  }
613
1342
  };
614
- return new _Workflow([...this.steps, node2], this.id);
1343
+ return this.appendStep(node2);
615
1344
  }
616
1345
  const agent = target;
617
1346
  const options = optionsOrFn;
618
1347
  const node = {
619
1348
  type: "step",
620
- id: agent.id,
1349
+ id: options?.id ?? agent.id,
621
1350
  execute: async (state) => {
622
1351
  const ctx = state.ctx;
623
1352
  await this.executeAgent(state, agent, ctx, options);
624
1353
  }
625
1354
  };
626
- return new _Workflow([...this.steps, node], this.id);
1355
+ return this.appendStep(node);
627
1356
  }
628
1357
  // ── gate: human-in-the-loop suspension point ────────────────
629
1358
  gate(id, options) {
@@ -649,19 +1378,20 @@ var Workflow = class _Workflow extends SealedWorkflow {
649
1378
  return state.output;
650
1379
  }
651
1380
  };
652
- return new _Workflow([...this.steps, node], this.id);
1381
+ return this.appendStep(node);
653
1382
  }
654
1383
  // ── branch: implementation ────────────────────────────────────
655
- branch(casesOrConfig) {
1384
+ branch(casesOrConfig, options) {
656
1385
  if (Array.isArray(casesOrConfig)) {
657
- return this.branchPredicate(casesOrConfig);
1386
+ return this.branchPredicate(casesOrConfig, options?.id);
658
1387
  }
659
- return this.branchSelect(casesOrConfig);
1388
+ return this.branchSelect(casesOrConfig, options?.id);
660
1389
  }
661
- branchPredicate(cases) {
1390
+ branchPredicate(cases, explicitId) {
662
1391
  const node = {
663
1392
  type: "step",
664
- id: "branch:predicate",
1393
+ id: explicitId ?? "branch:predicate",
1394
+ category: "branch",
665
1395
  execute: async (state) => {
666
1396
  const ctx = state.ctx;
667
1397
  const input = state.output;
@@ -673,21 +1403,43 @@ var Workflow = class _Workflow extends SealedWorkflow {
673
1403
  await this.executeAgent(state, branchCase.agent, ctx, branchCase);
674
1404
  return;
675
1405
  }
676
- throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${JSON.stringify(input)}`);
1406
+ let inputRepr;
1407
+ try {
1408
+ inputRepr = JSON.stringify(input);
1409
+ if (inputRepr === void 0) inputRepr = String(input);
1410
+ } catch {
1411
+ inputRepr = `[unserializable ${typeof input}]`;
1412
+ }
1413
+ throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${inputRepr}`);
677
1414
  }
678
1415
  };
679
- return new _Workflow([...this.steps, node], this.id);
1416
+ return this.appendStep(node);
680
1417
  }
681
- branchSelect(config) {
1418
+ branchSelect(config, explicitId) {
682
1419
  const node = {
683
1420
  type: "step",
684
- id: "branch:select",
1421
+ id: explicitId ?? "branch:select",
1422
+ category: "branch",
685
1423
  execute: async (state) => {
686
1424
  const ctx = state.ctx;
687
1425
  const input = state.output;
688
1426
  const key = await config.select({ ctx, input });
689
- let agent = config.agents[key];
1427
+ const keyDeclared = Object.prototype.hasOwnProperty.call(config.agents, key);
1428
+ if (keyDeclared && config.agents[key] === void 0) {
1429
+ throw new WorkflowBranchError(
1430
+ "select",
1431
+ `Agent for key "${key}" was declared but the value is undefined. This usually means a conditional spread set the value to undefined. Available keys: ${Object.keys(config.agents).join(", ")}`
1432
+ );
1433
+ }
1434
+ let agent = keyDeclared ? config.agents[key] : void 0;
690
1435
  if (!agent) {
1436
+ if (config.onUnknownKey) {
1437
+ config.onUnknownKey({
1438
+ key,
1439
+ availableKeys: Object.keys(config.agents),
1440
+ ctx
1441
+ });
1442
+ }
691
1443
  if (config.fallback) {
692
1444
  agent = config.fallback;
693
1445
  } else {
@@ -697,32 +1449,37 @@ var Workflow = class _Workflow extends SealedWorkflow {
697
1449
  await this.executeAgent(state, agent, ctx, config);
698
1450
  }
699
1451
  };
700
- return new _Workflow([...this.steps, node], this.id);
1452
+ return this.appendStep(node);
701
1453
  }
702
1454
  // ── foreach: array iteration ─────────────────────────────────
703
1455
  /**
704
1456
  * Map each item of an array through an agent or sub-workflow.
705
1457
  *
706
1458
  * @param target Agent or `SealedWorkflow` invoked once per item.
1459
+ * @param options.id Override the default step id (`foreach:<agentId>` or
1460
+ * the workflow's id). Required when chaining multiple foreach over the same
1461
+ * target — the construction-time `(type, id)` walk rejects duplicates.
707
1462
  * @param options.concurrency Max items in flight at any moment (default 1).
708
1463
  * Backed by a semaphore: as soon as one item completes, the next launches —
709
1464
  * no lockstep batching.
710
- * @param options.onError Per-iteration error handler. When provided, a single
711
- * item's failure no longer aborts the foreach. Return a `TNextOutput` value
712
- * to substitute for the failed item, return `Workflow.SKIP` to omit the
713
- * index (shortening the output array), or throw / return a rejected promise
714
- * to abort the foreach step (the thrown error is caught by any downstream
715
- * `.catch()`). When omitted, the existing fail-fast behavior is preserved.
716
- * `onError` is invoked sequentially in index order after all items settle.
1465
+ * @param options.onError Per-iteration error handler. **Bypassed entirely on
1466
+ * the suspension path** (when any item hits a nested gate) — see the
1467
+ * foreach concurrency hazards in the README. Otherwise: return a
1468
+ * `TNextOutput` value to substitute, return `Workflow.SKIP` to omit, throw
1469
+ * to abort. Invoked sequentially in index order after all items settle.
717
1470
  */
718
1471
  foreach(target, options) {
719
1472
  const concurrency = options?.concurrency ?? 1;
720
1473
  const onError = options?.onError;
721
1474
  const isWorkflow = target instanceof SealedWorkflow;
722
- const id = isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
1475
+ const defaultId = isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
1476
+ const id = options?.id ?? defaultId;
723
1477
  const node = {
724
1478
  type: "step",
725
1479
  id,
1480
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1481
+ nestedWorkflow: isWorkflow ? target : void 0,
1482
+ category: "foreach",
726
1483
  execute: async (state) => {
727
1484
  const items = state.output;
728
1485
  if (!Array.isArray(items)) {
@@ -731,14 +1488,58 @@ var Workflow = class _Workflow extends SealedWorkflow {
731
1488
  const ctx = state.ctx;
732
1489
  const results = new Array(items.length);
733
1490
  const skipped = /* @__PURE__ */ new Set();
1491
+ const itemStates = new Array(items.length);
734
1492
  const executeItem = async (item, index) => {
735
- const itemState = { ctx: state.ctx, output: item, mode: "generate" };
736
- if (isWorkflow) {
737
- await this.executeNestedWorkflow(itemState, target);
738
- } else {
739
- await this.executeAgent(itemState, target, ctx);
1493
+ const itemState = {
1494
+ ctx: state.ctx,
1495
+ output: item,
1496
+ mode: "generate",
1497
+ abortSignal: state.abortSignal
1498
+ };
1499
+ itemStates[index] = itemState;
1500
+ const itemStart = performance.now();
1501
+ await this.fireHook(state, "onItemStart", {
1502
+ stepId: id,
1503
+ type: "foreach",
1504
+ itemIndex: index,
1505
+ ctx: state.ctx,
1506
+ input: item
1507
+ });
1508
+ try {
1509
+ if (isWorkflow) {
1510
+ await this.executeNestedWorkflow(itemState, target);
1511
+ } else {
1512
+ await this.executeAgent(itemState, target, ctx);
1513
+ }
1514
+ results[index] = itemState.output;
1515
+ await this.fireHook(state, "onItemFinish", {
1516
+ stepId: id,
1517
+ type: "foreach",
1518
+ itemIndex: index,
1519
+ ctx: state.ctx,
1520
+ output: itemState.output,
1521
+ durationMs: performance.now() - itemStart
1522
+ });
1523
+ } catch (error) {
1524
+ await this.fireHook(state, "onItemError", {
1525
+ stepId: id,
1526
+ type: "foreach",
1527
+ itemIndex: index,
1528
+ ctx: state.ctx,
1529
+ error,
1530
+ durationMs: performance.now() - itemStart
1531
+ });
1532
+ throw error;
1533
+ }
1534
+ };
1535
+ const mergeItemWarnings = () => {
1536
+ for (let idx = 0; idx < items.length; idx++) {
1537
+ const its = itemStates[idx];
1538
+ if (!its?.warnings) continue;
1539
+ for (const w of its.warnings) {
1540
+ pushWarning(state, w.source, `${id}[${idx}]:${w.stepId}`, w.error);
1541
+ }
740
1542
  }
741
- results[index] = itemState.output;
742
1543
  };
743
1544
  const handleRejection = async (error, item, index) => {
744
1545
  if (!onError) throw error;
@@ -754,49 +1555,244 @@ var Workflow = class _Workflow extends SealedWorkflow {
754
1555
  results[index] = recovered;
755
1556
  }
756
1557
  };
1558
+ const failures = [];
1559
+ const signal = state.abortSignal;
757
1560
  if (concurrency <= 1) {
758
1561
  for (let i = 0; i < items.length; i++) {
1562
+ if (signal?.aborted) {
1563
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1564
+ continue;
1565
+ }
759
1566
  try {
760
1567
  await executeItem(items[i], i);
761
1568
  } catch (error) {
762
- await handleRejection(error, items[i], i);
1569
+ failures.push({ index: i, error });
763
1570
  }
764
1571
  }
765
1572
  } else {
766
- const sem = new Semaphore(concurrency);
767
- const failures = [];
768
- await Promise.all(items.map(async (item, i) => {
769
- await sem.acquire();
1573
+ let nextIndex = 0;
1574
+ const worker = async () => {
1575
+ while (true) {
1576
+ const i = nextIndex++;
1577
+ if (i >= items.length) return;
1578
+ if (signal?.aborted) {
1579
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1580
+ continue;
1581
+ }
1582
+ try {
1583
+ await executeItem(items[i], i);
1584
+ } catch (error) {
1585
+ failures.push({ index: i, error });
1586
+ }
1587
+ }
1588
+ };
1589
+ const workers = Array.from(
1590
+ { length: Math.min(concurrency, items.length) },
1591
+ () => worker()
1592
+ );
1593
+ await Promise.all(workers);
1594
+ }
1595
+ failures.sort((a, b) => a.index - b.index);
1596
+ const gateFailures = [];
1597
+ const nonGateFailures = [];
1598
+ for (const f of failures) {
1599
+ if (f.error instanceof NestedGateUnsupportedError) {
1600
+ gateFailures.push({ index: f.index, error: f.error });
1601
+ } else {
1602
+ nonGateFailures.push(f);
1603
+ }
1604
+ }
1605
+ mergeItemWarnings();
1606
+ if (gateFailures.length > 0) {
1607
+ for (const nr of nonGateFailures) {
1608
+ pushWarning(state, "foreach-sibling", `${id}[${nr.index}]`, nr.error);
1609
+ }
1610
+ const lowest = gateFailures[0];
1611
+ const otherSuspensions = gateFailures.slice(1).map((g) => ({
1612
+ index: g.index,
1613
+ gateId: g.error.gateId
1614
+ }));
1615
+ const siblingErrors = nonGateFailures.map((nr) => nr.error);
1616
+ throw new NestedGateUnsupportedError(
1617
+ lowest.error.gateId,
1618
+ lowest.error.workflowId,
1619
+ siblingErrors,
1620
+ otherSuspensions
1621
+ );
1622
+ }
1623
+ for (const { index, error } of nonGateFailures) {
1624
+ await handleRejection(error, items[index], index);
1625
+ }
1626
+ state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
1627
+ }
1628
+ };
1629
+ return this.appendStep(node);
1630
+ }
1631
+ // Implementation
1632
+ parallel(branches, options) {
1633
+ const isTuple = Array.isArray(branches);
1634
+ const entries = isTuple ? branches.map((target, i) => ({ key: i, index: i, target })) : Object.entries(branches).map(([k, t], i) => ({ key: k, index: i, target: t }));
1635
+ const branchCount = entries.length;
1636
+ const requestedConcurrency = options?.concurrency;
1637
+ let effectiveConcurrency;
1638
+ if (requestedConcurrency === void 0) {
1639
+ effectiveConcurrency = Math.min(branchCount, 5);
1640
+ } else {
1641
+ effectiveConcurrency = requestedConcurrency;
1642
+ }
1643
+ if (requestedConcurrency === void 0 && branchCount > 5) {
1644
+ warnOnce(
1645
+ "pipeai:parallel-cap",
1646
+ `pipeai: parallel() with ${branchCount} branches capped at concurrency 5 by default. Pass { concurrency: ${branchCount} } (or Infinity) to opt in, or set { concurrency: N } if you want fewer.`
1647
+ );
1648
+ }
1649
+ const onError = options?.onError;
1650
+ const id = options?.id ?? (isTuple ? "parallel:tuple" : "parallel:record");
1651
+ const node = {
1652
+ type: "step",
1653
+ id,
1654
+ category: "parallel",
1655
+ execute: async (state) => {
1656
+ const ctx = state.ctx;
1657
+ const input = state.output;
1658
+ const results = isTuple ? new Array(branchCount) : {};
1659
+ const branchStates = new Array(branchCount);
1660
+ const executeBranch = async ({ key, index, target }) => {
1661
+ const branchState = { ctx: state.ctx, output: input, mode: "generate" };
1662
+ branchStates[index] = branchState;
1663
+ const branchStart = performance.now();
1664
+ const itemIndex = isTuple ? index : key;
1665
+ await this.fireHook(state, "onItemStart", {
1666
+ stepId: id,
1667
+ type: "parallel",
1668
+ itemIndex,
1669
+ ctx: state.ctx,
1670
+ input
1671
+ });
1672
+ try {
1673
+ if (target instanceof SealedWorkflow) {
1674
+ await this.executeNestedWorkflow(branchState, target);
1675
+ } else {
1676
+ await this.executeAgent(branchState, target, ctx);
1677
+ }
1678
+ results[key] = branchState.output;
1679
+ await this.fireHook(state, "onItemFinish", {
1680
+ stepId: id,
1681
+ type: "parallel",
1682
+ itemIndex,
1683
+ ctx: state.ctx,
1684
+ output: branchState.output,
1685
+ durationMs: performance.now() - branchStart
1686
+ });
1687
+ } catch (error) {
1688
+ await this.fireHook(state, "onItemError", {
1689
+ stepId: id,
1690
+ type: "parallel",
1691
+ itemIndex,
1692
+ ctx: state.ctx,
1693
+ error,
1694
+ durationMs: performance.now() - branchStart
1695
+ });
1696
+ throw error;
1697
+ }
1698
+ };
1699
+ const failures = [];
1700
+ const eff = Number.isFinite(effectiveConcurrency) ? Math.max(1, effectiveConcurrency) : branchCount;
1701
+ if (eff <= 1) {
1702
+ for (const e of entries) {
770
1703
  try {
771
- await executeItem(item, i);
1704
+ await executeBranch(e);
772
1705
  } catch (error) {
773
- failures.push({ index: i, error });
774
- } finally {
775
- sem.release();
1706
+ failures.push({ key: e.key, index: e.index, error });
776
1707
  }
777
- }));
778
- failures.sort((a, b) => a.index - b.index);
779
- for (const { index, error } of failures) {
780
- await handleRejection(error, items[index], index);
781
1708
  }
1709
+ } else {
1710
+ let nextIndex = 0;
1711
+ const worker = async () => {
1712
+ while (true) {
1713
+ const i = nextIndex++;
1714
+ if (i >= branchCount) return;
1715
+ const e = entries[i];
1716
+ try {
1717
+ await executeBranch(e);
1718
+ } catch (error) {
1719
+ failures.push({ key: e.key, index: e.index, error });
1720
+ }
1721
+ }
1722
+ };
1723
+ const workers = Array.from(
1724
+ { length: Math.min(eff, branchCount) },
1725
+ () => worker()
1726
+ );
1727
+ await Promise.all(workers);
782
1728
  }
783
- state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
1729
+ for (let idx = 0; idx < branchCount; idx++) {
1730
+ const bs = branchStates[idx];
1731
+ if (!bs?.warnings) continue;
1732
+ for (const w of bs.warnings) {
1733
+ pushWarning(state, w.source, `${id}[${entries[idx].key}]:${w.stepId}`, w.error);
1734
+ }
1735
+ }
1736
+ const gateFailures = [];
1737
+ const nonGateFailures = [];
1738
+ for (const f of failures) {
1739
+ if (f.error instanceof NestedGateUnsupportedError) gateFailures.push({ key: f.key, index: f.index, error: f.error });
1740
+ else nonGateFailures.push(f);
1741
+ }
1742
+ gateFailures.sort((a, b) => a.index - b.index);
1743
+ nonGateFailures.sort((a, b) => a.index - b.index);
1744
+ if (gateFailures.length > 0) {
1745
+ for (const nr of nonGateFailures) {
1746
+ pushWarning(state, "foreach-sibling", `${id}[${nr.key}]`, nr.error);
1747
+ }
1748
+ const lowest = gateFailures[0];
1749
+ const otherSuspensions = gateFailures.slice(1).map((g) => ({ index: g.index, gateId: g.error.gateId }));
1750
+ const siblingErrors = nonGateFailures.map((nr) => nr.error);
1751
+ throw new NestedGateUnsupportedError(
1752
+ lowest.error.gateId,
1753
+ lowest.error.workflowId,
1754
+ siblingErrors,
1755
+ otherSuspensions
1756
+ );
1757
+ }
1758
+ for (const { key, index, error } of nonGateFailures) {
1759
+ if (!onError) throw error;
1760
+ const recovered = await onError({
1761
+ error,
1762
+ key: isTuple ? void 0 : key,
1763
+ index: isTuple ? index : void 0,
1764
+ ctx: state.ctx
1765
+ });
1766
+ if (recovered === _Workflow.SKIP) {
1767
+ results[key] = void 0;
1768
+ } else {
1769
+ results[key] = recovered;
1770
+ }
1771
+ }
1772
+ state.output = results;
784
1773
  }
785
1774
  };
786
- return new _Workflow([...this.steps, node], this.id);
1775
+ return this.appendStep(node);
787
1776
  }
788
1777
  // ── repeat: conditional loop ─────────────────────────────────
789
1778
  repeat(target, options) {
790
1779
  const maxIterations = options.maxIterations ?? 10;
791
1780
  const isWorkflow = target instanceof SealedWorkflow;
792
- const id = isWorkflow ? target.id ?? "repeat" : `repeat:${target.id}`;
1781
+ const defaultId = isWorkflow ? target.id ?? "repeat" : `repeat:${target.id}`;
1782
+ const id = options.id ?? defaultId;
793
1783
  const predicate = options.until ?? (async (p) => !await options.while(p));
794
1784
  const node = {
795
1785
  type: "step",
796
1786
  id,
1787
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1788
+ nestedWorkflow: isWorkflow ? target : void 0,
1789
+ category: "repeat",
797
1790
  execute: async (state) => {
798
1791
  const ctx = state.ctx;
799
1792
  for (let i = 1; i <= maxIterations; i++) {
1793
+ if (state.abortSignal?.aborted) {
1794
+ throw state.abortSignal.reason ?? new Error("Workflow aborted");
1795
+ }
800
1796
  if (isWorkflow) {
801
1797
  await this.executeNestedWorkflow(state, target);
802
1798
  } else {
@@ -812,7 +1808,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
812
1808
  throw new WorkflowLoopError(maxIterations, maxIterations);
813
1809
  }
814
1810
  };
815
- return new _Workflow([...this.steps, node], this.id);
1811
+ return this.appendStep(node);
816
1812
  }
817
1813
  // ── catch ─────────────────────────────────────────────────────
818
1814
  catch(id, fn) {
@@ -824,27 +1820,29 @@ var Workflow = class _Workflow extends SealedWorkflow {
824
1820
  id,
825
1821
  catchFn: fn
826
1822
  };
827
- return new _Workflow([...this.steps, node], this.id);
828
- }
829
- // ── finally (terminal — returns sealed workflow) ──────────────
830
- finally(id, fn) {
831
- const node = {
832
- type: "finally",
833
- id,
834
- execute: async (state) => {
835
- await fn({ ctx: state.ctx });
836
- }
837
- };
838
- return new SealedWorkflow([...this.steps, node], this.id);
1823
+ return this.appendStep(node);
839
1824
  }
1825
+ // `.finally()` is inherited from SealedWorkflow now (it lives there so
1826
+ // multi-finally chains are possible — `.finally().finally()`).
840
1827
  };
1828
+
1829
+ // src/index.ts
1830
+ var SKIP = Workflow.SKIP;
841
1831
  // Annotate the CommonJS export names for ESM import in node:
842
1832
  0 && (module.exports = {
843
1833
  Agent,
1834
+ CHECKPOINT_STEP_ID,
1835
+ CheckpointTimeoutError,
1836
+ NestedGateUnsupportedError,
1837
+ SKIP,
1838
+ TOOL_PROVIDER_BRAND,
1839
+ ToolProvider,
844
1840
  Workflow,
845
1841
  WorkflowBranchError,
846
1842
  WorkflowLoopError,
847
- WorkflowSuspended,
848
- defineTool
1843
+ defineTool,
1844
+ getActiveWriter,
1845
+ isToolProvider,
1846
+ migrateSnapshot
849
1847
  });
850
1848
  //# sourceMappingURL=index.cjs.map