pipeai 0.3.0 → 0.8.1

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;
420
+ }
421
+ };
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;
308
429
  }
309
430
  };
310
- var SealedWorkflow = class {
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,38 +695,102 @@ 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;
355
710
  }
356
711
  },
357
712
  ...options?.onError ? { onError: options.onError } : {},
358
- ...options?.onFinish ? { onFinish: options.onFinish } : {}
713
+ ...options?.onFinish ? { onFinish: options.onFinish } : {},
714
+ ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
715
+ ...options?.generateId ? { generateId: options.generateId } : {}
359
716
  });
360
717
  return {
361
718
  stream,
362
719
  output: outputPromise
363
720
  };
364
721
  }
722
+ // Helper — converts terminal RuntimeState into a WorkflowResult; freezes
723
+ // snapshot + warnings if requested via runOptions.
724
+ buildResult(state) {
725
+ const warnings = state.warnings ?? [];
726
+ if (state.suspension && resolveFreezeSnapshots(state)) {
727
+ deepFreeze(warnings);
728
+ }
729
+ if (state.suspension) {
730
+ return { status: "suspended", snapshot: state.suspension, warnings };
731
+ }
732
+ return { status: "complete", output: state.output, warnings };
733
+ }
365
734
  // ── Internal: execute pipeline ────────────────────────────────
366
- async execute(state, startIndex = 0) {
735
+ async execute(state, startIndex = 0, opts, initialError = null) {
367
736
  if (this.steps.length === 0) {
368
737
  throw new Error("Workflow has no steps. Add at least one step before calling generate() or stream().");
369
738
  }
370
- let pendingError = null;
739
+ if (opts !== void 0 && state.runOptions === void 0) {
740
+ state.runOptions = opts;
741
+ }
742
+ const ckptCadence = opts?.onCheckpoint && opts.checkpointWhen === void 0 ? opts.checkpointEvery ?? Math.max(1, Math.ceil(this.cachedExecutableStepCount / 4)) : 0;
743
+ let pendingError = initialError;
744
+ let abortPromoted = false;
745
+ const makeAbortError = (signal) => ({
746
+ error: signal.reason ?? new Error("Workflow aborted"),
747
+ stepId: "abort",
748
+ source: "step"
749
+ });
371
750
  for (let i = startIndex; i < this.steps.length; i++) {
751
+ if (state.abortSignal?.aborted) {
752
+ if (!abortPromoted) {
753
+ abortPromoted = true;
754
+ state.suspension = void 0;
755
+ if (pendingError) demotePendingError(state, pendingError);
756
+ pendingError = makeAbortError(state.abortSignal);
757
+ } else if (!pendingError) {
758
+ pendingError = makeAbortError(state.abortSignal);
759
+ }
760
+ }
372
761
  const node = this.steps[i];
373
762
  if (node.type === "finally") {
374
- await node.execute(state);
763
+ const stepId2 = node.id;
764
+ const finStart = performance.now();
765
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "finally", ctx: state.ctx, input: state.output });
766
+ try {
767
+ await node.execute(state);
768
+ await this.fireHook(state, "onStepFinish", {
769
+ stepId: stepId2,
770
+ type: "finally",
771
+ ctx: state.ctx,
772
+ output: state.output,
773
+ durationMs: performance.now() - finStart,
774
+ suspended: false
775
+ });
776
+ } catch (e) {
777
+ await this.fireHook(state, "onStepError", {
778
+ stepId: stepId2,
779
+ type: "finally",
780
+ ctx: state.ctx,
781
+ error: e,
782
+ durationMs: performance.now() - finStart
783
+ });
784
+ if (pendingError) demotePendingError(state, pendingError);
785
+ pendingError = { error: e, stepId: stepId2, source: "finally" };
786
+ }
375
787
  continue;
376
788
  }
377
789
  if (node.type === "catch") {
378
- if (!pendingError) continue;
790
+ if (state.suspension || !pendingError || state.checkpointFailed) continue;
791
+ const stepId2 = node.id;
792
+ const cStart = performance.now();
793
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "catch", ctx: state.ctx, input: state.output });
379
794
  try {
380
795
  state.output = await node.catchFn({
381
796
  error: pendingError.error,
@@ -384,49 +799,183 @@ var SealedWorkflow = class {
384
799
  stepId: pendingError.stepId
385
800
  });
386
801
  pendingError = null;
387
- } catch (catchError) {
388
- pendingError = { error: catchError, stepId: node.id };
802
+ await this.fireHook(state, "onStepFinish", {
803
+ stepId: stepId2,
804
+ type: "catch",
805
+ ctx: state.ctx,
806
+ output: state.output,
807
+ durationMs: performance.now() - cStart,
808
+ suspended: false
809
+ });
810
+ } catch (e) {
811
+ await this.fireHook(state, "onStepError", {
812
+ stepId: stepId2,
813
+ type: "catch",
814
+ ctx: state.ctx,
815
+ error: e,
816
+ durationMs: performance.now() - cStart
817
+ });
818
+ if (pendingError) demotePendingError(state, pendingError);
819
+ pendingError = { error: e, stepId: stepId2, source: "catch" };
389
820
  }
390
821
  continue;
391
822
  }
823
+ if (state.suspension || pendingError) continue;
392
824
  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,
