Codex 怎么定义、分发和执行工具?

每个工具都是一份发给模型的 spec 加一个执行它的 handler,绑在同一个对象上。模型发来的调用先按名字查注册表,再过并行门、hook、参数解析、审批,执行完的输出截断后写回历史,无论成败都回一条 output 给模型。MCP server 的工具也走同一条流水线,只是名字前面多了 mcp__<server> 这个命名空间。

源码:openai/codex commit 7993248(2026-10-01)。实测:本机 codex-cli 0.159.2 + 一个本地假 Responses API,不调真实模型。

1工具 = spec + handler

第 2 周我们手写 loop 时,工具定义(TOOLS 列表)和实现(TOOL_IMPLS 字典)是分开的两份东西,靠名字对上。Codex 把它们绑在同一个 trait 上:

// codex-rs/tools/src/tool_executor.rs:106-130(节选)
pub trait ToolExecutor<Invocation>: Send + Sync {
    /// The concrete tool name handled by this runtime instance.
    fn tool_name(&self) -> ToolName;

    fn spec(&self) -> ToolSpec;

    /// The preferred exposure before the host applies step-specific policy.
    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }
    // ...
    fn supports_parallel_tool_calls(&self) -> bool {
        false
    }

    /// Handles one invocation without retaining capabilities borrowed by the host.
    fn handle<'a>(&'a self, invocation: Invocation) -> ToolExecutorFuture<'a>
    where
        Invocation: 'a;
}

逐行看:tool_name() 是注册表里的键(命名空间 + 名字);spec() 和 handle() 在同一个对象上,不会出现"定义里有、实现里没有"的漂移;supports_parallel_tool_calls 默认 false,想并行的工具要自己声明,这是"默认安全"的写法;exposure 决定模型能不能直接看到它(默认 Direct,另有 Deferred、Hidden 等共 6 种,tool_executor.rs:49-99)。

ToolSpec 是发给 OpenAI Responses API 的工具定义,一个枚举五种形态(tools/src/tool_spec.rs:20-56):

变体序列化后的 type谁在用
Functionfunctionexec_command、view_image 等,参数是 JSON Schema
Namespacenamespace一组工具的容器。每个 MCP server 一个,如 mcp__bughunt
Freeformcustomapply_patch:输入是纯文本,用 Lark 语法描述格式
ToolSearchtool_search按需搜索"延迟加载"的工具
WebSearchweb_search服务端托管的搜索

每个 turn 开始前,build_tool_router(core/src/tools/spec_plan.rs:123-188)按固定顺序把工具装进注册表:内置工具 → MCP 工具(再按策略决定直接列出还是延迟)→ 扩展工具 → 动态工具 → 托管工具(web_search),最后 finalize_tool_router 生成发给模型的列表。

2schema:只保留一个子集,strict 一律 false

Codex 的 JsonSchema 是个结构体(tools/src/json_schema/types.rs:35-75),只认 type、description、encrypted、enum、items、minItems、properties、required、additionalProperties、anyOf/oneOf/allOf、$ref/$defs/definitions 这些字段。外部来的 schema(MCP server、动态工具)先经过 sanitize_json_schema(tools/src/json_schema.rs:71-80):const 改成单值 enum,缺 type 的按关键字推断。结构体里没有的字段在反序列化时直接丢掉:数组的 minItems 会保留,minimum、pattern、maxLength 会丢。{"minimum": 1} 解析后只剩 {"type":"number"}(测试 json_schema_tests.rs:198-214)。

拿第 2 周的 bughunt server 实测。SDK 生成的 submit_bug_report 输入 schema 和 Codex 发给模型的版本:

// MCP server 声明的(python SDK 生成)
{"type":"object","title":"submit_bug_reportArguments",
 "properties":{"module":{"title":"Module","type":"string"}, "title":{...}, "steps":{...},
               "severity":{"enum":["low","medium","high"],"title":"Severity","type":"string"}},
 "required":["module","title","steps","severity"]}

