pipeai 0.2.1 → 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,13 +59,58 @@ function resolveValue(value, ctx, input) {
50
59
  }
51
60
  return value;
52
61
  }
53
- async function extractOutput(result, hasStructuredOutput) {
62
+ async function extractOutput(result, hasStructuredOutput, schema) {
54
63
  if (hasStructuredOutput) {
55
64
  const output = await result.output;
56
- 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;
57
74
  }
58
75
  return await result.text;
59
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
+ }
60
114
 
61
115
  // src/tool-provider.ts
62
116
  var TOOL_PROVIDER_BRAND = /* @__PURE__ */ Symbol.for("agent-workflow.ToolProvider");
@@ -87,10 +141,17 @@ var Agent = class {
87
141
  id;
88
142
  description;
89
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;
90
152
  config;
91
153
  _hasDynamicConfig;
92
154
  _resolvedStaticTools = null;
93
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
155
  _passthrough;
95
156
  _onStepFinish;
96
157
  _onFinish;
@@ -98,6 +159,7 @@ var Agent = class {
98
159
  this.id = config.id;
99
160
  this.description = config.description ?? "";
100
161
  this.hasOutput = config.output !== void 0;
162
+ this.validateOutput = config.validateOutput;
101
163
  this.config = config;
102
164
  this._hasDynamicConfig = [
103
165
  config.model,
@@ -106,8 +168,7 @@ var Agent = class {
106
168
  config.messages,
107
169
  config.tools,
108
170
  config.activeTools,
109
- config.toolChoice,
110
- config.stopWhen
171
+ config.toolChoice
111
172
  ].some((v) => typeof v === "function");
112
173
  if (!this._hasDynamicConfig) {
113
174
  const rawTools = config.tools ?? {};
@@ -121,6 +182,7 @@ var Agent = class {
121
182
  description: _desc,
122
183
  input: _inputSchema,
123
184
  output: _output,
185
+ validateOutput: _validateOutput,
124
186
  model: _m,
125
187
  system: _s,
126
188
  prompt: _p,
@@ -140,25 +202,13 @@ var Agent = class {
140
202
  }
141
203
  async generate(ctx, ...args) {
142
204
  const input = args[0];
143
- const resolved = await this.resolveConfig(ctx, input);
144
- const options = this.buildCallOptions(resolved, ctx, input);
145
- try {
146
- return await (0, import_ai2.generateText)(options);
147
- } catch (error) {
148
- if (this.config.onError) {
149
- await this.config.onError({ error, ctx, input, writer: getActiveWriter() });
150
- }
151
- throw error;
152
- }
205
+ const callOptions = args[1];
206
+ return this.generateWithOptions(ctx, input, callOptions ?? {});
153
207
  }
154
208
  async stream(ctx, ...args) {
155
209
  const input = args[0];
156
- const resolved = await this.resolveConfig(ctx, input);
157
- const options = this.buildCallOptions(resolved, ctx, input);
158
- return (0, import_ai2.streamText)({
159
- ...options,
160
- onError: this.config.onError ? ({ error }) => this.config.onError({ error, ctx, input, writer: getActiveWriter() }) : void 0
161
- });
210
+ const callOptions = args[1];
211
+ return this.streamWithOptions(ctx, input, callOptions ?? {});
162
212
  }
163
213
  asTool(ctx, options) {
164
214
  return this.createToolInstance(ctx, options);
@@ -179,24 +229,86 @@ var Agent = class {
179
229
  return (0, import_ai2.tool)({
180
230
  description: this.description,
181
231
  inputSchema: this.config.input,
182
- 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;
183
238
  const writer = getActiveWriter();
184
239
  if (writer) {
185
- const result2 = await this.stream(ctx, toolInput);
240
+ const result2 = await this.streamWithOptions(ctx, toolInput, { abortSignal });
186
241
  writer.merge(result2.toUIMessageStream());
187
- if (options?.mapOutput) return options.mapOutput(result2);
188
- 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);
189
247
  }
190
- const result = await this.generate(ctx, toolInput);
248
+ const result = await this.generateWithOptions(ctx, toolInput, { abortSignal });
191
249
  if (options?.mapOutput) return options.mapOutput(result);
192
- return extractOutput(result, this.hasOutput);
250
+ return extractOutput(result, this.hasOutput, this.validateOutput);
193
251
  }
194
252
  // TS cannot simplify the SDK's `NeverOptional<TOutput, ...>` conditional in a
195
253
  // generic context, so we cast through `unknown` instead of `any`.
196
254
  });
197
255
  }
198
- // 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
+ }
199
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
+ }
200
312
  return {
201
313
  ...this._passthrough,
202
314
  model: resolved.model,
@@ -204,11 +316,16 @@ var Agent = class {
204
316
  activeTools: resolved.activeTools,
205
317
  toolChoice: resolved.toolChoice,
206
318
  stopWhen: resolved.stopWhen,
207
- ...resolved.messages ? { messages: resolved.messages } : { prompt: resolved.prompt ?? "" },
208
- ...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 } : {},
209
323
  ...this.config.output ? { output: this.config.output } : {},
210
- onStepFinish: this._onStepFinish ? (event) => this._onStepFinish({ result: event, ctx, input, writer: getActiveWriter() }) : void 0,
211
- 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() }) } : {}
212
329
  };