825
+ const stepId2 = node.id;
826
+ const gStart = performance.now();
827
+ await this.fireHook(state, "onStepStart", { stepId: stepId2, type: "gate", ctx: state.ctx, input: state.output });
828
+ try {
829
+ if (node.condition && !await node.condition(state)) {
830
+ await this.fireHook(state, "onStepFinish", {
831
+ stepId: stepId2,
832
+ type: "gate",
833
+ ctx: state.ctx,
834
+ output: state.output,
835
+ durationMs: performance.now() - gStart,
836
+ suspended: false
837
+ });
838
+ continue;
839
+ }
840
+ const snapshot = {
841
+ version: 2,
842
+ kind: "gate",
843
+ resumeFromIndex: i,
844
+ output: state.output,
845
+ gateId: node.id,
846
+ gatePayload: await node.payload(state)
847
+ };
848
+ state.suspension = snapshot;
849
+ if (resolveFreezeSnapshots(state)) deepFreeze(snapshot);
850
+ await this.fireHook(state, "onStepFinish", {
851
+ stepId: stepId2,
852
+ type: "gate",
853
+ ctx: state.ctx,
854
+ output: state.output,
855
+ durationMs: performance.now() - gStart,
856
+ suspended: true
857
+ });
858
+ } catch (e) {
859
+ pendingError = { error: e, stepId: node.id, source: "step" };
860
+ }
861
+ continue;
862
+ }
863
+ const obsType = getObservabilityType(node);
864
+ const stepId = node.id;
865
+ const sStart = performance.now();
866
+ const stepInput = state.output;
867
+ await this.fireHook(state, "onStepStart", { stepId, type: obsType, ctx: state.ctx, input: stepInput });
868
+ try {
869
+ await node.execute(state);
870
+ await this.fireHook(state, "onStepFinish", {
871
+ stepId,
872
+ type: obsType,
873
+ ctx: state.ctx,
402
874
  output: state.output,
403
- gateId: node.id,
404
- gatePayload
875
+ durationMs: performance.now() - sStart,
876
+ suspended: false
405
877
  });
878
+ } catch (e) {
879
+ pendingError = { error: e, stepId: node.id, source: "step" };
880
+ const obsError = await this.fireHook(state, "onStepError", {
881
+ stepId,
882
+ type: obsType,
883
+ ctx: state.ctx,
884
+ error: e,
885
+ durationMs: performance.now() - sStart
886
+ });
887
+ if (obsError !== void 0 && typeof e === "object" && e !== null) {
888
+ try {
889
+ e.cause = obsError;
890
+ } catch {
891
+ }
892
+ }
893
+ }
894
+ const leaked = state.suspension;
895
+ if (leaked) {
896
+ state.suspension = void 0;
897
+ throw new Error(`internal: suspension bubbled from non-gate step "${node.id}" (gate "${leaked.gateId}").`);
898
+ }
899
+ if (!pendingError && !state.suspension && opts?.onCheckpoint) {
900
+ const shouldCheckpoint = opts.checkpointWhen ? opts.checkpointWhen({ stepIndex: i, stepId: node.id, ctx: state.ctx }) : (i + 1) % ckptCadence === 0;
901
+ if (shouldCheckpoint) {
902
+ const ckptStart = performance.now();
903
+ try {
904
+ await emitCheckpoint(
905
+ state,
906
+ opts,
907
+ i + 1,
908
+ this.cachedStepShapeHash
909
+ );
910
+ } catch (e) {
911
+ pendingError = { error: e, stepId: CHECKPOINT_STEP_ID, source: "onCheckpoint" };
912
+ state.checkpointFailed = true;
913
+ await this.fireHook(state, "onStepError", {
914
+ stepId: CHECKPOINT_STEP_ID,
915
+ type: "step",
916
+ ctx: state.ctx,
917
+ error: e,
918
+ durationMs: performance.now() - ckptStart
919
+ });
920
+ }
921
+ }
922
+ }
923
+ }
924
+ if (pendingError && !state.suspension) {
925
+ if (state.checkpointFailed) {
926
+ const warningsArr = state.warnings ?? [];
927
+ const checkpointError = pendingError.source === "onCheckpoint" ? pendingError.error : warningsArr.find((w) => w.source === "onCheckpoint")?.error;
928
+ const finallyErrors = warningsArr.filter((w) => w.source === "finally").map((w) => w.error);
929
+ const all = pendingError.source === "finally" ? [...finallyErrors, pendingError.error] : finallyErrors;
930
+ if (all.length > 0) {
931
+ console.warn(
932
+ `pipeai: ${all.length} .finally() error(s) suppressed by checkpoint-failure precedence:`,
933
+ all
934
+ );
935
+ }
936
+ throw checkpointError ?? pendingError.error;
406
937
  }
407
- if (pendingError) continue;
938
+ const isFinallyPath = pendingError.source === "finally" || (state.warnings?.some((w) => w.source === "finally") ?? false);
939
+ if (isFinallyPath) {
940
+ const all = [...(state.warnings ?? []).map((w) => w.error), pendingError.error];
941
+ throw new AggregateError(all, `Workflow failed with ${all.length} error(s) from .finally() bodies`);
942
+ }
943
+ throw pendingError.error;
944
+ } else if (pendingError && state.suspension) {
945
+ demotePendingError(state, pendingError);
408
946
  try {
409
- await node.execute(state);
410
- } catch (error) {
411
- if (error instanceof WorkflowSuspended) throw error;
412
- pendingError = { error, stepId: node.id };
947
+ await this.observability?.onStepError?.({
948
+ stepId: pendingError.stepId,
949
+ type: pendingErrorSourceToStepType(pendingError.source),
950
+ ctx: state.ctx,
951
+ error: pendingError.error,
952
+ durationMs: 0
953
+ });
954
+ } catch (obsError) {
955
+ pushWarning(state, "onStepError", pendingError.stepId, obsError);
413
956
  }
957
+ pendingError = null;
414
958
  }
415
- if (pendingError) throw pendingError.error;
416
959
  }
417
960
  // ── Internal: execute a nested workflow within a step/loop ─────
418
961
  // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
419
962
  // access rules only allow calling workflow.execute() from the same class.
963
+ //
964
+ // Contract: clears any inner suspension before re-throwing as
965
+ // NestedGateUnsupportedError. The outer execute() therefore never observes
966
+ // a leaked `state.suspension` from non-gate nodes (defensive invariant).
420
967
  async executeNestedWorkflow(state, workflow) {
968
+ const savedRunOptions = state.runOptions;
969
+ state.runOptions = void 0;
421
970
  try {
422
971
  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;
972
+ } finally {
973
+ state.runOptions = savedRunOptions;
974
+ }
975
+ if (state.suspension) {
976
+ const gateId = state.suspension.gateId;
977
+ state.suspension = void 0;
978
+ throw new NestedGateUnsupportedError(gateId, workflow.id);
430
979
  }
431
980
  }
432
981
  // ── Internal: execute an agent within a step/branch ───────────