// Codex 发出去的(mcp__bughunt 命名空间里的一项)
{"type":"function","name":"submit_bug_report","strict":false,
 "parameters":{"type":"object",
   "properties":{"module":{"type":"string"},"severity":{"type":"string","enum":["low","medium","high"]},
                 "steps":{"type":"array","items":{"type":"string"}},"title":{"type":"string"}},
   "required":["module","title","steps","severity"]}}

三个变化:title 和 default 被丢了;属性按字母序重排(properties 是 BTreeMap);strict 是 false。最后这点在源码里写死:tool_definition_to_responses_api_tool 对 MCP 和动态工具一律 strict: false(tools/src/responses_api.rs:164-173),内置的 exec_command 也是 strict: false(core/src/tools/handlers/shell_spec.rs:106)。

后果:Codex 不保证模型给的参数符合 schema,本地也不按 schema 校验 MCP 参数。实测 severity: "critical" 原样转给了 bughunt,由 server 的 pydantic 拒绝。schema 里的 minimum、pattern、maxLength 这类约束,模型甚至看不到,只能靠 server 自己校验并把错误写清楚。这正是第 2 周「模型怎么'调用'一个函数?」那一章说的"边界处一律校验"。

schema 太大也会压缩:单个 MCP 工具的输入 schema 超过 5,000 字节(tools/src/json_schema/compaction.rs:15,可用 tool_input_schema_max_bytes 按 server 调整)时,依次做四轮越来越有损的压缩:去掉描述、去掉 $defs、折叠深层对象、修剪组合关键字,每轮之前先看是否已经够小(compaction.rs:18-37)。源码注释说这是 "best-effort rather than a hard cap",四轮做完仍可能超限。

3内置工具和 apply_patch 的补丁格式

本机 0.159.2 用模型 slug gpt-5.5 指向本地假服务,第一次请求里实际发出的工具:

工具类型能否并行做什么
exec_commandfunction是在 PTY 里跑命令,必填 cmd,可选 workdir、tty、yield_time_ms、max_output_tokens 等;长命令返回 session ID
write_stdinfunction是往还在跑的 session 写输入、取新输出
apply_patchcustom(Lark 语法)否改文件
view_imagefunction是把本地图片放进上下文
list_mcp_resources 等 3 个function是读 MCP resource
request_user_inputfunction否向用户提问
get_goal / create_goal / update_goalfunction否目标管理
tool_searchtool_search是搜索延迟加载的工具(MCP 工具就在这里面)
web_searchweb_search—服务端执行,不经过本地分发

工具清单随模型元数据和 feature flag 变化,不同版本、不同模型会不一样。"能否并行"一栏来自各 handler 的 supports_parallel_tool_calls(exec_command 在 handlers/unified_exec/exec_command.rs:142-144)。已经没有单独的老 shell 工具。

apply_patch:不用 JSON,用语法

apply_patch 是 custom 工具,format 是 {"type":"grammar","syntax":"lark","definition":...}(core/src/tools/handlers/apply_patch_spec.rs:5-28),描述里写着 "This is a FREEFORM tool, so do not wrap the patch in JSON."。语法全文(core/assets/tools/apply_patch.lark:1-19):

start: begin_patch hunk+ end_patch
begin_patch: "*** Begin Patch" LF
end_patch: "*** End Patch" LF?

hunk: add_hunk | delete_hunk | update_hunk
add_hunk: "*** Add File: " filename LF add_line+
delete_hunk: "*** Delete File: " filename LF
update_hunk: "*** Update File: " filename LF change_move? change?

filename: /(.+)/
add_line: "+" /(.*)/ LF -> line

change_move: "*** Move to: " filename LF
change: (change_context | change_line)+ eof_line?
change_context: ("@@" | "@@ " /(.+)/) LF
change_line: ("+" | "-" | " ") /(.*)/ LF
eof_line: "*** End of File" LF

%import common.LF

一个补丁长这样:

*** Begin Patch
*** Update File: src/cart.py
@@ def checkout(cart):
-    if cart.qty >= 0:
+    if cart.qty > 0:
         submit(cart)
*** End Patch

