deer-workflow 动态 Agent 工作流运行时
一个 TypeScript/Bun 包,把 workflow()、phase()、parallel()、pipeline()、Runner、事件 JSONL 和 Codex/Claude 适配器组合成轻量 Agent orchestration runtime。
它解决什么,凭什么值得拆
deer-workflow 的入口不是把 prompt 丢给某个模型,而是先把 Workflow 当成可加载模块处理:src/flow/workflow.ts:90 定义泛型 workflow(),src/flow/workflow.ts:94 读取父上下文,src/flow/workflow.ts:101 解析脚本路径,src/flow/workflow.ts:102 组装执行上下文,src/flow/context.ts:30 再用 AsyncLocalStorage 绑定异步调用链。因此它解决的是 Agent 任务编排的可观察运行时,而不是又一个聊天封装。
它值得拆的原因在于边界很清楚:src/flow/types.ts:60 把 workflowId、parentId、depth、scriptPath 和 args 固定成执行契约,src/flow/types.ts:119 把 meta 约束成 name、description、phases 与 exampleArgs,src/flow/workflow.ts:232 强制 kebab-case,src/flow/workflow.ts:246 要求 phases 非空,src/flow/workflow.ts:271 还会解析 JSON-safe exampleArgs。这里的设计目标是让自动化可以读懂一条工作流,而不是只能读自然语言说明。
▸核心数据
▸为什么不是普通脚本
执行路径的关键是事件先行:src/flow/workflow.ts:110 进入 workflow context 后,src/flow/workflow.ts:112 先发 workflow:start,src/flow/workflow.ts:123 在发现 metadata 后发 workflow:meta,src/flow/workflow.ts:131 成功时发 workflow:end,失败时由 src/flow/workflow.ts:139 发 workflow:error。这个顺序让 TUI、JSONL writer 和外部监控都能以同一事件协议观察任务。
运行核心
与很多图编排库不同,parallel 和 pipeline 被刻意写得很小:src/flow/parallel.ts:22 接受 lazy task tuple,src/flow/parallel.ts:25 用 Promise.all 开始并发,src/flow/parallel.ts:29 把单个失败降级为 null;src/flow/pipeline.ts:78 则让每个 item 自己穿过 stage,src/flow/pipeline.ts:91 同样把失败项转成 null。这是“让语义留在 TypeScript,Agent runtime 可替换”的小内核路线。
▸workflow() 如何建上下文
90export async function workflow<TOutput = unknown, TArgs = unknown>( 91 target: WorkflowTarget, 92 args?: TArgs, 93): Promise<TOutput> { 94 const parentContext = getWorkflowContext(); 95 const depth = parentContext ? parentContext.depth + 1 : 0; 96 97 if (depth > MAX_NESTED_WORKFLOW_DEPTH) { 98 throw new WorkflowNestingError(depth); 99 } 100 101 const scriptPath = resolveWorkflowPath(target, parentContext); 102 const context: WorkflowExecutionContext<TArgs | undefined> = { 103 id: crypto.randomUUID(), 104 parentId: parentContext?.id, 105 depth, 106 scriptPath, 107 args, 108 }; 109 110 return runInWorkflowContext(context, async () => { 111 const startedAt = performance.now(); 112 emitWorkflowEvent({ 113 type: "workflow:start", 114 ...toWorkflowEventContext(context), 115 }); 116 117 try { 118 const definition = await loadWorkflowDefinition< 119 TArgs | undefined, 120 TOutput 121 >(scriptPath); 122 if (definition.meta !== undefined) { 123 emitWorkflowEvent({ 124 type: "workflow:meta", 125 ...toWorkflowEventContext(context), 126 meta: definition.meta, 127 }); 128 } 129 const result = await definition.handler(args, context); 130 endCurrentPhase(context); 131 emitWorkflowEvent({ 132 type: "workflow:end", 133 ...toWorkflowEventContext(context), 134 durationMs: elapsedMilliseconds(startedAt), 135 }); 136 return result; 137 } catch (error) { 138 endCurrentPhase(context); 139 emitWorkflowEvent({ 140 type: "workflow:error", 141 ...toWorkflowEventContext(context), 142 durationMs: elapsedMilliseconds(startedAt), 143 error: serializeWorkflowError(error), 144 }); 145 throw error; 146 } 147 });
phase() 的实现也显示它更像工作流可视化协议:src/flow/phase.ts:34 接收阶段标题,src/flow/phase.ts:40 必须找到当前 workflow context,src/flow/phase.ts:49 会先结束旧阶段,src/flow/phase.ts:52 发 start 事件,src/flow/phase.ts:74 在结束时补 duration。Agent 执行很难直接调试,阶段事件给了 UI 和日志一个稳定坐标。
流程原语
▸parallel:并发 Barrier 与 null 隔离
Runner 是运行时和外界的分界:src/runner/workflow-runner.ts:22 定义可复用 WorkflowRunner,src/runner/workflow-runner.ts:35 允许注入 emitter,src/runner/workflow-runner.ts:36 默认挂 JSON writer,src/runner/workflow-runner.ts:60 把 emitter 放入事件上下文,src/runner/workflow-runner.ts:61 又把 log sink 接入同一条事件线。这解释了为什么 CLI 能同时支持交互 TUI 和机器可读 JSONL。
22export async function parallel<const TTasks extends readonly ParallelTask[]>( 23 tasks: TTasks, 24): Promise<ParallelResults<TTasks>> { 25 const results = await Promise.all( 26 Array.from(tasks, async (task) => { 27 try { 28 return await task(); 29 } catch { 30 return null; 31 } 32 }), 33 ); 34 35 return results as ParallelResults<TTasks>; 36}
▸pipeline:每个 item 独立穿过 stages
Agent 适配层没有把 Codex 和 Claude 混成一个魔法对象,而是把公共能力抽到 src/agents/types.ts:33 的 AgentOptions、src/agents/types.ts:59 的 Agent 接口和 src/agents/agent.ts:9 的 bindAgent。Codex 路径在 src/agents/codex-agent.ts:117 启动 run,Claude 路径在 src/agents/claude-agent.ts:114 启动 run;二者都接收 cwd、model、schema、sandbox、env 和 signal。
78export async function pipeline<TOriginal>( 79 items: readonly TOriginal[], 80 ...stages: RuntimePipelineStage<TOriginal>[] 81): Promise<Array<unknown | null>> { 82 return Promise.all( 83 Array.from(items, async (original, index) => { 84 let value: unknown = original; 85 86 try { 87 for (const stage of stages) { 88 value = await stage(value as never, original, index); 89 } 90 return value; 91 } catch { 92 return null; 93 } 94 }), 95 ); 96}
事件流
Codex 适配的工程感较强:src/agents/codex-agent.ts:128 创建临时目录,src/agents/codex-agent.ts:131 约定 last-message 输出,src/agents/codex-agent.ts:137 在 schema 模式写临时 schema 文件,src/agents/codex-agent.ts:176 保留 exitCode/stdout/stderr,src/agents/codex-agent.ts:201 最后删除临时目录。这些细节让 Agent runtime 可被测试,而不是依赖终端副作用。
▸Runner 把 workflow、log 与事件写入同一条线
52 run<TOutput = unknown, TArgs = unknown>( 53 target: WorkflowTarget, 54 args?: TArgs, 55 ): Promise<TOutput> { 56 if (this.#disposed) { 57 throw new Error("WorkflowRunner has been disposed."); 58 } 59 60 return runWithWorkflowEventEmitter(this.events, () => 61 withLogSink( 62 (message) => { 63 const context = getWorkflowContext(); 64 if (!context) { 65 return; 66 } 67 68 this.events.emit({ 69 type: "log", 70 ...toWorkflowEventContext(context), 71 message, 72 ...(context.phase === undefined ? {} : { phase: context.phase }), 73 }); 74 }, 75 () => workflow<TOutput, TArgs>(target, args), 76 ), 77 ); 78 }
▸事件 envelope
14export class WorkflowEventEmitter { 15 readonly #listeners = new Set<WorkflowEventListener>(); 16 #sequence = 0; 17 18 /** 19 * Subscribes to every Workflow event. 20 * 21 * @param listener - Callback invoked for each event. 22 * @returns A function that removes this subscription. 23 */ 24 on(listener: WorkflowEventListener): () => void { 25 this.#listeners.add(listener); 26 return () => this.off(listener); 27 } 28 29 /** 30 * Removes a previously registered listener. 31 * 32 * @param listener - Callback originally passed to {@link on}. 33 * @returns `true` when the listener was registered. 34 */ 35 off(listener: WorkflowEventListener): boolean { 36 return this.#listeners.delete(listener); 37 } 38 39 /** 40 * Adds envelope metadata and synchronously emits an event. 41 * 42 * @param input - Typed event payload without sequence or timestamp. 43 * @returns The complete immutable event delivered to listeners. 44 */ 45 emit(input: WorkflowEventInput): WorkflowEvent { 46 const event = Object.freeze({ 47 ...input, 48 sequence: ++this.#sequence, 49 timestamp: new Date().toISOString(), 50 }) as WorkflowEvent; 51 52 for (const listener of [...this.#listeners]) { 53 listener(event); 54 } 55 56 return event; 57 }
CLI 的 run 命令把这些层串起来:src/cli/run.ts:29 先解析参数,src/cli/run.ts:30 读取 JSON input,src/cli/run.ts:40 创建 WorkflowRunner,src/cli/run.ts:45 把事件交给 TUI presenter,src/cli/run.ts:51 执行 runner.run。默认模式把最终结果写 stdout,而 src/cli/run.ts:41 到 src/cli/run.ts:43 的 print 模式会把事件 JSONL 写到 stdout。
测试与验证
测试覆盖直接证明核心边界:tests/flow/workflow.test.ts:160 验证 args、depth 和 phase 注入,tests/flow/workflow.test.ts:175 验证嵌套路径相对父 workflow 解析,tests/flow/workflow.test.ts:187 拒绝过深嵌套,tests/runner/workflow-runner.test.ts:73 断言事件顺序,tests/runner/workflow-runner.test.ts:120 断言失败时也会关闭 phase 并序列化错误。
▸Runner 事件顺序测试
73 test("writes ordered lifecycle, phase, and log events as JSON Lines", async () => { 74 const lines: string[] = []; 75 const runner = new WorkflowRunner({ 76 logWriter: (line) => lines.push(line), 77 }); 78 79 const result = await runner.run<string>(successfulWorkflowPath); 80 const events = lines.map(parseEvent); 81 82 expect(result).toBe("done"); 83 expect(events.map((event) => event.type)).toEqual([ 84 "workflow:start", 85 "workflow:meta", 86 "workflow:phase:start", 87 "log", 88 "workflow:phase:end", 89 "workflow:phase:start", 90 "log", 91 "workflow:phase:end", 92 "workflow:end", 93 ]); 94 expect(events.map((event) => event.sequence)).toEqual([ 95 1, 2, 3, 4, 5, 6, 7, 8, 9, 96 ]); 97 expect(new Set(events.map((event) => event.workflowId)).size).toBe(1); 98 99 const metadata = events[1]; 100 expect(metadata?.type).toBe("workflow:meta"); 101 if (metadata?.type === "workflow:meta") { 102 expect(metadata.meta.name).toBe("successful"); 103 expect(metadata.meta.phases.map((phase) => phase.title)).toEqual([ 104 "Research", 105 "Synthesis", 106 ]); 107 expect(metadata.meta.exampleArgs).toEqual({ topic: "Agent runtimes" }); 108 } 109 110 const firstLog = events[3]; 111 expect(firstLog?.type).toBe("log"); 112 if (firstLog?.type === "log") { 113 expect(firstLog.phase).toBe("Research"); 114 expect(firstLog.message).toBe("Collecting sources"); 115 } 116 117 runner.dispose(); 118 });
▸Pipeline 失败隔离测试
39 test("isolates an item failure and skips its remaining stages", async () => { 40 const secondStageItems: number[] = []; 41 42 const results = await pipeline( 43 [1, 2, 3], 44 (value) => { 45 if (value === 2) { 46 throw new Error("item failure"); 47 } 48 return value * 10; 49 }, 50 (value, original) => { 51 secondStageItems.push(original); 52 return value + 1; 53 }, 54 ); 55 56 expect(results).toEqual([11, null, 31]); 57 expect(secondStageItems).toEqual([1, 3]); 58 });
技术亮点
package.json:6 暴露 deer-workflow CLI,package.json:9 到 package.json:16 暴露 agents、events、flow、logging、runner 等子路径,src/index.ts:1 到 src/index.ts:5 再做统一 export。这个 API 面积不大,但它把 Agent 运行时、流程原语和事件协议放在同一个包里,适合作为 coding-agent workflow 的轻量底座。技术亮点之外,别忽略这些
▸上线前需要补的工程约束
需要注意的短板同样来自源码:src/flow/workflow.ts:20 把嵌套深度限制为 1,复杂 DAG 需要在用户层拆解;src/flow/pipeline.ts:91 和 src/flow/parallel.ts:29 都把错误吞成 null,调用者必须主动检查;src/agents/claude-agent.ts:191 与 src/agents/codex-agent.ts:205 依赖 Bun.which 查 CLI,可移植性取决于宿主环境。