@@ -436,59 +985,141 @@ var SealedWorkflow = class {
436
985
  async executeAgent(state, agent, ctx, options) {
437
986
  const input = state.output;
438
987
  const hasStructuredOutput = agent.hasOutput;
988
+ const abortSignal = state.abortSignal;
989
+ const agentCallOpts = abortSignal ? { abortSignal } : void 0;
439
990
  if (state.mode === "stream" && state.writer) {
440
991
  const writer = state.writer;
441
992
  await runWithWriter(writer, async () => {
442
- const result = await agent.stream(ctx, state.output);
993
+ const result = await agent.stream(ctx, state.output, agentCallOpts);
443
994
  if (options?.handleStream) {
444
- await options.handleStream({ result, writer, ctx });
995
+ await options.handleStream({ result, writer, ctx, input });
445
996
  } else {
446
997
  writer.merge(result.toUIMessageStream());
447
998
  }
448
- if (options?.onStreamResult) {
449
- await options.onStreamResult({ result, ctx, input });
999
+ const hookParams = {
1000
+ mode: "stream",
1001
+ result,
1002
+ ctx,
1003
+ input
1004
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1005
+ };
1006
+ if (options?.onResult) {
1007
+ await options.onResult(hookParams);
450
1008
  }
451
- if (options?.mapStreamResult) {
452
- state.output = await options.mapStreamResult({ result, ctx, input });
1009
+ if (options?.mapResult) {
1010
+ state.output = await options.mapResult(hookParams);
453
1011
  } else {
454
- state.output = await extractOutput(result, hasStructuredOutput);
1012
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
455
1013
  }
456
1014
  });
457
1015
  } else {
458
- const result = await agent.generate(ctx, state.output);
459
- if (options?.onGenerateResult) {
460
- await options.onGenerateResult({ result, ctx, input });
1016
+ const result = await agent.generate(ctx, state.output, agentCallOpts);
1017
+ const hookParams = {
1018
+ mode: "generate",
1019
+ result,
1020
+ ctx,
1021
+ input
1022
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1023
+ };
1024
+ if (options?.onResult) {
1025
+ await options.onResult(hookParams);
461
1026
  }
462
- if (options?.mapGenerateResult) {
463
- state.output = await options.mapGenerateResult({ result, ctx, input });
1027
+ if (options?.mapResult) {
1028
+ state.output = await options.mapResult(hookParams);
464
1029
  } else {
465
- state.output = await extractOutput(result, hasStructuredOutput);
1030
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
466
1031
  }
467
1032
  }
468
1033
  }
469
1034
  // ── Gate: load persisted state for resumption ──────────────────
470
1035
  loadState(gateId, snapshot) {
471
- if (snapshot.gateId !== gateId) {
1036
+ if (snapshot.version === 2 && snapshot.kind === "checkpoint") {
1037
+ throw new Error(`loadState: received a checkpoint snapshot. Use resumeFrom() for checkpoint resume; loadState() is for gates.`);
1038
+ }
1039
+ const gateLike = snapshot;
1040
+ if (gateLike.gateId !== gateId) {
472
1041
  throw new Error(
473
- `loadState: gate ID mismatch \u2014 expected "${gateId}" but snapshot has "${snapshot.gateId}".`
1042
+ `loadState: gate ID mismatch \u2014 expected "${gateId}" but snapshot has "${gateLike.gateId}".`
474
1043
  );
475
1044
  }
476
- const gateIndex = this.findGateIndex(snapshot);
1045
+ this.ensureDuplicateCheck();
1046
+ const gateIndex = this.findGateIndex(gateLike);
477
1047
  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
- );
1048
+ return new ResumedWorkflow(this.steps, gateIndex + 1, {
1049
+ mode: "gate",
1050
+ schema: gateNode.schema,
1051
+ mergeFn: gateNode.merge,
1052
+ priorOutput: gateLike.output,
1053
+ snapshot: gateLike,
1054
+ observability: this.observability
1055
+ });
1056
+ }
1057
+ // ── Checkpoint resume ──────────────────────────────────────────
1058
+ /**
1059
+ * Resume from a checkpoint snapshot. Validates the step-shape hash unless
1060
+ * `{ skipShapeCheck: true }` is passed. Throws on:
1061
+ * - gate snapshots (use `loadState` instead)
1062
+ * - missing/corrupted `stepShapeHash`
1063
+ * - shape mismatch (unless skipped)
1064
+ * - out-of-bounds `resumeFromIndex`
1065
+ * - 0-step workflow (structural invariant)
1066
+ *
1067
+ * Returns a `CheckpointResumedWorkflow` whose `generate(ctx, opts?)` takes
1068
+ * NO response arg — the state is seeded from the snapshot's output. The
1069
+ * matching gate-resume path (`loadState`) keeps the `response` arg.
1070
+ */
1071
+ resumeFrom(snapshot, options) {
1072
+ const isGate = snapshot.version === 2 && snapshot.kind === "gate" || snapshot.version === 1 && snapshot.gateId !== void 0;
1073
+ if (isGate) {
1074
+ throw new Error(`resumeFrom: received a gate snapshot. Use loadState() for gate resume; resumeFrom() is for checkpoints.`);
1075
+ }
1076
+ if (this.steps.length === 0) {
1077
+ throw new Error("resumeFrom: workflow has no steps; snapshot is structurally invalid.");
1078
+ }
1079
+ const ckpt = snapshot;
1080
+ const idx = ckpt.resumeFromIndex;
1081
+ if (!Number.isInteger(idx) || idx < 0 || idx > this.steps.length) {
1082
+ throw new Error(`resumeFrom: resumeFromIndex (${idx}) out of bounds for ${this.steps.length}-step workflow.`);
1083
+ }
1084
+ if (!options?.skipShapeCheck) {
1085
+ if (!ckpt.stepShapeHash) {
1086
+ throw new Error("resumeFrom: snapshot missing stepShapeHash; corrupted or hand-crafted.");
1087
+ }
1088
+ this.ensureDuplicateCheck();
1089
+ if (this.cachedStepShapeHash !== ckpt.stepShapeHash) {
1090
+ throw new Error("resumeFrom: workflow shape mismatch; cannot safely resume. Pass { skipShapeCheck: true } to override.");
1091
+ }
1092
+ } else {
1093
+ this.ensureDuplicateCheck();
1094
+ }
1095
+ return new CheckpointResumedWorkflow(this.steps, idx, {
1096
+ mode: "checkpoint",
1097
+ priorOutput: ckpt.output,
1098
+ snapshot: ckpt,
1099
+ observability: this.observability
1100
+ });
1101
+ }
1102
+ /**
1103
+ * Append a `.finally()` body to a sealed workflow, returning another sealed
1104
+ * workflow. Allows multi-finally chains (`.finally().finally()`). A throwing
1105
+ * `.finally` body does NOT abort subsequent ones — they all run.
1106
+ */
1107
+ finally(id, fn) {
1108
+ const node = {
1109
+ type: "finally",
1110
+ id,
1111
+ execute: async (state) => {
1112
+ await fn({ ctx: state.ctx });
1113
+ }
1114
+ };
1115
+ return new _SealedWorkflow([...this.steps, node], this.id, this.observability);
485
1116
  }
486
1117
  findGateIndex(snapshot) {
487
- if (snapshot.version !== 1) {
1118
+ if (snapshot.version !== 1 && snapshot.version !== 2) {
488
1119
  throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
489
1120
  }
490
1121
  const hint = snapshot.resumeFromIndex;
491
- if (hint >= 0 && hint < this.steps.length) {
1122
+ if (Number.isInteger(hint) && hint >= 0 && hint < this.steps.length) {
492
1123
  const node = this.steps[hint];
493
1124
  if (node.type === "gate" && node.id === snapshot.gateId) {
494
1125
  return hint;
@@ -511,12 +1142,12 @@ var ResumedWorkflow = class extends SealedWorkflow {
511
1142
  mergeFn;
512
1143
  priorOutput;
513
1144
  /** @internal */
514
- constructor(steps, startIndex, schema, mergeFn, priorOutput) {
515
- super(steps);
1145
+ constructor(steps, startIndex, config) {
1146
+ super(steps, void 0, config.observability);
516
1147
  this.startIndex = startIndex;
517
- this.schema = schema;
518
- this.mergeFn = mergeFn;
519
- this.priorOutput = priorOutput;
1148
+ this.schema = config.schema;
1149
+ this.mergeFn = config.mergeFn;
1150
+ this.priorOutput = config.priorOutput;
520
1151
  }
521
1152
  validateResponse(response) {
522
1153
  if (this.schema) {
@@ -525,15 +1156,31 @@ var ResumedWorkflow = class extends SealedWorkflow {
525
1156
  return response;
526
1157
  }
527
1158
  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 };
1159
+ const rawResponse = args[0];
1160
+ const opts = args[1];
1161
+ let output = this.priorOutput;
1162
+ let initialError = null;
1163
+ try {
1164
+ const response = this.validateResponse(rawResponse);
1165
+ output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
1166
+ } catch (error) {
1167
+ initialError = { error, stepId: "gate:resume", source: "step" };
1168
+ }
1169
+ const state = {
1170
+ ctx,
1171
+ output,
1172
+ mode: "generate",
1173
+ runOptions: opts,
1174
+ abortSignal: opts?.abortSignal
1175
+ };
1176
+ await this.execute(state, this.startIndex, opts, initialError);
1177
+ return this.buildResult(state);
533
1178
  }
534
1179
  stream(ctx, ...args) {
535
- const response = this.validateResponse(args[0]);
1180
+ const rawResponse = args[0];
536
1181
  const options = args[1];
1182
+ const opts = args[2];
1183
+ const abortSignal = opts?.abortSignal;
537
1184
  let resolveOutput;
538
1185
  let rejectOutput;
539
1186
  const outputPromise = new Promise((res, rej) => {
@@ -544,25 +1191,103 @@ var ResumedWorkflow = class extends SealedWorkflow {
544
1191
  });
545
1192
  const mergeFn = this.mergeFn;
546
1193
  const priorOutput = this.priorOutput;
1194
+ const startIndex = this.startIndex;
547
1195
  const stream = (0, import_ai3.createUIMessageStream)({
548
1196
  execute: async ({ writer }) => {
549
- const output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
1197
+ let output = priorOutput;
1198
+ let initialError = null;
1199
+ try {
1200
+ const response = this.validateResponse(rawResponse);
1201
+ output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
1202
+ } catch (error) {
1203
+ initialError = { error, stepId: "gate:resume", source: "step" };
1204
+ }
550
1205
  const state = {
551
1206
  ctx,
552
1207
  output,
553
1208
  mode: "stream",
554
- writer
1209
+ writer,
1210
+ runOptions: opts,
1211
+ abortSignal
1212
+ };
1213
+ try {
1214
+ await this.execute(state, startIndex, opts, initialError);
1215
+ const result = this.buildResult(state);
1216
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1217
+ resolveOutput(result);
1218
+ } catch (error) {
1219
+ rejectOutput(error);
1220
+ throw error;
1221
+ }
1222
+ },
1223
+ ...options?.onError ? { onError: options.onError } : {},
1224
+ ...options?.onFinish ? { onFinish: options.onFinish } : {},
1225
+ ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
1226
+ ...options?.generateId ? { generateId: options.generateId } : {}
1227
+ });
1228
+ return { stream, output: outputPromise };
1229
+ }
1230
+ };
1231
+ var CheckpointResumedWorkflow = class extends SealedWorkflow {
1232
+ startIndex;
1233
+ priorOutput;
1234
+ /** @internal */
1235
+ constructor(steps, startIndex, config) {
1236
+ super(steps, void 0, config.observability);
1237
+ this.startIndex = startIndex;
1238
+ this.priorOutput = config.priorOutput;
1239
+ }
1240
+ // Override with widened arg list compatible with parent's `[input?, opts?]`.
1241
+ // Inputs are ignored — state is seeded from the snapshot's `output` field.
1242
+ async generate(ctx, ...args) {
1243
+ const opts = args[1];
1244
+ this.validateRunOptions(opts);
1245
+ const state = {
1246
+ ctx,
1247
+ output: this.priorOutput,
1248
+ mode: "generate",
1249
+ runOptions: opts
1250
+ };
1251
+ await this.execute(state, this.startIndex, opts);
1252
+ return this.buildResult(state);
1253
+ }
1254
+ stream(ctx, ...args) {
1255
+ const options = args[1];
1256
+ const opts = args[2];
1257
+ this.validateRunOptions(opts);
1258
+ let resolveOutput;
1259
+ let rejectOutput;
1260
+ const outputPromise = new Promise((res, rej) => {
1261
+ resolveOutput = res;
1262
+ rejectOutput = rej;
1263
+ });
1264
+ outputPromise.catch(() => {
1265
+ });
1266
+ const priorOutput = this.priorOutput;
1267
+ const startIndex = this.startIndex;
1268
+ const stream = (0, import_ai3.createUIMessageStream)({
1269
+ execute: async ({ writer }) => {
1270
+ const state = {
1271
+ ctx,
1272
+ output: priorOutput,
1273
+ mode: "stream",
1274
+ writer,
1275
+ runOptions: opts
555
1276
  };
556
1277
  try {
557
- await this.execute(state, this.startIndex);
558
- resolveOutput(state.output);
1278
+ await this.execute(state, startIndex, opts);
1279
+ const result = this.buildResult(state);
1280
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1281
+ resolveOutput(result);
559
1282
  } catch (error) {
560
1283
  rejectOutput(error);
561
1284
  throw error;
562
1285
  }
563
1286
  },
564
1287
  ...options?.onError ? { onError: options.onError } : {},
565
- ...options?.onFinish ? { onFinish: options.onFinish } : {}
1288
+ ...options?.onFinish ? { onFinish: options.onFinish } : {},
1289
+ ...options?.originalMessages ? { originalMessages: options.originalMessages } : {},
1290
+ ...options?.generateId ? { generateId: options.generateId } : {}
566
1291
  });
567
1292
  return { stream, output: outputPromise };
568
1293
  }
@@ -574,15 +1299,21 @@ var Workflow = class _Workflow extends SealedWorkflow {
574
1299
  * shortening it relative to the input array.
575
1300
  */
576
1301
  static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
577
- constructor(steps = [], id) {
578
- super(steps, id);
1302
+ constructor(steps = [], id, observability) {
1303
+ super(steps, id, observability);
579
1304
  }
580
1305
  static create(options) {
581
- return new _Workflow([], options?.id);
1306
+ return new _Workflow([], options?.id, options?.observability);
582
1307
  }
583
1308
  static from(agent, options) {
584
1309
  return new _Workflow([]).step(agent, options);
585
1310
  }
1311
+ // Builder helper — append a step and return a re-typed Workflow.
1312
+ // Centralizes the `[...steps, node] as any` + new Workflow + observability/id
1313
+ // forwarding pattern used by every combinator method.
1314
+ appendStep(node) {
1315
+ return new _Workflow([...this.steps, node], this.id, this.observability);
1316
+ }
586
1317
  // ── step: implementation ──────────────────────────────────────
587
1318
  step(target, optionsOrFn) {
588
1319
  if (target instanceof SealedWorkflow) {
@@ -590,11 +1321,15 @@ var Workflow = class _Workflow extends SealedWorkflow {
590
1321
  const node2 = {
591
1322
  type: "step",
592
1323
  id: workflow.id ?? "nested-workflow",
1324
+ nestedWorkflow: workflow,
1325
+ // Feeds the recursive stepShapeHash walk.
1326
+ category: "nested",
1327
+ // Observability event type.
593
1328
  execute: async (state) => {
594
1329
  await this.executeNestedWorkflow(state, workflow);
595
1330
  }
596
1331
  };
597
- return new _Workflow([...this.steps, node2], this.id);
1332
+ return this.appendStep(node2);
598
1333
  }
599
1334
  if (typeof target === "string") {
600
1335
  if (typeof optionsOrFn !== "function") {
@@ -611,19 +1346,19 @@ var Workflow = class _Workflow extends SealedWorkflow {
611
1346
  });
612
1347
  }
613
1348
  };
614
- return new _Workflow([...this.steps, node2], this.id);
1349
+ return this.appendStep(node2);
615
1350
  }
616
1351
  const agent = target;
617
1352
  const options = optionsOrFn;
618
1353
  const node = {
619
1354
  type: "step",
620
- id: agent.id,
1355
+ id: options?.id ?? agent.id,
621
1356
  execute: async (state) => {
622
1357
  const ctx = state.ctx;
623
1358
  await this.executeAgent(state, agent, ctx, options);
624
1359
  }
625
1360
  };
626
- return new _Workflow([...this.steps, node], this.id);
1361
+ return this.appendStep(node);
627
1362
  }
628
1363
  // ── gate: human-in-the-loop suspension point ────────────────
629
1364
  gate(id, options) {
@@ -649,19 +1384,20 @@ var Workflow = class _Workflow extends SealedWorkflow {
649
1384
  return state.output;
650
1385
  }
651
1386
  };
652
- return new _Workflow([...this.steps, node], this.id);
1387
+ return this.appendStep(node);
653
1388
  }
654
1389
  // ── branch: implementation ────────────────────────────────────
655
- branch(casesOrConfig) {
1390
+ branch(casesOrConfig, options) {
656
1391
  if (Array.isArray(casesOrConfig)) {
657
- return this.branchPredicate(casesOrConfig);
1392
+ return this.branchPredicate(casesOrConfig, options?.id);
658
1393
  }
659
- return this.branchSelect(casesOrConfig);
1394
+ return this.branchSelect(casesOrConfig, options?.id);
660
1395
  }
661
- branchPredicate(cases) {
1396
+ branchPredicate(cases, explicitId) {
662
1397
  const node = {
663
1398
  type: "step",
664
- id: "branch:predicate",
1399
+ id: explicitId ?? "branch:predicate",
1400
+ category: "branch",
665
1401
  execute: async (state) => {
666
1402
  const ctx = state.ctx;
667
1403
  const input = state.output;
@@ -673,21 +1409,43 @@ var Workflow = class _Workflow extends SealedWorkflow {
673
1409
  await this.executeAgent(state, branchCase.agent, ctx, branchCase);
674
1410
  return;
675
1411
  }
676
- throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${JSON.stringify(input)}`);
1412
+ let inputRepr;
1413
+ try {
1414
+ inputRepr = JSON.stringify(input);
1415
+ if (inputRepr === void 0) inputRepr = String(input);
1416
+ } catch {
1417
+ inputRepr = `[unserializable ${typeof input}]`;
1418
+ }
1419
+ throw new WorkflowBranchError("predicate", `No branch matched and no default branch (a case without \`when\`) was provided. Input: ${inputRepr}`);
677
1420
  }
678
1421
  };
679
- return new _Workflow([...this.steps, node], this.id);
1422
+ return this.appendStep(node);
680
1423
  }
681
- branchSelect(config) {
1424
+ branchSelect(config, explicitId) {
682
1425
  const node = {
683
1426
  type: "step",
684
- id: "branch:select",
1427
+ id: explicitId ?? "branch:select",
1428
+ category: "branch",
685
1429
  execute: async (state) => {
686
1430
  const ctx = state.ctx;
687
1431
  const input = state.output;
688
1432
  const key = await config.select({ ctx, input });
689
- let agent = config.agents[key];
1433
+ const keyDeclared = Object.prototype.hasOwnProperty.call(config.agents, key);
1434
+ if (keyDeclared && config.agents[key] === void 0) {
1435
+ throw new WorkflowBranchError(
1436
+ "select",
1437
+ `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(", ")}`
1438
+ );
1439
+ }
1440
+ let agent = keyDeclared ? config.agents[key] : void 0;
690
1441
  if (!agent) {
1442
+ if (config.onUnknownKey) {
1443
+ config.onUnknownKey({
1444
+ key,
1445
+ availableKeys: Object.keys(config.agents),
1446
+ ctx
1447
+ });
1448
+ }
691
1449
  if (config.fallback) {
692
1450
  agent = config.fallback;
693
1451
  } else {
@@ -697,32 +1455,37 @@ var Workflow = class _Workflow extends SealedWorkflow {
697
1455
  await this.executeAgent(state, agent, ctx, config);
698
1456
  }
699
1457
  };
700
- return new _Workflow([...this.steps, node], this.id);
1458
+ return this.appendStep(node);
701
1459
  }
702
1460
  // ── foreach: array iteration ─────────────────────────────────
703
1461
  /**
704
1462
  * Map each item of an array through an agent or sub-workflow.
705
1463
  *
706
1464
  * @param target Agent or `SealedWorkflow` invoked once per item.
1465
+ * @param options.id Override the default step id (`foreach:<agentId>` or
1466
+ * the workflow's id). Required when chaining multiple foreach over the same
1467
+ * target — the construction-time `(type, id)` walk rejects duplicates.
707
1468
  * @param options.concurrency Max items in flight at any moment (default 1).
708
1469
  * Backed by a semaphore: as soon as one item completes, the next launches —
709
1470
  * 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.
1471
+ * @param options.onError Per-iteration error handler. **Bypassed entirely on
1472
+ * the suspension path** (when any item hits a nested gate) — see the
1473
+ * foreach concurrency hazards in the README. Otherwise: return a
1474
+ * `TNextOutput` value to substitute, return `Workflow.SKIP` to omit, throw
1475
+ * to abort. Invoked sequentially in index order after all items settle.
717
1476
  */
718
1477
  foreach(target, options) {
719
1478
  const concurrency = options?.concurrency ?? 1;
720
1479
  const onError = options?.onError;
721
1480
  const isWorkflow = target instanceof SealedWorkflow;
722
- const id = isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
1481
+ const defaultId = isWorkflow ? target.id ?? "foreach" : `foreach:${target.id}`;
1482
+ const id = options?.id ?? defaultId;
723
1483
  const node = {
724
1484
  type: "step",
725
1485
  id,
1486
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1487
+ nestedWorkflow: isWorkflow ? target : void 0,
1488
+ category: "foreach",
726
1489
  execute: async (state) => {
727
1490
  const items = state.output;
728
1491
  if (!Array.isArray(items)) {
@@ -731,14 +1494,58 @@ var Workflow = class _Workflow extends SealedWorkflow {
731
1494
  const ctx = state.ctx;
732
1495
  const results = new Array(items.length);
733
1496
  const skipped = /* @__PURE__ */ new Set();
1497
+ const itemStates = new Array(items.length);
734
1498
  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);
1499
+ const itemState = {
1500
+ ctx: state.ctx,
1501
+ output: item,
1502
+ mode: "generate",
1503
+ abortSignal: state.abortSignal
1504
+ };
1505
+ itemStates[index] = itemState;
1506
+ const itemStart = performance.now();
1507
+ await this.fireHook(state, "onItemStart", {
1508
+ stepId: id,
1509
+ type: "foreach",
1510
+ itemIndex: index,
1511
+ ctx: state.ctx,
1512
+ input: item
1513
+ });
1514
+ try {
1515
+ if (isWorkflow) {
1516
+ await this.executeNestedWorkflow(itemState, target);
1517
+ } else {
1518
+ await this.executeAgent(itemState, target, ctx);
1519
+ }
1520
+ results[index] = itemState.output;
1521
+ await this.fireHook(state, "onItemFinish", {
1522
+ stepId: id,
1523
+ type: "foreach",
1524
+ itemIndex: index,
1525
+ ctx: state.ctx,
1526
+ output: itemState.output,
1527
+ durationMs: performance.now() - itemStart
1528
+ });
1529
+ } catch (error) {
1530
+ await this.fireHook(state, "onItemError", {
1531
+ stepId: id,
1532
+ type: "foreach",
1533
+ itemIndex: index,
1534
+ ctx: state.ctx,
1535
+ error,
1536
+ durationMs: performance.now() - itemStart
1537
+ });
1538
+ throw error;
1539
+ }
1540
+ };
1541
+ const mergeItemWarnings = () => {
1542
+ for (let idx = 0; idx < items.length; idx++) {
1543
+ const its = itemStates[idx];
1544
+ if (!its?.warnings) continue;
1545
+ for (const w of its.warnings) {
1546
+ pushWarning(state, w.source, `${id}[${idx}]:${w.stepId}`, w.error);
1547
+ }
740
1548
  }
741
- results[index] = itemState.output;
742
1549
  };
743
1550
  const handleRejection = async (error, item, index) => {
744
1551
  if (!onError) throw error;
@@ -754,49 +1561,244 @@ var Workflow = class _Workflow extends SealedWorkflow {
754
1561
  results[index] = recovered;
755
1562
  }
756
1563
  };
1564
+ const failures = [];
1565
+ const signal = state.abortSignal;
757
1566
  if (concurrency <= 1) {
758
1567
  for (let i = 0; i < items.length; i++) {
1568
+ if (signal?.aborted) {
1569
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1570
+ continue;
1571
+ }
759
1572
  try {
760
1573
  await executeItem(items[i], i);
761
1574
  } catch (error) {
762
- await handleRejection(error, items[i], i);
1575
+ failures.push({ index: i, error });
763
1576
  }
764
1577
  }
765
1578
  } else {
766
- const sem = new Semaphore(concurrency);
767
- const failures = [];
768
- await Promise.all(items.map(async (item, i) => {
769
- await sem.acquire();
1579
+ let nextIndex = 0;
1580
+ const worker = async () => {
1581
+ while (true) {
1582
+ const i = nextIndex++;
1583
+ if (i >= items.length) return;
1584
+ if (signal?.aborted) {
1585
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1586
+ continue;
1587
+ }
1588
+ try {
1589
+ await executeItem(items[i], i);
1590
+ } catch (error) {
1591
+ failures.push({ index: i, error });
1592
+ }
1593
+ }
1594
+ };
1595
+ const workers = Array.from(
1596
+ { length: Math.min(concurrency, items.length) },
1597
+ () => worker()
1598
+ );
1599
+ await Promise.all(workers);
1600
+ }
1601
+ failures.sort((a, b) => a.index - b.index);
1602
+ const gateFailures = [];
1603
+ const nonGateFailures = [];
1604
+ for (const f of failures) {
1605
+ if (f.error instanceof NestedGateUnsupportedError) {
1606
+ gateFailures.push({ index: f.index, error: f.error });
1607
+ } else {
1608
+ nonGateFailures.push(f);
1609
+ }
1610
+ }
1611
+ mergeItemWarnings();
1612
+ if (gateFailures.length > 0) {
1613
+ for (const nr of nonGateFailures) {
1614
+ pushWarning(state, "foreach-sibling", `${id}[${nr.index}]`, nr.error);
1615
+ }
1616
+ const lowest = gateFailures[0];
1617
+ const otherSuspensions = gateFailures.slice(1).map((g) => ({
1618
+ index: g.index,
1619
+ gateId: g.error.gateId
1620
+ }));
1621
+ const siblingErrors = nonGateFailures.map((nr) => nr.error);
1622
+ throw new NestedGateUnsupportedError(
1623
+ lowest.error.gateId,
1624
+ lowest.error.workflowId,
1625
+ siblingErrors,
1626
+ otherSuspensions
1627
+ );
1628
+ }
1629
+ for (const { index, error } of nonGateFailures) {
1630
+ await handleRejection(error, items[index], index);
1631
+ }
1632
+ state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
1633
+ }
1634
+ };
1635
+ return this.appendStep(node);
1636
+ }
1637
+ // Implementation
1638
+ parallel(branches, options) {
1639
+ const isTuple = Array.isArray(branches);
1640
+ 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 }));
1641
+ const branchCount = entries.length;
1642
+ const requestedConcurrency = options?.concurrency;
1643
+ let effectiveConcurrency;
1644
+ if (requestedConcurrency === void 0) {
1645
+ effectiveConcurrency = Math.min(branchCount, 5);
1646
+ } else {
1647
+ effectiveConcurrency = requestedConcurrency;
1648
+ }
1649
+ if (requestedConcurrency === void 0 && branchCount > 5) {
1650
+ warnOnce(
1651
+ "pipeai:parallel-cap",
1652
+ `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.`
1653
+ );
1654
+ }
1655
+ const onError = options?.onError;
1656
+ const id = options?.id ?? (isTuple ? "parallel:tuple" : "parallel:record");
1657
+ const node = {
1658
+ type: "step",
1659
+ id,
1660
+ category: "parallel",
1661
+ execute: async (state) => {
1662
+ const ctx = state.ctx;
1663
+ const input = state.output;
1664
+ const results = isTuple ? new Array(branchCount) : {};
1665
+ const branchStates = new Array(branchCount);
1666
+ const executeBranch = async ({ key, index, target }) => {
1667
+ const branchState = { ctx: state.ctx, output: input, mode: "generate" };
1668
+ branchStates[index] = branchState;
1669
+ const branchStart = performance.now();
1670
+ const itemIndex = isTuple ? index : key;
1671
+ await this.fireHook(state, "onItemStart", {
1672
+ stepId: id,
1673
+ type: "parallel",
1674
+ itemIndex,
1675
+ ctx: state.ctx,
1676
+ input
1677
+ });
1678
+ try {
1679
+ if (target instanceof SealedWorkflow) {
1680
+ await this.executeNestedWorkflow(branchState, target);
1681
+ } else {
1682
+ await this.executeAgent(branchState, target, ctx);
1683
+ }
1684
+ results[key] = branchState.output;
1685
+ await this.fireHook(state, "onItemFinish", {
1686
+ stepId: id,
1687
+ type: "parallel",
1688
+ itemIndex,
1689
+ ctx: state.ctx,
1690
+ output: branchState.output,
1691
+ durationMs: performance.now() - branchStart
1692
+ });
1693
+ } catch (error) {
1694
+ await this.fireHook(state, "onItemError", {
1695
+ stepId: id,
1696
+ type: "parallel",
1697
+ itemIndex,
1698
+ ctx: state.ctx,
1699
+ error,
1700
+ durationMs: performance.now() - branchStart
1701
+ });
1702
+ throw error;
1703
+ }
1704
+ };
1705
+ const failures = [];
1706
+ const eff = Number.isFinite(effectiveConcurrency) ? Math.max(1, effectiveConcurrency) : branchCount;
1707
+ if (eff <= 1) {
1708
+ for (const e of entries) {
770
1709
  try {
771
- await executeItem(item, i);
1710
+ await executeBranch(e);
772
1711
  } catch (error) {
773
- failures.push({ index: i, error });
774
- } finally {
775
- sem.release();
1712
+ failures.push({ key: e.key, index: e.index, error });
776
1713
  }
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
1714
  }
1715
+ } else {
1716
+ let nextIndex = 0;
1717
+ const worker = async () => {
1718
+ while (true) {
1719
+ const i = nextIndex++;
1720
+ if (i >= branchCount) return;
1721
+ const e = entries[i];
1722
+ try {
1723
+ await executeBranch(e);
1724
+ } catch (error) {
1725
+ failures.push({ key: e.key, index: e.index, error });
1726
+ }
1727
+ }
1728
+ };
1729
+ const workers = Array.from(
1730
+ { length: Math.min(eff, branchCount) },
1731
+ () => worker()
1732
+ );
1733
+ await Promise.all(workers);
782
1734
  }
783
- state.output = skipped.size === 0 ? results : results.filter((_, i) => !skipped.has(i));
1735
+ for (let idx = 0; idx < branchCount; idx++) {
1736
+ const bs = branchStates[idx];
1737
+ if (!bs?.warnings) continue;
1738
+ for (const w of bs.warnings) {
1739
+ pushWarning(state, w.source, `${id}[${entries[idx].key}]:${w.stepId}`, w.error);
1740
+ }
1741
+ }
1742
+ const gateFailures = [];
1743
+ const nonGateFailures = [];
1744
+ for (const f of failures) {
1745
+ if (f.error instanceof NestedGateUnsupportedError) gateFailures.push({ key: f.key, index: f.index, error: f.error });
1746
+ else nonGateFailures.push(f);
1747
+ }
1748
+ gateFailures.sort((a, b) => a.index - b.index);
1749
+ nonGateFailures.sort((a, b) => a.index - b.index);
1750
+ if (gateFailures.length > 0) {
1751
+ for (const nr of nonGateFailures) {
1752
+ pushWarning(state, "foreach-sibling", `${id}[${nr.key}]`, nr.error);
1753
+ }
1754
+ const lowest = gateFailures[0];
1755
+ const otherSuspensions = gateFailures.slice(1).map((g) => ({ index: g.index, gateId: g.error.gateId }));
1756
+ const siblingErrors = nonGateFailures.map((nr) => nr.error);
1757
+ throw new NestedGateUnsupportedError(
1758
+ lowest.error.gateId,
1759
+ lowest.error.workflowId,
1760
+ siblingErrors,
1761
+ otherSuspensions
1762
+ );
1763
+ }
1764
+ for (const { key, index, error } of nonGateFailures) {
1765
+ if (!onError) throw error;
1766
+ const recovered = await onError({
1767
+ error,
1768
+ key: isTuple ? void 0 : key,
1769
+ index: isTuple ? index : void 0,
1770
+ ctx: state.ctx
1771
+ });
1772
+ if (recovered === _Workflow.SKIP) {
1773
+ results[key] = void 0;
1774
+ } else {
1775
+ results[key] = recovered;
1776
+ }
1777
+ }
1778
+ state.output = results;
784
1779
  }
785
1780
  };
786
- return new _Workflow([...this.steps, node], this.id);
1781
+ return this.appendStep(node);
787
1782
  }
788
1783
  // ── repeat: conditional loop ─────────────────────────────────
789
1784
  repeat(target, options) {
790
1785
  const maxIterations = options.maxIterations ?? 10;
791
1786
  const isWorkflow = target instanceof SealedWorkflow;
792
- const id = isWorkflow ? target.id ?? "repeat" : `repeat:${target.id}`;
1787
+ const defaultId = isWorkflow ? target.id ?? "repeat" : `repeat:${target.id}`;
1788
+ const id = options.id ?? defaultId;
793
1789
  const predicate = options.until ?? (async (p) => !await options.while(p));
794
1790
  const node = {
795
1791
  type: "step",
796
1792
  id,
1793
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1794
+ nestedWorkflow: isWorkflow ? target : void 0,
1795
+ category: "repeat",
797
1796
  execute: async (state) => {
798
1797
  const ctx = state.ctx;
799
1798
  for (let i = 1; i <= maxIterations; i++) {
1799
+ if (state.abortSignal?.aborted) {
1800
+ throw state.abortSignal.reason ?? new Error("Workflow aborted");
1801
+ }
800
1802
  if (isWorkflow) {
801
1803
  await this.executeNestedWorkflow(state, target);
802
1804
  } else {
@@ -812,7 +1814,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
812
1814
  throw new WorkflowLoopError(maxIterations, maxIterations);
813
1815
  }
814
1816
  };
815
- return new _Workflow([...this.steps, node], this.id);
1817
+ return this.appendStep(node);
816
1818
  }
817
1819
  // ── catch ─────────────────────────────────────────────────────
818
1820
  catch(id, fn) {
@@ -824,27 +1826,29 @@ var Workflow = class _Workflow extends SealedWorkflow {
824
1826
  id,
825
1827
  catchFn: fn
826
1828
  };
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);
1829
+ return this.appendStep(node);
839
1830
  }
1831
+ // `.finally()` is inherited from SealedWorkflow now (it lives there so
1832
+ // multi-finally chains are possible — `.finally().finally()`).
840
1833
  };
1834
+
1835
+ // src/index.ts
1836
+ var SKIP = Workflow.SKIP;
841
1837
  // Annotate the CommonJS export names for ESM import in node:
842
1838
  0 && (module.exports = {
843
1839
  Agent,
1840
+ CHECKPOINT_STEP_ID,
1841
+ CheckpointTimeoutError,
1842
+ NestedGateUnsupportedError,
1843
+ SKIP,
1844
+ TOOL_PROVIDER_BRAND,
1845
+ ToolProvider,
844
1846
  Workflow,
845
1847
  WorkflowBranchError,
846
1848
  WorkflowLoopError,
847
- WorkflowSuspended,
848
- defineTool
1849
+ defineTool,
1850
+ getActiveWriter,
1851
+ isToolProvider,
1852
+ migrateSnapshot
849
1853
  });
850
1854
  //# sourceMappingURL=index.cjs.map