pipeai 0.3.0 → 0.8.0

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