@fyeeme/pi-dynamic-workflows 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fyeeme
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,373 @@
1
+ # @fyeeme/pi-dynamic-workflows
2
+
3
+ **Deterministic TypeScript workflow orchestration for [pi](https://github.com/earendil-works/pi-mono).**
4
+
5
+ Define a workflow as a declarative list of typed steps, run it, and get resumable, budget-bounded, abortable execution. Fuses the pi-dynamic-workflows design (10 step primitives + heuristic planner + outcome collectors) with Claude Code's workflow-engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort, dynamic budget, runaway caps).
6
+
7
+ Languages: **English** | [中文](README.zh-CN.md)
8
+
9
+ ---
10
+
11
+ ## Why
12
+
13
+ A workflow run is a list of steps (`agent` / `code` / `log` / `fan_out` / `loop_until` / `loop_until_dry` / `adversarial` / `tournament` / `classify_route` / `sub_workflow`). The engine guarantees:
14
+
15
+ - **Determinism** — workflow `.ts` files are AST-guarded against `Date.now()` / `Math.random()` / `new Date()`; run ids are a pure function of `(timestamp, sequence)`.
16
+ - **Resume without re-dispatch** — every agent call is keyed by `sha256(workflow + prompt + signature)` and journaled; re-running the same workflow replays cached agents (zero subprocess spawns).
17
+ - **Per-agent abort** — each in-flight agent owns an `AbortController`; `skipAgent`/`retryAgent` targets one call without disturbing siblings.
18
+ - **Budget + runaway caps** — `maxAgents` / `maxTokens` enforced via a live pool; `MAX_BATCH=4096`, `MAX_LIFETIME_AGENTS=1000` throw `BudgetExceededError` on exceed (no silent truncation).
19
+ - **Testable without `pi`** — the agent dispatch is injectable; tests pass a fake and run with no binary, no provider API, no tokens.
20
+
21
+ ---
22
+
23
+ ## Install
24
+
25
+ This is a pi extension package (workspace / local), not yet published to npm. From a pi workspace:
26
+
27
+ ```bash
28
+ npm install --ignore-scripts # hydrate (the package is a workspace dep)
29
+ ```
30
+
31
+ This resolves [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)
32
+ (`^0.3.0`, from the npm registry — no sibling-repo layout requirement).
33
+
34
+ Then import the public API from the package root module (a TypeScript barrel; the package ships `.ts` source):
35
+
36
+ ```ts
37
+ import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
38
+ ```
39
+
40
+ > The package's `pi.extensions` entry (`./index.ts`) registers the `run_workflow`
41
+ > tool and the `/wf-inspect` command. The engine is also fully usable via the
42
+ > imports shown here.
43
+
44
+ ---
45
+
46
+ ## Quick start
47
+
48
+ ```ts
49
+ import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
50
+
51
+ const wf = defineWorkflow({
52
+ name: "draft-and-refine",
53
+ steps: [
54
+ { id: "draft", type: "agent", prompt: "Draft a one-paragraph release note." },
55
+ { id: "refine", type: "agent", prompt: (ctx) => `Refine this into crisp prose:\n\n${ctx.step("draft").results}` },
56
+ ],
57
+ });
58
+
59
+ const result = await runWorkflow({ workflow: wf, cwd: process.cwd(), now: Date.now() });
60
+ console.log(result.status, result.steps[1].results);
61
+ ```
62
+
63
+ `runWorkflow` spawns one `pi --mode json -p --no-session` subprocess per agent call (the default dispatch), so you need `pi` on `PATH` with a configured provider. For tests or offline runs, inject a fake dispatch (see the tutorial).
64
+
65
+ ---
66
+
67
+ ## Tutorial
68
+
69
+ ### 1. Define a workflow
70
+
71
+ `defineWorkflow` is a typed identity helper — it gives you full checking on the `steps` discriminated union.
72
+
73
+ ```ts
74
+ const wf = defineWorkflow({
75
+ name: "research",
76
+ budget: { maxAgents: 10, maxTokens: 50_000 },
77
+ steps: [
78
+ { id: "gather", type: "agent", prompt: "List 3 sources on topic X." },
79
+ { id: "summarize", type: "agent", prompt: (ctx) => `Summarize:\n${ctx.step("gather").results}` },
80
+ ],
81
+ });
82
+ ```
83
+
84
+ `ctx.input` is the run's initial input; `ctx.step(id)` returns a prior step's `{ results, stats }` (throws if that id hasn't executed yet).
85
+
86
+ ### 2. Run it
87
+
88
+ ```ts
89
+ const result = await runWorkflow({
90
+ workflow: wf,
91
+ cwd: process.cwd(),
92
+ now: 1700000000000, // deterministic inception time (ms); also the journal/run-id seed
93
+ input: "topic X",
94
+ });
95
+ // result.status: "completed" | "failed" | "aborted"
96
+ // result.steps: StepResult[] (one per executed step, in order)
97
+ // result.stats: aggregated { tokens, cost, durationMs, agents, failures }
98
+ // result.journalFile: path to the per-workflow JSONL journal
99
+ ```
100
+
101
+ `now` is **required and deterministic** — pass the run's inception time; the engine never reads the clock for identity. The same `(workflow, prompts)` always produces the same cache keys.
102
+
103
+ ### 3. fan_out — parallel agents + merge
104
+
105
+ ```ts
106
+ const wf = defineWorkflow({
107
+ name: "parallel-research",
108
+ steps: [
109
+ {
110
+ id: "fan",
111
+ type: "fan_out",
112
+ over: () => ["alpha", "beta", "gamma"],
113
+ agent: (topic) => ({ prompt: `Research ${topic}.` }),
114
+ parallelism: 3,
115
+ merge: (results) => results.join("\n---\n"),
116
+ },
117
+ ],
118
+ });
119
+ ```
120
+
121
+ `fan_out` pre-checks the whole batch fits the budget (`MAX_BATCH=4096`); each item is cache-keyed and abortable independently.
122
+
123
+ ### 4. loop_until — iterate until a condition / budget
124
+
125
+ ```ts
126
+ const wf = defineWorkflow({
127
+ name: "refine-loop",
128
+ steps: [
129
+ {
130
+ id: "loop",
131
+ type: "loop_until",
132
+ prompt: (ctx, i) => `Draft ${i + 1}. Current:\n${ctx.step("loop")?.results ?? ctx.input}`,
133
+ until: (ctx, i) => i >= 3,
134
+ maxIterations: 5,
135
+ },
136
+ ],
137
+ });
138
+ ```
139
+
140
+ Each iteration is its own cache-keyed agent call; `maxIterations` + the budget bound the loop.
141
+
142
+ ### 5. Composites — adversarial / tournament / classify_route
143
+
144
+ Built on the same `dispatchAgentCall`, so they get cache-resume, budget, and abort for free.
145
+
146
+ ```ts
147
+ // Produce a candidate, then N judges grade it against a rubric and tally.
148
+ defineWorkflow({
149
+ name: "review",
150
+ steps: [
151
+ {
152
+ id: "adv",
153
+ type: "adversarial",
154
+ produce: { prompt: "Write the function." },
155
+ rubric: ["correctness", "handles empty input", "no off-by-one"],
156
+ judges: 3, // default; minPass defaults to majority
157
+ },
158
+ ],
159
+ });
160
+ // results: { candidate, passed, passCount, minPass, judges: [{pass, reason}] }
161
+
162
+ // N distinct candidates, M judges rank them, pick the majority winner.
163
+ defineWorkflow({
164
+ name: "pick",
165
+ steps: [{ id: "tmt", type: "tournament", candidates: 3, judges: 2, produce: { prompt: "Solve X." } }],
166
+ });
167
+ // results: { candidates, winner, judges: [{winner, reason}] }
168
+
169
+ // Classify input, then run the matching route's sub-steps.
170
+ defineWorkflow({
171
+ name: "route",
172
+ steps: [
173
+ {
174
+ id: "cr",
175
+ type: "classify_route",
176
+ classifier: { prompt: (ctx) => `Classify intent: ${ctx.input}` },
177
+ routes: {
178
+ bug: [{ id: "file", type: "agent", prompt: "File a bug report." }],
179
+ faq: [{ id: "answer", type: "agent", prompt: "Answer the FAQ." }],
180
+ },
181
+ fallback: [{ id: "escalate", type: "agent", prompt: "Escalate to a human." }],
182
+ },
183
+ ],
184
+ });
185
+ // results: { category, matched, route: StepResult[], routeStatus }
186
+ ```
187
+
188
+ Judge/classifier JSON is parsed leniently (LLMs return `"true"`/`"0"` as strings); route nesting is depth-capped to catch cycles.
189
+
190
+ ### 6. Resume — re-run dispatches nothing
191
+
192
+ The journal lives at `<cwd>/.pi/workflows/<workflow.name>/journal.jsonl` (per-workflow, so the **same workflow re-run hits the cache across runs**; run id is excluded from the key):
193
+
194
+ ```ts
195
+ const first = await runWorkflow({ workflow: wf, cwd, now: T0 }); // dispatches every agent
196
+ const second = await runWorkflow({ workflow: wf, cwd, now: T1 }); // dispatches ZERO agents — all cached
197
+ ```
198
+
199
+ A changed prompt changes its cache key → that agent re-dispatches; unchanged ones replay.
200
+
201
+ ### 7. Per-agent abort & skip
202
+
203
+ The runner owns an `AgentSpawnRegistry`. Grab it and target one in-flight call:
204
+
205
+ ```ts
206
+ import { createSpawnRegistry, skipAgent } from "@fyeeme/pi-dynamic-workflows/sessions/spawn.ts";
207
+
208
+ const registry = createSpawnRegistry();
209
+ const runP = runWorkflow({ workflow: fanOutWf, cwd, now, registry });
210
+ // ...once `fan#2` is in flight:
211
+ skipAgent(registry, "fan#2"); // aborts only that call; siblings keep running
212
+ const result = await runP; // status "completed" — the batch finished without item 2
213
+ ```
214
+
215
+ `abortAgent(registry, callId)` aborts one call and fires `onAgentSkip`; `retryAgent(registry, callId)` aborts one call and fires `onAgentRetry`. **No automatic re-dispatch is wired yet** — the aborted call settles as skipped/failed and the run continues/fails per the step's normal settle semantics; `retryAgent` is a notify-and-abort primitive for external controllers today. Call ids are `${step.id}#${n}` (1-based).
216
+
217
+ ### 8. Budget enforcement
218
+
219
+ ```ts
220
+ const wf = defineWorkflow({
221
+ name: "capped",
222
+ budget: { maxAgents: 2 },
223
+ steps: [{ id: "fan", type: "fan_out", over: () => [1, 2, 3], agent: (i) => ({ prompt: `${i}` }) }],
224
+ });
225
+ const result = await runWorkflow({ workflow: wf, cwd, now });
226
+ // result.status === "failed", result.error matches /budget|exhausted/i
227
+ ```
228
+
229
+ `maxAgents` is checked before a batch/agent spawns; `maxTokens` is enforced as agents settle (`BudgetPool.isExhausted`). Both throw `BudgetExceededError` — never silently truncate.
230
+
231
+ ### 9. Outcome collectors
232
+
233
+ Extract a structured value from an agent's text output:
234
+
235
+ ```ts
236
+ import { collect } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
237
+
238
+ const urls = collect<string[]>({ kind: "url" }, result.steps[0].results as string);
239
+ const json = collect({ kind: "json" }, agentText); // first balanced JSON value
240
+ const paths = collect<string[]>({ kind: "file_path" }, agentText);
241
+ ```
242
+
243
+ `url` / `file_path` / `json` are pure functions of text — apply them on any `StepResult.results`.
244
+
245
+ ### 10. Heuristic planner
246
+
247
+ A keyword sketch that turns a goal into a single-step workflow scaffold (compare → tournament, review → adversarial, classify → classify_route, else agent). A starting point to edit, not a real NL planner:
248
+
249
+ ```ts
250
+ import { heuristicallyPlan } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
251
+
252
+ const wf = heuristicallyPlan("compare three sorting approaches", { judges: 3 });
253
+ // wf.steps[0].type === "tournament"
254
+ ```
255
+
256
+ ### 11. Load a `.ts` workflow file
257
+
258
+ ```ts
259
+ import { loadWorkflowModule } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
260
+
261
+ const mod = await loadWorkflowModule<{ workflow: ReturnType<typeof defineWorkflow> }>({
262
+ filePath: "./my-workflow.ts",
263
+ });
264
+ const wf = mod.workflow;
265
+ ```
266
+
267
+ The loader runs the deterministic AST guard **before** jiti-imports the file — a workflow body that calls `Date.now()` / `Math.random()` / `new Date()` is rejected at load time (those would destabilize cache keys). Note: the guard scans the entry file only; keep workflows single-file or guard imported helpers separately.
268
+
269
+ ### 12. Test without `pi`
270
+
271
+ Inject a fake dispatch — no binary, no provider, no tokens. This is exactly how the package's own 108 tests work:
272
+
273
+ ```ts
274
+ import { runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
275
+ import type { AgentDispatch } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
276
+
277
+ const fake: AgentDispatch = async (_registry, opts) => ({
278
+ callId: opts.callId,
279
+ exitCode: 0,
280
+ messages: [{ role: "assistant", content: [{ type: "text", text: `out:${opts.task}` }], /* ...rest */ } as never],
281
+ stderr: "",
282
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 15, turns: 1 },
283
+ model: "fake",
284
+ stopReason: "stop",
285
+ aborted: false,
286
+ });
287
+
288
+ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispatch: fake });
289
+ ```
290
+
291
+ ---
292
+
293
+ ## Step types reference
294
+
295
+ | type | payload highlights | result |
296
+ |---|---|---|
297
+ | `agent` | `prompt: string \| (ctx)=>string`, `model?`, `tools?`, `systemPrompt?` | final assistant text |
298
+ | `code` | `transform: (ctx) => unknown` (pure, no dispatch, not cached) | the transform's return value |
299
+ | `log` | `message: string \| (ctx)=>string` (narrative, zero dispatch/tokens) | the message (fires `onLog`) |
300
+ | `fan_out` | `over()`, `agent(item,i)`, `parallelism?`, `merge?` | merged array (or `merge` output) |
301
+ | `loop_until` | `prompt(ctx,i)`, `until(ctx,i)`, `maxIterations?` | array of per-iteration outputs |
302
+ | `adversarial` | `produce`, `rubric[]`, `judges?`, `minPass?` | `{ candidate, passed, passCount, judges }` |
303
+ | `tournament` | `candidates`, `judges`, `produce` | `{ candidates, winner, judges }` |
304
+ | `classify_route` | `classifier`, `routes: Record<cat, Step[]>`, `fallback?` | `{ category, matched, route, routeStatus }` |
305
+ | `sub_workflow` | `workflow: WorkflowDefinition`, `input?`, `inheritBudget?` | `{ steps, status, workflowName, error }` |
306
+ | `loop_until_dry` | `agent(item, i)`, `keyOf?`, `merge?`, `maxRounds?`, `dryThreshold?` | array of discovered items |
307
+
308
+ Every step accepts `id`, `retry?: { maxRetries }`, and
309
+ `onBudgetExhaust?: "throw" | "null"` — under `"null"`, a step whose budget
310
+ runs out returns `null` (degraded) instead of aborting the run; the run result
311
+ reports degraded steps in `degradedSteps`. Default `"throw"` preserves the
312
+ fail-fast guarantee.
313
+
314
+ **Template strictness** — string prompts are filled in a single left-to-right
315
+ pass over `{{input}}` / `{{item}}` / `{{step.<id>}}`. Any other `{{...}}` (or an
316
+ out-of-context token, e.g. `{{item}}` in a non-fan_out prompt) raises a
317
+ `compile`-category error that aborts the run: a literal `{{...}}` in a prompt
318
+ (e.g. mustache/jinja examples) must be avoided or the prompt text adjusted, it
319
+ is no longer passed through verbatim (0.1.0 behavior).
320
+
321
+ ---
322
+
323
+ ## API reference
324
+
325
+ ### `runWorkflow(opts)` → `Promise<RunResult>`
326
+
327
+ | option | | |
328
+ |---|---|---|
329
+ | `workflow` | `WorkflowDefinition` | required |
330
+ | `cwd` | `string` | required (journal base) |
331
+ | `now` | `number` | required — deterministic inception ms |
332
+ | `input?` | `unknown` | `ctx.input` |
333
+ | `budget?` | `Budget` | overrides `workflow.budget` |
334
+ | `signal?` | `AbortSignal` | run-wide abort |
335
+ | `listeners?` | `AgentLifecycleListeners` | `onAgentStart/End/Skip/Retry/CacheHit` + `onLog` (log steps) + `onUpdate` (streamed deltas) |
336
+ | `dispatch?` | `AgentDispatch` | default = real `spawnAgent` |
337
+ | `maxPromptBytes?` | `number` | A6 size guard — resolved task prompt **and** effective systemPrompt over this byte size throw a `size-limit` error before spawn (default 256 KB) |
338
+ | `policyGate?` | `(wf) => { allow, reason? }` | A6 gate — return `{ allow: false }` to abort the run before any dispatch |
339
+ | `registry?` | `AgentSpawnRegistry` | for external `skipAgent`/`abortAgent` |
340
+ | `journalDir?` | `string` | default `<cwd>/.pi/workflows/<name>` |
341
+ | `sequence?` | `number` | run-id disambiguator |
342
+
343
+ `RunResult = { runId, status, steps: StepResult[], stats: StepStats, journalFile?, error?, errorCategory?, degradedSteps? }`.
344
+
345
+ ### Also exported
346
+ `defineWorkflow`, `loadWorkflowModule`, `collect` (+ `urlCollector`/`filePathCollector`/`jsonCollector`/`parseFirstJson`), `heuristicallyPlan`, `createSpawnRegistry`/`abortAgent`/`skipAgent`/`retryAgent` (from `sessions/spawn.ts`), and all step/result/context types.
347
+
348
+ ---
349
+
350
+ ## Design — Claude Code fusion
351
+
352
+ | mechanism | module | what it does |
353
+ |---|---|---|
354
+ | deterministic sandbox | `src/determinism/ast-guard.ts` | AST-bans non-deterministic APIs in workflow source |
355
+ | deterministic run id | `src/state/names.ts` | `generateRunId({timestamp, sequence})` is pure |
356
+ | cache-key resume | `src/cache/{key,journal}.ts` | `sha256(workflow+prompt+signature)` + per-run JSONL journal |
357
+ | per-agent abort | `sessions/spawn.ts` | `Map<callId, ChildProcess>` + per-call `AbortController`; abort → SIGTERM on one process |
358
+ | budget + caps | `src/budget/{pool,caps}.ts` | live `BudgetPool` + `MAX_BATCH`/`MAX_LIFETIME_AGENTS` |
359
+
360
+ The three paradigm conflicts (imperative CC ↔ declarative graph) are resolved: the budget loop becomes a pre-check value fan_out reads; in-process AbortControllers become a subprocess map; the vm sandbox becomes a load-time AST guard over jiti-loaded source.
361
+
362
+ ---
363
+
364
+ ## Testing
365
+
366
+ ```bash
367
+ node_modules/.bin/tsc -p packages/extensions/pi-dynamic-workflows/tsconfig.json --noEmit # typecheck
368
+ node_modules/.bin/vitest --run packages/extensions/pi-dynamic-workflows # 108 tests
369
+ ```
370
+
371
+ A real-`pi` subprocess smoke (default dispatch) lives at `examples/smoke-real-pi.ts` — run it manually when `pi` + a provider are configured.
372
+
373
+ License: MIT.