Agent X-Ray
NotesSkillsAbout
Notes/源码拆解/Codex Harness/第3章

第3章:协议先行 —— SQ/EQ 队列与多前端

8 分钟 · 更新于 2026-09-01

第3章:协议先行 —— SQ/EQ 队列与多前端

这是本教程最核心的一章。Codex 的所有架构特征——多前端、可远程、可嵌入、可测试——都是同一个决定的后果:内核与界面之间只有两条队列,没有函数调用。 本章拆这个决定的实现与代价。


一、问题:一个 agent 内核要喂饱多少种前端

先数一下 Codex 需要支撑的前端形态:

形态进程关系语言位置
TUI(codex同进程Rust本机终端
无头执行(codex exec同进程Rust本机 / CI
IDE 插件(VS Code / Cursor / Windsurf)跨进程TypeScript本机
桌面 App(codex app跨进程非 Rust本机
MCP 客户端(codex mcp-server跨进程任意本机
云端任务(Codex Web)跨机器——远端

如果内核暴露的是 Rust 函数,那么后面四种全都做不了。所以 Codex 从一开始就把内核的对外接口定义成消息而不是调用

官方规范里对这个定位讲得很直白 [源码 codex-rs/docs/protocol_v1.md]:

The term "UI" is used to refer to the application driving Codex. This may be the CLI / TUI chat-like interface that users operate, or it may be a GUI interface like a VSCode extension. The UI is external to Codex, as Codex is intended to be operated by arbitrary UI implementations.

("UI"指驱动 Codex 的应用程序。它可能是用户操作的 CLI / TUI 聊天界面,也可能是 VSCode 扩展这样的 GUI 界面。UI 对 Codex 而言是外部的,因为 Codex 的设计目标就是被任意 UI 实现所驱动。


二、SQ 与 EQ:两条单向队列

2.1 基本形状

text
        Submission Queue (SQ)              Event Queue (EQ)
UI  ──────── Submission ────────►  Codex  ──────── Event ────────►  UI
             { id, op }                            { id, msg }
  • SQ(提交队列):UI → Codex,载荷是 Op 枚举
  • EQ(事件队列):Codex → UI,载荷是 EventMsg 枚举
  • 每个 Submission 带一个由 UI 生成的字符串 ID(sub_id
  • 每个 Event 的 ID 不唯一,等于触发当前任务的那个用户轮次提交的 sub_id——这就是 UI 把事件归组到某一轮的依据

对 Rust 侧调用者,入口就一个方法 [源码 core/src/codex_thread.rs:247]:

rust
pub async fn submit(&self, op: Op) -> CodexResult<String>

丢一个 Op 进去,拿一个 sub_id 回来,剩下的从事件流里读。

2.2 两个枚举的规模

这是本章第一个值得记住的数字 [源码 protocol/src/protocol.rs]:

枚举变体数位置
Op(UI → Codex)约 30protocol.rs:545
EventMsg(Codex → UI)80protocol.rs:1298

输入 30、输出 80 这个不对称非常说明问题:agent 的复杂度不在"用户能让它做什么",而在"它做的过程中有多少种状态需要被外界看见"。

Op 的主要变体(节选):

Op作用
TurnInput { … }发起一轮(携带完整的 per-turn 上下文:cwd、模型、沙箱、审批策略)
Interrupt打断当前轮
ExecApproval / PatchApproval批准或拒绝一次命令执行 / 补丁应用
UserInputAnswer回答 request_user_input 工具的提问
RequestPermissionsResponse回应权限升级请求
ApproveGuardianDeniedAction覆盖 Guardian 的拒绝决定(第 11 章)
Compact手动触发压缩
ThreadRollback { num_turns }回滚 N 轮
Review { review_request }进入代码复核模式
RefreshMcpServers / ReloadUserConfig热重载
SuspendTurnAndShutdown挂起当前轮并退出(可恢复)
RealtimeConversation*(6 个)语音实时会话

EventMsg 的 80 个变体按语义分组:

代表变体数量级
生命周期TurnStarted / TurnComplete / TurnAborted / SessionConfigured~8
助手输出AgentMessage / AgentMessageContentDelta / AgentReasoning / AgentReasoningRawContent~8
命令执行ExecCommandBegin / ExecCommandOutputDelta / ExecCommandEnd / TerminalInteraction~5
补丁PatchApplyBegin / PatchApplyUpdated / PatchApplyEnd / TurnDiff4
审批请求ExecApprovalRequest / ApplyPatchApprovalRequest / RequestPermissions / RequestUserInput / ElicitationRequest5
MCPMcpStartupUpdate / McpToolCallBegin / McpToolCallEnd~5
内置工具WebSearchBegin/End / ImageGenerationBegin/End / ViewImageToolCall~5
上下文TokenCount / ContextCompacted / ThreadRolledBack3
安全GuardianWarning / GuardianAssessment / SafetyBuffering3
复核模式EnteredReviewMode / ExitedReviewMode2
实时语音RealtimeConversation*5
其它Error / Warning / StreamError / DeprecationNotice / ModelReroute / PlanUpdate / RawResponseItem~25

可迁移的判断 ② Begin / Delta / End 三段式贯穿了每一类长耗时操作。 命令执行、补丁应用、MCP 调用、Web 搜索、图片生成,全是这个模式。

好处是 UI 可以对任何长操作套同一套渲染逻辑:开始时画个占位、delta 到了就追加、结束时定型。如果你在设计自己的 agent 事件流,先把这个三段式定死,比事后给每种操作单独设计事件要省太多。


三、三级生命周期的术语约定

协议规范定义了本教程后续都会用到的四个词 [源码 docs/protocol_v1.md]:

术语定义关键约束
SessionCodex 当前的配置与状态一个 Codex 实例一个 Session
TaskCodex 响应用户输入而执行的一次工作一个 Session 同时只能有一个 Task
TurnTask 里的一次迭代一次模型请求 + SSE 收集 + 工具执行
ModelResponses REST API见第 5 章

Task 的终止条件规范里列了五条:

  1. 模型完成了任务,没有输出可以喂给下一个 Turn
  2. 新的用户输入中止了当前 Task 并开启新的
  3. UI 发来 Op::Interrupt
  4. 遇到致命错误(如模型连接超过重试上限)
  5. 被用户审批阻塞

规范与代码已经分叉 规范开头自己就写了:"NOTE: The code might not completely match this spec."

实际读代码会发现术语已经演进:现在的三级是 Task → Turn → Sampling Request(采样请求),代码里也叫 "step"。规范里的 "Turn" 大致对应今天的一次采样请求,而今天的 run_turn 是一个包含多次采样的循环。第 4 章会用代码里的术语。

这不是文档失职,而是一个真实项目的常态。读架构时,规范给你概念地图,代码给你真相。

还有一条向后兼容的痕迹值得注意:

Note: For v1 wire compatibility, EventMsg::TurnStarted and EventMsg::TurnComplete serialize as task_started / task_complete. The deserializer accepts both task_* and turn_* tags.

内部改名了,线上格式不能改,于是序列化标签保留旧名,反序列化两个都收。这是有真实下游用户的项目才会有的负担。


四、协议之上:app-server 与四种传输

SQ/EQ 本身是进程内的 Rust 通道。要跨进程用,需要一层包装——这就是 app-server(连同协议和传输,共 199943 行)。

4.1 从队列到 JSON-RPC

app-server 把队列语义翻译成 JSON-RPC 2.0 的方法调用与通知 [源码 docs/codex_mcp_interface.md]:

v2 主要 RPC:

分组方法
线程thread/startthread/resumethread/forkthread/readthread/list
轮次turn/startturn/steerturn/interrupt
账号account/readaccount/login/startaccount/login/cancelaccount/logoutaccount/rateLimits/read
配置config/readconfig/value/writeconfig/batchWrite
元数据model/listapp/listcollaborationMode/list

通知(Codex → 客户端): thread/startedturn/completedaccount/login/completed,加上 codex/event/* 这一族——后者就是 EQ 事件的直通管道

反向请求(Codex → 客户端): applyPatchApprovalexecCommandApproval。注意这是服务端向客户端发起请求,因为审批必须由人做决定。JSON-RPC 的双向性在这里是刚需。

注意 turn/steer 这个方法:它不是"打断"也不是"新开一轮",而是往正在跑的轮次里插话。这个能力在协议层就存在,说明"用户中途改主意"被当成一等公民处理,而不是事后打补丁。

4.2 四种传输

app-server-transport 提供了四条路 [源码 app-server-transport/src/transport/]:

传输文件用途
stdiostdio.rsMCP 标准、IDE 插件
Unix socketunix_socket.rs本机守护进程(app-server-daemon
WebSocketwebsocket.rs远程控制
remote controlremote_control/云端接入

外加 auth.rs——跨进程了就需要认证

协议规范对传输的态度是刻意开放的:

Can operate over any transport that supports bi-directional streaming: cross-thread channels, IPC channels, stdin/stdout, TCP, HTTP2, gRPC. Events still serialize cleanly to newline-delimited JSON for non-framed transports.

4.3 Codex 作为别人的工具

codex mcp-server 这条命令的含义值得单独说:它把整个 Codex 变成一个 MCP 服务器,任何 MCP 客户端(包括 Claude Code、包括另一个 Codex)都能把它当工具调用。

这是协议先行的直接红利。因为内核本来就只认消息,多加一种 wire 格式就是多写一个适配器,内核一行不用改。


五、这个设计给出了什么

5.1 可测试性

这是最容易被低估的收益。因为内核的全部行为都表现为"喂进 N 个 Op,吐出 M 个 EventMsg",测试可以完全不碰 UI:

  • app-server-test-client(4080 行)是专门的测试客户端
  • core/tests/suite/ 下大量测试直接构造 Op、断言 EventMsg 序列
  • 14168 个测试里相当一部分是这种形态

对比一下:如果 agent 内核直接调 UI 的渲染函数,你就得 mock 渲染层才能测循环逻辑。

5.2 可远程化

WebSocket 传输 + 认证 = 内核跑在别处。云端 Codex 和本地 Codex 共用同一份内核代码,这在架构上是免费的。

5.3 前端可用任意语言

IDE 插件是 TypeScript,桌面 App 是另一套技术栈,它们都不需要 FFI,只需要能收发 JSON。

5.4 事件即日志

第 12 章会讲到,会话持久化(rollout)记录的就是这条事件流。"给 UI 看的"和"存下来的"是同一批数据,不需要维护两套。这和 dsh 的"事件溯源 + surface 投影"(dsh 教程第 8 章)思路一致——两个独立项目走到同一个答案,通常说明这个答案是对的。


六、代价:三条

6.1 所有交互都要先变成消息

模型想问用户一个问题?不能弹窗,得走 EventMsg::RequestUserInput → UI 渲染 → Op::UserInputAnswer 回来。审批、权限升级、MCP elicitation、动态工具调用,全是这套往返。

EventMsg 里有 5 个变体专门用于"向用户要东西",Op 里有对应的 5 个回应变体。这 10 个变体的存在本身就是解耦的账单。

6.2 版本兼容负担

前面提到的 task_started / turn_started 双标签只是冰山一角。app-server-protocol 分了 v1 / v2 两套类型,v1 里还留着 getConversationSummarygitDiffToRemotefuzzyFileSearch 这些兼容 RPC。

协议一旦有外部消费者就冻结了。 这是内部函数调用没有的成本。

6.3 单 Task 约束

规范写死:一个 Session 同时只能跑一个 Task。要并行怎么办?

Since only 1 Task can be run at a time, for parallel tasks it is recommended that a single Codex be run for each thread of work.

(由于同时只能跑一个 Task,并行任务建议为每条工作线索各跑一个 Codex 实例。)

并行的粒度是"整个引擎实例",不是"一个 Session 内的多个任务"。 第 14 章讲多 agent 时会看到,spawn_agent 本质上就是起一个新的内核实例。这个约束大幅简化了状态管理(一个 Session 的状态永远只被一个 Task 改),代价是并发模型比较粗。


七、和另外两个 harness 的对照

维度PidshCodex
内核与 UI 的关系库调用插件树内的一批插件进程间协议
事件机制事件驱动(Pi 教程第 7 章)事件溯源 + surface 投影SQ/EQ 双队列
换前端的成本写一个新的 TS 前端写一批 UI 插件写一个 JSON 客户端,任意语言
远程运行不支持需要 host 层配合传输层换一个即可
版本兼容负担

dsh 的"事件日志是真源,消息历史是派生物"和 Codex 的"EQ 是唯一出口"讲的是同一件事的两个侧面。差别在边界画在哪:dsh 把边界画在插件树内部(同进程),Codex 画在进程外。


八、动手复核

bash
cd codex/codex-rs

# 1. 数一数两个枚举的变体
grep -n 'pub enum Op' -A200 protocol/src/protocol.rs | grep -cE '^[0-9]+-    [A-Z]'
grep -n 'pub enum EventMsg' -A400 protocol/src/protocol.rs | grep -cE '^[0-9]+-    [A-Z][A-Za-z]+\('

# 2. 协议规范全文(含 Mermaid 时序图)
cat docs/protocol_v1.md

# 3. app-server 的方法清单
cat docs/codex_mcp_interface.md

# 4. 四种传输
ls app-server-transport/src/transport/

# 5. 唯一的提交入口
grep -n 'pub async fn submit' core/src/codex_thread.rs

实际看一眼事件流(把 Codex 当 MCP 服务器起来,然后手工发 JSON-RPC):

bash
codex mcp-server
# 另开一个终端,用任意 MCP 客户端连接;或直接对 stdin 喂 JSON-RPC 行

九、总结

  1. SQ/EQ 两条单向队列是 Codex 的架构原点,多前端、可远程、可嵌入、可测试全是它的推论
  2. 输入 30 个 Op、输出 80 个 EventMsg——不对称说明 agent 的复杂度在"过程可见性"而非"指令种类"
  3. Begin / Delta / End 三段式统一了所有长耗时操作的事件形状,这一招可以直接抄
  4. 规范和代码已经分叉:规范里的 Turn 是今天的采样请求,今天的 Turn 是一个循环。读架构要以代码为准
  5. 代价是真实的:所有人机交互变成往返消息、协议版本冻结、一个 Session 只能跑一个 Task

下一章进入引擎内部:run_turn 这个 400 行的循环里,到底发生了什么。


  • 第2章-工程骨架-102个crate与单二进制多入口
  • 第4章-Agent-Loop-Task-Turn-Step三级循环
  • 第12章-会话持久化-rollout与线程恢复 —— 事件流如何变成可恢复的会话
  • dsh 教程第 8 章 —— 事件溯源的对照
  • Pi 教程第 7 章 —— 库内事件驱动的对照

本章目录
一、问题:一个 agent 内核要喂饱多少种前端二、SQ 与 EQ:两条单向队列三、三级生命周期的术语约定四、协议之上:app-server 与四种传输五、这个设计给出了什么六、代价:三条七、和另外两个 harness 的对照八、动手复核九、总结Related Documents
苏ICP备2025204887号-2