pipeai 0.8.1 → 0.8.4

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/README.md CHANGED
@@ -4,7 +4,7 @@ A typed multi-agent workflow pipeline built on top of the [Vercel AI SDK v6](htt
4
4
 
5
5
  Agents are pure AI SDK wrappers that return native `GenerateTextResult` / `StreamTextResult`. Workflows chain agents into pipelines with automatic stream merging, deterministic agent routing, and typed output extraction.
6
6
 
7
- The library is ~1000 lines across 4 files. It's designed to be read, understood, and modified a thin composition layer over AI SDK, not a framework to learn around.
7
+ It's a lean composition layer over the Vercel AI SDK — not a framework to learn around. Agents return native AI SDK results and workflows merge native streams, so anything you can do with the AI SDK still works underneath; pipeai stays out of the way and stays fully compatible.
8
8
 
9
9
  ## Core Concepts
10
10
 
@@ -299,6 +299,23 @@ const pipeline = Workflow.create<Ctx>()
299
299
  });
300
300
  ```
301
301
 
302
+ An inline `.step(id, fn)` handler receives `{ ctx, input, writer? }`. When the workflow runs via `.stream(...)`, `writer` is the same `UIMessageStreamWriter` the agent steps merge into — so an inline step can surface mid-pipeline progress (status/data parts) to the client before the terminal agent starts emitting tokens. `writer` is `undefined` in generate mode (`.generate(...)`), so guard with `?.`:
303
+
304
+ ```ts
305
+ const pipeline = Workflow.create<Ctx>()
306
+ .step("plan", async ({ ctx, input, writer }) => {
307
+ writer?.write({ type: "data-status", data: { phase: "planning" } });
308
+ return planner.run(ctx, input);
309
+ })
310
+ .step("search", async ({ ctx, input, writer }) => {
311
+ writer?.write({ type: "data-status", data: { phase: "searching", queries: input.queries.length } });
312
+ return ctx.search.run(input.queries);
313
+ })
314
+ .step(synthesizerAgent); // terminal streaming agent
315
+ ```
316
+
317
+ For ambient writer access from helper functions called *inside* a step, compose the AI SDK's `createUIMessageStream` directly — pipeai only threads `writer` into the inline step handler itself.
318
+
302
319
  ### Running a workflow
303
320
 
304
321
  ```ts
@@ -349,6 +366,31 @@ const pipeline = Workflow.create<Ctx>()
349
366
 
350
367
  Nested workflows can be arbitrarily deep — a workflow step can contain another workflow that itself contains nested workflows.
351
368
 
369
+ ### Conditional steps via `when` / `otherwise`
370
+
371
+ Any `step` form — agent, inline `step(id, fn)`, or nested `step(workflow)` — accepts a `when` predicate. When it returns false the step is **skipped** and its body never runs:
372
+
373
+ ```ts
374
+ const pipeline = Workflow.create<Ctx, Input>()
375
+ // skip → passthrough: input is forwarded unchanged
376
+ .step(enrichAgent, { when: ({ input }) => input.needsEnrichment })
377
+ // skip → `otherwise` produces the value
378
+ .step("search", runSearch, {
379
+ when: ({ input }) => input.intent === "search",
380
+ otherwise: ({ input }) => ({ ...input, results: [] }),
381
+ })
382
+ // conditionally run a whole sub-pipeline
383
+ .step(productPipeline, { when: ({ input }) => input.intent === "product" });
384
+ ```
385
+
386
+ The output type reflects what can actually happen — this is deliberate, so a skipped step can't be mistaken for one that always ran:
387
+
388
+ - **`when` + `otherwise`** → output stays `TNextOutput` (`otherwise` returns it).
389
+ - **`when` without `otherwise`** → output widens to `TOutput | TNextOutput` (skip passes the input through). For same-shape tap/enrich steps the union collapses to a single type; when the shapes differ and you'd rather keep a single type, supply `otherwise` to produce a default.
390
+ - **no `when`** → `TNextOutput`, exactly as before.
391
+
392
+ `when` / `otherwise` throwing propagates as a normal step error (a downstream `.catch()` can observe it). A skipped step still fires the `onStepStart` / `onStepFinish` observability events with its passthrough/`otherwise` value as `output`.
393
+
352
394
  ### Predicate branching via `branch()`
