一个生产级 Agent 长什么样?Codex 的仓库地图

核心 + 协议 + 多个客户端。agent loop 只住在 core 里;TUI、codex exec、SDK 都不直接碰它,而是通过 app-server 的 thread / turn / item 协议说话。

上一周我们写的 loop 是一个函数,输入 prompt、返回结果。Codex 把同一件事拆成"提交队列 + 事件队列",再在外面包一层协议。读懂这层分层,后面五讲(loop、工具、压缩、沙箱、测试)才知道每段代码在地图上的哪个位置。

1为什么读 Codex:和第 2 周的 loop 对照

第 2 周的 run_agent() 一个函数干完所有事:发请求、看 stop_reason、执行工具、追加 messages、判断停不停。它的调用方只能等它返回。生产环境里调用方不止一个,需求也多出很多:

第 2 周的写法生产里会遇到的问题Codex 的做法(本讲只看位置)
run_agent(task) 阻塞到结束才返回界面要实时显示流式文字、命令输出core 往事件队列推 EventMsg,客户端边收边画
没法在中途插话、取消用户要中断、要补一句话、要审批一条命令客户端往提交队列塞 Op::Interrupt、Op::TurnInput、Op::ExecApproval
只有一个调用方终端界面、无人值守的 CI、桌面 App、Python 脚本都要用对外只暴露 app-server 协议,各客户端各自实现
messages 列表就是全部状态要恢复会话、要回放、要给 UI 一个稳定的数据模型thread / turn / item 三层模型;会话写进 rollout 文件

所以本周第一讲不讲 loop 细节,先把地图画出来:哪些 crate 是核心,协议分几层,一次用户提交从键盘走到模型、再走回屏幕要经过哪些文件。

本周所有引用基于 openai/codex commit 7993248(2026-10-01),链接都是固定到这个 commit 的永久链接。本机装的 codex CLI 是 0.159.2,命令行参数以本机 --help 为准。

2仓库现状:几乎全是 Rust

统计方法:在这个 commit 的浅克隆里,用 git ls-files '*.rs' | xargs cat | wc -l 这类命令按扩展名数物理行数(含空行、注释和测试,不是 tokei 那种只数代码行),再按目录拆开看。

语言文件数行数都在哪
Rust5,0571,985,6775,040 个在 codex-rs/,其余是 Bazel 测试规则和自研 lint
TypeScript76111,524737 个文件(8,168 行)是 app-server-protocol/schema/typescript/ 下由 ts-rs 从 Rust 类型生成的绑定;手写的只有 sdk/typescript(24 个文件,3,356 行,其中 src/ 1,051 行)
JavaScript3(+2 个 .cjs、1 个 .mjs)413(.cjs 197,.mjs 598)npm 启动器 codex-cli/bin/codex.js 295 行;其余 .js 是 ESLint 配置和一个代理的 npm 入口;.cjs 是 TS SDK 的 Jest 配置和一个示例技能脚本,.mjs 是同一个示例技能(codex-rs/skills 里的 openai-docs)的另一个脚本
Python19662,029sdk/python 77 个文件 27,839 行(其中 generated/ 13,412 行);scripts/ 39 个文件 16,897 行(含 MCP 一致性测试 scripts/mcp_conformance 9 个文件 11,491 行和打包脚本);.github/ 的 CI 脚本 33 个 6,421 行;third_party/voice 26 个 5,346 行;codex-rs/skills 的示例技能脚本 9 个 2,764 行;其余零散

codex-rs/Cargo.toml 的 [workspace] members 一共 155 项。codex-cli/bin/codex.js 做的事就是按平台挑一个 npm 可选依赖里的原生二进制,然后 spawn(binaryPath, process.argv.slice(2))。也就是说,你 npm i -g @openai/codex 装到的是一个 Rust 程序。

TS 文件里七成多是生成的,这本身就是分层的证据:协议类型只在 Rust 里定义一次(app-server-protocol),TS 绑定由 ts-rs 生成,Python SDK 的 generated/v2_all.py 由 datamodel-codegen 从协议的 JSON Schema 生成(文件头注释写明了来源)。例外是 TS SDK 的 events.ts:它对应的是 exec 的 JSONL,是手写的,注释写着"based on event types from codex-rs/exec/src/exec_events.rs"。

