pipeai 0.1.1 → 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
@@ -596,6 +596,7 @@ const { stream, output } = pipeline.stream(ctx, initialInput, {
596
596
  | `.branch({ select, agents })` | Key routing. `select` returns a key, runs the matching agent. |
597
597
  | `.foreach(target, opts?)` | Map each array element through an agent or workflow. `opts.concurrency` controls parallelism (default: 1). |
598
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)`. |
599
600
  | `.catch(id, fn)` | Handle errors. `fn` receives `{ error, ctx, lastOutput, stepId }` and returns a recovery value. |
600
601
  | `.finally(id, fn)` | Always runs. `fn` receives `{ ctx }`. |
601
602
 
@@ -618,6 +619,191 @@ Auto-extraction priority for `step()` with an agent:
618
619
  | `foreach()` | Deterministic | Items don't stream | Process each element of an array through an agent or workflow |
619
620
  | `repeat()` | Condition function | Each iteration streams | Iterative refinement until a quality threshold is met |
620
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
+
621
807
  ## Full Example
622
808
 
623
809
  ```ts
package/dist/index.cjs CHANGED
@@ -24,6 +24,7 @@ __export(index_exports, {
24
24
  Workflow: () => Workflow,
25
25
  WorkflowBranchError: () => WorkflowBranchError,
26
26
  WorkflowLoopError: () => WorkflowLoopError,
27
+ WorkflowSuspended: () => WorkflowSuspended,
27
28
  defineTool: () => defineTool
28
29
  });
29
30
  module.exports = __toCommonJS(index_exports);
@@ -276,6 +277,14 @@ var WorkflowLoopError = class extends Error {
276
277
  this.name = "WorkflowLoopError";
277
278
  }
278
279
  };
280
+ var WorkflowSuspended = class extends Error {
281
+ snapshot;
282
+ constructor(snapshot) {
283
+ super(`Workflow suspended at gate "${snapshot.gateId}"`);
284
+ this.name = "WorkflowSuspended";
285
+ this.snapshot = snapshot;
286
+ }
287
+ };
279
288
  var SealedWorkflow = class {
280
289
  id;
281
290
  steps;
@@ -332,12 +341,13 @@ var SealedWorkflow = class {
332
341
  };
333
342
  }
334
343
  // ── Internal: execute pipeline ────────────────────────────────
