让模型写脚本 编排一支 Agent 舰队
复刻 Claude Code「动态工作流」的 Pi 扩展——模型不再逐条执行任务,而是写一段 JavaScript 脚本,由 acorn AST 双层校验 + node:vm 确定性沙箱执行,agent()/parallel()/pipeline() 把工作 fan-out 到多个内存态隔离 Pi 子会话,最后合成结构化结果。
# Michaelliv/pi-dynamic-workflows · TypeScript · MIT · 唯一运行时依赖 acorn
数据流全景
从用户一句话到一支 Agent 舰队,整条链路只穿过 六个阶段:模型写脚本 → AST 校验 → vm 沙箱执行 → fan-out 子会话 → 结构化合成 → 流式回显。
脚本契约
15 条 promptGuidelines 教会模型脚本契约:export const meta 头 + phase / agent / parallel / pipeline 编排体。
双层 AST 防线
剥 Markdown 围栏 → assertDeterministicAst 确定性检查 → evaluateLiteral 求 meta 字面量 → 切出脚本体。
vm 沙箱
脚本体包成 async IIFE,白名单 globals + 并发限流器 min(cores-2, 16) + token budget 护栏。
内存态子会话
每个 agent() 走 SessionManager.inMemory + createCodingTools,跑完即 dispose;有 schema 时走 structured_output。
流式快照
WorkflowSnapshot 增量更新 TUI 进度树,最终 result 必须可 structuredClone。
嫁接而非自建
不发明新引擎,把「编排原语」干净嫁接到宿主 Pi 已有的会话/工具/模型设施上。
workflow 工具,对标 Anthropic 在 Claude Code 中发布的 dynamic workflows 能力——模型自己写一段编排脚本,决定开多少子 Agent、怎么分阶段、怎么汇总。Pi 三件套(pi-ai / pi-coding-agent / pi-tui)全部走 peerDependencies。核心模块拆解
整个项目惊人地小:src/ 仅约 1100 行 TypeScript,六个源码文件各管一段,职责切割得非常干净。
| 模块 / 路径 | 行数 | 作用 |
|---|---|---|
src/workflow.ts | 455 | 心脏:acorn AST 解析 + 确定性校验 + meta 字面量求值器 + vm 沙箱运行时;agent/parallel/pipeline/phase/log/budget 全部在这里闭包实现 |
src/workflow-tool.ts | 209 | Pi 工具定义:TypeBox schema、15 条 promptGuidelines(教模型写脚本的"说明书")、围栏剥离、快照回调装配、abort 时把 running agent 翻成 skipped |
src/agent.ts | 131 | WorkflowAgent:每次 run() 用 createAgentSession + SessionManager.inMemory 起一个全新 Pi 会话,标准 coding 工具齐备,结束必 dispose |
src/structured-output.ts | 47 | 终结型工具:Pi 先按 schema 验参再执行,terminate: true 让子 Agent 在这次调用上直接收口,省一轮 assistant 回复 |
src/display.ts | 235 | 快照模型 + 纯文本渲染器:◆ 头行 / phase 分组 / ✓●✗- 状态图标 / maxAgents 截断 / 日志尾巴 |
extensions/workflow.ts | 14 | 扩展入口:registerTool + session_start 时自动 setActiveTools 激活 |
AST 脚本解析器
源码:parseWorkflowScript(workflow.ts:228)。对模型生成的脚本做两层静态校验——meta 字面量求值 + 全树确定性检查,两层各司其职,互不依赖。
第一层是 meta 契约:脚本第一条语句必须是 export const meta = {...},且 meta 的值由 evaluateLiteral 用纯 AST 求值——不是 eval。它只接受 ObjectExpression / ArrayExpression / Literal / 无插值 TemplateLiteral / 负数 UnaryExpression 五种节点,spread、computed key、方法、稀疏数组全部抛错;连 __proto__ / constructor / prototype 三个保留键名都显式封死,防原型污染。求值完成后,meta export 被从源码里切掉(script.slice(0, first.start) + script.slice(first.end)),剩下的才是要执行的脚本体。
case "ObjectExpression": {
for (const prop of node.properties) {
if (prop.type === "SpreadElement") throw new Error(`spread not allowed in ${path}`);
if (prop.computed) throw new Error(`computed keys not allowed in ${path}`);
const key = propertyKey(prop.key, path);
if (key === "__proto__" || key === "constructor" || key === "prototype") {
throw new Error(`reserved key name not allowed in ${path}: ${key}`); // 防原型污染
}
out[key] = evaluateLiteral(prop.value, `${path}.${key}`);
}
}
// staticStringOf 折叠 'no'+'w',让 Date['no'+'w']() 也逃不掉确定性检查
第二层是确定性检查:assertDeterministicAst 递归遍历整棵 AST,命中 Date.now() / Math.random() / new Date() 即拒绝。有意思的是它对抗绕过的细节——staticStringOf(workflow.ts:357)会做静态字符串折叠,处理 BinaryExpression 的 +:左右操作数都能静态求出字符串时就拼接,所以 Date['no'+'w']()、Math[`random`]() 这类 computed member 写法同样被抓(git log 里专门有一个 "Fix determinism checks for prompt mentions" 的 PR #23 修过这块边界)。
只管 meta 对象
把 meta 从 AST 节点变成真实 JS 值。任何非纯字面量构造(spread / method / 插值模板)都触发带路径的明确错误,如 meta.phases[0]。
遍历整棵树
脚本体 + meta 全覆盖,只管确定性。两层不需要彼此的信息,分工干净。
assertDeterministicAst(ast)(第 237 行)再 evaluateLiteral(declarator.init, "meta")(第 258 行)。这意味着脚本体里若有 Date.now(),整个脚本在 meta 求值之前就被拒——保证 meta 求值过程中不触发副作用,也堵住"meta 没问题但脚本体有非确定性"的漏网。node:vm 沙箱运行时
源码:runWorkflow(workflow.ts:63)。脚本体被包成 (async () => {...})() 丢进 vm.createContext 的隔离上下文——上下文里只有白名单 globals。
const context = vm.createContext({
agent, parallel, pipeline, log, phase, // 编排原语
args: options.args, budget, // 注入数据
cwd: options.cwd ?? process.cwd(),
process: Object.freeze({ cwd: () => options.cwd ?? process.cwd() }), // 阉割版 process
console: { log, info: log, warn, error }, // console → 工作流日志
JSON, Math, Array, Object, String, Number, Boolean, Set, Map, Promise,
});
const wrapped = `(async () => {\n${body}\n})()`;
const result = await new vm.Script(wrapped, { filename: `${meta.name}.js` }).runInContext(context);
await Promise.allSettled([...pendingAgentRuns]); // 不留孤儿 agent
assertStructuredCloneable(result, "workflow result"); // 抓"忘了 await"
process 被替换成 Object.freeze({ cwd: () => ... })——脚本能问"在哪个目录",但拿不到 env、拿不到 exit。console.log 被重定向到 workflow 的 log(),模型习惯写的 console.log 也会变成进度条上的日志行而不是黑洞。
并发控制是一个 17 行的手写信号量 createLimiter:并发上限 min(max(1, hardwareConcurrency - 2), 16),超出的 agent() 调用排队等 slot。token budget 则是个冻结对象 { total, spent(), remaining() }——每个 agent 跑完用 JSON.stringify(result).length / 4 粗估消耗记账,预算耗尽后下一次 agent() 直接 throw。这给 while (budget.remaining() > 50_000) {...} 这类 loop-until-budget 模式提供了硬护栏。
收尾有两个容易被忽略但很关键的动作:① await Promise.allSettled([...pendingAgentRuns])——脚本主体 return 了,但若有忘记 await 的 agent 还在跑,runtime 会等它们全部落地,不留孤儿 agent;② assertStructuredCloneable(result)——结果必须能 structuredClone,clone 失败时抛出的错误直接点名最常见病因:"did you forget to await agent(), parallel(), or pipeline()?"。
| 类别 | 白名单项 | 说明 |
|---|---|---|
| 编排原语 | agent, parallel, pipeline, phase, log | 5 个——脚本的核心 API |
| 注入数据 | args, cwd, budget, process | 4 个——外部数据的只读入口,process 被冻结到只剩 cwd() |
| 重定向 I/O | console | log/info/warn/error 全部指向 workflow 的 log(),不是真正的 stdout |
| 安全内建 | JSON, Math, Array, Object, String, Number, Boolean, Set, Map, Promise | 10 个——只读内置对象引用,无 __proto__ 链可探索 |
budget 被 Object.freeze(),但 spent() / remaining() 是捕获外层 state.spent 的函数。脚本若试图 budget.total = 999999,严格模式抛 TypeError、非严格静默失败——硬性上限不会被子 Agent 篡改。fail-soft 错误哲学
三个原语共享同一条纪律:单点失败降级为 null + 日志,整个工作流继续跑;只有用户 abort 才向上抛。
agent() 内部 catch 到错误时写一行 agent <label> failed: ... 日志、回调 onAgentEnd(result: null)、返回 null——display 层据此把该 agent 标成 ✗。parallel() 对每个 thunk 单独 try/catch,pipeline() 对每个 item 的每个 stage 单独 try/catch,某个 item 在 stage 2 挂了只让这一条流水线变 null,其他 item 照常流过全部 stage。promptGuidelines 相应地教模型:"Check for nulls before synthesizing conclusions."
const run = limiter(async () => {
state.agentCount++;
options.onAgentStart?.({ label, phase: assignedPhase, prompt: taskPrompt });
try {
const result = await agentRunner.run(taskPrompt, { label, schema, signal, instructions });
state.spent += estimateTokens(result); // JSON 长度 / 4 粗估记账
return result;
} catch (error) {
if (options.signal?.aborted) throw error; // abort → fail-hard,向上传播
log(`agent ${label} failed: ${error.message}`); // 其他错误 → fail-soft
options.onAgentEnd?.({ label, phase, result: null });
return null; // 失败 = null,不连坐
}
});
pendingAgentRuns.add(run); // 登记在册,收尾时 allSettled 兜底
parallel() 还有一个针对模型高频错误的防呆校验:传入数组里只要有一个元素不是函数(典型病例 parallel(items.map(item => agent(...))),map 时 promise 已经全部起跑,限流器形同虚设),立刻抛带修复示例的 TypeError:"parallel() expects an array of functions, not promises. Wrap each call: () => agent(...)"。pipeline 的 stage 回调签名是 (prev, originalItem, index)——后段 stage 不需要让前段刻意透传上下文,原始 item 永远在第二个参数里。
| 异常来源 | 检测位置 | 处理策略 | 返回值 |
|---|---|---|---|
| LLM 调用失败 | agent.ts:79 session.prompt() | catch → 写日志 → fail-soft | null + 错误日志 |
| schema 子 Agent 未调 structured_output | agent.ts:83 capture.called | throw "finished without calling structured_output" | fail-soft 变 null |
| token budget 耗尽 | workflow.ts:103 budget.remaining() | throw "workflow token budget exhausted" | 整个 workflow abort |
| 用户按 Esc(AbortSignal) | workflow.ts:125 signal?.aborted | throw error → fail-hard 向外传播 | running → skipped("aborted") |
| 返回值藏 Promise(忘 await) | workflow.ts:217 assertStructuredCloneable | structuredClone 抛错,点名病因 | 整个 workflow abort |
agentRunner.run() 前后各放一次 throwIfAborted()(workflow.ts:113 与 120)。子 Agent 执行中按 Esc,两次检查之间会等 LLM 调用返回再检测到 abort——它依赖子 Agent 自己响应 AbortSignal(session.abort()),而非在 vm 沙箱层面硬中断,比"随时中断 LLM"简单得多。WorkflowAgent 内存态子会话
子 Agent 不是轻量 prompt 调用,而是真正的 Pi 会话。WorkflowAgent.run()(agent.ts:49)每次都 createAgentSession,会话不落盘、不进历史、跑完即焚。
关键参数是 SessionManager.inMemory(cwd)——会话不落盘、不进历史列表、跑完即焚;工具集是 createCodingTools(cwd),read/write/edit/bash 一应俱全,所以子 Agent 能像正常 Pi 一样读仓库、跑命令。abort 走标准 AbortSignal 链:父信号 addEventListener('abort') → session.abort(),finally 里必 session.dispose()。父子之间的通信带宽被压到最小:进去一段 prompt,出来一段文本或一个 schema 对象,子会话塞满的上下文不会污染父窗口——和 Claude Code subagent 的隔离哲学一致。
结构化输出是这个项目里性价比最高的 47 行(structured-output.ts):传了 opts.schema 时,子会话被注入一个 structured_output 工具 + 四条"最终输出契约"指令。Pi 框架先按 JSON Schema 验参才会调 execute,所以参数即是已验证的返回值;execute 返回 terminate: true,子 Agent 在这次工具调用上直接终止——省掉一整轮 assistant 收尾回复的 token。如果子 Agent 跑完都没调 structured_output,则抛错(fail-soft 规则接手变 null)。没传 schema 时,返回值是从消息列表从后往前找的第一段非空 assistant 文本。
return defineTool({
name: "structured_output",
parameters: schema, // Pi 先验参,参数即已验证的返回值
async execute(_toolCallId, params) {
capture.value = params;
capture.called = true;
return {
content: [{ type: "text", text: "Structured output received." }],
terminate: true, // 子 Agent 就此收口,省一轮 assistant 回复
};
},
});
不落盘隔离
会话数据存内存 Map,不落盘、不共享、不持久化。子 Agent 里跑 rm -rf / 也只影响这个 in-memory 实例(命令本身无额外沙箱,Pi shell 工具不做过滤)。
一次性监听器
addEventListener('abort', onAbort, { once:true }) → session.abort();finally 里 removeAbortListener 主动摘除防泄漏,dispose 后旧 abort 事件不再触发。
全局 + per-call
this.instructions 与 options.instructions 是拼接非覆盖;options.label 单独加在 prompt 最前一行,保证子 Agent 第一眼看到身份。
phase 与进度树
一个挺有味道的设计决策(PR #19 "Support dynamic workflow phases"):meta.phases 只是文档性质的预告,真正驱动进度展示的是运行时的 phase(title) 调用。
phase 在循环里动态创建、在条件分支里跳过,都是合法的——被跳过的分支不会留下一行空进度。agent 归组的优先级是 opts.phase ?? state.currentPhase:并发场景下(pipeline 的后段 stage 跑起来时全局 currentPhase 早就变了)用显式 opts.phase 避免归组错乱。渲染端(display.ts)按 phase 聚合出 done/running/errors/skipped 计数,每组最多显示 maxAgents 个,更早的折叠成 "… N earlier agents",每行一个状态图标:○ queued ● running ✓ done ✗ error - skipped。
◆ Workflow: audit_repo (5/7 done, 2 running)
✓ Scan 2/2
#1 ✓ repo inventory
#2 ✓ entry points
▶ Review 3/5 · 2 running
#3 ✓ src/workflow.ts review
#4 ✓ src/agent.ts review
#5 ✓ display review
#6 ● tool definition review
#7 ● tests review
… 0 earlier agents
log: 12 findings so far, deduping…
abort 路径在 workflow-tool.ts 里收尾得很整齐:捕获到 abort 后,把快照里所有 running 的 agent 翻成 skipped(error 字段标 "aborted"),重算计数,最后再渲染一次完成态——用户按下 Esc 看到的不是僵死的进度条,而是一棵明确标注了"谁被中断"的树。
result.agentCount === 0 时直接抛错——workflow 必须至少调一次 agent(),禁止模型拿它当"摆造型的进度条"。对照原版取舍
把它和 Anthropic 原版 Claude Code 的 Workflow 工具逐项对照——核心编排原语高保真复刻,重运维特性整体缺席,还有三个参数处于"API 兼容但实现为提示词"的灰色地带。
| 能力 | Claude Code 原版 | pi-dynamic-workflows |
|---|---|---|
| meta 纯字面量契约 | ✅ | ✅ AST 求值,连原型污染键都封了 |
| agent / parallel / pipeline / phase / log / args / budget | ✅ | ✅ 签名与语义对齐(含 stage 的 (prev, original, index)) |
| 确定性禁令(Date.now / Math.random / new Date) | ✅ | ✅ AST 层拦截,含 computed-member 绕过 |
| 失败 → null + 日志,abort → 抛 | ✅ | ✅ 同款错误哲学 |
| schema 结构化输出 | ✅ StructuredOutput 工具 | ✅ terminate:true 终结型工具,还省一轮收尾 |
| 恢复执行(resumeFromRunId / journal 缓存) | ✅ | ❌ 未实现(README 明示 prototype 边界) |
| workflow() 嵌套子工作流 / 命名工作流库 | ✅ | ❌ 未实现 |
| /workflows 实时管理面板 | ✅ | ❌ 未实现(TUI 内联进度树替代) |
| isolation: 'worktree'(git 隔离副本) | ✅ 真实创建 worktree | ⚠️ 参数接受,但只变成一行提示词 |
| model / agentType 覆盖 | ✅ 真实切换模型/子 Agent 类型 | ⚠️ 参数接受,但只变成提示词指令 |
function buildAgentInstructions(phase, options) {
const lines = [];
if (phase) lines.push(`Workflow phase: ${phase}`);
if (options.agentType) lines.push(`Act as workflow subagent type: ${options.agentType}`);
if (options.isolation) lines.push(`Requested isolation: ${options.isolation}`); // 没有真 worktree!
if (options.model) lines.push(`Requested model: ${options.model}`); // 没有真切模型!
return lines.join("\n"); // isolation/model/agentType 都只是注入子 Agent 的提示词
}
失败模式总览
把前面散落各节的边界条件汇成一张表——每一条都有明确的触发条件与处理方式,没有静默吞掉的角落。
| 场景 / 边界 | 触发条件 | 处理方式 |
|---|---|---|
| 模型输出带 Markdown 围栏 | ```js ... ``` 包裹脚本 | normalizeWorkflowScript 正则剥离围栏再解析 |
| meta 不是纯字面量 | spread / computed key / 插值模板 / 函数调用 | AST 求值器抛带路径的错误(如 meta.phases[0]) |
| 非确定性 API | Date.now / Math.random / new Date(含 computed 写法) | AST 遍历直接拒绝整个脚本 |
| parallel 传了 promise | parallel(items.map(i => agent(...))) | TypeError 带修复示例 "Wrap each call: () => agent(...)" |
| 单个 agent 失败 | LLM/工具异常 | fail-soft:null + 日志 + ✗ 图标,工作流继续 |
| 用户按 Esc | AbortSignal 触发 | fail-hard:级联 session.abort(),running → skipped("aborted") |
| 忘记 await | 返回值里藏 Promise | structuredClone 检查抛错,消息直接点名病因 |
| 孤儿 agent | 脚本 return 时仍有未 await 的 run | pendingAgentRuns 集合 + Promise.allSettled 兜底等待 |
| 预算耗尽 | spent ≥ tokenBudget | 下一次 agent() 抛 "workflow token budget exhausted" |
| schema 子 Agent 没交差 | 跑完未调 structured_output | 抛 "finished without calling structured_output" → fail-soft 变 null |
| 装样子的工作流 | 只声明 phase 不调 agent() | agentCount === 0 时整体抛错拒收 |
差异化与技术亮点
本站此前的项目,要么自建多 Agent 引擎,要么做记忆/索引/质量配套层。pi-dynamic-workflows 是第一个纯编排原语项目。
它不造 Agent、不管模型、不碰记忆,只回答一个问题——"当模型决定要 fan-out 时,让它用什么语言表达编排逻辑?" 答案是:用真正的编程语言(JavaScript),配真正的解释器约束(AST 校验 + vm 沙箱),而不是又一层 YAML/JSON DSL。控制流是确定性的代码,智能在叶子节点的子 Agent 里——这个分工把"LLM 驱动的编排"(每一步都问模型下一步干嘛)换成了"模型一次性写出编排程序",可审查、可测试(160 行 parser 单测覆盖接受/拒绝形状)、可复现。
它同时是一个值得注意的生态信号:Claude Code 在 2026 年发布 dynamic workflows 后,13 天内社区就在另一个开源 agent(Pi)上完成了高保真复刻并收获近 900 stars——头部 agent 的交互范式正在以周为单位向开源生态扩散,而 Pi 的扩展机制(registerTool + peerDependencies)让这种移植只需要 1100 行代码。
JS 而非 DSL
循环、条件、loop-until-budget 全是原生语法;meta 用 AST 字面量求值保证可静态解析,连原型污染键都封死。
唯一运行时依赖
运行时依赖只有 acorn 一个;Pi 三件套全走 peerDependencies。1100 行 src 完成解析、沙箱、编排、渲染四件事。
失败不连坐
单 agent 失败 → null + 日志,绝不连坐;abort → 级联 fail-hard。structuredClone 检查把"忘了 await"变成一条人话错误。
省一轮收尾
结构化输出走终结型工具:schema 验参即返回值,子 Agent 在工具调用上直接收口,每个子 Agent 省一轮 assistant 回复的 token。
| 项 | 说明 |
|---|---|
| vm 沙箱的安全边界 | node:vm 不是安全沙箱(官方明示可逃逸)。此处约束 API 面与确定性是合理的——子 Agent 本身就有完整 shell 权限——但"沙箱"不应理解为安全隔离。 |
| budget 记账精度 | JSON.stringify(result).length / 4 只估算了结果文本,子 Agent 中间消耗的 input/output token 完全未计入,实际开销可能远大于账面值。 |
| isolation / model / agentType | 三个参数均只注入提示词(见第 08 节),并发写文件的工作流在 Pi 上仍会互相冲突,待上游支持。 |
| Stars 时效 | 895 stars / 45 forks 为 2026-06-10 快照,仓库创建于 2026-05-28,数据变化较快。 |