为什么不用 JSON 参数:补丁里全是换行、引号、反斜杠,塞进 JSON 字符串要层层转义,模型很容易转义错。OpenAI 文档把 custom 工具描述为输入可以是"不受约束的自由文本,或者用 Lark / regex 语法定义的格式"。语法约束发生在服务端;Codex 本地还会再解析一遍补丁,所以实测里绕过语法直接送一个坏补丁,得到的是 apply_patch verification failed: invalid patch: The first line of the patch must be '*** Begin Patch'。

4分发:一次调用经过哪些关卡

流里每出现一个 response.output_item.done,ToolRouter::build_tool_call(core/src/tools/router.rs:248-300)先把它变成内部的 ToolCall:function_call → ToolPayload::Function { arguments },custom_tool_call → ToolPayload::Custom { input },namespace 字段和 name 合成 ToolName。然后 ToolCallRuntime::handle_tool_call 把它 spawn 成一个任务,按顺序过下面这些关:

  1. 并行门(parallel.rs:205-209):可并行的拿读锁,其余拿写锁。注意它在查注册表之前,未知工具查不到"能否并行",按 false 处理,拿写锁(router.rs:237-241)。
  2. 查注册表(registry.rs:551-571):找不到就是 RespondToModel("unsupported call: <名字>")。
  3. payload 类型检查(registry.rs:584-600):function 工具收到 custom 调用(或反过来)是 Fatal。
  4. PreToolUse hook(registry.rs:602-654):可以拦下(回一条说明给模型),也可以改写参数。
  5. handler:解析参数、审批、执行。参数 JSON 解析失败是 RespondToModel("failed to parse function arguments: ...")(handlers/mod.rs:86-93)。
  6. PostToolUse hook(registry.rs:709-771):成功的结果还能被 hook 拦下或附加反馈。
  7. 格式化、截断、回填:结果变成 function_call_output / custom_tool_call_output,按调用顺序写进历史(in_flight 是 FuturesOrdered)。

错误分两类,但模型只看到文字

// codex-rs/tools/src/function_call_error.rs:4-10
pub enum FunctionCallError {
    #[error("{0}")]
    RespondToModel(String),
    #[error("Fatal error: {0}")]
    Fatal(String),
}

RespondToModel:错误文字当作工具输出还给模型,让它自己改;Fatal:不该发生的内部错误。

RespondToModel 和其他非 Fatal 错误在 failure_response(parallel.rs:303-328)里变成一条 output,内部标记 success: false。但 FunctionCallOutputPayload 序列化时只输出 body,success 是"内部元数据"(protocol/src/models.rs:2175-2183, 2253-2263)。也就是说,模型看到的只有那句文字,没有 Claude API 那种 is_error 标志。错误信息写得清不清楚,直接决定模型能不能自己改对。

Fatal 并不会终止 turn。工具任务返回 Fatal 后,drain_in_flight 只调用 error_or_panic(core/src/session/turn.rs:2494-2496):debug 构建直接 panic,release 构建只记一条 error 日志(core/src/util.rs:81-87)。这次调用没有 output,发下一次请求前历史规范化会补一条内容为 "aborted" 的 output(core/src/context_manager/normalize.rs:51-67)。实测:用 function_call 格式调 apply_patch,日志里有 Fatal error: tool apply_patch invoked with incompatible payload,模型收到 aborted,turn 照常结束;用 custom_tool_call 调 exec_command 也一样。custom 调用缺 output 时,规范化阶段还会再调一次 error_or_panic(normalize.rs:87-92),debug 构建在这里同样会 panic。

5并行:一把读写锁

// codex-rs/core/src/tools/parallel.rs:205-209
                let guard = if supports_parallel {
                    Either::Left(lock.read().await)
                } else {
                    Either::Right(lock.write().await)
                };

声明可并行的拿读锁,可以和其他读锁同时持有;其余拿写锁,独占。

