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.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,13 +24,58 @@ function resolveValue(value, ctx, input) {
23
24
  }
24
25
  return value;
25
26
  }
26
- async function extractOutput(result, hasStructuredOutput) {
27
+ async function extractOutput(result, hasStructuredOutput, schema) {
27
28
  if (hasStructuredOutput) {
28
29
  const output = await result.output;
29
- 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;
30
39
  }
31
40
  return await result.text;
32
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
+ }
33
79
 
34
80
  // src/tool-provider.ts
35
81
  var TOOL_PROVIDER_BRAND = /* @__PURE__ */ Symbol.for("agent-workflow.ToolProvider");
@@ -60,10 +106,17 @@ var Agent = class {
60
106
  id;
61
107
  description;
62
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;
63
117
  config;
64
118
  _hasDynamicConfig;
65
119
  _resolvedStaticTools = null;
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
120
  _passthrough;
68
121
  _onStepFinish;
69
122
  _onFinish;
@@ -71,6 +124,7 @@ var Agent = class {
71
124
  this.id = config.id;
72
125
  this.description = config.description ?? "";
73
126
  this.hasOutput = config.output !== void 0;
127
+ this.validateOutput = config.validateOutput;
74
128
  this.config = config;
75
129
  this._hasDynamicConfig = [
76
130
  config.model,
@@ -79,8 +133,7 @@ var Agent = class {
79
133
  config.messages,
80
134
  config.tools,
81
135
  config.activeTools,
82
- config.toolChoice,
83
- config.stopWhen
136
+ config.toolChoice
84
137
  ].some((v) => typeof v === "function");
85
138
  if (!this._hasDynamicConfig) {
86
139
  const rawTools = config.tools ?? {};
@@ -94,6 +147,7 @@ var Agent = class {
94
147
  description: _desc,
95
148
  input: _inputSchema,
96
149
  output: _output,
150
+ validateOutput: _validateOutput,
97
151
  model: _m,
98
152
  system: _s,
99
153
  prompt: _p,
@@ -113,25 +167,13 @@ var Agent = class {
113
167
  }
114
168
  async generate(ctx, ...args) {
115
169
  const input = args[0];
116
- const resolved = await this.resolveConfig(ctx, input);
117
- const options = this.buildCallOptions(resolved, ctx, input);
118
- try {
119
- return await generateText(options);
120
- } catch (error) {
121
- if (this.config.onError) {
122
- await this.config.onError({ error, ctx, input, writer: getActiveWriter() });
123
- }
124
- throw error;
125
- }
170
+ const callOptions = args[1];
171
+ return this.generateWithOptions(ctx, input, callOptions ?? {});
126
172
  }
127
173
  async stream(ctx, ...args) {
128
174
  const input = args[0];
129
- const resolved = await this.resolveConfig(ctx, input);
130
- const options = this.buildCallOptions(resolved, ctx, input);
131
- return streamText({
132
- ...options,
133
- onError: this.config.onError ? ({ error }) => this.config.onError({ error, ctx, input, writer: getActiveWriter() }) : void 0
134
- });
175
+ const callOptions = args[1];
176
+ return this.streamWithOptions(ctx, input, callOptions ?? {});
135
177
  }
136
178
  asTool(ctx, options) {
137
179
  return this.createToolInstance(ctx, options);
@@ -152,24 +194,86 @@ var Agent = class {
152
194
  return tool2({
153
195
  description: this.description,
154
196
  inputSchema: this.config.input,
155
- 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;
156
203
  const writer = getActiveWriter();
157
204
  if (writer) {
158
- const result2 = await this.stream(ctx, toolInput);
205
+ const result2 = await this.streamWithOptions(ctx, toolInput, { abortSignal });
159
206
  writer.merge(result2.toUIMessageStream());
160
- if (options?.mapOutput) return options.mapOutput(result2);
161
- 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);
162
212
  }
163
- const result = await this.generate(ctx, toolInput);
213
+ const result = await this.generateWithOptions(ctx, toolInput, { abortSignal });
164
214
  if (options?.mapOutput) return options.mapOutput(result);
165
- return extractOutput(result, this.hasOutput);
215
+ return extractOutput(result, this.hasOutput, this.validateOutput);
166
216
  }
167
217
  // TS cannot simplify the SDK's `NeverOptional<TOutput, ...>` conditional in a
168
218
  // generic context, so we cast through `unknown` instead of `any`.
169
219
  });
170
220
  }
171
- // 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
+ }
172
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
+ }
173
277
  return {
174
278
  ...this._passthrough,
175
279
  model: resolved.model,
@@ -177,11 +281,16 @@ var Agent = class {
177
281
  activeTools: resolved.activeTools,
178
282
  toolChoice: resolved.toolChoice,
179
283
  stopWhen: resolved.stopWhen,
180
- ...resolved.messages ? { messages: resolved.messages } : { prompt: resolved.prompt ?? "" },
181
- ...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 } : {},
182
288
  ...this.config.output ? { output: this.config.output } : {},
183
- onStepFinish: this._onStepFinish ? (event) => this._onStepFinish({ result: event, ctx, input, writer: getActiveWriter() }) : void 0,
184
- 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() }) } : {}
185
294
  };
