PIWorkflows
Michaelliv/pi-dynamic-workflows · 源码级 基于 src/ 六文件逐行阅读

让模型写脚本 编排一支 Agent 舰队

复刻 Claude Code「动态工作流」的 Pi 扩展——模型不再逐条执行任务,而是写一段 JavaScript 脚本,由 acorn AST 双层校验 + node:vm 确定性沙箱执行,agent()/parallel()/pipeline() 把工作 fan-out 到多个内存态隔离 Pi 子会话,最后合成结构化结果。

# Michaelliv/pi-dynamic-workflows · TypeScript · MIT · 唯一运行时依赖 acorn

895
Stars(13 天)
~1.1k 行
src/ 全量代码
acorn ×1
唯一运行时依赖
node:vm
确定性脚本沙箱
向下探索
01
dataflow · one sentence → a fleet

数据流全景

从用户一句话到一支 Agent 舰队,整条链路只穿过 六个阶段:模型写脚本 → AST 校验 → vm 沙箱执行 → fan-out 子会话 → 结构化合成 → 流式回显。

图 01 · 一句话请求的执行管线
用户请求
"审查这个仓库"
prompt
模型写 workflow 脚本
15 条 promptGuidelines
parse
acorn AST 校验
剥围栏 → 确定性 → meta 求值
exec
node:vm 沙箱
async IIFE + 限流器
fan-out
agent() 子会话
inMemory + codingTools
render
快照 + 结构化结果
structuredClone 校验
step 02 · 写脚本

脚本契约

15 条 promptGuidelines 教会模型脚本契约:export const meta 头 + phase / agent / parallel / pipeline 编排体。

step 03 · 校验

双层 AST 防线

剥 Markdown 围栏 → assertDeterministicAst 确定性检查 → evaluateLiteral 求 meta 字面量 → 切出脚本体。

step 04 · 执行

vm 沙箱

脚本体包成 async IIFE,白名单 globals + 并发限流器 min(cores-2, 16) + token budget 护栏。

step 05 · fan-out

内存态子会话

每个 agent() 走 SessionManager.inMemory + createCodingTools,跑完即 dispose;有 schema 时走 structured_output。

step 06 · 回显

流式快照

WorkflowSnapshot 增量更新 TUI 进度树,最终 result 必须可 structuredClone。

scope

嫁接而非自建

不发明新引擎,把「编排原语」干净嫁接到宿主 Pi 已有的会话/工具/模型设施上。

定位一句话:pi-dynamic-workflows 是开源 coding agent Pi(earendil-works)的扩展,给 Pi 注册一个 workflow 工具,对标 Anthropic 在 Claude Code 中发布的 dynamic workflows 能力——模型自己写一段编排脚本,决定开多少子 Agent、怎么分阶段、怎么汇总。Pi 三件套(pi-ai / pi-coding-agent / pi-tui)全部走 peerDependencies。
02
modules · the source map

核心模块拆解

整个项目惊人地小:src/ 仅约 1100 行 TypeScript,六个源码文件各管一段,职责切割得非常干净。

表 02 · 六文件职责分工
模块 / 路径行数作用
src/workflow.ts455心脏:acorn AST 解析 + 确定性校验 + meta 字面量求值器 + vm 沙箱运行时;agent/parallel/pipeline/phase/log/budget 全部在这里闭包实现
src/workflow-tool.ts209Pi 工具定义:TypeBox schema、15 条 promptGuidelines(教模型写脚本的"说明书")、围栏剥离、快照回调装配、abort 时把 running agent 翻成 skipped
src/agent.ts131WorkflowAgent:每次 run() 用 createAgentSession + SessionManager.inMemory 起一个全新 Pi 会话,标准 coding 工具齐备,结束必 dispose
src/structured-output.ts47终结型工具:Pi 先按 schema 验参再执行,terminate: true 让子 Agent 在这次调用上直接收口,省一轮 assistant 回复
src/display.ts235快照模型 + 纯文本渲染器:◆ 头行 / phase 分组 / ✓●✗- 状态图标 / maxAgents 截断 / 日志尾巴
extensions/workflow.ts14扩展入口:registerTool + session_start 时自动 setActiveTools 激活
它和自建引擎的本质区别:它不造 Agent、不管模型、不碰记忆——只把控制流(编排)做成确定性代码,把智能留在叶子节点的子 Agent 里。这是它和 Kimi Code 的 Session 多 Agent、Ongrid 的 Coordinator+Specialist 等「自建多 Agent 引擎」最本质的分野。
03
parser · two layers of static defence

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)),剩下的才是要执行的脚本体。

