deerworkflow
§ 00 · Source-level Observatory

deer-workflow 动态 Agent 工作流运行时

一个 TypeScript/Bun 包,把 workflow()、phase()、parallel()、pipeline()、Runner、事件 JSONL 和 Codex/Claude 适配器组合成轻量 Agent orchestration runtime。

★259
GitHub Stars
7.8K
LOC
90
Files
2026-07-27
Last commit
CLI / API │ ▼ WorkflowRunner ──► EventEmitter ──► JSONL / TUI │ ▼ workflow() ──► phase() ──► parallel() / pipeline() │ ▼ CodexAgent / ClaudeAgent
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

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。这里的设计目标是让自动化可以读懂一条工作流,而不是只能读自然语言说明。

▸核心数据

架构模式
TypeScript workflow runtime + JSONL event bus + CLI adapters
核心抽象
WorkflowExecutionContext、WorkflowMeta、WorkflowRunner、AgentOptions
性能/精度数据
7.8K LOC、90 files、Bun test suite;运行事件按 sequence 排序。
下游依赖
Bun、TypeScript、Codex CLI、Claude Code CLI,可作为 Agent workflow library 引入。

▸为什么不是普通脚本

执行路径的关键是事件先行: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 和外部监控都能以同一事件协议观察任务。

§ 01 · ARCHITECTURE

运行核心

与很多图编排库不同,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() 如何建上下文

src/src/flow/workflow.ts · L90-147TypeScript
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 和日志一个稳定坐标。

深度阅读 · workflow loader 与 phase 生命周期
§ 02 · CORE MODULES

流程原语

▸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。

src/src/flow/parallel.ts · L22-36TypeScript
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。

src/src/flow/pipeline.ts · L78-96TypeScript
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}
深度阅读 · parallel / pipeline 的失败语义
§ 03 · KEY FLOW

事件流

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 与事件写入同一条线

src/src/runner/workflow-runner.ts · L52-78TypeScript
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

src/src/events/emitter.ts · L14-57TypeScript
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。

深度阅读 · Codex / Claude 适配器
§ 04 · TESTS & VALIDATION

测试与验证

测试覆盖直接证明核心边界: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 事件顺序测试

src/tests/runner/workflow-runner.test.ts · L73-118TypeScript
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 失败隔离测试

src/tests/flow/pipeline.test.ts · L39-58TypeScript
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  });
§ 05 · HIGHLIGHTS

技术亮点

上下文显式
从打包面看,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 的轻量底座。
事件可机器消费
Runner 和 EventEmitter 让命令行、TUI、日志和自动化共享同一事件协议。
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

▸上线前需要补的工程约束

需要注意的短板同样来自源码: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,可移植性取决于宿主环境。

嵌套深度
MAX_NESTED_WORKFLOW_DEPTH 只允许一层嵌套,复杂 DAG 需要自行拆分。
错误语义
parallel/pipeline 用 null 表示失败,调用者需要建立显式结果校验。
Generated · 2026-07-28Source · deerwork-ai/deer-workflow↑ 回到索引