186
295
  }
187
296
  resolveConfig(ctx, input) {
@@ -203,18 +312,27 @@ var Agent = class {
203
312
  return this.resolveConfigAsync(ctx, input);
204
313
  }
205
314
  async resolveConfigAsync(ctx, input) {
206
- const [model, prompt, system, messages, rawTools, activeTools, toolChoice, stopWhen] = await Promise.all([
315
+ const [model, prompt, system, messages, rawTools, activeTools, toolChoice] = await Promise.all([
207
316
  resolveValue(this.config.model, ctx, input),
208
317
  resolveValue(this.config.prompt, ctx, input),
209
318
  resolveValue(this.config.system, ctx, input),
210
319
  resolveValue(this.config.messages, ctx, input),
211
320
  resolveValue(this.config.tools, ctx, input),
212
321
  resolveValue(this.config.activeTools, ctx, input),
213
- resolveValue(this.config.toolChoice, ctx, input),
214
- resolveValue(this.config.stopWhen, ctx, input)
322
+ resolveValue(this.config.toolChoice, ctx, input)
215
323
  ]);
216
324
  const tools = this.resolveTools(rawTools ?? {}, ctx);
217
- 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
+ };
218
336
  }
219
337
  resolveTools(tools, ctx) {
220
338
  const entries = Object.entries(tools);
@@ -252,37 +370,284 @@ var WorkflowLoopError = class extends Error {
252
370
  this.name = "WorkflowLoopError";
253
371
  }
254
372
  };
255
- var WorkflowSuspended = class extends Error {
256
- snapshot;
257
- constructor(snapshot) {
258
- super(`Workflow suspended at gate "${snapshot.gateId}"`);
259
- this.name = "WorkflowSuspended";
260
- 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;
261
396
  }
262
397
  };
263
- 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 {
264
490
  id;
265
491
  steps;
266
- 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) {
267
500
  this.steps = steps;
268
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
+ }
269
627
  }
270
628
  // ── Execution ─────────────────────────────────────────────────
271
629
  async generate(ctx, ...args) {
630
+ this.ensureDuplicateCheck();
272
631
  const input = args[0];
632
+ const opts = args[1];
633
+ this.validateRunOptions(opts);
273
634
  const state = {
274
635
  ctx,
275
636
  output: input,
276
- mode: "generate"
277
- };
278
- await this.execute(state);
279
- return {
280
- output: state.output
637
+ mode: "generate",
638
+ runOptions: opts,
639
+ abortSignal: opts?.abortSignal
281
640
  };
641
+ await this.execute(state, 0, opts);
642
+ return this.buildResult(state);
282
643
  }
283
644
  stream(ctx, ...args) {
645
+ this.ensureDuplicateCheck();
284
646
  const input = args[0];
285
647
  const options = args[1];
648
+ const opts = args[2];
649
+ this.validateRunOptions(opts);
650
+ const abortSignal = opts?.abortSignal;
286
651
  let resolveOutput;
287
652
  let rejectOutput;
288
653
  const outputPromise = new Promise((res, rej) => {
@@ -297,11 +662,15 @@ var SealedWorkflow = class {
297
662
  ctx,
298
663
  output: input,
299
664
  mode: "stream",
300
- writer
665
+ writer,
666
+ runOptions: opts,
667
+ abortSignal
301
668
  };
302
669
  try {
303
- await this.execute(state);
304
- resolveOutput(state.output);
670
+ await this.execute(state, 0, opts);
671
+ const result = this.buildResult(state);
672
+ maybeWarnStreamOnErrorOnSuspend(result, options);
673
+ resolveOutput(result);
305
674
  } catch (error) {
306
675
  rejectOutput(error);
307
676
  throw error;
@@ -315,20 +684,78 @@ var SealedWorkflow = class {
315
684
  output: outputPromise
316
685
  };
317
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
+ }
318
699
  // ── Internal: execute pipeline ────────────────────────────────
319
- async execute(state, startIndex = 0) {
700
+ async execute(state, startIndex = 0, opts, initialError = null) {
320
701
  if (this.steps.length === 0) {
321
702
  throw new Error("Workflow has no steps. Add at least one step before calling generate() or stream().");
322
703
  }
323
- 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
+ });
324
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
+ }
325
726
  const node = this.steps[i];
326
727
  if (node.type === "finally") {
327
- 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
+ }
328
752
  continue;
329
753
  }
330
754
  if (node.type === "catch") {
331
- 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 });
332
759
  try {
333
760
  state.output = await node.catchFn({
334
761
  error: pendingError.error,
@@ -337,49 +764,183 @@ var SealedWorkflow = class {
337
764
  stepId: pendingError.stepId
338
765
  });
339
766
  pendingError = null;
340
- } catch (catchError) {
341
- 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" };
342
785
  }
343
786
  continue;
344
787
  }
788
+ if (state.suspension || pendingError) continue;
345
789
  if (node.type === "gate") {
346
- if (pendingError) continue;
347
- if (node.condition) {
348
- const shouldSuspend = await node.condition(state);
349
- if (!shouldSuspend) continue;
350
- }
351
- const gatePayload = await node.payload(state);
352
- throw new WorkflowSuspended({
353
- version: 1,
354
- 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,
355
839
  output: state.output,
356
- gateId: node.id,
357
- gatePayload
840
+ durationMs: performance.now() - sStart,
841
+ suspended: false
358
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
+ }
359
858
  }
360
- if (pendingError) continue;
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;
902
+ }
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);
361
911
  try {
362
- await node.execute(state);
363
- } catch (error) {
364
- if (error instanceof WorkflowSuspended) throw error;
365
- 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);
366
921
  }