src/workflow.ts — evaluateLiteral 的拒绝面
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 修过这块边界)。

补充 · 双层防线的完整闭环
layer 1 · evaluateLiteral

只管 meta 对象

把 meta 从 AST 节点变成真实 JS 值。任何非纯字面量构造(spread / method / 插值模板)都触发带路径的明确错误,如 meta.phases[0]。

layer 2 · assertDeterministicAst

遍历整棵树

脚本体 + meta 全覆盖,只管确定性。两层不需要彼此的信息,分工干净。

顺序很关键:先 assertDeterministicAst(ast)(第 237 行)再 evaluateLiteral(declarator.init, "meta")(第 258 行)。这意味着脚本体里若有 Date.now(),整个脚本在 meta 求值之前就被拒——保证 meta 求值过程中不触发副作用,也堵住"meta 没问题但脚本体有非确定性"的漏网。
04
runtime · whitelist sandbox

node:vm 沙箱运行时

源码:runWorkflow(workflow.ts:63)。脚本体被包成 (async () => {...})() 丢进 vm.createContext 的隔离上下文——上下文里只有白名单 globals。

src/workflow.ts — vm 上下文:能看到的就这么多
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()?"。

表 03 · 白名单上下文(workflow.ts:186-212,16 项分四类)
类别白名单项说明
编排原语agent, parallel, pipeline, phase, log5 个——脚本的核心 API
注入数据args, cwd, budget, process4 个——外部数据的只读入口,process 被冻结到只剩 cwd()
重定向 I/Oconsolelog/info/warn/error 全部指向 workflow 的 log(),不是真正的 stdout
安全内建JSON, Math, Array, Object, String, Number, Boolean, Set, Map, Promise10 个——只读内置对象引用,无 __proto__ 链可探索
node:vm 不是安全沙箱:Node.js 官方文档明确说明可被逃逸。项目用它的目的有二——① 确定性约束(vm 上下文中没有 Date/Math.random 等全局)② API 面限制(脚本只能访问白名单上的 16 个符号)。真正的安全边界由宿主 Pi 提供:子 Agent 跑在独立 Pi 会话里。
budget 冻结的边界:budget 被 Object.freeze(),但 spent() / remaining() 是捕获外层 state.spent 的函数。脚本若试图 budget.total = 999999,严格模式抛 TypeError、非严格静默失败——硬性上限不会被子 Agent 篡改。
05
resilience · fail-soft, never cascade

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."

src/workflow.ts — agent() 的 fail-soft 与 abort 的 fail-hard
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 永远在第二个参数里。

图 02 · agent() 生命周期状态机
A1 limiter 排队
超上限入等待队列
A2 onAgentStart
status = running
A3 WorkflowAgent.run()
起内存态 Pi 会话
A4 session.prompt()
子 Agent 执行
A5 token 记账
spent += estimate
A6 onAgentEnd
done / error
表 04 · 关键路径异常处理
异常来源检测位置处理策略返回值
LLM 调用失败agent.ts:79 session.prompt()catch → 写日志 → fail-softnull + 错误日志
schema 子 Agent 未调 structured_outputagent.ts:83 capture.calledthrow "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?.abortedthrow error → fail-hard 向外传播running → skipped("aborted")
返回值藏 Promise(忘 await)workflow.ts:217 assertStructuredCloneablestructuredClone 抛错,点名病因整个 workflow abort
两次 abort 检查点:A3→A5 之间在 agentRunner.run() 前后各放一次 throwIfAborted()(workflow.ts:113 与 120)。子 Agent 执行中按 Esc,两次检查之间会等 LLM 调用返回再检测到 abort——它依赖子 Agent 自己响应 AbortSignal(session.abort()),而非在 vm 沙箱层面硬中断,比"随时中断 LLM"简单得多。
06
subagent · one call = one full session

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 文本。