3155 个 crate,只看主线

不要逐个看 crate。按"一次请求会经过谁"分成六组就够了(下面的互动地图可以点开每个 crate 看依赖):

分组crate(目录名)一句话
入口 / 客户端cli、tui、execcli 是 codex 这个多子命令二进制;tui 是交互界面;exec 是非交互模式
协议边界app-server-protocol、app-server、app-server-client、app-server-transport对外协议的类型、服务端、进程内客户端、stdio / unix socket / websocket 传输
核心core、protocolcore 有会话、turn、agent loop;protocol 定义 Op / EventMsg 等内部协议类型
模型与工具codex-api、codex-client、tools、apply-patch、codex-mcp、rmcp-client、hooksResponses API 流式解析与重试、工具契约、补丁、MCP 客户端、钩子
安全sandboxing、linux-sandbox、execpolicy、shell-commandOS 沙箱、Starlark 命令策略、危险命令判定
状态与可观测rollout、state、thread-store、config、otel会话记录、SQLite、线程存储、分层配置、OpenTelemetry

依赖数据:读每个 member 的 Cargo.toml,取 [dependencies] 和 [target.*.dependencies] 里属于 workspace 的项,不含 dev-dependencies。

4三层协议:Op / EventMsg → thread / turn / item → 客户端

第一层:core 内部的 SQ / EQ

protocol/src/protocol.rs:1-4 的模块注释直接写明:用 SQ(Submission Queue)/ EQ(Event Queue)模式在用户和 agent 之间异步通信。两个队列在会话创建时建好:

// codex-rs/core/src/session/mod.rs:591-592
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
  1. tx_sub / rx_sub:提交队列,有界,容量 SUBMISSION_CHANNEL_CAPACITY = 512(mod.rs:504)。客户端提交太快时,发送方会等,形成背压。
  2. tx_event / rx_event:事件队列,无界。core 推事件时永远不会因为 UI 读得慢而卡住 agent loop。

客户端往 SQ 里放的是 Submission { id, op, ... },op 的类型是 Op 枚举(protocol.rs:586-766,29 个变体);core 从 EQ 里吐出 Event { id, msg },msg 是 EventMsg(protocol.rs:1352-1575,1352 行起是文档注释,枚举本体在 1358-1575 行,83 个变体)。

// codex-rs/core/src/session/mod.rs:998-1009(SessionIo::submit_with_id)
pub(crate) async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
    if sub.trace.is_none() {
        sub.trace = current_span_w3c_trace_context();
    }
    self.tx_sub
        .send(sub)
        .await
        .map_err(|_| CodexErr::InternalAgentDied)?;
    Ok(())
}
  1. pub(crate) async fn:只在 core 这个 crate 内可见的异步函数。外面的人拿不到它,只能通过 CodexThread 的公开方法间接调用。
  2. if sub.trace.is_none():没带 trace 上下文就补上当前 span 的 W3C trace,提交和后续事件能串成一条链路(第 2 周 trace 那一章的生产版)。
  3. self.tx_sub.send(sub).await:塞进提交队列;队列满了就在这里等。
  4. .map_err(|_| CodexErr::InternalAgentDied)?:发送失败只有一种可能,接收端(core 的循环)已经退出了,于是把它翻译成"agent 死了"这个明确的错误,? 把错误往上抛。

另一头是 handlers.rs:422 的 submission_loop:一个 loop 不停地 rx_sub.recv(),对 sub.op 做 match,Op::Interrupt 就中断,Op::TurnInput 就交给 turn_input::handle。这就是"客户端只提交意图,core 自己决定怎么执行"。

两个 id 是同一个:提交的 id 用 Uuid::now_v7() 生成(mod.rs:1086-1092),注释写明 app-server 把"开启 turn 的那次提交的 id"当作对外的 turn id。TurnStartedEvent.turn_id 就取自 turn_context.sub_id(mod.rs:2232-2235)。所以从日志里拿到一个 turn id,可以直接回溯到是哪次提交触发的。