请求里的 parallel_tool_calls 除了走 Responses Lite 的模型以外都是 true(core/src/client.rs:1001,实测请求体也是),模型可以一次发多个调用;真正能不能同时跑,由本地这把锁决定。tokio 的 RwLock 是公平锁。Codex 的 Cargo.lock 锁定 tokio 1.52.3,它的 src/sync/rwlock.rs:41-47 类型文档写的是:"a read lock will not be given out until all write lock requests that were queued before it have been acquired and released",也就是排在写锁后面的读锁要等写锁用完。实测 exec、exec、2 秒的 MCP 调用(写锁)、exec 各 2 秒,请求间隔 6.12 秒,后面的读锁没有插队。

实测三次 replay_steps(每次 2 秒):默认配置下两次请求间隔 6.06 秒,在 [mcp_servers.bughunt] 加 supports_parallel_tool_calls = true 后是 2.02 秒;三个 exec_command "sleep 2" 是 2.09 秒。每条 output 里的 Wall time 都约 2 秒:它只量 handler 自己的执行时间,排队等锁的时间不算。

MCP 工具能并行的条件(core/src/tools/handlers/mcp.rs:148-159):server 配置了 supports_parallel_tool_calls = true,或者工具的 annotations 里 readOnlyHint: true。源码注释的理由是"实现正确的 MCP server 应该能承受对只读工具的并行调用"。

6输出截断:保头保尾,砍中间

exec_command 的输出预算取两者中小的那个(core/src/tools/context.rs:481-489):调用参数 max_output_tokens(默认 10,000,core/src/unified_exec/mod.rs:79),和模型元数据里的 truncation_policy(内置模型目录里是 10,000 tokens;找不到模型元数据时的兜底是 10,000 字节,protocol/src/openai_models.rs:1088)。token 按 4 字节估算(utils/string/src/truncate.rs:4, 77-80)。

超出预算时,预算一半给开头、一半给结尾,中间换成标记(truncate.rs:132-143)。输出 \(N\) 字节、预算 \(B\) 个 token 时:

$$\text{保留} = 4B\ \text{字节},\qquad \text{标记里的数} = \left\lceil \frac{N - 4B}{4} \right\rceil,\qquad \text{Original token count} = \left\lceil \frac{N}{4} \right\rceil$$

实测 print('x'*200000)(\(N = 200{,}001\),含换行):标记是 …40001 tokens truncated…,头部写 Original token count: 50001,整条 output 40,213 字节。MCP 工具的输出按每个工具的 output_token_limit 或模型策略截断,再乘 1.2 的序列化余量(context.rs:184-210、utils/output-truncation/src/lib.rs:14-18)。

和第 2 周对照:我们写的是 f.read()[:20_000],只留开头。测试日志的失败摘要、编译器的最后一个错误都在结尾,只留开头会把最有用的部分砍掉。

7MCP client:连接、命名空间、超时、审批

每个 server 在 config.toml 里是一个 [mcp_servers.<名字>] 表(config/src/config_toml.rs:293-294),传输二选一(config/src/mcp_types.rs:612-646):有 command 就是 stdio(Codex 把 server 当子进程拉起),有 url 就是 Streamable HTTP;两个都写会报错 url is not supported for stdio(本机实测)。

