@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.
@@ -0,0 +1,359 @@
1
+ # @fyeeme/pi-dynamic-workflows
2
+
3
+ **为 [pi](https://github.com/earendil-works/pi-mono) 打造的确定性 TypeScript 工作流编排。**
4
+
5
+ 把工作流定义成一份类型化的声明式步骤列表,运行后即可获得**可恢复、受预算约束、可中止**的执行。融合 pi-dynamic-workflows 设计(9 个步骤原语 + 启发式 planner + outcome 收集器)与 Claude Code 工作流引擎的协调机制(确定性沙箱、缓存键恢复、按 agent 中止、动态预算、失控上限)。
6
+
7
+ 语言:[English](README.md) | **中文**
8
+
9
+ ---
10
+
11
+ ## 为什么需要它
12
+
13
+ 一次运行 = 一份步骤列表(`agent` / `code` / `fan_out` / `loop_until` / `adversarial` / `tournament` / `classify_route`)。引擎保证:
14
+
15
+ - **确定性** —— workflow `.ts` 文件经 AST 守卫,禁止 `Date.now()` / `Math.random()` / `new Date()`;run id 是 `(timestamp, sequence)` 的纯函数。
16
+ - **恢复即不重派** —— 每个 agent 调用以 `sha256(workflow + prompt + signature)` 为键写入 journal;重跑同一 workflow 会回放缓存的 agent(零子进程派发)。
17
+ - **按 agent 中止** —— 每个在途 agent 持有自己的 `AbortController`;`skipAgent`/`retryAgent` 只针对一个调用,不打扰兄弟调用。
18
+ - **预算 + 失控上限** —— `maxAgents` / `maxTokens` 由实时池强制;`MAX_BATCH=4096`、`MAX_LIFETIME_AGENTS=1000` 超限抛 `BudgetExceededError`(绝不静默截断)。
19
+ - **无需 `pi` 即可测试** —— agent 派发可注入;测试传一个 fake dispatch,无需二进制、无需 provider API、无需 token。
20
+
21
+ ---
22
+
23
+ ## 安装
24
+
25
+ 这是一个 pi 扩展包(workspace / 本地),尚未发布到 npm。在 pi workspace 中:
26
+
27
+ ```bash
28
+ npm install --ignore-scripts # 水合(本包是 workspace 依赖)
29
+ ```
30
+
31
+ 这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.3.0`,无需保持同级仓库目录结构)。
32
+
33
+ 随后从包根模块导入公共 API(TypeScript barrel,包直接以 `.ts` 源码分发):
34
+
35
+ ```ts
36
+ import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
37
+ ```
38
+
39
+ > 包的 `pi.extensions` 入口(`./index.ts`)目前仍是脚手架——把 `run_workflow` 工具接进 pi 是后续工作。引擎本身已可经上述导入直接使用。
40
+
41
+ ---
42
+
43
+ ## 快速上手
44
+
45
+ ```ts
46
+ import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
47
+
48
+ const wf = defineWorkflow({
49
+ name: "draft-and-refine",
50
+ steps: [
51
+ { id: "draft", type: "agent", prompt: "起草一段发布说明。" },
52
+ { id: "refine", type: "agent", prompt: (ctx) => `把下面改写得更精炼:\n\n${ctx.step("draft").results}` },
53
+ ],
54
+ });
55
+
56
+ const result = await runWorkflow({ workflow: wf, cwd: process.cwd(), now: Date.now() });
57
+ console.log(result.status, result.steps[1].results);
58
+ ```
59
+
60
+ `runWorkflow` 默认每次 agent 调用派生一个 `pi --mode json -p --no-session` 子进程(默认 dispatch),因此需要 `pi` 在 `PATH` 上并配置好 provider。测试或离线运行时注入一个 fake dispatch 即可(见教程)。
61
+
62
+ ---
63
+
64
+ ## 使用教程
65
+
66
+ ### 1. 定义工作流
67
+
68
+ `defineWorkflow` 是一个类型化恒等助手——让你对 `steps` 判别联合获得完整类型检查。
69
+
70
+ ```ts
71
+ const wf = defineWorkflow({
72
+ name: "research",
73
+ budget: { maxAgents: 10, maxTokens: 50_000 },
74
+ steps: [
75
+ { id: "gather", type: "agent", prompt: "列出关于主题 X 的 3 个来源。" },
76
+ { id: "summarize", type: "agent", prompt: (ctx) => `总结:\n${ctx.step("gather").results}` },
77
+ ],
78
+ });
79
+ ```
80
+
81
+ `ctx.input` 是本次运行的初始输入;`ctx.step(id)` 返回某个已执行步骤的 `{ results, stats }`(若该 id 尚未执行则抛错)。
82
+
83
+ ### 2. 运行
84
+
85
+ ```ts
86
+ const result = await runWorkflow({
87
+ workflow: wf,
88
+ cwd: process.cwd(),
89
+ now: 1700000000000, // 确定性起始时间(ms),同时是 journal/run-id 的种子
90
+ input: "主题 X",
91
+ });
92
+ // result.status: "completed" | "failed" | "aborted"
93
+ // result.steps: StepResult[](按顺序,每个已执行步骤一条)
94
+ // result.stats: 汇总 { tokens, cost, durationMs, agents, failures }
95
+ // result.journalFile: 该 workflow 的 JSONL journal 路径
96
+ ```
97
+
98
+ `now` **必填且确定性**——传入本次运行的起始时间;引擎绝不读时钟来生成身份。相同的 `(workflow, prompts)` 永远生成相同的缓存键。
99
+
100
+ ### 3. fan_out —— 并行 agent + 合并
101
+
102
+ ```ts
103
+ const wf = defineWorkflow({
104
+ name: "parallel-research",
105
+ steps: [
106
+ {
107
+ id: "fan",
108
+ type: "fan_out",
109
+ over: () => ["alpha", "beta", "gamma"],
110
+ agent: (topic) => ({ prompt: `研究 ${topic}。` }),
111
+ parallelism: 3,
112
+ merge: (results) => results.join("\n---\n"),
113
+ },
114
+ ],
115
+ });
116
+ ```
117
+
118
+ `fan_out` 会先预检整批是否在预算内(`MAX_BATCH=4096`);每个 item 独立缓存键、独立可中止。
119
+
120
+ ### 4. loop_until —— 迭代到条件 / 预算
121
+
122
+ ```ts
123
+ const wf = defineWorkflow({
124
+ name: "refine-loop",
125
+ steps: [
126
+ {
127
+ id: "loop",
128
+ type: "loop_until",
129
+ prompt: (ctx, i) => `第 ${i + 1} 稿。当前:\n${ctx.step("loop")?.results ?? ctx.input}`,
130
+ until: (ctx, i) => i >= 3,
131
+ maxIterations: 5,
132
+ },
133
+ ],
134
+ });
135
+ ```
136
+
137
+ 每次迭代都是独立的缓存键 agent 调用;`maxIterations` 与预算共同约束循环。
138
+
139
+ ### 5. 组合模式 —— adversarial / tournament / classify_route
140
+
141
+ 它们构建在同一个 `dispatchAgentCall` 之上,因此天然享有缓存恢复、预算与中止。
142
+
143
+ ```ts
144
+ // 生成候选,再由 N 个评判者按 rubric 打分并汇总。
145
+ defineWorkflow({
146
+ name: "review",
147
+ steps: [
148
+ {
149
+ id: "adv",
150
+ type: "adversarial",
151
+ produce: { prompt: "写这个函数。" },
152
+ rubric: ["正确性", "处理空输入", "无 off-by-one"],
153
+ judges: 3, // 默认;minPass 默认为过半数
154
+ },
155
+ ],
156
+ });
157
+ // results: { candidate, passed, passCount, minPass, judges: [{pass, reason}] }
158
+
159
+ // N 个不同候选,M 个评判者排名,选出多数赢家。
160
+ defineWorkflow({
161
+ name: "pick",
162
+ steps: [{ id: "tmt", type: "tournament", candidates: 3, judges: 2, produce: { prompt: "解决 X。" } }],
163
+ });
164
+ // results: { candidates, winner, judges: [{winner, reason}] }
165
+
166
+ // 分类输入,再运行匹配路由的子步骤。
167
+ defineWorkflow({
168
+ name: "route",
169
+ steps: [
170
+ {
171
+ id: "cr",
172
+ type: "classify_route",
173
+ classifier: { prompt: (ctx) => `分类意图:${ctx.input}` },
174
+ routes: {
175
+ bug: [{ id: "file", type: "agent", prompt: "提一个 bug 报告。" }],
176
+ faq: [{ id: "answer", type: "agent", prompt: "回答这个 FAQ。" }],
177
+ },
178
+ fallback: [{ id: "escalate", type: "agent", prompt: "转给人工。" }],
179
+ },
180
+ ],
181
+ });
182
+ // results: { category, matched, route: StepResult[], routeStatus }
183
+ ```
184
+
185
+ 评判/分类的 JSON 采用宽松解析(LLM 常把 `"true"`/`"0"` 当字符串返回);路由嵌套有深度上限以防循环。
186
+
187
+ ### 6. 恢复 —— 重跑零派发
188
+
189
+ journal 位于 `<cwd>/.pi/workflows/<workflow.name>/journal.jsonl`(按 workflow 而非按 run,因此**同一 workflow 重跑可跨 run 命中缓存**;run id 不计入键):
190
+
191
+ ```ts
192
+ const first = await runWorkflow({ workflow: wf, cwd, now: T0 }); // 每个 agent 都派发
193
+ const second = await runWorkflow({ workflow: wf, cwd, now: T1 }); // 零派发——全部缓存命中
194
+ ```
195
+
196
+ prompt 变了 → 该 agent 的缓存键变 → 重新派发;没变的则回放。
197
+
198
+ ### 7. 按 agent 中止与跳过
199
+
200
+ runner 持有一个 `AgentSpawnRegistry`。拿到它即可针对单个在途调用:
201
+
202
+ ```ts
203
+ import { createSpawnRegistry, skipAgent } from "@fyeeme/pi-dynamic-workflows/sessions/spawn.ts";
204
+
205
+ const registry = createSpawnRegistry();
206
+ const runP = runWorkflow({ workflow: fanOutWf, cwd, now, registry });
207
+ // ...等 `fan#2` 进入在途状态后:
208
+ skipAgent(registry, "fan#2"); // 只中止这一个;兄弟调用继续
209
+ const result = await runP; // status "completed"——这批除第 2 项外都跑完了
210
+ ```
211
+
212
+ `abortAgent(registry, callId)` 中止一个调用;`retryAgent(registry, callId)` 中止以便 runner 重新派发。调用 id 形如 `${step.id}#${n}`(从 1 起)。
213
+
214
+ ### 8. 预算强制
215
+
216
+ ```ts
217
+ const wf = defineWorkflow({
218
+ name: "capped",
219
+ budget: { maxAgents: 2 },
220
+ steps: [{ id: "fan", type: "fan_out", over: () => [1, 2, 3], agent: (i) => ({ prompt: `${i}` }) }],
221
+ });
222
+ const result = await runWorkflow({ workflow: wf, cwd, now });
223
+ // result.status === "failed",result.error 匹配 /budget|exhausted/i
224
+ ```
225
+
226
+ `maxAgents` 在派发整批/单个 agent 前检查;`maxTokens` 在 agent 落定后由 `BudgetPool.isExhausted` 强制。二者都抛 `BudgetExceededError`——绝不静默截断。
227
+
228
+ ### 9. Outcome 收集器
229
+
230
+ 从 agent 的文本输出里抽取结构化值:
231
+
232
+ ```ts
233
+ import { collect } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
234
+
235
+ const urls = collect<string[]>({ kind: "url" }, result.steps[0].results as string);
236
+ const json = collect({ kind: "json" }, agentText); // 第一个平衡的 JSON 值
237
+ const paths = collect<string[]>({ kind: "file_path" }, agentText);
238
+ ```
239
+
240
+ `url` / `file_path` / `json` 都是文本的纯函数——可对任意 `StepResult.results` 使用。
241
+
242
+ ### 10. 启发式 planner
243
+
244
+ 按关键词把目标草拟成单步工作流脚手架(compare → tournament、review → adversarial、classify → classify_route,其余 → agent)。这是一个待你打磨的起点,不是真正的 NL 规划器:
245
+
246
+ ```ts
247
+ import { heuristicallyPlan } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
248
+
249
+ const wf = heuristicallyPlan("比较三种排序方案", { judges: 3 });
250
+ // wf.steps[0].type === "tournament"
251
+ ```
252
+
253
+ ### 11. 加载 `.ts` 工作流文件
254
+
255
+ ```ts
256
+ import { loadWorkflowModule } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
257
+
258
+ const mod = await loadWorkflowModule<{ workflow: ReturnType<typeof defineWorkflow> }>({
259
+ filePath: "./my-workflow.ts",
260
+ });
261
+ const wf = mod.workflow;
262
+ ```
263
+
264
+ loader 在 jiti 加载**之前**跑确定性 AST 守卫——workflow 体内若调用 `Date.now()` / `Math.random()` / `new Date()` 会在加载时被拒(这些会让缓存键失稳)。注意:守卫只扫描入口文件;请让 workflow 单文件,或单独守卫被引入的 helper。
265
+
266
+ ### 12. 无需 `pi` 即可测试
267
+
268
+ 注入一个 fake dispatch——无二进制、无 provider、无 token。本包自带的 108 个测试就是这样跑的:
269
+
270
+ ```ts
271
+ import { runWorkflow, type AgentDispatch } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
272
+
273
+ const fake: AgentDispatch = async (_registry, opts) => ({
274
+ callId: opts.callId,
275
+ exitCode: 0,
276
+ messages: [{ role: "assistant", content: [{ type: "text", text: `out:${opts.task}` }], /* ...其余字段 */ } as never],
277
+ stderr: "",
278
+ usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 15, turns: 1 },
279
+ model: "fake",
280
+ stopReason: "stop",
281
+ aborted: false,
282
+ });
283
+
284
+ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispatch: fake });
285
+ ```
286
+
287
+ ---
288
+
289
+ ## 步骤类型速查
290
+
291
+ | type | payload 要点 | 结果 |
292
+ |---|---|---|
293
+ | `agent` | `prompt: string \| (ctx)=>string`、`model?`、`tools?`、`systemPrompt?` | 最后一条 assistant 文本 |
294
+ | `code` | `transform: (ctx) => unknown`(纯函数、不派发、不缓存) | transform 的返回值 |
295
+ | `log` | `message: string \| (ctx)=>string`(叙事行、零派发零 token) | 该消息(触发 `onLog`) |
296
+ | `fan_out` | `over()`、`agent(item,i)`、`parallelism?`、`merge?` | 合并后的数组(或 `merge` 的输出) |
297
+ | `loop_until` | `prompt(ctx,i)`、`until(ctx,i)`、`maxIterations?` | 每轮输出的数组 |
298
+ | `adversarial` | `produce`、`rubric[]`、`judges?`、`minPass?` | `{ candidate, passed, passCount, judges }` |
299
+ | `tournament` | `candidates`、`judges`、`produce` | `{ candidates, winner, judges }` |
300
+ | `classify_route` | `classifier`、`routes: Record<cat, Step[]>`、`fallback?` | `{ category, matched, route, routeStatus }` |
301
+
302
+ 每个步骤都接受 `id`、`retry?: { maxRetries }` 与
303
+ `onBudgetExhaust?: "throw" | "null"`——`"null"` 下预算耗尽时该步骤返回 `null`
304
+ (降级)而不是中止运行;运行结果用 `degradedSteps` 记录降级步骤。默认
305
+ `"throw"` 保持 fail-fast 保证。
306
+
307
+ ---
308
+
309
+ ## API 参考
310
+
311
+ ### `runWorkflow(opts)` → `Promise<RunResult>`
312
+
313
+ | 选项 | | |
314
+ |---|---|---|
315
+ | `workflow` | `WorkflowDefinition` | 必填 |
316
+ | `cwd` | `string` | 必填(journal 基目录) |
317
+ | `now` | `number` | 必填——确定性起始 ms |
318
+ | `input?` | `unknown` | `ctx.input` |
319
+ | `budget?` | `Budget` | 覆盖 `workflow.budget` |
320
+ | `signal?` | `AbortSignal` | 整运行中止信号 |
321
+ | `listeners?` | `AgentLifecycleListeners` | `onAgentStart/End/Skip/Retry/CacheHit` + `onLog`(log 步骤)+ `onUpdate`(流式 delta) |
322
+ | `dispatch?` | `AgentDispatch` | 默认 = 真实 `spawnAgent` |
323
+ | `maxPromptBytes?` | `number` | A6 尺寸守卫——解析后 prompt 超过该字节数在派发前抛 `size-limit`(默认 256 KB) |
324
+ | `policyGate?` | `(wf) => { allow, reason? }` | A6 门控——返回 `{ allow: false }` 在任何派发前中止运行 |
325
+ | `registry?` | `AgentSpawnRegistry` | 供外部 `skipAgent`/`abortAgent` |
326
+ | `journalDir?` | `string` | 默认 `<cwd>/.pi/workflows/<name>` |
327
+ | `sequence?` | `number` | run-id 消歧 |
328
+
329
+ `RunResult = { runId, status, steps: StepResult[], stats: StepStats, journalFile?, error?, errorCategory?, degradedSteps? }`。
330
+
331
+ ### 同时导出
332
+ `defineWorkflow`、`loadWorkflowModule`、`collect`(含 `urlCollector`/`filePathCollector`/`jsonCollector`/`parseFirstJson`)、`heuristicallyPlan`、`createSpawnRegistry`/`abortAgent`/`skipAgent`/`retryAgent`(来自 `sessions/spawn.ts`),以及全部步骤/结果/上下文类型。
333
+
334
+ ---
335
+
336
+ ## 设计 —— Claude Code 融合
337
+
338
+ | 机制 | 模块 | 作用 |
339
+ |---|---|---|
340
+ | 确定性沙箱 | `src/determinism/ast-guard.ts` | AST 级禁止 workflow 源码中的非确定性 API |
341
+ | 确定性 run id | `src/state/names.ts` | `generateRunId({timestamp, sequence})` 为纯函数 |
342
+ | 缓存键恢复 | `src/cache/{key,journal}.ts` | `sha256(workflow+prompt+signature)` + 每运行 JSONL journal |
343
+ | 按 agent 中止 | `sessions/spawn.ts` | `Map<callId, ChildProcess>` + 每调用 `AbortController`;中止 → 对单个进程 SIGTERM |
344
+ | 预算 + 上限 | `src/budget/{pool,caps}.ts` | 实时 `BudgetPool` + `MAX_BATCH`/`MAX_LIFETIME_AGENTS` |
345
+
346
+ 三处范式冲突(CC 命令式 ↔ 声明式图)已化解:预算循环变成 fan_out 读取的预检值;进程内 AbortController 变成子进程表;vm 沙箱变成对 jiti 加载源码的加载期 AST 守卫。
347
+
348
+ ---
349
+
350
+ ## 测试
351
+
352
+ ```bash
353
+ node_modules/.bin/tsc -p packages/extensions/pi-dynamic-workflows/tsconfig.json --noEmit # 类型检查
354
+ node_modules/.bin/vitest --run packages/extensions/pi-dynamic-workflows # 108 个测试
355
+ ```
356
+
357
+ 真实 `pi` 子进程冒烟(默认 dispatch)位于 `examples/smoke-real-pi.ts`——在 `pi` 与 provider 配置妥当后手动运行。
358
+
359
+ License:MIT。