335
- async execute(state) {
344
+ async execute(state, startIndex = 0) {
336
345
  if (this.steps.length === 0) {
337
346
  throw new Error("Workflow has no steps. Add at least one step before calling generate() or stream().");
338
347
  }
339
348
  let pendingError = null;
340
- for (const node of this.steps) {
349
+ for (let i = startIndex; i < this.steps.length; i++) {
350
+ const node = this.steps[i];
341
351
  if (node.type === "finally") {
342
352
  await node.execute(state);
343
353
  continue;
@@ -357,10 +367,26 @@ var SealedWorkflow = class {
357
367
  }
358
368
  continue;
359
369
  }
370
+ if (node.type === "gate") {
371
+ if (pendingError) continue;
372
+ if (node.condition) {
373
+ const shouldSuspend = await node.condition(state);
374
+ if (!shouldSuspend) continue;
375
+ }
376
+ const gatePayload = await node.payload(state);
377
+ throw new WorkflowSuspended({
378
+ version: 1,
379
+ resumeFromIndex: i,
380
+ output: state.output,
381
+ gateId: node.id,
382
+ gatePayload
383
+ });
384
+ }
360
385
  if (pendingError) continue;
361
386
  try {
362
387
  await node.execute(state);
363
388
  } catch (error) {
389
+ if (error instanceof WorkflowSuspended) throw error;
364
390
  pendingError = { error, stepId: node.id };
365
391
  }
366
392
  }
@@ -370,7 +396,16 @@ var SealedWorkflow = class {
370
396
  // Defined on SealedWorkflow (not Workflow) because TypeScript's protected
371
397
  // access rules only allow calling workflow.execute() from the same class.
372
398
  async executeNestedWorkflow(state, workflow) {
373
- await workflow.execute(state);
399
+ try {
400
+ await workflow.execute(state);
401
+ } catch (error) {
402
+ if (error instanceof WorkflowSuspended) {
403
+ throw new Error(
404
+ `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.`
405
+ );
406
+ }
407
+ throw error;
408
+ }
374
409
  }
375
410
  // ── Internal: execute an agent within a step/branch ───────────
376
411
  // In stream mode, output extraction awaits the full stream before returning.
@@ -409,6 +444,106 @@ var SealedWorkflow = class {
409
444
  }
410
445
  }
411
446
  }
447
+ // ── Gate: load persisted state for resumption ──────────────────
448
+ loadState(gateId, snapshot) {
449
+ if (snapshot.gateId !== gateId) {
450
+ throw new Error(
451
+ `loadState: gate ID mismatch \u2014 expected "${gateId}" but snapshot has "${snapshot.gateId}".`
452
+ );
453
+ }
454
+ const gateIndex = this.findGateIndex(snapshot);
455
+ const gateNode = this.steps[gateIndex];
456
+ return new ResumedWorkflow(
457
+ this.steps,
458
+ gateIndex + 1,
459
+ gateNode.schema,
460
+ gateNode.merge,
461
+ snapshot.output
462
+ );
463
+ }
464
+ findGateIndex(snapshot) {
465
+ if (snapshot.version !== 1) {
466
+ throw new Error(`Unsupported snapshot version: ${snapshot.version}`);
467
+ }
468
+ const hint = snapshot.resumeFromIndex;
469
+ if (hint >= 0 && hint < this.steps.length) {
470
+ const node = this.steps[hint];
471
+ if (node.type === "gate" && node.id === snapshot.gateId) {
472
+ return hint;
473
+ }
474
+ }
475
+ for (let i = 0; i < this.steps.length; i++) {
476
+ const node = this.steps[i];
477
+ if (node.type === "gate" && node.id === snapshot.gateId) {
478
+ return i;
479
+ }
480
+ }
481
+ throw new Error(
482
+ `Gate "${snapshot.gateId}" not found in workflow. The workflow definition may have changed since the snapshot was created.`
483
+ );
484
+ }
485
+ };
486
+ var ResumedWorkflow = class extends SealedWorkflow {
487
+ startIndex;
488
+ schema;
489
+ mergeFn;
490
+ priorOutput;
491
+ /** @internal */
492
+ constructor(steps, startIndex, schema, mergeFn, priorOutput) {
493
+ super(steps);
494
+ this.startIndex = startIndex;
495
+ this.schema = schema;
496
+ this.mergeFn = mergeFn;
497
+ this.priorOutput = priorOutput;
498
+ }
499
+ validateResponse(response) {
500
+ if (this.schema) {
501
+ return this.schema.parse(response);
502
+ }
503
+ return response;
504
+ }
505
+ async generate(ctx, ...args) {
506
+ const response = this.validateResponse(args[0]);
507
+ const output = this.mergeFn ? await this.mergeFn({ priorOutput: this.priorOutput, response }) : response;
508
+ const state = { ctx, output, mode: "generate" };
509
+ await this.execute(state, this.startIndex);
510
+ return { output: state.output };
511
+ }
512
+ stream(ctx, ...args) {
513
+ const response = this.validateResponse(args[0]);
514
+ const options = args[1];
515
+ let resolveOutput;
516
+ let rejectOutput;
517
+ const outputPromise = new Promise((res, rej) => {
518
+ resolveOutput = res;
519
+ rejectOutput = rej;
520
+ });
521
+ outputPromise.catch(() => {
522
+ });
523
+ const mergeFn = this.mergeFn;
524
+ const priorOutput = this.priorOutput;
525
+ const stream = (0, import_ai3.createUIMessageStream)({
526
+ execute: async ({ writer }) => {
527
+ const output = mergeFn ? await mergeFn({ priorOutput, response }) : response;
528
+ const state = {
529
+ ctx,
530
+ output,
531
+ mode: "stream",
532
+ writer
533
+ };
534
+ try {
535
+ await this.execute(state, this.startIndex);
536
+ resolveOutput(state.output);
537
+ } catch (error) {
538
+ rejectOutput(error);
539
+ throw error;
540
+ }
541
+ },
542
+ ...options?.onError ? { onError: options.onError } : {},
543
+ ...options?.onFinish ? { onFinish: options.onFinish } : {}
544
+ });
545
+ return { stream, output: outputPromise };
546
+ }
412
547
  };
413
548
  var Workflow = class _Workflow extends SealedWorkflow {
414
549
  constructor(steps = [], id) {
@@ -462,6 +597,32 @@ var Workflow = class _Workflow extends SealedWorkflow {
462
597
  };
463
598
  return new _Workflow([...this.steps, node], this.id);
464
599
  }
600
+ // ── gate: human-in-the-loop suspension point ────────────────
601
+ gate(id, options) {
602
+ if (this.steps.some((s) => s.type === "gate" && s.id === id)) {
603
+ throw new Error(`Workflow: duplicate gate ID "${id}". Each gate must have a unique identifier.`);
604
+ }
605
+ const node = {
606
+ type: "gate",
607
+ id,
608
+ schema: options?.schema,
609
+ condition: options?.condition ? async (state) => options.condition({
610
+ ctx: state.ctx,
611
+ input: state.output
612
+ }) : void 0,
613
+ merge: options?.merge ? (params) => options.merge(params) : void 0,
614
+ payload: async (state) => {
615
+ if (options?.payload) {
616
+ return options.payload({
617
+ ctx: state.ctx,
618
+ input: state.output
619
+ });
620
+ }
621
+ return state.output;
622
+ }
623
+ };
624
+ return new _Workflow([...this.steps, node], this.id);
625
+ }
465
626
  // ── branch: implementation ────────────────────────────────────
466
627
  branch(casesOrConfig) {
467
628
  if (Array.isArray(casesOrConfig)) {
@@ -608,6 +769,7 @@ var Workflow = class _Workflow extends SealedWorkflow {
608
769
  Workflow,
609
770
  WorkflowBranchError,
610
771
  WorkflowLoopError,
772
+ WorkflowSuspended,
611
773
  defineTool
612
774
  });
613
775
  //# sourceMappingURL=index.cjs.map