机制源码行为
启动超时codex-mcp/src/rmcp_client.rs:106默认 30 秒,startup_timeout_sec 可改
单次调用超时rmcp_client.rs:107、codex-mcp/src/binding.rs:313-318默认 300 秒,tool_timeout_sec 可改。调用方也传了超时时取较小值,但模型发起的调用传的是 None(mcp_tool_call.rs:466),实际就是 tool_timeout_sec
命名空间rmcp_client.rs:842-862、codex-mcp/src/tools.rs:139-142server 名字清洗成 [A-Za-z0-9_],加前缀 mcp__,作为一个 namespace 工具发出;工具名本身不变
重名与长度tools.rs:225-300清洗后撞名的,加 _ + SHA1 前 12 位;模型可见名最长 128
启用 / 禁用tools.rs:63-96enabled_tools 白名单、disabled_tools 黑名单
参数 JSON 坏了core/src/mcp_tool_call.rs:145-160不调 server,直接回 err: <serde 错误>
审批mcp_tool_call.rs:2472-2503默认 auto:工具没有任何 annotations 时需要审批;readOnlyHint: true 免审批
必需 servermcp_types.rs:238-242required = true 时起不来就让 codex exec 失败;否则只是工具"不存在"
headless 跑评测时的坑:codex exec 默认把审批策略设成 never(exec/src/lib.rs:574-576,审批人是 AutoReview 时会重算),而 never 下需要用户审批的 MCP 调用直接被拒(mcp_tool_call.rs:1626-1630)。实测(-s workspace-write)bughunt 的工具都没标注,模型收到的是 MCP tool call requires approval, but approval policy is never。自动批准的条件(codex-mcp/src/mcp/mod.rs:88-110):工具 approval_mode = "approve";或者审批策略是 never,且 permission profile 是 Disabled(danger-full-access 映射到它)/ External,或是 Managed 但可写全盘。打开 strict auto review 时自动批准整个跳过(mcp_tool_call.rs:1521)。正经的解决办法:给只读工具标 readOnlyHint,或者在配置里对具体工具设 approval_mode = "approve"。

Codex 能不能当 MCP server?

在这个 commit 里不能。workspace 里没有 mcp-server crate,CLI 子命令只有 mcp,说明是 "Manage external MCP servers for Codex"(cli/src/main.rs);本机 0.159.2 的 codex --help 也没有 mcp-server。对外集成走 codex app-server 的 JSON-RPC。什么时候移除的,浅克隆看不到历史,未核实。

▶互动演示:工具调用分发模拟器

选一个模型发来的工具调用,单步看它在 Codex 里走过哪些关卡、在哪一关停下、最后回填给模型的是什么。错误文字、截断标记和格式都取自本机 0.159.2 的实测输出;截断数字按源码公式实时计算。