Op 只 derive 了 Debug,没有 Serialize,里面还带着 oneshot::Sender 这种进程内的回调通道。它天生不能过网络,只能在进程内用。对外需要另一层能序列化的协议,这就是 app-server。

第二层:app-server 的 thread / turn / item

app-server 的消息格式是简化版的 JSON-RPC:rpc.rs:1-2 的注释原文是 "We do not do true JSON-RPC 2.0, as we neither send nor expect the "jsonrpc": "2.0" field."。方法都在 app-server-protocol/src/protocol/common.rs 里用宏声明:

方向例子位置
客户端 → 服务端请求initialize、thread/start、thread/resume、turn/start、turn/steer、turn/interruptcommon.rs:499、551、557、1038、1050、1056
服务端 → 客户端请求item/commandExecution/requestApproval(审批)common.rs:1778
服务端 → 客户端通知thread/started、turn/started、turn/completed、item/started、item/completed、item/agentMessage/delta、thread/tokenUsage/updatedcommon.rs:1932-1975

thread 是一次会话,turn 是用户发起的一次请求(对应第 2 周的一次 run_agent),item 是 turn 里的一个单元:一条 agent 消息、一次命令执行、一次文件修改、一次 MCP 调用。TurnStatus 只有四种:completed / interrupted / failed / inProgress(v2/turn.rs:33-38)。传输可以是 stdio://、unix://、ws://IP:PORT(transport/mod.rs:81-86)。

从第一层到第二层的翻译在 app-server 里:每个 thread 有一个监听任务 conversation.next_event()(thread_lifecycle.rs:302),拿到 EventMsg 后交给 apply_bespoke_event_handling(bespoke_event_handling.rs:141),变成 ServerNotification,发给订阅了这个 thread 的所有连接。文字增量、item 开始 / 结束这类一一对应的映射集中在 event_mapping.rs:362-419。

第三层:客户端

客户端怎么接 app-server依据
TUI没指定远程时,先用 50ms 超时探测本机 app-server daemon 的控制 socket:连得上就用 LocalDaemon(允许回退到嵌入),连不上才进程内嵌入(Embedded);也能连远程(Remote)。部分启动参数会跳过探测,直接嵌入tui/src/lib.rs:317-326、:511-535、:1030-1037、startup_orchestration.rs:302-315
codex execInProcessAppServerClient::start(...),进程内嵌入;--json 时把通知再投影成 JSONLexec/src/lib.rs:986
TS SDKspawn codex exec --experimental-json,读 JSONL(--experimental-json 是 --json 的别名)sdk/typescript/src/exec.ts:92、exec/src/cli.rs:58-65
Python SDK启动 codex app-server --listen stdio://,走 JSON-RPCsdk/python/.../client.py:256
两个 SDK 接入的层不一样:Python SDK 接的是完整的 app-server 协议(审批这类服务端请求、turn/steer、文字增量通知都在这一层);TS SDK 拿到的是 exec 再投影一次的 JSONL,粒度更粗(下面的实验会看到它连文字增量都没有)。拿两个 SDK 做对比实验时,先确认差异是不是来自接入层。

5CI 怎么强制分层

分层光靠约定会被一次"图省事"的 import 打破。Codex 把它写成了 CI 检查:repo-checks.yml:23-24 运行 .github/scripts/verify_tui_core_boundary.py,而 repo-checks 是阻塞门禁 blocking-ci.yml 的一环(:33-36),最后汇总到 if: always() 的 "CI required" job。

脚本只有 91 行,做两件事(:16-83):

  1. 查清单:读 codex-rs/tui/Cargo.toml,dependencies / dev-dependencies / build-dependencies 以及各平台 target.* 下的同名段,查的是依赖的键名:FORBIDDEN_PACKAGE in dependencies(:47-48),键名是 codex-core 就算违规。所以靠 package = "codex-core" 改名引入能绕过这一步:键名写成 codex_core 的话,源码里的 codex_core:: 还会被第二步抓到;改成别的名字,两步都抓不到。
  2. 查源码:扫 tui/**/*.rs 的每一行,匹配 codex_core::、use codex_core、extern crate codex_core 三个正则。

