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 +223 -24
- package/dist/index.cjs +219 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +77 -16
- package/dist/index.d.ts +77 -16
- package/dist/index.js +218 -37
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
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 `
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
|
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
|
|
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 control — the 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
|
|
456
|
+
await result.text; // consume without forwarding to the client
|
|
446
457
|
},
|
|
447
458
|
})
|
|
448
|
-
.
|
|
449
|
-
|
|
450
|
-
|
|
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.
|
|
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 —
|
|
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({
|