922
+ pendingError = null;
367
923
  }
368
- if (pendingError) throw pendingError.error;
369
924
  }
370
925
  // ── Internal: execute a nested workflow within a step/loop ─────
371
926
  // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
372
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).
373
932
  async executeNestedWorkflow(state, workflow) {
933
+ const savedRunOptions = state.runOptions;
934
+ state.runOptions = void 0;
374
935
  try {
375
936
  await workflow.execute(state);
376
- } catch (error) {
377
- if (error instanceof WorkflowSuspended) {
378
- throw new Error(
379
- `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.`
380
- );
381
- }
382
- 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);
383
944
  }
384
945
  }
385
946
  // ── Internal: execute an agent within a step/branch ───────────
@@ -389,59 +950,141 @@ var SealedWorkflow = class {
389
950
  async executeAgent(state, agent, ctx, options) {
390
951
  const input = state.output;
391
952
  const hasStructuredOutput = agent.hasOutput;
953
+ const abortSignal = state.abortSignal;
954
+ const agentCallOpts = abortSignal ? { abortSignal } : void 0;
392
955
  if (state.mode === "stream" && state.writer) {
393
956
  const writer = state.writer;
394
957
  await runWithWriter(writer, async () => {
395
- const result = await agent.stream(ctx, state.output);
958
+ const result = await agent.stream(ctx, state.output, agentCallOpts);
396
959
  if (options?.handleStream) {
397
960
  await options.handleStream({ result, writer, ctx });
398
961
  } else {
399
962
  writer.merge(result.toUIMessageStream());
400
963
  }
401
- if (options?.onStreamResult) {
402
- 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);
403
973
  }
404
- if (options?.mapStreamResult) {
405
- state.output = await options.mapStreamResult({ result, ctx, input });
974
+ if (options?.mapResult) {
975
+ state.output = await options.mapResult(hookParams);
406
976
  } else {
407
- state.output = await extractOutput(result, hasStructuredOutput);
977
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
408
978
  }
409
979
  });
410
980
  } else {
411
- const result = await agent.generate(ctx, state.output);
412
- if (options?.onGenerateResult) {
413
- 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);
414
991
  }
415
- if (options?.mapGenerateResult) {
416
- state.output = await options.mapGenerateResult({ result, ctx, input });
992
+ if (options?.mapResult) {
993
+ state.output = await options.mapResult(hookParams);
417
994
  } else {
418
- state.output = await extractOutput(result, hasStructuredOutput);
995
+ state.output = await extractOutput(result, hasStructuredOutput, agent.validateOutput);
419
996
  }
420
997
  }
421
998
  }
422
999
  // ── Gate: load persisted state for resumption ──────────────────
423
1000
  loadState(gateId, snapshot) {
424
- 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) {
425
1006
  throw new Error(
426
- `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}".`
427
1008
  );
428
1009
  }
429
- const gateIndex = this.findGateIndex(snapshot);
1010
+ this.ensureDuplicateCheck();
1011
+ const gateIndex = this.findGateIndex(gateLike);
430
1012
  const gateNode = this.steps[gateIndex];
431
- return new ResumedWorkflow(
432
- this.steps,
433
- gateIndex + 1,
434
- gateNode.schema,
435
- gateNode.merge,
436
- snapshot.output
437
- );
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);
438
1081
  }
