Skip to content

B2|工具系统:模型看见的和机器执行的必须拆开 ​

先别把 tools 数组当成能力本身 ​

读完 B1,下一件几乎人人会做的事:搜 tools = vec!,把模型请求里那份 JSON 当成「agent 能干什么」。然后你会看到 exec_command、apply_patch、一堆 MCP 名字,以为再 match name 调函数就结束了。

这份 JSON 只是 Spec:这一枪采样时模型被允许看见的表面。真正跑副作用的是 Handler;要不要问人、进哪种沙箱,是 Runtime 声明、Orchestrator 执行的。四层拆开,Step 才能在 A2 里「重算工具列表」而不改主循环。

B3 才把沙箱和 ExecPolicy 写透。这一章只把工具系统的骨头立住,并回答两个产品级问题:为什么能并行、为什么改文件不准走万能 shell。


1. Spec / Handler / Runtime / Orchestrator 各管一件不能混的事 ​

四层各管一件事,数据只能沿一个方向流:

如果把这四层焊成一个 match tool_name { ... },任何一格出问题都会牵连其它格:换模型能力等于改循环,每个工具自己决定要不要弹审批,TUI 和 codex exec 的同意语义立刻分叉。拆开之后,B1 的 Step 只需要重算最左边那格(ToolSpec 列表),右边三格的实现不用跟着变。

ToolSpec 序列化之后就是 Responses API 的 Tool(tools/src/tool_spec.rs:19-22)。变体包括 function、namespace、tool_search、web_search、freeform。它回答的是:模型这轮能点哪些名字、参数长什么样。 它不跑命令。

Handler 接 call。core/src/tools/handlers/mod.rs 里一长串 *Handler:ExecCommandHandler、ApplyPatchHandler、PlanHandler、McpHandler、ViewImageHandler……它们解析参数、发生命周期事件、决定走哪条 Runtime。ToolRouter::build_tool_call 把模型的 FunctionCall 翻成 ToolName + ToolPayload(router.rs:243)。Router 是翻译,不是执行。

Runtime 才是「这次尝试」的合同。ToolRuntime<Req, Out>: Approvable + Sandboxable(tools/sandboxing.rs:363-383)。Approvable 回答问不问人、要不要升沙箱;Sandboxable 回答偏好哪种隔离、失败能不能升级。run() 拿着已经选好的 SandboxAttempt 干活。内置的 apply_patch 执行器写得很白:验证和审批在上游,这里只在选定的文件系统和沙箱上下文里落盘(tools/runtimes/apply_patch.rs:1-5)。

Orchestrator 是所有 Runtime 必须过的同一条流水线。模块注释就是设计宣言(tools/orchestrator.rs:1-7):approval → select sandbox → attempt → 拒绝后升级重试,审批结果可缓存,不必再问一遍。 Handler 不许自己 if denied { unsandbox }。

四层合在一个 match tool_name 会怎样?换模型能力等于改循环;每个工具自己决定要不要弹审批;TUI 和 codex exec 的同意语义立刻分叉。B1 的 Session 总线能共用,前提是工具副作用也走同一条 Orchestrator。

还有一层容易漏掉的契约:ToolExecutor(tools/src/tool_executor.rs:101-129)把 spec() 和 handle() 绑在同一个对象上。注释说 host 可以在上面叠路由、hooks、遥测,但不要重新拆开 spec 和 runtime。CoreToolRuntime 再给 core 加 hook 名、search metadata、就绪等待(registry.rs:51-73)。


2. ToolRouter 是这一 Step 冻住的计划,不是全局注册表 ​

A2 说 StepContext 带着这一枪的 tool_router。那份 router 从哪来?build_tool_router(spec_plan.rs:125-194)在 capture step 时现算:

  1. add_core_tool_sources 按 feature、环境、是否 Guardian 往 ToolRegistry 里塞 handler
  2. 非 Guardian 再挂 MCP、extension、dynamic tools、hosted web_search
  3. finalize_tool_router 处理 code-mode 碰撞、tool_search、可见 spec 列表(code-mode 见 附录 D)

ToolRouter 自己的注释(router.rs:73-76):一份定稿的工具计划 = 广告出去的 surfaces + 对得上的可执行 runtime。 模型看见的 model_visible_specs 和能 dispatch 的 registry 必须是同一次 finalize 的产物。所以 A2 才要求「上下文、广告出去的工具、真正执行的 call 共用一个 request view」。

Guardian 评审员故意只拿 exec_command / write_stdin / view_image(spec_plan.rs:974-1029),还要求父级 filesystem 限制能被 managed sandbox 执行。评审员不是「少几个工具的 RegularTask」,是另一份 plan。

