给 Codex 加一个工具:从能运行到合同完整
复盘 get_context_remaining 的三次真实演进,追踪 feature gate、模块接线、双输出合同、上下文计算与测试怎样一起构成可维护的工具改动。
工具执行机制那一章为了隔离主链,设计过一个返回固定版本号的教学工具。它适合看清 ToolSpec -> PlannedTools -> registry -> ToolOutput -> follow-up,却故意绕开了真实业务计算、功能开关和兼容演进。
真正给 Codex 加工具时,麻烦往往从“已经能调用”之后才开始。
get_context_remaining 是一个很小的真实案例:没有参数,不读文件,不启动进程,也不需要审批。它只回答当前 context window 还剩多少 token。即便这样,工具合同仍经历了三次关键改动:第一次接通 direct 调用,第二次补 code mode 的结构化合同,第三次修正 BodyAfterPrefix 下的计算口径。中间还有一次 token-budget context 的统一简化,改变了 direct 文本的外层表示。
这些变化都在 rust-v0.144.6 的历史中,最终实现也保留在固定 tag。沿它们往回看,比再写一个“Hello World 工具”更能说明一次改动为什么会半完成。
可复现实验:四条测试锁住四种合同
我在固定 tag 的 detached checkout 中运行:
test -z "${CODEX_SANDBOX_NETWORK_DISABLED:-}"
just test -p codex-core get_context_remaining
运行时 CODEX_SANDBOX_NETWORK_DISABLED 未设置,本地 loopback mock server 可用。结果是 4 passed, 0 failed。四条测试分别是:
get_context_remaining_returns_token_budget_remaining_fragment
get_context_remaining_uses_body_after_prefix_window
get_context_remaining_returns_unknown_when_threshold_is_unbounded
code_mode_get_context_remaining_returns_structured_result
第一条不只断言返回文案,还检查 feature 开启后请求里确实暴露了工具,并用原 call_id 把 direct 文本送入 follow-up request。第二条把 scope 切成 BodyAfterPrefix,防止 handler 又退回总 token 相减。第三条固定 context window 不可用时的 unknown 分支。第四条从 code mode 执行 tools.get_context_remaining({}),断言拿到的是 JSON 对象,而不是要求调用者解析模型可见文本。
没有一条测试需要真实模型。它们使用本地 mock Responses server,控制 function call、usage 和后续请求,失败时能指出断的是可见性、计算、输出投影,还是调用闭环。
这里有一个容易制造假绿的环境边界:四条测试都有 skip_if_no_network!(Ok(()))。如果设置了 CODEX_SANDBOX_NETWORK_DISABLED,命令仍可能显示通过,但测试会在断言前返回。复现实验时要确认该变量未设置,并看到 loopback mock request 实际发生;只抄一行 4 passed 不够。
三次工具改动,中间还改过一次表示
第一次实现来自 dac5f07403e。它只改了六个文件:新增 spec 与 handler,在 handlers/mod.rs 接线,在 spec_plan.rs 注册,补 direct 集成测试,同时让 TokenBudgetRemainingContext 能表达未知窗口。
这版已经能工作。Feature::TokenBudget 开启后,模型能看到 get_context_remaining;handler 读取总 token usage,用 context window 相减,返回 TokenBudgetRemainingContext 文本。对当时的 direct 路径来说,闭环是通的。
但它被注册成 DirectModelOnly,output_schema 也是 None。这意味着 code mode 既看不到这个 nested tool,也没有稳定的机器可读返回值。
第二次改动 7516eb5c70f 把两种消费者分开处理:direct 模式继续收到带 <token_budget> wrapper 的英文 fragment;code mode 得到 { "tokens_left": number | null }。planner 不再把它限制为 DirectModelOnly,spec 增加 output schema,handler 也从通用文本输出换成了自定义 ToolOutput。
随后 aaf737fa59c4 统一简化 token-budget context,移除了 lineage、near-compaction reminder 和 get_context_remaining 新输出的 wrapper,同时保留对旧 wrapped rollout fragment 的识别。最终 tag 中的 pure-text direct 输出来自这次表示调整,不是 7516eb5c70f。
第三次改动 77e7ce13741 修的是更隐蔽的错。handler 原本自己计算:
model_context_window - active_context_tokens
当 auto-compact scope 是 Total 时,这个结果看起来合理。切到 BodyAfterPrefix 后,稳定 prefix 不应占用 body window,且 full context window 仍是另一条硬上限。工具自己相减,会和真正决定 compaction 的代码给出不同答案。最终版不再重算,直接复用 context_window_token_status(...).tokens_until_compaction。
flowchart TD
accTitle: get_context_remaining 从能运行到合同完整的三次演进
accDescr: 第一次工具提交接通 TokenBudget gate、planner、handler 与 direct follow-up;第二次增加 code mode 暴露、nullable output schema 和结构化结果;中间一次 token-budget context 简化移除 direct wrapper;第三次工具修复把剩余 token 计算收回统一 accounting。
A["dac5f07403e\n能被 direct 模型调用"] --> B["7516eb5c70f\n补 code mode 与 output schema"]
B --> C["aaf737fa59c4\ndirect 输出改为纯文本"]
C --> D["77e7ce13741\n复用统一 token accounting"]
D --> E["rust-v0.144.6\n四条合同测试"]
这个时间线没有说明前面的提交质量差。真实改动经常先覆盖眼前入口,随后因为新消费者或新配置语义暴露缺口。值得学的是:缺口出现后,代码把业务真值收回了共享函数,而不是在两个 handler 里继续打补丁。
先决定复用哪个开关
新增工具的第一反应常常是新增一个 feature。get_context_remaining 没这么做。
它和 new_context 都属于 token-budget 能力,只有 Feature::TokenBudget 开启时才有意义。planner 因此在同一个分支里注册二者:new_context 保持 direct-model-only,get_context_remaining 使用默认 exposure,同时服务 direct 与 code mode。
复用已有 feature 的依据不是“少改一个配置文件”,而是生命周期一致:同一批用户应该同时获得剩余预算查询与新 context window 能力;关闭 TokenBudget 时,两者都不应暴露。如果工具需要独立发布节奏、权限或回滚边界,才值得新增 feature,并承担 config、schema、文档和测试扩散。
这里也有一个负向合同:feature 关闭时,不只是 prompt 里不显示 spec,runtime registry 也不该留下一个可被旁路调用的同名 executor。工具执行机制那一章已经解释 PlannedTools 怎样同时产出可见 specs 与 registry,本章只需把 feature gate 放在这份共同计划之前。
文件接线少一处,工具就可能只是“存在”
最终 tag 把 spec 和 runtime 放在两个文件:
get_context_remaining_spec.rs -> 名称、描述、输入 schema、输出 schema
get_context_remaining.rs -> ToolExecutor、业务计算、两种输出投影
handlers/mod.rs -> 模块声明与 handler re-export
spec_plan.rs -> feature gate、exposure 与 planner 注册
这四处各有不同责任。只新增文件,Rust 不会自动发现模块;只在 mod.rs 导出 handler,planner 不会采用;只把 spec 放进请求,没有 runtime 就会在模型真的调用时失败;只注册 runtime 而隐藏 spec,则只有明确设计成 hidden/deferred 的路径才合理。
GetContextRemainingHandler 同时提供 tool_name() 与 spec(),planner 通过 planned_tools.add(...) 让同一个 executor 进入统一规划。这减少了名字漂移,但没有消除模块与 feature 接线的责任。
一个结果为什么要投影两次
工具的业务结果只有一个:Option<i64>。消费者却有两种。
direct 模式要把结果作为 function_call_output 送回模型。为了和既有 token-budget context 保持一致,它渲染为:
You have 6500 tokens left in this context window.
窗口未知时是:
You have unknown tokens left in this context window.
code mode 不应解析这两句话。GetContextRemainingOutput::code_mode_result 返回:
{ "tokens_left": 6500 }
或者:
{ "tokens_left": null }
所以 spec 的 output schema 不能只写 integer,也不能省略 tokens_left。字段始终存在,值允许为 null。code_mode.rs 会把工具的 input/output schema 放进 nested tool definition;若实现返回的 JSON 与 schema 不一致,direct 路径仍可能全绿,code mode 调用者却拿到一份撒谎的合同。
剩余 token 的所有权不在工具里
第三次修复最值得保留。context_window_token_status 同时处理:
Total与BodyAfterPrefix两种 scope;- auto-compact soft limit;
- full context window hard limit;
- prefix baseline;
- saturating subtraction 与零下限;
- 两种可用边界都不存在时的
None;只要任一剩余量存在,就保留那一项。
在 Total scope 下,tokens_until_compaction 只使用 auto-compact limit 的剩余量。BodyAfterPrefix 还要单独守住 full context window;只有 soft 与 hard 两种剩余量都存在时才取较小值。工具、提醒和自动压缩共享这份结果,才不会出现“工具说还能放 8k,下一步却立即 compact”的分叉。
这一点和compaction 那一章直接相连:get_context_remaining 报的不是 session 累计用量还剩多少,也不是模型理论最大窗口减去所有历史 token 的简单值;它回答的是当前配置下,离下一次 compaction boundary 还有多少。
测试真正锁住了什么
四条现成测试覆盖了四个关键行为,但还不是 spec-runtime 的完整矩阵:
| 合同 | 断言重点 | 漏掉后的典型假绿 |
|---|---|---|
| 可见性与 direct 闭环 | feature 开启、spec 出现在请求、原 call id 收到文本输出 | handler 单测能跑,模型请求里根本没有工具 |
| scope 语义 | BodyAfterPrefix 返回与 compaction 相同的剩余量 | Total 默认配置全绿,长 prefix 环境报错 |
| nullable 边界 | 无可用 threshold 时返回 unknown | 强行用 0 代替未知,模型误以为窗口耗尽 |
| code mode 合同 | nested 调用返回 { tokens_left } JSON | direct 文本正常,程序化消费者无法稳定使用 |
尤其要注意最后一行:code mode 测试检查实际 JSON,却不读取 output_schema,runtime 也不会拿 schema 自动校验 code_mode_result。把 nullable 声明改错甚至删掉 schema,这四条仍可能通过。这个缺口会留给本章的改造练习,而不是把现有测试说成已经证明了一切。
源码依据
本章的最终行为依据固定在 rust-v0.144.6。三次工具改动加一次表示调整用来还原演进顺序,不代表要把旧实现抄回当前 tag。尤其是第一次提交中的 DirectModelOnly 和手工相减,已经被后续合同修正。
实验只证明 get_context_remaining 这四条现成测试在固定 tag 通过。它没有证明所有 core 测试都通过,也没有证明未来 tag 仍使用同一个 feature、字段名或 accounting scope。
失败边界
- 只写 spec:模型可能看得到名字,runtime dispatch 时找不到 executor。
- 只写 handler:代码能编译,planner 没注册时永远不会被调用。
- 新增独立 feature 却漏掉配置 schema:本地硬编码可用,用户没有稳定开启方式。
- direct 输出正确、
code_mode_result错:人工对话可用,nested program 得到错误字段或类型。 - output schema 把
tokens_left写成必为整数:窗口未知时实现只能违约或伪造0。 - handler 自己计算 token:Total 下可能全绿,
BodyAfterPrefix、hard limit 或 baseline 改动后发生漂移。 - 输入 schema 禁止额外字段、handler 却只检查 payload kind:schema 是模型侧约束,不等于 runtime 已严格解析
{}。 - 看到工具只读就声明并行安全:读取的是 session 状态,是否允许并行仍要按一致性要求单独判断。
动手改一个地方
给这个零参数工具补一条 spec-runtime 一致性测试,而不是再增加一个返回字段。
先定义带 #[serde(deny_unknown_fields)] 的空参数类型,并使用现有 parse_arguments 解析 function arguments;只有显式拒绝未知字段后,输入 {} 才会成功而 { "unexpected": true } 会返回可交给模型修正的调用错误。然后在同一组测试中断言:
- input schema 的
additionalProperties为false; - output schema 要求
tokens_left,且允许 integer 或 null; code_mode_result的 known/unknown 两种结果都通过这份 schema;- feature 关闭时,direct specs 与 registry 都没有这个名字;
- feature 开启时,direct follow-up 仍保留原 call id。
这个练习不改 token 计算,也不增加 feature。目标只是让“spec 说不接受额外参数”从模型提示变成 runtime 合同,同时防止 schema 与两种输出再次分叉。
这一章建立了什么
新增工具的完成线不在 handler 返回 Ok。feature gate、planner 接线、可见范围、direct 与 code mode 输出、共享业务计算和测试矩阵,必须一起闭合。
工具输出会回到模型,但客户端不一定看到一个独立事件。下一章换一个真实改动面,追 GuardianWarning 怎样从 core 穿过 app-server,最终出现在 TUI:新增一个事件。