pipeai 0.3.0 → 0.8.1

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