213
330
  }
214
331
  resolveConfig(ctx, input) {
@@ -230,18 +347,27 @@ var Agent = class {
230
347
  return this.resolveConfigAsync(ctx, input);
231
348
  }
232
349
  async resolveConfigAsync(ctx, input) {
233
- const [model, prompt, system, messages, rawTools, activeTools, toolChoice, stopWhen] = await Promise.all([
350
+ const [model, prompt, system, messages, rawTools, activeTools, toolChoice] = await Promise.all([
234
351
  resolveValue(this.config.model, ctx, input),
235
352
  resolveValue(this.config.prompt, ctx, input),
236
353
  resolveValue(this.config.system, ctx, input),
237
354
  resolveValue(this.config.messages, ctx, input),
238
355
  resolveValue(this.config.tools, ctx, input),
239
356
  resolveValue(this.config.activeTools, ctx, input),
240
- resolveValue(this.config.toolChoice, ctx, input),
241
- resolveValue(this.config.stopWhen, ctx, input)
357
+ resolveValue(this.config.toolChoice, ctx, input)
242
358
  ]);
243
359
  const tools = this.resolveTools(rawTools ?? {}, ctx);
244
- 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
+ };
245
371
  }
246
372
  resolveTools(tools, ctx) {
247
373
  const entries = Object.entries(tools);
@@ -277,37 +403,284 @@ var WorkflowLoopError = class extends Error {
277
403
  this.name = "WorkflowLoopError";
278
404
  }
279
405
  };
280
- var WorkflowSuspended = class extends Error {
281
- snapshot;
282
- constructor(snapshot) {
283
- super(`Workflow suspended at gate "${snapshot.gateId}"`);
284
- this.name = "WorkflowSuspended";
285
- 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;
286
429
  }
287
430
  };
288
- 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 {
289
523
  id;
290
524
  steps;
291
- 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) {
292
533
  this.steps = steps;
293
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
+ }
294
660
  }
295
661
  // ── Execution ─────────────────────────────────────────────────
296
662
  async generate(ctx, ...args) {
663
+ this.ensureDuplicateCheck();
297
664
  const input = args[0];
665
+ const opts = args[1];
666
+ this.validateRunOptions(opts);
298
667
  const state = {
299
668
  ctx,
300
669
  output: input,
301
- mode: "generate"
302
- };
303
- await this.execute(state);
304
- return {
305
- output: state.output
670
+ mode: "generate",
671
+ runOptions: opts,
672
+ abortSignal: opts?.abortSignal
306
673
  };
674
+ await this.execute(state, 0, opts);
675
+ return this.buildResult(state);
307
676
  }
308
677
  stream(ctx, ...args) {
678
+ this.ensureDuplicateCheck();
309
679
  const input = args[0];
310
680
  const options = args[1];
681
+ const opts = args[2];
682
+ this.validateRunOptions(opts);
683
+ const abortSignal = opts?.abortSignal;
311
684
  let resolveOutput;
312
685
  let rejectOutput;
313
686
  const outputPromise = new Promise((res, rej) => {
@@ -322,11 +695,15 @@ var SealedWorkflow = class {
322
695
  ctx,
323
696
  output: input,
324
697
  mode: "stream",
325
- writer
698
+ writer,
699
+ runOptions: opts,
700
+ abortSignal
326
701
  };
327
702
  try {
328
- await this.execute(state);
329
- resolveOutput(state.output);
703
+ await this.execute(state, 0, opts);
704
+ const result = this.buildResult(state);
705
+ maybeWarnStreamOnErrorOnSuspend(result, options);
706
+ resolveOutput(result);
330
707
  } catch (error) {
331
708
  rejectOutput(error);
332
709
  throw error;
@@ -340,20 +717,78 @@ var SealedWorkflow = class {
340
717
  output: outputPromise
341
718
  };
342
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
+ }
343
732
  // ── Internal: execute pipeline ────────────────────────────────
344
- async execute(state, startIndex = 0) {
733
+ async execute(state, startIndex = 0, opts, initialError = null) {
345
734
  if (this.steps.length === 0) {
346
735
  throw new Error("Workflow has no steps. Add at least one step before calling generate() or stream().");
347
736
  }
348
- 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
+ });
349
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
+ }
350
759
  const node = this.steps[i];
351
760
  if (node.type === "finally") {
352
- 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
+ }
353
785
  continue;
354
786
  }
355
787
  if (node.type === "catch") {
356
- 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 });
357
792
  try {
358
793
  state.output = await node.catchFn({
359
794
  error: pendingError.error,
@@ -362,49 +797,183 @@ var SealedWorkflow = class {
362
797
  stepId: pendingError.stepId
363
798
  });
364
799
  pendingError = null;
365
- } catch (catchError) {
366
- 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" };
367
818
  }
368
819
  continue;
369
820
  }
821
+ if (state.suspension || pendingError) continue;
370
822
  if (node.type === "gate") {
371
- if (pendingError) continue;
372
- if (node.condition) {
373
- const shouldSuspend = await node.condition(state);
374
- if (!shouldSuspend) continue;
375
- }
376
- const gatePayload = await node.payload(state);
377
- throw new WorkflowSuspended({
378
- version: 1,
379
- 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,
380
872
  output: state.output,
381
- gateId: node.id,
382
- gatePayload
873
+ durationMs: performance.now() - sStart,
874
+ suspended: false
383
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
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`);
384
940
  }
385
- if (pendingError) continue;
941
+ throw pendingError.error;
942
+ } else if (pendingError && state.suspension) {
943
+ demotePendingError(state, pendingError);
386
944
  try {
387
- await node.execute(state);
388
- } catch (error) {
389
- if (error instanceof WorkflowSuspended) throw error;
390
- 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);
391
954
  }
955
+ pendingError = null;
392
956
  }
393
- if (pendingError) throw pendingError.error;
394
957
  }
395
958
  // ── Internal: execute a nested workflow within a step/loop ─────
396
959
  // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
397
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).
398
965
  async executeNestedWorkflow(state, workflow) {
966
+ const savedRunOptions = state.runOptions;
967
+ state.runOptions = void 0;
399
968
  try {
400
969
  await workflow.execute(state);
401
- } catch (error) {
402
- if (error instanceof WorkflowSuspended) {
403
- throw new Error(
404
- `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.`
405
- );
406
- }
407
- 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);
408
977
  }
409
978
  }
410
979
  // ── Internal: execute an agent within a step/branch ───────────
@@ -414,59 +983,141 @@ var SealedWorkflow = class {
414
983
  async executeAgent(state, agent, ctx, options) {
415
984
  const input = state.output;
416
985
  const hasStructuredOutput = agent.hasOutput;
986
+ const abortSignal = state.abortSignal;
987
+ const agentCallOpts = abortSignal ? { abortSignal } : void 0;
417
988
  if (state.mode === "stream" && state.writer) {
418
989
  const writer = state.writer;
419
990
  await runWithWriter(writer, async () => {
420
- const result = await agent.stream(ctx, state.output);
991
+ const result = await agent.stream(ctx, state.output, agentCallOpts);
421
992
  if (options?.handleStream) {
422
993
  await options.handleStream({ result, writer, ctx });
423
994
  } else {
424
995
  writer.merge(result.toUIMessageStream());
425
996
  }
426
- if (options?.onStreamResult) {
427
- 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);
428
1006
  }
429
- if (options?.mapStreamResult) {
430
- state.output = await options.mapStreamResult({ result, ctx, input });
1007
+ if (options?.mapResult) {
1008
+ state.output = await options.mapResult(hookParams);
431
1009
  } else {
432
- state.output = await extractOutput(result, hasStructuredOutput);
1010
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
433
1011
  }
434
1012
  });
435
1013
  } else {
436
- const result = await agent.generate(ctx, state.output);
437
- if (options?.onGenerateResult) {
438
- 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);
439
1024
  }
440
- if (options?.mapGenerateResult) {
441
- state.output = await options.mapGenerateResult({ result, ctx, input });
1025
+ if (options?.mapResult) {
1026
+ state.output = await options.mapResult(hookParams);
442
1027
  } else {
443
- state.output = await extractOutput(result, hasStructuredOutput);
1028
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
444
1029
  }
445
1030
  }
446
1031
  }
447
1032
  // ── Gate: load persisted state for resumption ──────────────────
448
1033
  loadState(gateId, snapshot) {
449
- 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) {
450
1039
  throw new Error(
451
- `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}".`
452
1041
  );
453
1042
  }
454
- const gateIndex = this.findGateIndex(snapshot);
1043
+ this.ensureDuplicateCheck();
1044
+ const gateIndex = this.findGateIndex(gateLike);
455
1045
  const gateNode = this.steps[gateIndex];
456
- return new ResumedWorkflow(
457
- this.steps,
458
- gateIndex + 1,
459
- gateNode.schema,
460
- gateNode.merge,
461
- snapshot.output
462
- );
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);
463
1114
  }