src/structured-output.ts — terminate:true 的终结型工具
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 回复
    };
  },
});
inMemory(cwd)

不落盘隔离

会话数据存内存 Map,不落盘、不共享、不持久化。子 Agent 里跑 rm -rf / 也只影响这个 in-memory 实例(命令本身无额外沙箱,Pi shell 工具不做过滤)。

abort 链 · 单向

一次性监听器

addEventListener('abort', onAbort, { once:true }) → session.abort();finally 里 removeAbortListener 主动摘除防泄漏,dispose 后旧 abort 事件不再触发。

instructions 拼接

全局 + per-call

this.instructions 与 options.instructions 是拼接非覆盖;options.label 单独加在 prompt 最前一行,保证子 Agent 第一眼看到身份。

子会话 prompt 构建(agent.ts:96-117):workflow 级全局上下文("你是代码审查 Agent")和具体 agent 调用的任务指令共存;label 不进 parts 数组而是独立首行——这样身份信息永远在 prompt 开头。
07
progress · declaration is doc, run is truth

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。

display.ts 渲染输出 · TUI 进度树
  ◆ 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(),禁止模型拿它当"摆造型的进度条"。
08
parity · cloned / absent / faked

对照原版取舍

把它和 Anthropic 原版 Claude Code 的 Workflow 工具逐项对照——核心编排原语高保真复刻,重运维特性整体缺席,还有三个参数处于"API 兼容但实现为提示词"的灰色地带。

表 05 · 逐项对照
能力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 类型⚠️ 参数接受,但只变成提示词指令
src/workflow.ts — buildAgentInstructions:⚠️ 行的真相
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 的提示词
}
09
failure modes · the whole surface

失败模式总览

把前面散落各节的边界条件汇成一张表——每一条都有明确的触发条件与处理方式,没有静默吞掉的角落。

表 06 · 失败模式与处理
场景 / 边界触发条件处理方式
模型输出带 Markdown 围栏```js ... ``` 包裹脚本normalizeWorkflowScript 正则剥离围栏再解析
meta 不是纯字面量spread / computed key / 插值模板 / 函数调用AST 求值器抛带路径的错误(如 meta.phases[0])
非确定性 APIDate.now / Math.random / new Date(含 computed 写法)AST 遍历直接拒绝整个脚本
parallel 传了 promiseparallel(items.map(i => agent(...)))TypeError 带修复示例 "Wrap each call: () => agent(...)"
单个 agent 失败LLM/工具异常fail-soft:null + 日志 + ✗ 图标,工作流继续
用户按 EscAbortSignal 触发fail-hard:级联 session.abort(),running → skipped("aborted")
忘记 await返回值里藏 PromisestructuredClone 检查抛错,消息直接点名病因
孤儿 agent脚本 return 时仍有未 await 的 runpendingAgentRuns 集合 + 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 时整体抛错拒收
10
significance · the first orchestration-primitive

差异化与技术亮点

本站此前的项目,要么自建多 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 完成解析、沙箱、编排、渲染四件事。

🛑 fail-soft 哲学

失败不连坐

单 agent 失败 → null + 日志,绝不连坐;abort → 级联 fail-hard。structuredClone 检查把"忘了 await"变成一条人话错误。

⚡ terminate:true

省一轮收尾

结构化输出走终结型工具: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,数据变化较快。
PI Workflows · 源码即文档
Michaelliv/pi-dynamic-workflows · TypeScript · MIT
基于 src/ 六文件逐行阅读撰写 · 含实际函数名与行号
github.com/Michaelliv/pi-dynamic-workflows ↗