439
1082
  findGateIndex(snapshot) {
440
- if (snapshot.version !== 1) {
1083
+ if (snapshot.version !== 1 && snapshot.version !== 2) {
441
1084
  throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
442
1085
  }
443
1086
  const hint = snapshot.resumeFromIndex;
444
- if (hint >= 0 && hint < this.steps.length) {
1087
+ if (Number.isInteger(hint) && hint >= 0 && hint < this.steps.length) {
445
1088
  const node = this.steps[hint];
446
1089
  if (node.type === "gate" && node.id === snapshot.gateId) {
447
1090
  return hint;
@@ -464,12 +1107,12 @@ var ResumedWorkflow = class extends SealedWorkflow {
464
1107
  mergeFn;
465
1108
  priorOutput;
466
1109
  /** @internal */
467
- constructor(steps, startIndex, schema, mergeFn, priorOutput) {
468
- super(steps);
1110
+ constructor(steps, startIndex, config) {
1111
+ super(steps, void 0, config.observability);
469
1112
  this.startIndex = startIndex;
470
- this.schema = schema;
471
- this.mergeFn = mergeFn;
472
- this.priorOutput = priorOutput;
1113
+ this.schema = config.schema;
1114
+ this.mergeFn = config.mergeFn;
1115
+ this.priorOutput = config.priorOutput;
473
1116
  }
474
1117
  validateResponse(response) {
475
1118
  if (this.schema) {
@@ -478,15 +1121,31 @@ var ResumedWorkflow = class extends SealedWorkflow {
478
1121
  return response;
479
1122
  }
480
1123
  async generate(ctx, ...args) {
481
- const response = this.validateResponse(args[0]);
482
- const output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
483
- const state = { ctx, output, mode: "generate" };
484
- await this.execute(state, this.startIndex);
485
- 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);
486
1143
  }
487
1144
  stream(ctx, ...args) {
488
- const response = this.validateResponse(args[0]);
1145
+ const rawResponse = args[0];
489
1146
  const options = args[1];
1147
+ const opts = args[2];
1148
+ const abortSignal = opts?.abortSignal;
490
1149
  let resolveOutput;
491
1150
  let rejectOutput;
492
1151
  const outputPromise = new Promise((res, rej) => {
@@ -497,18 +1156,92 @@ var ResumedWorkflow = class extends SealedWorkflow {
497
1156
  });
498
1157
  const mergeFn = this.mergeFn;
499
1158
  const priorOutput = this.priorOutput;
1159
+ const startIndex = this.startIndex;
500
1160
  const stream = createUIMessageStream({
501
1161
  execute: async ({ writer }) => {
502
- 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
+ }
503
1170
  const state = {
504
1171
  ctx,
505
1172
  output,
506
1173
  mode: "stream",
507
- 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
508
1239
  };
509
1240
  try {
510
- await this.execute(state, this.startIndex);
511
- resolveOutput(state.output);
1241
+ await this.execute(state, startIndex, opts);
1242
+ const result = this.buildResult(state);
1243
+ maybeWarnStreamOnErrorOnSuspend(result, options);
1244
+ resolveOutput(result);
512
1245
  } catch (error) {
513
1246
  rejectOutput(error);
514
1247
  throw error;
@@ -521,15 +1254,27 @@ var ResumedWorkflow = class extends SealedWorkflow {
521
1254
  }
522
1255
  };
523
1256
  var Workflow = class _Workflow extends SealedWorkflow {
524
- constructor(steps = [], id) {
525
- super(steps, id);
1257
+ /**
1258
+ * Sentinel value for `foreach`'s `onError` handler. Returning `Workflow.SKIP`
1259
+ * from `onError` omits the failed item's index from the output array,
1260
+ * shortening it relative to the input array.
1261
+ */
1262
+ static SKIP = /* @__PURE__ */ Symbol("pipeai.foreach.skip");
1263
+ constructor(steps = [], id, observability) {
1264
+ super(steps, id, observability);
526
1265
  }
527
1266
  static create(options) {
528
- return new _Workflow([], options?.id);
1267
+ return new _Workflow([], options?.id, options?.observability);
529
1268
  }
530
1269
  static from(agent, options) {
531
1270
  return new _Workflow([]).step(agent, options);
532
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
+ }
533
1278
  // ── step: implementation ──────────────────────────────────────
534
1279
  step(target, optionsOrFn) {
535
1280
  if (target instanceof SealedWorkflow) {
@@ -537,11 +1282,15 @@ var Workflow = class _Workflow extends SealedWorkflow {
537
1282
  const node2 = {
538
1283
  type: "step",
539
1284
  id: workflow.id ?? "nested-workflow",
1285
+ nestedWorkflow: workflow,
1286
+ // Feeds the recursive stepShapeHash walk.
1287
+ category: "nested",
1288
+ // Observability event type.
540
1289
  execute: async (state) => {
541
1290
  await this.executeNestedWorkflow(state, workflow);
542
1291
  }
543
1292
  };
544
- return new _Workflow([...this.steps, node2], this.id);
1293
+ return this.appendStep(node2);
545
1294
  }
546
1295
  if (typeof target === "string") {
547
1296
  if (typeof optionsOrFn !== "function") {
@@ -558,19 +1307,19 @@ var Workflow = class _Workflow extends SealedWorkflow {
558
1307
  });
559
1308
  }
560
1309
  };
561
- return new _Workflow([...this.steps, node2], this.id);
1310
+ return this.appendStep(node2);
562
1311
  }
563
1312
  const agent = target;
564
1313
  const options = optionsOrFn;
565
1314
  const node = {
566
1315
  type: "step",
567
- id: agent.id,
1316
+ id: options?.id ?? agent.id,
568
1317
  execute: async (state) => {
569
1318
  const ctx = state.ctx;
570
1319
  await this.executeAgent(state, agent, ctx, options);
571
1320
  }
572
1321
  };
573
- return new _Workflow([...this.steps, node], this.id);
1322
+ return this.appendStep(node);
574
1323
  }
575
1324
  // ── gate: human-in-the-loop suspension point ────────────────
576
1325
  gate(id, options) {
@@ -596,19 +1345,20 @@ var Workflow = class _Workflow extends SealedWorkflow {
596
1345
  return state.output;
597
1346
  }
598
1347
  };
599
- return new _Workflow([...this.steps, node], this.id);
1348
+ return this.appendStep(node);
600
1349
  }
601
1350
  // ── branch: implementation ────────────────────────────────────
602
- branch(casesOrConfig) {
1351
+ branch(casesOrConfig, options) {
603
1352
  if (Array.isArray(casesOrConfig)) {
604
- return this.branchPredicate(casesOrConfig);
1353
+ return this.branchPredicate(casesOrConfig, options?.id);
605
1354
  }
606
- return this.branchSelect(casesOrConfig);
1355
+ return this.branchSelect(casesOrConfig, options?.id);
607
1356
  }
608
- branchPredicate(cases) {
1357
+ branchPredicate(cases, explicitId) {
609
1358
  const node = {
610
1359
  type: "step",
611
- id: "branch:predicate",
1360
+ id: explicitId ?? "branch:predicate",
1361
+ category: "branch",
612
1362
  execute: async (state) => {
613
1363
  const ctx = state.ctx;
614
1364
  const input = state.output;
@@ -620,21 +1370,43 @@ var Workflow = class _Workflow extends SealedWorkflow {
620
1370
  await this.executeAgent(state, branchCase.agent, ctx, branchCase);
621
1371
  return;
622
1372
  }
623
- 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}`);
624
1381
  }
625
1382
  };
626
- return new _Workflow([...this.steps, node], this.id);
1383
+ return this.appendStep(node);
627
1384
  }
628
- branchSelect(config) {
1385
+ branchSelect(config, explicitId) {
629
1386
  const node = {
630
1387
  type: "step",
631
- id: "branch:select",
1388
+ id: explicitId ?? "branch:select",
1389
+ category: "branch",
632
1390
  execute: async (state) => {
633
1391
  const ctx = state.ctx;
634
1392
  const input = state.output;
635
1393
  const key = await config.select({ ctx, input });
636
- 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;
637
1402
  if (!agent) {
1403
+ if (config.onUnknownKey) {
1404
+ config.onUnknownKey({
1405
+ key,
1406
+ availableKeys: Object.keys(config.agents),
1407
+ ctx
1408
+ });
1409
+ }
638
1410
  if (config.fallback) {
639
1411
  agent = config.fallback;
640
1412
  } else {
@@ -644,16 +1416,37 @@ var Workflow = class _Workflow extends SealedWorkflow {
644
1416
  await this.executeAgent(state, agent, ctx, config);
645
1417
  }
646
1418
  };
647
- return new _Workflow([...this.steps, node], this.id);
1419
+ return this.appendStep(node);
648
1420
  }
649
1421
  // ── foreach: array iteration ─────────────────────────────────
1422
+ /**
1423
+ * Map each item of an array through an agent or sub-workflow.
1424
+ *
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.
1429
+ * @param options.concurrency Max items in flight at any moment (default 1).
1430
+ * Backed by a semaphore: as soon as one item completes, the next launches —
1431
+ * no lockstep batching.
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.
1437
+ */
650
1438
  foreach(target, options) {
651
1439
  const concurrency = options?.concurrency ?? 1;
1440
+ const onError = options?.onError;
652
1441
  const isWorkflow = target instanceof SealedWorkflow;
653
- 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;
654
1444
  const node = {
655
1445
  type: "step",
656
1446
  id,
1447
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1448
+ nestedWorkflow: isWorkflow ? target : void 0,
1449
+ category: "foreach",
657
1450
  execute: async (state) => {
658
1451
  const items = state.output;
659
1452
  if (!Array.isArray(items)) {
@@ -661,42 +1454,312 @@ var Workflow = class _Workflow extends SealedWorkflow {
661
1454
  }
662
1455
  const ctx = state.ctx;
663
1456
  const results = new Array(items.length);
1457
+ const skipped = /* @__PURE__ */ new Set();
1458
+ const itemStates = new Array(items.length);
664
1459
  const executeItem = async (item, index) => {
665
- const itemState = { ctx: state.ctx, output: item, mode: "generate" };
666
- if (isWorkflow) {
667
- await this.executeNestedWorkflow(itemState, target);
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
+ }
1509
+ }
1510
+ };
1511
+ const handleRejection = async (error, item, index) => {
1512
+ if (!onError) throw error;
1513
+ const recovered = await onError({
1514
+ error,
1515
+ item,
1516
+ index,
1517
+ ctx: state.ctx
1518
+ });
1519
+ if (recovered === _Workflow.SKIP) {
1520
+ skipped.add(index);
668
1521
  } else {
669
- await this.executeAgent(itemState, target, ctx);
1522
+ results[index] = recovered;
670
1523
  }
671
- results[index] = itemState.output;
672
1524
  };
1525
+ const failures = [];
1526
+ const signal = state.abortSignal;
673
1527
  if (concurrency <= 1) {
674
1528
  for (let i = 0; i < items.length; i++) {
675
- await executeItem(items[i], i);
1529
+ if (signal?.aborted) {
1530
+ failures.push({ index: i, error: signal.reason ?? new Error("Workflow aborted") });
1531
+ continue;
1532
+ }
1533
+ try {
1534
+ await executeItem(items[i], i);
1535
+ } catch (error) {
1536
+ failures.push({ index: i, error });
1537
+ }
1538
+ }
1539
+ } else {
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) {
1670
+ try {
1671
+ await executeBranch(e);
1672
+ } catch (error) {
1673
+ failures.push({ key: e.key, index: e.index, error });
1674
+ }
676
1675
  }
677
1676
  } else {
678
- for (let i = 0; i < items.length; i += concurrency) {
679
- const batch = items.slice(i, i + concurrency);
680
- await Promise.all(batch.map((item, j) => executeItem(item, i + j)));
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);
1695
+ }
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;
681
1737
  }
682
1738
  }
683
1739
  state.output = results;
684
1740
  }
685
1741
  };
686
- return new _Workflow([...this.steps, node], this.id);
1742
+ return this.appendStep(node);
687
1743
  }
688
1744
  // ── repeat: conditional loop ─────────────────────────────────
689
1745
  repeat(target, options) {
690
1746
  const maxIterations = options.maxIterations ?? 10;
691
1747
  const isWorkflow = target instanceof SealedWorkflow;
692
- 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;
693
1750
  const predicate = options.until ?? (async (p) => !await options.while(p));
694
1751
  const node = {
695
1752
  type: "step",
696
1753
  id,
1754
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1755
+ nestedWorkflow: isWorkflow ? target : void 0,
1756
+ category: "repeat",
697
1757
  execute: async (state) => {
698
1758
  const ctx = state.ctx;
699
1759
  for (let i = 1; i <= maxIterations; i++) {
1760
+ if (state.abortSignal?.aborted) {
1761
+ throw state.abortSignal.reason ?? new Error("Workflow aborted");
1762
+ }
700
1763
  if (isWorkflow) {
701
1764
  await this.executeNestedWorkflow(state, target);
702
1765
  } else {
@@ -712,7 +1775,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
712
1775
  throw new WorkflowLoopError(maxIterations, maxIterations);
713
1776
  }
714
1777
  };
715
- return new _Workflow([...this.steps, node], this.id);
1778
+ return this.appendStep(node);
716
1779
  }
717
1780
  // ── catch ─────────────────────────────────────────────────────
718
1781
  catch(id, fn) {
@@ -724,26 +1787,28 @@ var Workflow = class _Workflow extends SealedWorkflow {
724
1787
  id,
725
1788
  catchFn: fn
726
1789
  };
727
- return new _Workflow([...this.steps, node], this.id);
728
- }
729
- // ── finally (terminal — returns sealed workflow) ──────────────
730
- finally(id, fn) {
731
- const node = {
732
- type: "finally",
733
- id,
734
- execute: async (state) => {
735
- await fn({ ctx: state.ctx });
736
- }
737
- };
738
- return new SealedWorkflow([...this.steps, node], this.id);
1790
+ return this.appendStep(node);
739
1791
  }
1792
+ // `.finally()` is inherited from SealedWorkflow now (it lives there so
1793
+ // multi-finally chains are possible — `.finally().finally()`).
740
1794
  };
1795
+
1796
+ // src/index.ts
1797
+ var SKIP = Workflow.SKIP;
741
1798
  export {
742
1799
  Agent,
1800
+ CHECKPOINT_STEP_ID,
1801
+ CheckpointTimeoutError,
1802
+ NestedGateUnsupportedError,
1803
+ SKIP,
1804
+ TOOL_PROVIDER_BRAND,
1805
+ ToolProvider,
743
1806
  Workflow,
744
1807
  WorkflowBranchError,
745
1808
  WorkflowLoopError,
746
- WorkflowSuspended,
747
- defineTool
1809
+ defineTool,
1810
+ getActiveWriter,
1811
+ isToolProvider,
1812
+ migrateSnapshot
748
1813
  };
749
1814
  //# sourceMappingURL=index.js.map