没有「每步一份 Router」会怎样?热更新 MCP 之后,模型还按旧列表点名,call 落到新 handler;或者审批缓存的 tool_name 对不上。工具系统的时间单位是 Step,不是进程寿命。


3. 内置工具是几类副作用,不是一份函数表 ​

add_core_tool_sources 分成 shell、MCP 资源、core utility、collaboration(spec_plan.rs:1032-1035)。对照你关心的那一批,按 副作用类型 看,不要按文件名背:

模型看见的名字副作用为什么是独立工具
exec_command + write_stdin进程 / PTYunified_exec:可恢复的长进程,不是一句 sh -c。无 UnifiedExec feature 时退化成 one-shot(spec_plan.rs:1104-1112)。缓冲与 stdin 再审批见 附录 B
apply_patch结构化改文件可预览、可审批、可进 turn diff;文法在独立 crate
update_plan改本轮计划条目PlanHandler 只发 Event,不碰磁盘(handlers/plan.rs)
web_search模型侧托管搜索ToolSpec::WebSearch,cached / indexed / live(hosted_spec.rs:14-20),不是本地 curl
view_image把图片读进模型上下文走 executor 文件系统,不是 open
request_permissions向用户要额外权限改的是同意旋钮,不是文件
request_user_input向用户提问审批/输入协议,默认 DirectModelOnly
spawn_agent 等开子 threadcollaboration 命名空间;D5 展开
MCP 工具外部服务 RPCMcpHandler 转到连接管理;资源另有 list/read
tool_search发现被推迟加载的工具见第 5 节
wait_for_environment等 starting 的执行后端不改文件;失败继续用已有工具(B6)
imagegen(命名空间 image_gen)生成图,再当输入看Extension 工具,不是系统 Skill 本身;闸在 image_generation_available(spec_plan.rs 里 Feature::ImageGeneration + 非 Free + provider 能力)。Skill imagegen(D1)只是说明书
request_plugin_install / list_available_plugins_to_install改本机安装状态问人装市场包装盒(D2),不是 MCP RPC,也不是往 core 加 Handler
new_context_window / get_context_remaining窗口管理Feature::TokenBudget 才挂;前者不摘要、直接开新窗(handlers/new_context_window.rs),后者只读剩余。和 B4 compact 相邻,不是第三种历史
send_message_to_user_async给人看一条消息人机通道;模型目录声明了才挂(spec_plan.rs 的 experimental_supported_tools)
current_time / sleep时钟CurrentTimeReminder / SleepTool;几乎零政策,别做成第二条审批

MCP 在 Guardian 路径上整段跳过(build_tool_router :156-186)。shell 工具还受 Feature::ShellTool、模型 shell_type、当前有没有 environment 约束。名单会随 feature flag 变,不变的是分类:进程、补丁、计划、托管搜索、生成字节、装插件、窗口、权限、人机、子代理、外部 RPC、执行后端就绪、时钟。新能力先问自己属于哪一类,再决定是新 Handler 还是 Extension contributor(下一节),而不是在 exec_command 的字符串里再开一个子协议。

默认 exec 是可恢复 PTY,不是一次 sh -c。 Feature::UnifiedExec 开着时,模型看见的仍是 exec_command / write_stdin 两个工具;打开走 Orchestrator,输出只留头尾,stdin 按现行政策再审,不能把新沙箱套到已经在跑的进程上——政策变了就开新终端。Op::Interrupt 取消这一轮采样,不杀 process store。同一 session 有进程上限。feature 关了才退化成 one-shot。缓冲、二次审批、64 个帽,见 附录 B。P0 抄的是这条可恢复路径;改文件仍走 apply_patch,不要用 PTY 里的 sed 当正路。


4. 并行是门闩,默认不准 ​

ToolExecutor::supports_parallel_tool_calls 默认 false(tool_executor.rs:122-124)。ToolCallRuntime 派活时问 router(parallel.rs:109),然后抢一把 RwLock:能并行的拿 read,不能的拿 write(:158-161)。不能并行的工具会堵住整个门闩,直到它结束。注释把「进门」标成 dispatch 等待结束、handler 真正开始(:163-164)。

这不是「tokio::spawn 越多越好」。改同一工作区的 patch、要对齐的 exec、大多数控制类工具,默认互斥。能并行的是那些声明了自己不踩别人脚的——典型是只读、或 MCP 上互相独立的 call。B1 的取消令牌按 call 往下传;并行中的某一个被 abort,不该把整把 write lock 的语义搞乱,所以门闩在 spawn 的任务里面获取。

没有这扇门会怎样?模型一次吐五个 exec_command,五个 shell 抢同一份工作区,turn diff 和审批顺序都变成竞态。并行是工具自己报名、总线执行的政策,不是采样循环的优化开关。


5. 工具目录必须能渐进加载 ​