我在本地跑了一遍:原样跑退出码 0;复制一份 tui 目录,在 lib.rs 末尾加一行 use codex_core::CodexThread; 再跑,输出:

codex-tui must not depend on or import codex-core directly.
Use the app-server protocol/client boundary instead; temporary embedded startup gaps belong behind codex_app_server_client::legacy_core.

- codex-rs/tui/src/lib.rs:3999 imports `codex_core`

退出码 1。下面的互动地图里可以点"模拟违规"重现这段逻辑。

这条规则管的是 API 边界,不是链接关系,有三个细节要知道:
① TUI 的传递依赖里仍然有 core:tui → app-server-client → app-server → core。core 作为传递依赖编进同一个二进制;没连上本机 daemon、走进程内嵌入时,core 就跑在 TUI 进程里。规则保证的是 TUI 的代码只能通过协议跟 core 说话。
② 留了一个过渡口子:app-server-client 的 legacy_core 模块只 re-export 了 codex_core::config(lib.rs:70-83),注释说是为了在启动和配置路径迁到 RPC 之前先去掉直接依赖。TUI 源码里引用 legacy_core::config 的地方有 191 处。
③ 只有 TUI 有这条检查。exec 的 Cargo.toml 直接依赖 codex-core,源码里也在用 codex_core::config 等;core 自己也依赖 app-server-protocol(4 个文件用到,主要是把 rollout 投影成 turn 历史)。分层是"方向上大体如此",不是严格的单向图。

同一个 workflow 里还有两条同类检查:所有 crate 的 Cargo.toml 必须继承 workspace 设置(:20-21),Bazel 的 clippy 参数必须和 Cargo 的 lint 配置一致(:26-27)。思路都一样:把"大家都应该这样做"写成一个几十行的脚本,挂进阻塞门禁。

6动手实验:codex exec --json 的事件流契约

先看本机 0.159.2 的帮助(不调模型):

$ codex exec --help
Run Codex non-interactively

Usage: codex exec [OPTIONS] [PROMPT]
       codex exec [OPTIONS] <COMMAND> [ARGS]
...
  -s, --sandbox <SANDBOX_MODE>
          Select the sandbox policy to use when executing model-generated shell commands
          [possible values: read-only, workspace-write, danger-full-access]
...
      --json
          Print events to stdout as JSONL

  -o, --output-last-message <FILE>
          Specifies file where the last message from the agent should be written

--json 输出的每一行是 ThreadEvent(exec_events.rs:8-37),只有 8 种:

type何时出现
thread.started第一个事件,带 thread_id,可用于 resume
turn.started提交 prompt 后
item.started / item.updated / item.completeditem 的生命周期,item.type 有 9 种:agent_message、reasoning、command_execution、file_change、mcp_tool_call、collab_tool_call、web_search、todo_list、error
turn.completed正常结束,带 usage(5 个 token 字段)
turn.failedturn 失败,带 error.message
error事件流上的错误通知。可重试的流错误(will_retry=true)也会输出这一行,之后 turn 仍可能 turn.completed;判断失败看 turn.failed 和退出码(jsonl_output.rs:447-457、bespoke_event_handling.rs:1054-1070、exec/src/lib.rs:1261-1266)

读投影代码 event_processor_with_jsonl_output.rs 会发现四条写测试时必须知道的行为:

  1. agent_message 和 reasoning 没有 item.started,只有 item.completed(:343-351)。app-server 的 item/agentMessage/delta 文字增量在这里直接丢掉,exec 的 JSONL 里看不到流式文字。
  2. turn 结束时会补齐:收到 turn/completed(含 interrupted / failed)时,先给"started 了但还没 completed"的 item 补发 item.completed(:370-385、:524)。补齐在 match status 之前,所以被中断的 turn 也有这些 item.completed。
  3. 被中断的 turn 没有结束事件:TurnStatus::Interrupted 分支只清掉最终消息,不输出 turn.completed 也不输出 turn.failed(:559-563)。中断和失败的退出码都是 1(exec/src/lib.rs:1267-1276、:1320-1323),从退出码分不出来,只能看有没有 turn.failed。
  4. 警告不受 turn 约束:ConfigWarning、Warning、DeprecationNotice 都被投影成 type 为 error 的 item.completed(:409-419、:427-446、:459-473),exec 对 ConfigWarning 和 DeprecationNotice 不做 thread / turn 过滤(exec/src/lib.rs:1597),而且 exec 在发出 turn/start 之后才开始读事件(:1222-1250),所以这些行可能排在 turn.started 前面。

