pipeai 0.1.0 → 0.2.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/README.md CHANGED
@@ -11,7 +11,7 @@ The library is ~1000 lines across 4 files. It's designed to be read, understood,
11
11
  | Primitive | Purpose |
12
12
  | -------------- | ---------------------------------------------------------------------------------------------------- |
13
13
  | `Agent` | A pure AI SDK wrapper. Supports `generate()`, `stream()`, `asTool()`, and `asToolProvider()`. |
14
- | `Workflow` | A typed pipeline that chains agents with `step()`, `branch()`, `foreach()`, `repeat()`, `catch()`, and `finally()`. |
14
+ | `Workflow` | A typed pipeline that chains agents with `step()`, `branch()`, `foreach()`, `repeat()`, `gate()`, `catch()`, and `finally()`. |
15
15
  | `defineTool` | A context-aware tool factory — injects runtime context into tool `execute` calls. |
16
16
 
17
17
  ## Installation
@@ -117,15 +117,17 @@ const agent = new Agent<Ctx>({
117
117
 
118
118
  ### AI SDK callbacks
119
119
 
120
- Same callback names as AI SDK v6, extended with `ctx` and `input`. The AI SDK event payload is available as `result`:
120
+ Same callback names as AI SDK v6, extended with `ctx`, `input`, and `writer`. The AI SDK event payload is available as `result`. When the agent runs inside a streaming workflow, `writer` is available for writing metadata or custom stream parts:
121
121
 
122
122
  ```ts
123
123
  const agent = new Agent<Ctx>({
124
124
  id: "monitored",
125
125
  model: openai("gpt-4o"),
126
126
  prompt: (ctx, input) => input,
127
- onStepFinish: ({ result, ctx }) => {
127
+ onStepFinish: ({ result, ctx, writer }) => {
128
128
  console.log(`Step done, used ${result.usage.totalTokens} tokens`);
129
+ // Stream progress metadata to the client
130
+ writer?.write({ type: "metadata", value: { tokensUsed: result.usage.totalTokens } });
129
131
  },
130
132
  onFinish: ({ result, ctx }) => {
131
133
  console.log(`Total: ${result.totalUsage.totalTokens} tokens`);
@@ -152,9 +154,9 @@ const agent = new Agent<Ctx>({
152
154
  | `activeTools` | `Resolvable` | Subset of tool names to enable. |
153
155
  | `toolChoice` | `Resolvable` | Tool choice strategy. Static or `(ctx, input) => toolChoice`. |
154
156
  | `stopWhen` | `Resolvable` | Condition for stopping the tool loop. Static or `(ctx, input) => condition`. |
155
- | `onStepFinish`| `({ result, ctx, input })`| Called after each step. |
156
- | `onFinish` | `({ result, ctx, input })`| Called when all steps complete. |
157
- | `onError` | `({ error, ctx, input })` | Called on error. |
157
+ | `onStepFinish`| `({ result, ctx, input, writer? })`| Called after each step. `writer` available in streaming workflows. |
158
+ | `onFinish` | `({ result, ctx, input, writer? })`| Called when all steps complete. |
159
+ | `onError` | `({ error, ctx, input, writer? })` | Called on error. |
158
160
  | `...` | AI SDK options | All other `streamText`/`generateText` options pass through (e.g. `temperature`, `maxTokens`, `maxRetries`, `headers`, `prepareStep`, `onChunk`, etc.). |
159
161
 
160
162
  ## `asTool()` — Agent as Tool
@@ -212,7 +214,7 @@ codingAgent.asTool(ctx, {
212
214
  });
213
215
  ```
214
216
 
215
- **Note:** `asTool()` uses `generate()` internally — sub-agent execution is non-streaming. This is an AI SDK tool loop constraint. For streaming multi-agent workflows, use `step()` with `branch()` instead.
217
+ **Automatic streaming:** When `asTool()` is used inside a streaming workflow, sub-agents automatically use `stream()` and merge their output to the parent's stream the user sees sub-agent responses in real-time. Outside of a streaming context (standalone use or generate mode), `asTool()` falls back to `generate()`. This is handled invisibly — no configuration needed.
216
218
 
217
219
  ## `asToolProvider()` — Deferred Context
218
220
 
@@ -236,11 +238,10 @@ This is useful when the agent is defined at module scope but the context isn't a
236
238
 
237
239
  ## defineTool — Context-Aware Tools
238
240
 
239
- `defineTool` wraps a tool definition so the agent's runtime context is injected into every `execute` call. The `input` field maps to AI SDK's `parameters`:
241
+ `defineTool` wraps a tool definition so the agent's runtime context is injected into every `execute` call. The `input` field maps to AI SDK's `parameters`. When running inside a streaming workflow, the `writer` is automatically available in the third parameter for streaming metadata or progress updates to the client:
240
242
 
241
243
  ```ts
242
244
  import { defineTool } from "pipeai";
243
- import { tool } from "ai";
244
245
 
245
246
  type Ctx = { db: Database; userId: string };
246
247
 
@@ -249,8 +250,11 @@ const define = defineTool<Ctx>();
249
250
  const searchOrders = define({
250
251
  description: "Search user orders",
251
252
  input: z.object({ query: z.string() }),
252
- execute: async ({ query }, ctx) => {
253
- return ctx.db.orders.search(ctx.userId, query);
253
+ execute: async ({ query }, ctx, { writer }) => {
254
+ writer?.write({ type: "metadata", value: { status: "searching" } });
255
+ const results = await ctx.db.orders.search(ctx.userId, query);
256
+ writer?.write({ type: "metadata", value: { status: "done", count: results.length } });
257
+ return results;
254
258
  },
255
259
  });
256
260
 
@@ -271,6 +275,8 @@ const agent = new Agent<Ctx>({
271
275
  });
272
276
  ```
273
277
 
278
+ The `writer` is `undefined` when running in generate mode or standalone — `?.` handles both cases naturally.
279
+
274
280
  ## Workflow
275
281
 
276
282
  A `Workflow` chains agents and transformation steps into a typed pipeline. Context is read-only — agents communicate through outputs.
@@ -313,6 +319,7 @@ Workflows can be passed as steps into other workflows. The nested workflow's ste
313
319
  // A reusable sub-workflow
314
320
  const classifyAndRoute = Workflow.create<Ctx>()
315
321
  .step(classifier, {
322
+ // Suppress the classifier's stream — only route the result
316
323
  handleStream: async ({ result }) => { await result.text; },
317
324
  })
318
325
  .branch({
@@ -402,7 +409,10 @@ const pipeline = Workflow.create<Ctx>()
402
409
  // Called during workflow.stream() — StreamTextResult (async access)
403
410
  mapStreamResult: async ({ result }) => ({
404
411
  text: await result.text,
405
- files: [],
412
+ files: (await result.steps)
413
+ .flatMap(s => s.toolResults)
414
+ .filter(tr => tr.toolName === "writeFile")
415
+ .map(tr => tr.args.path),
406
416
  }),
407
417
  });
408
418
  ```
@@ -423,10 +433,11 @@ const pipeline = Workflow.create<Ctx>()
423
433
  });
424
434
  },
425
435
  // Called during workflow.stream()
426
- onStreamResult: async ({ result, ctx, input }) => {
436
+ onStreamResult: async ({ result, ctx }) => {
427
437
  await ctx.db.conversations.save(ctx.userId, {
428
438
  role: "assistant",
429
439
  content: await result.text,
440
+ toolCalls: await result.toolCalls,
430
441
  });
431
442
  },
432
443
  });
@@ -434,7 +445,7 @@ const pipeline = Workflow.create<Ctx>()
434
445
 
435
446
  ### Fine-grained stream control
436
447
 
437
- Override how each agent's stream is merged into the workflow stream. By default, every agent's output is merged into the workflow stream via `writer.merge(result.toUIMessageStream())`. Use `handleStream` to change thisfor example, to suppress intermediate agents so only the final response streams to the client:
448
+ Override how each agent's stream is merged into the workflow stream. By default, every agent's output is merged via `writer.merge(result.toUIMessageStream())`. Use `handleStream` to take controlthe callback receives `{ result, writer, ctx }`:
438
449
 
439
450
  ```ts
440
451
  const pipeline = Workflow.create<Ctx>()
@@ -442,14 +453,16 @@ const pipeline = Workflow.create<Ctx>()
442
453
  // the structured classification output, only the final response
443
454
  .step(classifier, {
444
455
  handleStream: async ({ result }) => {
445
- await result.text; // consume the stream without forwarding it
456
+ await result.text; // consume without forwarding to the client
446
457
  },
447
458
  })
448
- .branch({
449
- select: ({ input }) => input.agent,
450
- agents: { bug: bugAgent, feature: featureAgent, question: questionAgent },
459
+ // Custom merging — e.g. add metadata annotations to the stream
460
+ .step(supportAgent, {
461
+ handleStream: async ({ result, writer, ctx }) => {
462
+ writer.write({ type: "metadata", value: { agentId: "support", userId: ctx.userId } });
463
+ writer.merge(result.toUIMessageStream());
464
+ },
451
465
  });
452
- // Only the selected agent's response streams to the client
453
466
  ```
454
467
 
455
468
  ### Array iteration via `foreach()`
@@ -485,7 +498,9 @@ const processItem = Workflow.create<Ctx, string>()
485
498
  .step(analyzeAgent)
486
499
  .step(enrichAgent);
487
500
 
488
- pipeline.foreach(processItem, { concurrency: 5 });
501
+ const pipeline = Workflow.create<Ctx>()
502
+ .step("fetch-items", async ({ ctx }) => ctx.db.items.getAll())
503
+ .foreach(processItem, { concurrency: 5 });
489
504
  ```
490
505
 
491
506
  **Type safety:** `foreach()` uses `ElementOf<TOutput>` to extract the array element type. If the previous step doesn't produce an array, the call is rejected at compile time.
@@ -581,6 +596,7 @@ const { stream, output } = pipeline.stream(ctx, initialInput, {
581
596
  | `.branch({ select, agents })` | Key routing. `select` returns a key, runs the matching agent. |
582
597
  | `.foreach(target, opts?)` | Map each array element through an agent or workflow. `opts.concurrency` controls parallelism (default: 1). |
583
598
  | `.repeat(target, opts)` | Loop an agent or workflow. Use `{ until }` or `{ while }` (mutually exclusive). `maxIterations` defaults to 10. |
599
+ | `.gate(id, opts?)` | Human-in-the-loop suspension point. Throws `WorkflowSuspended` with a serializable snapshot. Resume via `loadState(gateId, snapshot)`. |
584
600
  | `.catch(id, fn)` | Handle errors. `fn` receives `{ error, ctx, lastOutput, stepId }` and returns a recovery value. |
585
601
  | `.finally(id, fn)` | Always runs. `fn` receives `{ ctx }`. |
586
602
 
@@ -603,6 +619,191 @@ Auto-extraction priority for `step()` with an agent:
603
619
  | `foreach()` | Deterministic | Items don't stream | Process each element of an array through an agent or workflow |
604
620
  | `repeat()` | Condition function | Each iteration streams | Iterative refinement until a quality threshold is met |
605
621
 
622
+ ## Human-in-the-Loop via `gate()`
623
+
624
+ `gate()` suspends a workflow at a designated point, producing a JSON-serializable snapshot. The consumer persists the snapshot, collects human input out-of-band (HTTP, WebSocket, CLI, queue — any transport), then resumes the workflow from where it left off.
625
+
626
+ ### Basic gate
627
+
628
+ ```ts
629
+ import { Workflow, WorkflowSuspended } from "pipeai";
630
+
631
+ const pipeline = Workflow.create<Ctx>()
632
+ .step(draftAgent)
633
+ .gate("review", {
634
+ payload: ({ input }) => ({ draft: input, instructions: "Please review this draft" }),
635
+ })
636
+ .step(publishAgent);
637
+
638
+ // Run — suspends at gate
639
+ try {
640
+ await pipeline.generate(ctx, input);
641
+ } catch (e) {
642
+ if (e instanceof WorkflowSuspended) {
643
+ await db.saveSnapshot(e.snapshot);
644
+ return res.status(202).json(e.snapshot.gatePayload);
645
+ }
646
+ }
647
+
648
+ // Resume — load state, pass gate ID + snapshot to generate or stream
649
+ const snapshot = await db.loadSnapshot(id);
650
+ const resumed = pipeline.loadState("review", snapshot);
651
+ const { output } = await resumed.generate(ctx, humanResponse);
652
+ ```
653
+
654
+ The `snapshot` is plain JSON — it survives `JSON.parse(JSON.stringify())`, database storage, and process restarts. The workflow definition (code) stays in the process; only the data is serialized.
655
+
656
+ ### Resuming with streaming
657
+
658
+ For chat applications where the client reconnects and needs a live stream for the remaining steps:
659
+
660
+ ```ts
661
+ const resumed = pipeline.loadState("review", snapshot);
662
+ const { stream, output } = resumed.stream(ctx, humanResponse);
663
+ return new Response(stream);
664
+ ```
665
+
666
+ The previous stream is gone — the library only streams forward from the resume point. Load prior chat history from your database and send it to the client before piping the resume stream.
667
+
668
+ ### Streaming suspension
669
+
670
+ When `stream()` hits a gate, the stream closes cleanly (partial content from steps before the gate is delivered). The `output` promise rejects with `WorkflowSuspended`:
671
+
672
+ ```ts
673
+ const { stream, output } = pipeline.stream(ctx, input);
674
+ pipeStreamToResponse(res, stream); // partial content delivered normally
675
+
676
+ try {
677
+ await output;
678
+ } catch (e) {
679
+ if (e instanceof WorkflowSuspended) {
680
+ await db.saveSnapshot(e.snapshot);
681
+ }
682
+ }
683
+ ```
684
+
685
+ ### Schema validation
686
+
687
+ Add a `schema` to validate the human response at runtime. The schema uses a structural type — any object with a `.parse()` method works (Zod, Valibot, ArkType, etc.):
688
+
689
+ ```ts
690
+ const pipeline = Workflow.create<Ctx>()
691
+ .step(draftAgent)
692
+ .gate("review", {
693
+ schema: z.object({ approved: z.boolean(), notes: z.string() }),
694
+ })
695
+ .step("publish", ({ input }) => {
696
+ if (!input.approved) return "Rejected";
697
+ return `Published with notes: ${input.notes}`;
698
+ });
699
+
700
+ // Resume — gate ID enables type inference, schema validates at runtime
701
+ const resumed = pipeline.loadState("review", snapshot);
702
+ await resumed.generate(ctx, { approved: true, notes: "lgtm" }); // passes
703
+ await resumed.generate(ctx, { approved: "yes" }); // throws parse error
704
+ ```
705
+
706
+ ### Multiple gates
707
+
708
+ A workflow can have multiple gates. Each `generate()`/`stream()` call advances to the next gate or completes:
709
+
710
+ ```ts
711
+ const pipeline = Workflow.create<Ctx>()
712
+ .step(draftAgent)
713
+ .gate("review")
714
+ .step("process", ({ input }) => `reviewed: ${input}`)
715
+ .gate("final-approval")
716
+ .step("publish", ({ input }) => `published: ${input}`);
717
+
718
+ // First gate
719
+ let snapshot: WorkflowSnapshot;
720
+ try { await pipeline.generate(ctx, input); }
721
+ catch (e) { snapshot = (e as WorkflowSuspended).snapshot; }
722
+
723
+ // Second gate
724
+ const resumed1 = pipeline.loadState("review", snapshot);
725
+ try { await resumed1.generate(ctx, "first approval"); }
726
+ catch (e) { snapshot = (e as WorkflowSuspended).snapshot; }
727
+
728
+ // Complete
729
+ const resumed2 = pipeline.loadState("final-approval", snapshot);
730
+ const { output } = await resumed2.generate(ctx, "final approval");
731
+ ```
732
+
733
+ ### Merging pre-gate output with response
734
+
735
+ The `snapshot.output` field contains the pre-gate output. Use it to merge with the human response:
736
+
737
+ ```ts
738
+ // The step after the gate needs both the draft and the approval
739
+ const resumed = pipeline.loadState("review", snapshot);
740
+ await resumed.generate(ctx, {
741
+ draft: snapshot.output, // pre-gate output
742
+ approval: humanResponse, // human's response
743
+ });
744
+ ```
745
+
746
+ ### Injecting updated context on resume
747
+
748
+ `ctx` is provided fresh on every `generate()`/`stream()` call — never serialized. Use it to inject updated chat history, refreshed auth tokens, or new database connections:
749
+
750
+ ```ts
751
+ const freshCtx = {
752
+ chatHistory: await db.loadChatHistory(userId), // includes messages added during the pause
753
+ db: getDbConnection(),
754
+ userId,
755
+ };
756
+ const resumed = pipeline.loadState("review", snapshot);
757
+ await resumed.stream(freshCtx, humanResponse);
758
+ ```
759
+
760
+ ### Conditional gates
761
+
762
+ Use `condition` to make a gate fire only when a predicate returns `true`. When the condition returns `false`, the gate is skipped and the current output passes through unchanged:
763
+
764
+ ```ts
765
+ const pipeline = Workflow.create<Ctx>()
766
+ .step(draftAgent)
767
+ .gate("review", {
768
+ condition: ({ input }) => input.needsReview,
769
+ })
770
+ .step(publishAgent);
771
+ ```
772
+
773
+ ### Merging pre-gate output with response
774
+
775
+ Use `merge` to combine the pre-gate output with the human response into a single value for the next step. Without `merge`, only the human response is forwarded:
776
+
777
+ ```ts
778
+ const pipeline = Workflow.create<Ctx>()
779
+ .step(draftAgent)
780
+ .gate("review", {
781
+ merge: ({ priorOutput, response }) => ({
782
+ draft: priorOutput,
783
+ approval: response,
784
+ }),
785
+ })
786
+ .step("publish", ({ input }) => {
787
+ // input is { draft, approval }
788
+ });
789
+ ```
790
+
791
+ ### Snapshot shape
792
+
793
+ ```ts
794
+ interface WorkflowSnapshot {
795
+ version: 1;
796
+ resumeFromIndex: number; // step index of the gate
797
+ output: unknown; // pre-gate output
798
+ gateId: string; // gate identifier
799
+ gatePayload: unknown; // data for the human
800
+ }
801
+ ```
802
+
803
+ ### Limitations
804
+
805
+ Gates inside nested workflows, `foreach()`, and `repeat()` are not yet supported — a descriptive error is thrown at runtime. Gates at the top level of a workflow work in all cases.
806
+
606
807
  ## Full Example
607
808
 
608
809
  ```ts
@@ -671,11 +872,9 @@ const questionAgent = new Agent<Ctx>({
671
872
 
672
873
  // 4. Compose workflow
673
874
  const pipeline = Workflow.create<Ctx>()
674
- // Classify silently — don't stream the structured JSON to the client
875
+ // Classify silently — consume the stream without forwarding to client
675
876
  .step(classifier, {
676
- handleStream: async ({ result }) => {
677
- await result.text;
678
- },
877
+ handleStream: async ({ result }) => { await result.text; },
679
878
  })
680
879
  // Route to the right specialist based on classification
681
880
  .branch({