353
395
 
354
396
  Route to different agents based on runtime conditions. The first matching `when` wins. A case without `when` acts as the default:
@@ -477,7 +519,7 @@ const pipeline = Workflow.create<Ctx>()
477
519
 
478
520
  ### Array iteration via `foreach()`
479
521
 
480
- `foreach()` maps each element of an array output through an agent or workflow. Items run in generate mode to avoid interleaved streams:
522
+ `foreach()` maps each element of an array output through an agent or workflow. By default items run in generate mode — `foreach` never auto-merges, since merging N concurrent streams would interleave into a garbled message (see [Streaming items](#streaming-foreach--parallel-items) to opt in via `handleStream`):
481
523
 
482
524
  ```ts
483
525
  const summarizer = new Agent<Ctx, string, string>({
@@ -494,14 +536,16 @@ const pipeline = Workflow.create<Ctx>()
494
536
  .step("combine", ({ input }) => input.join("\n\n"));
495
537
  ```
496
538
 
497
- Concurrent processing with bounded parallelism:
539
+ By default `foreach` is **unbounded** — every item runs concurrently. Pass `concurrency` to throttle (e.g. against provider rate limits):
498
540
 
499
541
  ```ts
500
- // Up to 3 items run simultaneously; the next launches as soon as one finishes.
542
+ // Cap at 3 items in flight; the next launches as soon as one finishes.
501
543
  .foreach(summarizer, { concurrency: 3 })
502
544
  ```
503
545
 
504
- `concurrency` is the **maximum number of items in flight at any moment** — backed by a semaphore. There's no lockstep batching: a slow item never blocks a finished slot from picking up the next pending one.
546
+ `concurrency` is the **maximum number of items in flight at any moment** — backed by a worker pool. There's no lockstep batching: a slow item never blocks a finished slot from picking up the next pending one.
547
+
548
+ > **Rate-limit hazard:** the unbounded default fires all items at once. For large arrays against a rate-limited provider, set an explicit `concurrency`.
505
549
 
506
550
  Works with nested workflows too:
507
551
 
@@ -515,6 +559,33 @@ const pipeline = Workflow.create<Ctx>()
515
559
  .foreach(processItem, { concurrency: 5 });
516
560
  ```
517
561
 
562
+ #### Streaming `foreach` / `parallel` items
563
+
564
+ When the workflow is run with `.stream(...)`, pass `handleStream` to `foreach` or `parallel` to run each **agent** item/branch in stream mode and control how it surfaces to the writer — the same hook as a single `.step(agent)`, plus an `itemIndex`:
565
+
566
+ ```ts
567
+ // foreach: itemIndex is the numeric item index
568
+ .foreach(summarizer, {
569
+ handleStream: ({ result, writer, input, itemIndex }) => {
570
+ writer.write({ type: "data-item-start", data: { itemIndex } });
571
+ writer.merge(result.toUIMessageStream());
572
+ },
573
+ })
574
+
575
+ // parallel record form: itemIndex is the branch key
576
+ .parallel({ summary: summarizer, sentiment: classifier }, {
577
+ handleStream: ({ result, writer, itemIndex }) => {
578
+ if (itemIndex === "summary") writer.merge(result.toUIMessageStream());
579
+ },
580
+ })
581
+ ```
582
+
583
+ - **No `handleStream`** → agent items run in generate mode (no auto-merge). `foreach`/`parallel` never auto-merge; you opt into surfacing explicitly.
584
+ - **`SealedWorkflow` items/branches** stream transitively via their own steps when the parent streams — `handleStream` is not called for them.
585
+ - `itemIndex`: `number` for `foreach` and tuple `parallel`; the key (`string`) for record `parallel`. `branch` threads the matched key (select) / case index (predicate) into its existing `handleStream`.
586
+ - Both default to unbounded concurrency, so streamed parts **interleave** (id-keyed, non-corrupting, but nondeterministic order). Set `concurrency: 1` if you want each item/branch to stream sequentially in order.
587
+ - Generate-mode runs (`.generate(...)`) never call `handleStream`.
588
+
518
589
  #### Per-item error recovery via `onError`
519
590
 
520
591
  By default a single item's failure aborts the whole `foreach`. Pass an `onError` handler to recover individual items — return a substitute value, return `Workflow.SKIP` to drop the failed index from the output array, or rethrow to abort the step (the throw is catchable by a downstream `.catch()`):
@@ -555,7 +626,9 @@ const pipeline = Workflow.create<Ctx, string>()
555
626
  .parallel([researcher, critic] as const);
556
627
  ```
557
628
 
558
- The same input (`state.output`) is fed to each branch. Default concurrency is `min(branches.length, 5)` most users want fan-out, but the cap protects against rate-limit pressure. Pass `concurrency: Infinity` (or `branches.length`) to opt out.
629
+ The same input (`state.output`) is fed to each branch. By default `parallel` is **unbounded** all branches run concurrently. Pass an explicit `concurrency` to throttle against rate-limit pressure.
630
+
631
+ Like `foreach`, `parallel` runs agent branches in generate mode and never auto-merges; pass `handleStream` to surface branch streams in a `.stream(...)` run — see [Streaming items](#streaming-foreach--parallel-items).
559
632
 
560
633
  ```ts
561
634
  .parallel({ a, b, c, d, e, f, g, h }, { concurrency: 3 }) // explicit override
@@ -582,7 +655,7 @@ The same input (`state.output`) is fed to each branch. Default concurrency is `m
582
655
 
583
656
  Gates inside parallel branches throw `NestedGateUnsupportedError`, same as `foreach` concurrent. The lowest-index suspending branch wins the marker; others contribute to `siblingSuspensions`. Multi-branch suspension semantics are finalized in F0.6 alongside `cancelOnFirstSuspend` — until then, all branches run to completion (or sibling-failure) before the marker reaches the caller.
584
657
 
585
- > **Rate-limit hazard:** `parallel`'s default `min(N, 5)` assumes ≥5 RPS headroom on your model provider. Symptoms of overflow: 429s and stair-stepped latency.
658
+ > **Rate-limit hazard:** `parallel`'s unbounded default fires all branches at once. With many branches on a rate-limited provider, set an explicit `concurrency`. Symptoms of overflow: 429s and stair-stepped latency.
586
659
 
587
660
  > **Concurrent ctx-mutation hazard:** branches share the `ctx` object by reference. Treat `ctx` as immutable inside parallel branches.
588
661
 
@@ -619,6 +692,8 @@ Use `while` for the opposite condition (repeat while true, stop when false):
619
692
 
620
693
  The `until` and `while` options are mutually exclusive — TypeScript enforces this at compile time.
621
694
 
695
+ Both forms are **do-while**: the body always runs at least once, then the predicate is checked against its `output`. So `while: () => false` still runs the body once — it is not a pre-check.
696
+
622
697
  When `maxIterations` is exceeded, a `WorkflowLoopError` is thrown — catchable by `.catch()`:
623
698
 
624
699
  ```ts
@@ -675,7 +750,7 @@ const { stream, output } = pipeline.stream(ctx, initialInput, {
675
750
  | `.step(id, fn)` | Transform the output. `fn` receives `{ ctx, input }` and returns the new output. |
676
751
  | `.branch([...cases])` | Predicate routing. First `when` match wins; case without `when` is default. |
677
752
  | `.branch({ select, agents })` | Key routing. `select` returns a key, runs the matching agent. |
678
- | `.foreach(target, opts?)` | Map each array element through an agent or workflow. `opts.concurrency` is the max items in flight (default: 1). `opts.onError` recovers per-item failures; return `Workflow.SKIP` to drop an index. |
753
+ | `.foreach(target, opts?)` | Map each array element through an agent or workflow. `opts.concurrency` is the max items in flight (default: unbounded). `opts.onError` recovers per-item failures; return `Workflow.SKIP` to drop an index. |
679
754
  | `.repeat(target, opts)` | Loop an agent or workflow. Use `{ until }` or `{ while }` (mutually exclusive). `maxIterations` defaults to 10. |
680
755
  | `.gate(id, opts?)` | Human-in-the-loop suspension point. Returns a result with `status: "suspended"` carrying a serializable snapshot. Resume via `loadState(gateId, snapshot)`. |
681
756
  | `.catch(id, fn)` | Handle errors. `fn` receives `{ error, ctx, lastOutput, stepId }` and returns a recovery value. Bypassed on suspension. |
@@ -954,9 +1029,9 @@ const final = await resumed.generate(ctx); // no response arg — state is see
954
1029
 
955
1030
  ### Cadence
956
1031
 
957
- - `checkpointEvery: N` — fire every N executable steps. Defaults to `max(1, ceil(executableCount / 4))` — 4 checkpoints across the run, floor of every step on tiny pipelines.
1032
+ - `checkpointEvery: N` — fire every N executable steps. Defaults to `max(1, ceil(stepCount / 4))` — 4 checkpoints across the run, floor of every step on tiny pipelines.
958
1033
  - `checkpointWhen({ stepIndex, stepId, ctx }) => boolean` — predicate variant. Mutually exclusive with `checkpointEvery`.
959
- - `.catch()` and `.finally()` nodes are NOT counted as executable, so adding cleanup doesn't surprise you with extra checkpoints.
1034
+ - The default-cadence denominator counts only checkpointable steps (`step` / `branch` / `foreach` / `repeat` / `parallel` / nested). `gate` nodes suspend or skip and never checkpoint, and `.catch()` / `.finally()` are cleanup none of them count, so adding them doesn't shift the cadence.
960
1035
 
961
1036
  ### Timeout via `AbortSignal`
962
1037
 
@@ -1051,9 +1126,12 @@ Pass an `observability` object to `Workflow.create()` to receive lifecycle event
1051
1126
  ```ts
1052
1127
  import { Workflow, type WorkflowObservability } from "pipeai";
1053
1128
 
1054
- const obs: WorkflowObservability = {
1129
+ // `WorkflowObservability<Ctx>` types `ctx` in every hook as your context.
1130
+ // It defaults to `unknown`, so the bare `WorkflowObservability` form still
1131
+ // works for context-agnostic hooks.
1132
+ const obs: WorkflowObservability<Ctx> = {
1055
1133
  onStepStart: ({ stepId, type, ctx, input }) => {
1056
- console.log(`step ${stepId} (${type}) starting`);
1134
+ console.log(`[${ctx.requestId}] step ${stepId} (${type}) starting`);
1057
1135
  },
1058
1136
  onStepFinish: ({ stepId, type, output, durationMs, suspended }) => {
1059
1137
  console.log(`step ${stepId} (${type}) finished in ${durationMs}ms, suspended=${suspended}`);
@@ -1068,6 +1146,8 @@ const pipeline = Workflow.create<Ctx, string>({ observability: obs })
1068
1146
  .step("respond", responder);
1069
1147
  ```
1070
1148
 
1149
+ `ctx` is typed as the workflow's context: pass `WorkflowObservability<Ctx>` (or just inline the object into `Workflow.create<Ctx>({ observability: { ... } })` and let `Ctx` flow in). The `input` / `output` fields stay `unknown` — they differ at every step in the chain, so only `ctx` (constant across the run) can be typed.
1150
+
1071
1151
  The hooks are threaded through every builder return, so any chain following `Workflow.create({ observability })` keeps the same hooks. `ResumedWorkflow` (gate resume via `loadState`) and `CheckpointResumedWorkflow` (checkpoint resume via `resumeFrom`) ALSO inherit it — events fire on resumed runs without re-wiring.
1072
1152
 
1073
1153
  ### Per-node firing rules