测开视角,这就是一份可以写断言的契约。我写了一个不依赖任何第三方库的检查脚本 code/codex_map/exec_jsonl_contract.py,规则全部来自上面的源码:第一个事件是 thread.started;item.started / item.updated 只出现在 turn 内,type 为 error 的 item.completed 可能出现在 turn.started 之前(配置警告、弃用提示),其余 item.completed 也只出现在 turn 内;item.started 的 id 在 turn 结束前必须 completed;turn.completed.usage 的 5 个字段都是非负整数;流结束时 turn 没有收尾要报出来(中断和失败退出码都是 1,没有 turn.failed 才是中断)。真实用法是 codex exec --json "..." | python3 exec_jsonl_contract.py(这会调用模型,我没有跑);不调模型时用 --selftest 跑 5 个按源码结构手写的样例,真实输出:

$ python3 exec_jsonl_contract.py --selftest
[通过] 正常:命令 + 回复
[违规] 违规:item 没收尾就 turn.completed
    - 第 4 行:turn.completed 时 item item_0(第 3 行 started)还没 completed
[违规] 违规:item 在 turn.started 之前
    - 第 2 行:item.completed 出现在 turn.started 之前或 turn 结束之后
[通过] 正常:turn.started 之前的配置警告
[违规] 中断:没有 turn.* 结束事件
    - 流结束时 turn 没有 turn.completed / turn.failed(被中断的 turn 就是这样;中断和失败的退出码都是 1,靠有没有 turn.failed 区分)
样例里的事件结构照着 exec_events.rs 写,不是真实模型输出。拿到真实 JSONL 后,第一步应该把它存成 fixture,让契约检查在 CI 里对 fixture 跑,而不是每次都调模型(第 1 周说过,靠多跑几次真实模型证明不了"不会出错")。

▶互动演示 1:可点击的架构地图

点一个 crate:下方显示职责、关键文件和依赖。绿色 = 它直接依赖的,黄色 = 直接依赖它的(只在图上 31 个节点里高亮,计数按全部 155 个 crate 算)。依赖数据取自这个 commit 各 crate 的 Cargo.toml,不含 dev-dependencies;虚线框是非 Rust 的分发物,它们和 Rust crate 之间是"启动子进程"关系,不是编译依赖。

选中它依赖的依赖它的
直接依赖(workspace 内)
传递依赖(workspace 内)
被多少 crate 直接依赖

CI 边界检查:verify_tui_core_boundary.py 的同款逻辑

给 TUI 加

▶互动演示 2:一次提交在各层之间怎么走

单步走一次 codex exec --json 的提交:客户端发 turn/start → app-server → 提交队列里的 Op::TurnInput → core → 事件队列里的 EventMsg → app-server 通知 → exec 的 JSONL。不调模型,模型和命令的输出是写好的,但每一步经过的函数、名字和映射规则都按源码。下面三栏分别是这一层"看到"的消息。

场景
客户端(exec)
发 JSON-RPC 请求,收通知
app-server
thread / turn / item 协议
core
SQ: Op → submission_loop
EQ: EventMsg
模型 / 工具
Responses API 流、exec_command
core 推出的 EventMsg
app-server 发出的通知
exec --json 输出的行
core:Event { id, msg }
app-server:请求 / 响应 / 通知
exec --json:stdout JSONL

✎练习