模型发来
运行方式 模型元数据
打印几个 x max_output_tokens
bughunt 配置 tool_timeout_sec
当前关卡
并行门
错误类别
工具原始输出
回填给模型
turn
分发流水线(高亮 = 当前关卡)
    模型输出的 item(SSE response.output_item.done)
    下一次请求里回填的 item(模型下一轮看到的)

    ▶互动演示:并行门时间线

    模型在同一次回复里发出几个调用,Codex 几乎同时把它们 spawn 出去,按出现顺序排队拿锁(tokio 的 RwLock 先进先出)。可并行的拿读锁(绿),其余拿写锁(蓝),斜纹是排队等锁。点按钮按顺序添加调用,点调用名后的 × 删除。

    添加调用
    bughunt
    实际总耗时(到最后一个结果)
    各调用 Wall time 之和
    最长单个调用

    ✎练习

    题 1(分发):模型调用了一个没注册的工具 delete_all_bugs。Codex 怎么处理?
    查注册表失败是 RespondToModel(registry.rs:551-571)。output 里只有这句文字,没有 is_error 标志;内部的 success: false 不会发给模型。C 不成立:每个 call_id 都必须有 output,否则下一次请求格式不对。
    题 2(并行门):模型一次发了四个调用,顺序是 exec_command(2 秒)、exec_command(2 秒)、apply_patch(1 秒)、exec_command(2 秒)。按先进先出的读写锁,到最后一个结果出来一共要几秒?(忽略调度开销)
    秒
    前两个 exec_command 拿读锁,0–2 秒同时跑;apply_patch 要写锁,等它们结束,2–3 秒;第四个 exec_command 排在写锁后面,虽然可并行也得等,3–5 秒。总共 5 秒。如果 apply_patch 排在最后,就是 2 + 1 = 3 秒:调用顺序会影响总耗时。这是按 tokio 公平锁先进先出排队的简化模型(忽略调度开销);用 2 秒的 MCP 调用代替 apply_patch 实测 exec、exec、MCP、exec,请求间隔 6.12 秒,和模型一致。
    题 3(截断):模型元数据的截断策略是 10,000 tokens,exec_command 不传 max_output_tokens。命令输出 200,001 字节(全是 ASCII)。截断标记 …N tokens truncated… 里的 N 是多少?
    tokens
    预算 min(10,000, 10,000) = 10,000 tokens = 40,000 字节,头尾各留 20,000。去掉 200,001 − 40,000 = 160,001 字节,按 4 字节一个 token 向上取整:⌈160,001 / 4⌉ = 40,001。本机实测的标记就是 …40001 tokens truncated…。
    题 4(MCP 审批):用 codex exec -s workspace-write 跑评测,bughunt 的工具都没写 annotations,配置里也没设 approval_mode。模型调用 list_injected_bugs,会怎样?
    exec 把审批策略设成 never(exec/src/lib.rs:574-576);默认 auto 模式下没有 annotations 的工具被当成可能有破坏性,需要审批;never 下需要审批就直接拒绝(mcp_tool_call.rs:1626-1630)。例外:工具设了 approval_mode = "approve";或者 permission profile 是 Disabled(danger-full-access)/ External,或 Managed 但可写全盘,这时 never 会自动批准。修法:只读工具标 readOnlyHint,或者给具体工具设 approval_mode = "approve"。
    题 5(测开视角):要测"模型用 function_call 格式调了 apply_patch(它其实是 custom 工具)"这条路径。用 release 构建的 codex,最合适的断言是?
    Fatal 只被 drain_in_flight 记日志(debug 构建会 panic),缺的 output 由历史规范化补成 "aborted",turn 继续。A 是按"Fatal 会终止 turn"的直觉写的断言,会失败。这也说明同一份测试在 debug 和 release 构建下行为不同,CI 要写清楚用哪种构建。

    8面试要点

    一句话讲清楚

    Codex 的每个工具是一个同时提供 spec 和 handler 的对象,每个 turn 装进注册表。模型的调用先过一把读写锁(只有声明可并行的工具能共享),再查注册表、检查 payload 类型、跑 PreToolUse hook,handler 里解析参数、审批、执行,输出按预算保头保尾截断后按调用顺序回填。出错分 RespondToModel 和 Fatal 两类,但发给模型的只有一段文字。MCP 工具以 mcp__<server> 命名空间接入,同一条流水线,另有启动 30 秒、调用 300 秒的默认超时和按 annotations 决定的审批。

    追问准备

    1. 为什么用读写锁而不是全部并行?大部分工具没仔细想过并发安全(两个 apply_patch 同时改一个文件),默认独占最保守;读写锁让少数确认安全的工具共享。代价:一个独占工具会挡住排在它后面的所有调用,等审批的 MCP 调用也一直占着写锁。
    2. 为什么 apply_patch 用语法而不是 JSON?补丁是多行文本,JSON 转义容易出错;语法让服务端约束格式。本地仍然再解析一遍,换了不支持语法的 provider 也不会把坏补丁写进文件。
    3. MCP 工具重名怎么办?模型看到的是"命名空间 + 工具名",命名空间来自 server 名。清洗后仍然冲突就追加 SHA1 前 12 位,名字总长不超过 128。调 server 时用的还是原始工具名。
    4. 怎么测?把模型换成假的 Responses API,脚本化发出畸形调用,断言 Codex 发回来的第二次请求体,而不是断言模型说了什么。Codex 自己的集成测试就是这么做的(见「不调真实模型,怎么测一个 Agent?」那一章)。

    常见错误说法

    ❌ "Codex 会按 schema 校验 MCP 参数":MCP 参数只做 JSON 解析,枚举越界照样发给 server。
    ❌ "工具报错时模型会收到 is_error":success 是内部元数据,模型只看到文字。
    ❌ "Fatal 错误会终止 turn":工具任务的 Fatal 在 release 构建里只记日志,模型收到 aborted。
    ❌ "parallel_tool_calls: true 就代表工具会并行执行":那只是允许模型一次发多个调用,本地还有读写锁。
    ❌ "输出截断就是保留前 N 个字符":Codex 保头保尾,砍中间,并在开头写原始 token 数。
    ❌ "codex mcp-server 可以把 Codex 当 MCP server 用":这个 commit 里没有这个子命令。

    公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。