464
1115
  findGateIndex(snapshot) {
465
- if (snapshot.version !== 1) {
1116
+ if (snapshot.version !== 1 && snapshot.version !== 2) {
466
1117
  throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
467
1118
  }
468
1119
  const hint = snapshot.resumeFromIndex;
469
- if (hint >= 0 && hint < this.steps.length) {
1120
+ if (Number.isInteger(hint) && hint >= 0 && hint < this.steps.length) {
470
1121
  const node = this.steps[hint];
471
1122
  if (node.type === "gate" && node.id === snapshot.gateId) {
472
1123
  return hint;
@@ -489,12 +1140,12 @@ var ResumedWorkflow = class extends SealedWorkflow {
489
1140
  mergeFn;
490
1141
  priorOutput;
491
1142
  /** @internal */
492
- constructor(steps, startIndex, schema, mergeFn, priorOutput) {
493
- super(steps);
1143
+ constructor(steps, startIndex, config) {
1144
+ super(steps, void 0, config.observability);
494
1145
  this.startIndex = startIndex;
495
- this.schema = schema;
496
- this.mergeFn = mergeFn;
497
- this.priorOutput = priorOutput;
1146
+ this.schema = config.schema;
1147
+ this.mergeFn = config.mergeFn;
1148
+ this.priorOutput = config.priorOutput;
498
1149
  }
499
1150
  validateResponse(response) {
500
1151
  if (this.schema) {
@@ -503,15 +1154,31 @@ var ResumedWorkflow = class extends SealedWorkflow {
503
1154
  return response;
504
1155
  }
505
1156
  async generate(ctx, ...args) {
506
- const response = this.validateResponse(args[0]);
507
- const output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
508
- const state = { ctx, output, mode: "generate" };
509
- await this.execute(state, this.startIndex);
510
- 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);
511
1176
  }
512
1177
  stream(ctx, ...args) {
513
- const response = this.validateResponse(args[0]);
1178
+ const rawResponse = args[0];
514
1179
  const options = args[1];
1180
+ const opts = args[2];
1181
+ const abortSignal = opts?.abortSignal;
515
1182
  let resolveOutput;
516
1183
  let rejectOutput;
517
1184
  const outputPromise = new Promise((res, rej) => {
@@ -522,18 +1189,92 @@ var ResumedWorkflow = class extends SealedWorkflow {
522
1189
  });
523
1190
  const mergeFn = this.mergeFn;
524
1191
  const priorOutput = this.priorOutput;
1192
+ const startIndex = this.startIndex;
525
1193
  const stream = (0, import_ai3.createUIMessageStream)({
526
1194
  execute: async ({ writer }) => {
527
- 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
+ }
528
1203
  const state = {
529
1204
  ctx,
530
1205
  output,
531
1206
  mode: "stream",
532
- writer
1207
+ writer,
1208
+ runOptions: opts,
1209
+ abortSignal
533
1210
  };
534
1211
  try {
535
- await this.execute(state, this.startIndex);
536
- resolveOutput(state.output);
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
1272
+ };
1273
+ try {
1274
+ await this.execute(state, startIndex, opts);
1275
+ const result = this.buildResult(state);
1276
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1277
+ resolveOutput(result);
537
1278
  } catch (error) {
538
1279
  rejectOutput(error);
539
1280
  throw error;
@@ -546,15 +1287,27 @@ var ResumedWorkflow = class extends SealedWorkflow {
546
1287
  }
547
1288
  };
548
1289
  var Workflow = class _Workflow extends SealedWorkflow {
549
- constructor(steps = [], id) {
550
- super(steps, id);
1290
+ /**
1291
+ * Sentinel value for `foreach`'s `onError` handler. Returning `Workflow.SKIP`
1292
+ * from `onError` omits the failed item's index from the output array,
1293
+ * shortening it relative to the input array.
1294
+ */
1295
+ static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
1296
+ constructor(steps = [], id, observability) {
1297
+ super(steps, id, observability);
551
1298
  }
552
1299
  static create(options) {
553
- return new _Workflow([], options?.id);
1300
+ return new _Workflow([], options?.id, options?.observability);
554
1301
  }
555
1302
  static from(agent, options) {
556
1303
  return new _Workflow([]).step(agent, options);
557
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
+ }
558
1311
  // ── step: implementation ──────────────────────────────────────
559
1312
  step(target, optionsOrFn) {
560
1313
  if (target instanceof SealedWorkflow) {
@@ -562,11 +1315,15 @@ var Workflow = class _Workflow extends SealedWorkflow {
562
1315
  const node2 = {
563
1316
  type: "step",
564
1317
  id: workflow.id ?? "nested-workflow",
1318
+ nestedWorkflow: workflow,
1319
+ // Feeds the recursive stepShapeHash walk.
1320
+ category: "nested",
1321
+ // Observability event type.
565
1322
  execute: async (state) => {
566
1323
  await this.executeNestedWorkflow(state, workflow);
567
1324
  }
568
1325
  };
569
- return new _Workflow([...this.steps, node2], this.id);
1326
+ return this.appendStep(node2);
570
1327
  }
571
1328
  if (typeof target === "string") {
572
1329
  if (typeof optionsOrFn !== "function") {
@@ -583,19 +1340,19 @@ var Workflow = class _Workflow extends SealedWorkflow {
583
1340
  });
584
1341
  }
585
1342
  };
586
- return new _Workflow([...this.steps, node2], this.id);
1343
+ return this.appendStep(node2);
587
1344
  }
588
1345
  const agent = target;
589
1346
  const options = optionsOrFn;
590
1347
  const node = {
591
1348
  type: "step",
592
- id: agent.id,
1349
+ id: options?.id ?? agent.id,
593
1350
  execute: async (state) => {
594
1351
  const ctx = state.ctx;
595
1352
  await this.executeAgent(state, agent, ctx, options);
596
1353
  }
597
1354
  };
598
- return new _Workflow([...this.steps, node], this.id);
1355
+ return this.appendStep(node);
599
1356
  }
600
1357
  // ── gate: human-in-the-loop suspension point ────────────────
601
1358
  gate(id, options) {
@@ -621,19 +1378,20 @@ var Workflow = class _Workflow extends SealedWorkflow {
621
1378
  return state.output;
622
1379
  }
623
1380
  };
624
- return new _Workflow([...this.steps, node], this.id);
1381
+ return this.appendStep(node);
625
1382
  }
626
1383
  // ── branch: implementation ────────────────────────────────────
627
- branch(casesOrConfig) {
1384
+ branch(casesOrConfig, options) {
628
1385
  if (Array.isArray(casesOrConfig)) {
629
- return this.branchPredicate(casesOrConfig);
1386
+ return this.branchPredicate(casesOrConfig, options?.id);
630
1387
  }
631
- return this.branchSelect(casesOrConfig);
1388
+ return this.branchSelect(casesOrConfig, options?.id);
632
1389
  }
633
- branchPredicate(cases) {
1390
+ branchPredicate(cases, explicitId) {
634
1391
  const node = {
635
1392
  type: "step",
636
- id: "branch:predicate",
1393
+ id: explicitId ?? "branch:predicate",
1394
+ category: "branch",
637
1395
  execute: async (state) => {
638
1396
  const ctx = state.ctx;
639
1397
  const input = state.output;
@@ -645,21 +1403,43 @@ var Workflow = class _Workflow extends SealedWorkflow {
645
1403
  await this.executeAgent(state, branchCase.agent, ctx, branchCase);
646
1404
  return;
647
1405
  }
648
- 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}`);
649
1414
  }
650
1415
  };
651
- return new _Workflow([...this.steps, node], this.id);
1416
+ return this.appendStep(node);
652
1417
  }
653
- branchSelect(config) {
1418
+ branchSelect(config, explicitId) {
654
1419
  const node = {
655
1420
  type: "step",
656
- id: "branch:select",
1421
+ id: explicitId ?? "branch:select",
1422
+ category: "branch",
657
1423
  execute: async (state) => {
658
1424
  const ctx = state.ctx;
659
1425
  const input = state.output;
660
1426
  const key = await config.select({ ctx, input });
661
- 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;
662
1435
  if (!agent) {
1436
+ if (config.onUnknownKey) {
1437
+ config.onUnknownKey({
1438
+ key,
1439
+ availableKeys: Object.keys(config.agents),
1440
+ ctx
1441
+ });
1442
+ }
663
1443
  if (config.fallback) {
664
1444
  agent = config.fallback;
665
1445
  } else {
@@ -669,16 +1449,37 @@ var Workflow = class _Workflow extends SealedWorkflow {
669
1449
  await this.executeAgent(state, agent, ctx, config);
670
1450
  }
671
1451
  };
672
- return new _Workflow([...this.steps, node], this.id);
1452
+ return this.appendStep(node);
673
1453
  }
674
1454
  // ── foreach: array iteration ─────────────────────────────────
1455
+ /**
1456
+ * Map each item of an array through an agent or sub-workflow.
1457
+ *
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.
1462
+ * @param options.concurrency Max items in flight at any moment (default 1).
1463
+ * Backed by a semaphore: as soon as one item completes, the next launches —
1464
+ * no lockstep batching.
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.
1470
+ */
675
1471
  foreach(target, options) {
676
1472
  const concurrency = options?.concurrency ?? 1;
1473
+ const onError = options?.onError;
677
1474
  const isWorkflow = target instanceof SealedWorkflow;
678
- 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;
679
1477
  const node = {
680
1478
  type: "step",
681
1479
  id,
1480
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1481
+ nestedWorkflow: isWorkflow ? target : void 0,
1482
+ category: "foreach",
682
1483
  execute: async (state) => {
683
1484
  const items = state.output;
684
1485
  if (!Array.isArray(items)) {
@@ -686,42 +1487,312 @@ var Workflow = class _Workflow extends SealedWorkflow {
686
1487
  }
687
1488
  const ctx = state.ctx;
688
1489
  const results = new Array(items.length);
1490
+ const skipped = /* @__PURE__ */ new Set();
1491
+ const itemStates = new Array(items.length);
689
1492
  const executeItem = async (item, index) => {
690
- const itemState = { ctx: state.ctx, output: item, mode: "generate" };
691
- if (isWorkflow) {
692
- await this.executeNestedWorkflow(itemState, target);
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
+ }
1542
+ }
1543
+ };
1544
+ const handleRejection = async (error, item, index) => {
1545
+ if (!onError) throw error;
1546
+ const recovered = await onError({
1547
+ error,
1548
+ item,
1549
+ index,
1550
+ ctx: state.ctx
1551
+ });
1552
+ if (recovered === _Workflow.SKIP) {
1553
+ skipped.add(index);
693
1554
  } else {
694
- await this.executeAgent(itemState, target, ctx);
1555
+ results[index] = recovered;
695
1556
  }
696
- results[index] = itemState.output;
697
1557
  };
1558
+ const failures = [];
1559
+ const signal = state.abortSignal;
698
1560
  if (concurrency <= 1) {
699
1561
  for (let i = 0; i < items.length; i++) {
700
- await executeItem(items[i], i);
1562
+ if (signal?.aborted) {
1563
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1564
+ continue;
1565
+ }
1566
+ try {
1567
+ await executeItem(items[i], i);
1568
+ } catch (error) {
1569
+ failures.push({ index: i, error });
1570
+ }
1571
+ }
1572
+ } else {
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) {
1703
+ try {
1704
+ await executeBranch(e);
1705
+ } catch (error) {
1706
+ failures.push({ key: e.key, index: e.index, error });
1707
+ }
701
1708
  }
702
1709
  } else {
703
- for (let i = 0; i < items.length; i += concurrency) {
704
- const batch = items.slice(i, i + concurrency);
705
- await Promise.all(batch.map((item, j) => executeItem(item, i + j)));
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);
1728
+ }
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;
706
1770
  }
707
1771
  }
708
1772
  state.output = results;
709
1773
  }
710
1774
  };
711
- return new _Workflow([...this.steps, node], this.id);
1775
+ return this.appendStep(node);
712
1776
  }
713
1777
  // ── repeat: conditional loop ─────────────────────────────────
714
1778
  repeat(target, options) {
715
1779
  const maxIterations = options.maxIterations ?? 10;
716
1780
  const isWorkflow = target instanceof SealedWorkflow;
717
- 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;
718
1783
  const predicate = options.until ?? (async (p) => !await options.while(p));
719
1784
  const node = {
720
1785
  type: "step",
721
1786
  id,
1787
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1788
+ nestedWorkflow: isWorkflow ? target : void 0,
1789
+ category: "repeat",
722
1790
  execute: async (state) => {
723
1791
  const ctx = state.ctx;
724
1792
  for (let i = 1; i <= maxIterations; i++) {
1793
+ if (state.abortSignal?.aborted) {
1794
+ throw state.abortSignal.reason ?? new Error("Workflow aborted");
1795
+ }
725
1796
  if (isWorkflow) {
726
1797
  await this.executeNestedWorkflow(state, target);
727
1798
  } else {
@@ -737,7 +1808,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
737
1808
  throw new WorkflowLoopError(maxIterations, maxIterations);
738
1809
  }
739
1810
  };
740
- return new _Workflow([...this.steps, node], this.id);
1811
+ return this.appendStep(node);
741
1812
  }
742
1813
  // ── catch ─────────────────────────────────────────────────────
743
1814
  catch(id, fn) {
@@ -749,27 +1820,29 @@ var Workflow = class _Workflow extends SealedWorkflow {
749
1820
  id,
750
1821
  catchFn: fn
751
1822
  };
752
- return new _Workflow([...this.steps, node], this.id);
753
- }
754
- // ── finally (terminal — returns sealed workflow) ──────────────
755
- finally(id, fn) {
756
- const node = {
757
- type: "finally",
758
- id,
759
- execute: async (state) => {
760
- await fn({ ctx: state.ctx });
761
- }
762
- };
763
- return new SealedWorkflow([...this.steps, node], this.id);
1823
+ return this.appendStep(node);
764
1824
  }
1825
+ // `.finally()` is inherited from SealedWorkflow now (it lives there so
1826
+ // multi-finally chains are possible — `.finally().finally()`).
765
1827
  };
1828
+
1829
+ // src/index.ts
1830
+ var SKIP = Workflow.SKIP;
766
1831
  // Annotate the CommonJS export names for ESM import in node:
767
1832
  0 && (module.exports = {
768
1833
  Agent,
1834
+ CHECKPOINT_STEP_ID,
1835
+ CheckpointTimeoutError,
1836
+ NestedGateUnsupportedError,
1837
+ SKIP,
1838
+ TOOL_PROVIDER_BRAND,
1839
+ ToolProvider,
769
1840
  Workflow,
770
1841
  WorkflowBranchError,
771
1842
  WorkflowLoopError,
772
- WorkflowSuspended,
773
- defineTool
1843
+ defineTool,
1844
+ getActiveWriter,
1845
+ isToolProvider,
1846
+ migrateSnapshot
774
1847
  });
775
1848
  //# sourceMappingURL=index.cjs.map