题 1(分层):关于 TUI 和 core 的关系,哪句话对?
规则管的是 API 边界:tui → app-server-client → app-server → core 是传递依赖,core 编进 TUI 的二进制,嵌入模式下还在同一进程。A 错在 legacy_core 只 re-export 了 config,不是 core 的全部;C 错在 TUI 不会自己起独立的 app-server 进程:它先探测本机已有的 daemon,连得上就连,连不上就用 AppServerTarget::Embedded 进程内嵌入;而且不管哪种方式,二进制里都有 core。
题 2(投影):用 codex exec --json 时,agent 回复的文字是怎么出现在 JSONL 里的?
map_started_item 对 AgentMessage / Reasoning 返回 None,增量通知落进 _ => CodexStatus::Running 分支被丢弃(event_processor_with_jsonl_output.rs:343-351)。C 是 app-server 协议里的通知名,exec 的 JSONL 用的是点号风格的 8 种事件。
题 3(数):一次 codex exec --json,模型先执行 1 条命令(命令有 started 和 completed),再流式输出 5 个文字增量组成回复,turn 正常结束。stdout 一共有几行 JSONL?(只算 thread / turn / item 事件,没有 reasoning 摘要、没有 todo_list、没有 warning)
行
thread.started、turn.started、item.started(命令)、item.completed(命令)、item.completed(agent_message)、turn.completed,共 6 行。5 个增量一行都不出现,增量个数怎么变行数都不变。前提是没有 reasoning 摘要:非空的 reasoning 摘要会多一行 item.completed(event_processor_with_jsonl_output.rs:152-160)。可以在演示 2 里把 N 拉到任意值验证。
题 4(SQ):一个普通 turn(不是 review / compact)正在运行,Python SDK 又对同一个 thread 发了 turn/start,输入非空、不带 output schema。core 怎么处理?
app-server 的 turn_start 调 start_or_steer_turn,模式是 TurnInputMode::StartOrSteer;core 的 start_or_steer 先调 steer_input,有活动 turn 就并入(turn_input.rs:299-339)。app-server 拿到 Steered 后返回的 turn id 是正在跑的那个。C 在题目条件下不对;但如果正在跑的是 review / compact turn、输入为空或 output schema 不一致,steer 会被拒(turn_input.rs:683-711),app-server 确实返回 JSON-RPC 错误,也不排队。另外 TUI 有活动 turn 时发的本来就是 turn/steer(tui/src/app/thread_routing.rs:751-763)。
题 5(契约测试):给 exec 的 JSONL 写契约断言,哪一条照字面写会误报?
被中断的 turn 走 TurnStatus::Interrupted 分支,不输出任何 turn.* 结束事件(:559-563)。C 要改成"没有结束事件时,结合是否发过中断、有没有 turn.failed 来判定"(中断和失败的退出码都是 1)。B 有源码保证:turn 结束时(含中断)会先补发未完成 item 的 item.completed。

7面试要点

一句话讲清楚

Codex 是"核心 + 协议 + 多客户端":agent loop 在 core 里,core 内部用 SQ / EQ 两个队列,客户端提交 Op、core 推 EventMsg;对外由 app-server 把它翻译成能序列化的 thread / turn / item JSON-RPC 协议,TUI、exec、SDK 都是这个协议的客户端。分层用 CI 脚本守着,TUI 不准直接 import core。

追问准备

  1. 为什么要两层协议,一层不行吗?Op 里带 oneshot::Sender 这种进程内回调,不能序列化;而且内部协议要能随便改。对外协议需要稳定、有 schema、能生成 TS / Python 类型,所以单独一层。
  2. 提交队列为什么有界、事件队列为什么无界?有界的提交队列给客户端背压;无界的事件队列保证 UI 读得慢时不会反过来卡住 agent loop。代价是 UI 一直不读时内存会涨,app-server-client 的注释也写了消费端队列是无界的,好让未读通知不挡住请求响应。
  3. 怎么测这种分层?三层各测各的:core 用 mock 模型服务器断言事件序列(本周最后一讲);app-server 对协议做契约测试和 schema 快照;客户端对 JSONL 做契约检查。加一条架构测试(像 verify_tui_core_boundary.py)防止有人绕过边界。
  4. 两个 SDK 的结果不一致,你先查什么?先查接入层:Python SDK 走 app-server,TS SDK 走 exec 的 JSONL 投影,后者没有文字增量、被中断的 turn 没有结束事件。

下一讲:生产级 agent loop。打开 core/src/session/turn.rs,看 run_turn 的两层循环、needs_follow_up,以及工具为什么在流还没结束时就开跑。

本页只有 KaTeX 的引用,没有公式;断网不影响交互。