把几十个 MCP 工具的完整 schema 塞进每一枪 prompt,上下文会先腐烂。ToolExposure(tool_executor.rs:51-79)把「注册了」和「模型第一眼看见」切开:

  • Direct:进初始可见列表
  • Deferred / DeferredModelOnly:先登记,靠 tool search 发现
  • Hidden:能 dispatch,模型看不见
  • CodeModeOnly:只给 code-mode 嵌套调用

finalize_tool_router 发现 registry 里存在 deferred 且带 search_info 的工具时,才挂上 tool_search(spec_plan.rs:372-406)。create_tool_search_tool 的参数就叫 query / limit,描述是 Search query for deferred tools(tool_search_spec.rs:16-24)。Handler 用 BM25 在已登记的 search metadata 上搜(handlers/tool_search.rs:1-16)。

这和 D1 Skills 的渐进披露是同一哲学:先给目录,点名再加载正文。工具这边加载的是 另一份 Spec,不是把 SKILL.md 拼进 prompt。MCP 还可以按 server 再裁一刀 apply_mcp_tool_exposure_policy(spec_plan.rs:197)。

没有暴露策略会怎样?要么第一枪 prompt 被 MCP 目录撑爆(B4 的 compact 永远在救火),要么模型根本不知道有这个工具。Deferred 是第三条路:注册给 dispatch,搜索给发现,可见列表保持短。


6. 进程内 Extension 是贡献点,不是又一套 Handler 目录 ​

读完第 3 节,下一件错事是每加一个产品能力就在 handlers/mod.rs 再挂一个 *Handler。图生成、Goal、Memories、Guardian v2、队列、git 署名,看起来都像「新工具」,实现却不该焊进 codex-core。

判断先说清楚:Plugin 是用户安装单元(D2);Extension 是进程内 contributor。 ext/extension-api 的 ExtensionRegistryBuilder 按 thread / turn / 工具 / MCP / world state / 审批往 Session 里插能力(registry.rs 的 builder 字段)。host 启动时 install(),core 只认 registry。ToolContributor 交出的 executor(contributors.rs 里 tools() / tools_for_step())再被 append_extension_tool_executors 适配进这一 Step 的 Router(spec_plan.rs)——看见的仍是 ToolSpec,过的仍是 Orchestrator。

app-server 的装配清单就在 app-server/src/extensions.rs 的 thread_extensions:queue、history-notes、goal、git-attribution、guardian-v2、memories、mcp、web-search、image-generation、skills。这是仓库 AGENTS.md「别再往 codex-core 塞东西」的物理缝。

没有这层缝会怎样?每个产品功能都胀 codex-core;或者把用户市场插件和进程内贡献点焊成一个词——装一个 Linear 插件被理解成改 run_turn。抄 harness:先留 contributor 接口,再写包装盒。


7. 写文件不走万能 shell ​

模型当然可以 exec_command 里 sed、python 改文件。Codex 不把这条路当正路。改文件的正路是 apply_patch:一份 freeform 工具,Lark 文法,描述里写明不要包 JSON(apply_patch_spec.rs:7-27)。独立 crate apply-patch/ 负责解析 hunk、在 executor 文件系统上应用(文法见 附录 E)。Runtime 假设验证和审批已经在上游做完(runtimes/apply_patch.rs:1-5)。

为什么不能「shell 就够了」:

  1. UI 要看 diff,不要看命令字符串。 patch 进 TurnDiffTracker,审批弹的是文件变更,不是 sed -i。
  2. 沙箱和同意要落在路径上。 Orchestrator 对 patch 走文件系统政策;一句 shell 的副作用无法静态知道写了哪几个文件。
  3. 模型就算走歪了,也要被拦回来。 exec_command 会 intercept_apply_patch(apply_patch.rs:499-522):命令行看起来像 apply_patch / applypatch heredoc(apply-patch/src/invocation.rs:28)时,不当成普通 shell,改走同一套验证 + Orchestrator。

没有这一层会怎样?每个前端自己用 ANSI 猜「这是一次编辑」;审批只能选择「允许这条未知命令」;远程 exec-server 上 sed 和本地 TUI 的 diff 对不上。结构化工具优于万能 shell——F2 会把这句收成反模式清单,证据在这里。

update_plan 是同一逻辑的轻量版:计划变更走专用工具和 Event,不让模型 echo 进一个 markdown 再假装那是任务状态。C5 写 Goal / Plan 产品形态;内核只保证它是工具,不是 prompt 约定。


8. 结语:带走一句话 ​

工具系统的核心不是注册了多少函数,而是 Spec / Handler / Runtime / Orchestrator 四层拆开——产品能力优先挂 Extension contributor,不要往 core 焊 Handler;默认跑命令是可恢复 PTY,改文件走 apply_patch。