Plan工具
update_plan 是一个副作用型工具:它不修改文件、不启动进程,也不返回一份供模型继续计算的计划对象。它解析模型传来的 checklist,向 session 发出 EventMsg::PlanUpdate,然后用固定文本 Plan updated 确认调用完成。真正有价值的数据沿事件通道流向 TUI、App Server 或测试 harness;固定文本只是模型侧的成功回执。
本文面向已经读过ToolSpec与函数规格、ToolRouter解析与分派和ToolLifecycle事件的读者。本文回答一个具体问题:一次 update_plan 调用如何从规格进入 handler,再变成可供不同客户端消费的计划更新;不展开 Plan mode 中模型生成的 <proposed_plan> 流式文本,也不把 update_plan 当作持久化任务管理器。读完后,读者应能定位工具何时注册、哪些模式拒绝它、哪些输入会发事件、固定输出为何不包含计划,以及客户端如何把同一事件渲染成 checklist。
1. 工具边界
1.1 注册条件
update_plan 不是无条件存在的内置工具。core tool plan 只有在 Config::update_plan_enabled 为 true 时才把 PlanHandler 放进 registry;配置项默认开启,显式设置 [tools.update_plan].enabled = false 时同时从可见规格和已注册 handler 中移除。
源码位置:codex-rs/core/src/tools/spec_plan.rs :: add_core_utility_tools
fn add_core_utility_tools(
context: &CoreToolPlanContext<'_>,
registry: &mut ToolRegistry,
) {
let turn_context = context.turn_context;
let features = turn_context.config.features.get();
if turn_context.config.update_plan_enabled {
registry.add(PlanHandler);
}
if features.enabled(Feature::DeferredExecutor) {
registry.add(
context
.wait_for_environment_tool_config
.map(Arc::as_ref)
.map_or_else(
WaitForEnvironmentHandler::default,
WaitForEnvironmentHandler::new,
),
);
}
}配置解析本身只负责将 TOML 的 enabled 变成布尔值,不检查计划内容,也不把“最多一个进行中步骤”实现成配置约束。
源码位置:codex-rs/core/src/config/mod.rs :: resolve_update_plan_enabled
fn resolve_update_plan_enabled(config_toml: &ConfigToml) -> bool {
config_toml
.tools
.as_ref()
.and_then(|tools| tools.update_plan.as_ref())
.is_none_or(|config| config.enabled)
}测试 update_plan_tool_respects_config_gate 分别检查默认配置和显式禁用配置,断言工具同时从 visible spec 与 registered tools 消失。这是工具可用性门槛,不是 handler 参数验证。
1.2 模式门槛
工具注册后仍可能被当前 turn 的模式拒绝。PlanHandler 把 ModeKind::Plan 视为不可用,因为 Plan mode 使用另一条 <proposed_plan> 流式解析和 PlanDelta 事件;update_plan 服务的是普通模式中的 TODO/checklist,不是 Plan mode 的提案流。
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: PlanHandler::handle_call
if turn.mode == ModeKind::Plan {
return Err(FunctionCallError::RespondToModel(
"update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(),
));
}这两个“plan”必须区分:update_plan 的输入是结构化步骤状态;Plan mode 的计划来自 assistant 文本中的 <proposed_plan>,由 session stream parser 维护单独的 plan item 生命周期。名称相近,但所有者、事件类型和消费者不同。
2. 输入规格
2.1 Schema形状
工具规格要求一个 plan 数组;每个元素必须包含 step 字符串和 status 枚举。explanation 可选,元素不允许未知字段,顶层也不允许未知字段。规格描述写着 “At most one step can be in_progress at a time”,但这只是给模型的说明文本,当前 JSON schema 和 handler 都没有执行这个不变量。
源码位置:codex-rs/core/src/tools/handlers/plan_spec.rs :: create_update_plan_tool
let plan_item_properties = BTreeMap::from([
(
"step".to_string(),
JsonSchema::string(Some("Task step text.".to_string())),
),
(
"status".to_string(),
JsonSchema::string_enum(
vec![json!("pending"), json!("in_progress"), json!("completed")],
Some("Step status.".to_string()),
),
),
]);
let properties = BTreeMap::from([
(
"explanation".to_string(),
JsonSchema::string(Some(
"Optional explanation for this plan update.".to_string(),
)),
),
(
"plan".to_string(),
JsonSchema::array(
JsonSchema::object(
plan_item_properties,
Some(vec!["step".to_string(), "status".to_string()]),
Some(false.into()),
),
Some("The list of steps".to_string()),
),
),
]);2.2 协议类型
schema 最终反映到 protocol crate 的 UpdatePlanArgs。serde(deny_unknown_fields) 作用于 PlanItemArg 和 UpdatePlanArgs,因此未知键、缺少 step、缺少 status 或未知状态都会在 handler 的反序列化阶段失败。
源码位置:codex-rs/protocol/src/plan_tool.rs :: UpdatePlanArgs
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
Pending,
InProgress,
Completed,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
pub struct PlanItemArg {
pub step: String,
pub status: StepStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, TS)]
#[serde(deny_unknown_fields)]
pub struct UpdatePlanArgs {
#[serde(default)]
pub explanation: Option<String>,
pub plan: Vec<PlanItemArg>,
}这里没有 id、owner、created_at 或 revision。每次事件携带的是一份完整的当前计划快照,而不是对某一步的增量操作;客户端若要显示最新状态,需要用后来的 PlanUpdate 替换之前的 checklist。
2.3 规格与运行时
ToolSpec 只定义模型可见的参数形状,UpdatePlanArgs 负责运行时反序列化,PlanHandler 负责模式检查和事件发送。三者没有共享一个“自动校验最多一个 in_progress”层,因此读源码时不能把描述文本误当成执行断言。
3. Handler路径
3.1 Payload解析
handler 只接受 function payload。解析失败返回 RespondToModel,不会发送 PlanUpdate 事件;这保证客户端不会收到一份 core 无法确认来源的半结构化计划。
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: PlanHandler::handle_call
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"update_plan handler received unsupported payload".to_string(),
));
}
};
if turn.mode == ModeKind::Plan {
return Err(FunctionCallError::RespondToModel(
"update_plan is a TODO/checklist tool and is not allowed in Plan mode".to_string(),
));
}
let args = parse_update_plan_arguments(&arguments)?;源码位置:codex-rs/core/src/tools/handlers/plan.rs :: parse_update_plan_arguments
fn parse_update_plan_arguments(
arguments: &str,
) -> Result<UpdatePlanArgs, FunctionCallError> {
serde_json::from_str::<UpdatePlanArgs>(arguments).map_err(|e| {
FunctionCallError::RespondToModel(
format!("failed to parse function arguments: {e}"),
)
})
}3.2 事件发送
解析完成后 handler 把完整 UpdatePlanArgs 移入 EventMsg::PlanUpdate,通过 session 的事件发送器发出。handler 没有本地计划状态,也没有等待客户端确认;事件发送完成后立即返回固定 output。
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: PlanHandler::handle_call
session
.send_event(turn.as_ref(), EventMsg::PlanUpdate(args))
.await;
Ok(boxed_tool_output(PlanToolOutput))这里的 send_event 是生效屏障:在它完成前,客户端不知道这次更新;但它也不是数据库提交,handler 不保存“当前计划”供下一次工具调用读取。
3.3 固定输出
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: PlanToolOutput
const PLAN_UPDATED_MESSAGE: &str = "Plan updated";
impl ToolOutput for PlanToolOutput {
fn log_output(&self) -> String {
PLAN_UPDATED_MESSAGE.to_string()
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(
&self,
call_id: &str,
_payload: &ToolPayload,
) -> ResponseInputItem {
let mut output = FunctionCallOutputPayload::from_text(
PLAN_UPDATED_MESSAGE.to_string(),
);
output.success = Some(true);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.to_string(),
output,
}
}
fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
JsonValue::Object(serde_json::Map::new())
}
}模型看到的是 Plan updated,Code Mode 得到的是空 JSON;完整计划只在 PlanUpdate 事件中传递给事件消费者。这种双通道设计 避免把大计划重复塞进模型历史,但也意味着仅查看 function call output 无法重建计划内容。日志通道同样只记录固定确认文本。
4. 事件消费者
4.1 TUI入口
TUI 将 PlanUpdate 转成 PlanUpdateCell。它只保存 explanation 和完整的 Vec<PlanItemArg>,渲染时按状态选择符号和样式:completed 使用删除线,in_progress 使用加粗青色,pending 使用弱化样式;空计划显示 (no steps provided)。
源码位置:codex-rs/tui/src/history_cell/plans.rs :: new_plan_update
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
}源码位置:codex-rs/tui/src/history_cell/plans.rs :: PlanUpdateCell::display_lines
let render_step = |status: &StepStatus, text: &str| -> Vec<Line<'static>> {
let (box_str, step_style) = match status {
StepStatus::Completed => ("✔ ", Style::default().crossed_out().dim()),
StepStatus::InProgress => ("□ ", Style::default().cyan().bold()),
StepStatus::Pending => ("□ ", Style::default().dim()),
};
let opts = RtOptions::new(width.saturating_sub(4).max(1) as usize)
.initial_indent(box_str.into())
.subsequent_indent(" ".into());
let step = Line::from(text.to_string().set_style(step_style));
let wrapped = adaptive_wrap_line(&step, opts);
let mut out = Vec::new();
push_owned_lines(&wrapped, &mut out);
out
};TUI 的 history cell 是显示快照,不是新的计划 owner。后续事件会创建新的 cell;渲染器不会从上一个 cell 推导状态转换。
4.2 App Server
App Server 收到 EventMsg::PlanUpdate 后转为 TurnPlanUpdatedNotification,保留 thread id、turn id、explanation 和每个 step 的状态。它明确注释该事件是 TODO/checklist 工具,不是 Plan mode 的计划更新。
源码位置:codex-rs/app-server/src/bespoke_event_handling.rs :: handle_turn_plan_update
async fn handle_turn_plan_update(
conversation_id: ThreadId,
event_turn_id: &str,
plan_update_event: UpdatePlanArgs,
outgoing: &ThreadScopedOutgoingMessageSender,
) {
let notification = TurnPlanUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.to_string(),
explanation: plan_update_event.explanation,
plan: plan_update_event
.plan
.into_iter()
.map(TurnPlanStep::from)
.collect(),
};
outgoing
.send_server_notification(ServerNotification::TurnPlanUpdated(notification))
.await;
}App Server 也不保存一个全局 plan 状态;它把事件转换为 thread-scoped notification,让客户端自行更新显示模型。
4.3 History渲染
TUI 收到 App Server notification 后重新构造协议层 UpdatePlanArgs,再调用 on_plan_update。这条路径说明同一个计划快照存在两种客户端入口:core event 直接驱动 TUI history,App Server notification 驱动外部客户端;两者共享 protocol 类型但不共享 UI 状态。
5. 状态不变量
5.1 已实现约束
当前真实约束来自类型系统和 serde:status 只能是 pending、in_progress 或 completed;每个 step 必须有字符串文本和状态;未知字段会被拒绝;顶层 plan 字段必需存在但可以是空数组。handler 保证解析成功后才发送事件。
5.2 未实现约束
“最多一个 in_progress”只存在于工具 description,没有出现在 UpdatePlanArgs 的自定义反序列化、handler validation 或客户端转换中。下面这类输入在当前 handler 逻辑上可以通过:
{
"plan": [
{"step": "A", "status": "in_progress"},
{"step": "B", "status": "in_progress"}
]
}这不是建议的使用方式,而是源码揭示的边界。若读者要增加该不变量,应该在 parse_update_plan_arguments 后增加显式检查,并为错误输入补 handler/harness 测试;仅修改 schema description 不会改变运行时行为。
5.3 快照替换
事件没有 revision、计划 id 或 step id。客户端只能把每次事件当作完整快照,按事件到达顺序更新当前显示。跨线程重放或多个 turn 并发时,thread id 和 turn id 由外层事件/notification 提供,不能从 UpdatePlanArgs 自身推导。
6. 模式区别
6.1 普通模式
普通模式下,模型可以把 update_plan 作为直接工具调用暴露给自身。工具调用进入 registry、经过 handler、发送 PlanUpdate 事件,并返回固定文本。客户端负责显示步骤,模型只知道调用成功。
6.2 Plan模式
Plan mode 不允许该 handler。计划文本由 assistant stream parser 识别 <proposed_plan>,创建 PlanDelta 和 TurnItem::Plan 等不同对象;它的生命周期与工具调用无关。把两者混用会导致错误的前置条件、事件名称和消费者判断。
6.3 Code Mode
Code Mode 对 update_plan 的 exposure 取决于 functions namespace 配置。若 direct_only_tool_namespaces 包含 functions,Code Mode Only 中它保持 DirectModelOnly;若 functions namespace 被排除于 Code Mode 嵌套工具,它仍可作为普通 Direct 工具存在。 两条路径都不会把 update_plan(args: ...) 展开进 code-mode freeform exec 描述,因为它不是 nested code tool。
源码位置:codex-rs/core/src/tools/spec_plan_tests.rs :: code_mode_only_exposes_default_namespace_tools_directly
plan.assert_visible_contains(&["update_plan"]);
assert_eq!(plan.exposure("update_plan"), ToolExposure::DirectModelOnly);
let ToolSpec::Freeform(exec) = plan.visible_spec(
codex_code_mode::PUBLIC_TOOL_NAME,
) else {
panic!("expected code mode exec tool");
};
assert!(!exec.description.contains("update_plan(args:"));7. 错误路径
7.1 Payload错误
非 function payload 返回 “unsupported payload”;这通常表示 router/registry 与 handler 暴露契约不一致,而不是计划内容有问题。
7.2 参数错误
serde 解析错误返回 failed to parse function arguments,不发送 PlanUpdate。core tool harness 测试 malformed payload 时等待 TurnComplete,断言没有 PlanUpdate,并检查模型收到的 function output 标记为失败。
源码位置:codex-rs/core/tests/suite/tool_harness.rs :: update_plan_tool_rejects_malformed_payload
assert!(
!saw_plan_update,
"did not expect PlanUpdate event for malformed payload"
);
let (output_text, success_flag) = call_output(&req, call_id);
assert!(
output_text.contains("failed to parse function arguments"),
"expected parse error message in output text, got {output_text:?}"
);
if let Some(success_flag) = success_flag {
assert!(!success_flag, "malformed plan payload should fail");
}7.3 模式错误
Plan mode 的拒绝发生在 serde 解析之前。因此即使输入 JSON 本身格式正确,handler 也会先返回模式错误,不发送计划事件;客户端不能通过发送空 plan 绕过该门槛。
7.4 发送路径
handler 对 session.send_event(...).await 没有检查一个显式返回的 ack,也没有本地重试/回滚逻辑。事件发送层的错误处理、客户端断开或 UI 渲染失败不属于 PlanHandler 自身的状态机;需要从 session event sender 和具体消费者继续追踪。
8. 生命周期
8.1 Control工具
PlanHandler 现在声明为 builtin control tool。registry 为它创建 ControlToolCallGuard:PreToolUse 阻断记录 Rejected,输入重写 失败或 handler 返回失败记录 Failed,成功 output 记录 Completed;若 future 在完成前被取消,guard 的默认状态保持 Interrupted。
源码位置:codex-rs/core/src/tools/handlers/plan.rs :: CoreToolRuntime for PlanHandler
impl CoreToolRuntime for PlanHandler {
fn is_builtin_control_tool(&self) -> bool {
true
}
}这类统计描述 control tool 调用结果,不保存计划内容。ControlToolCallFact 只记录 thread、turn、call、tool、时间和状态。
8.2 Hook顺序
PlanHandler 没有覆盖 Hook 方法,但 CoreToolRuntime 对 function payload 提供默认 PreToolUse/PostToolUse 投影。因此 PreToolUse 可以在 handler 前阻断或重写整个 UpdatePlanArgs JSON;PostToolUse 则在 PlanUpdate 已发送、PlanToolOutput 已生成后运行。
这带来一个重要边界:PostToolUse block 可以拒绝 Plan updated 回灌给模型,却不能撤销已经发给 TUI/App Server 的 PlanUpdate 事件。要阻止计划更新发生,应使用 PreToolUse,而不是 PostToolUse。
8.3 取消时机
取消发生在 handler 进入前,outer runtime 返回 aborted response,事件不会发送。取消发生在 send_event await 期间,最终结果 取决于 event sender 和 dispatch task 谁先完成;PlanHandler 没有补偿事件。由于计划更新是 UI 通知而不是文件事务,不能声称 它具备回滚语义。
8.4 客户端断开
TUI history cell 和 App Server notification 都是消费者本地状态。一个客户端没有收到事件,不等于另一个客户端也没收到;也不等于 session 保存了一个可查询的 plan store。要诊断“计划没显示”,需先确认 EventMsg::PlanUpdate 是否发出,再分别检查 TUI 或 App Server 分支。
9. 验证路径
9.1 Harness正常路径
源码位置:codex-rs/core/tests/suite/tool_harness.rs :: update_plan_tool_emits_plan_update_event
测试输入包含 explanation、一个 in_progress step 和一个 pending step;断言收到 EventMsg::PlanUpdate,字段值与输入一致,并断言模型 function output 为 Plan updated。它同时覆盖事件通道和固定回执,不证明客户端渲染样式。
9.2 Harness错误路径
源码位置:codex-rs/core/tests/suite/tool_harness.rs :: update_plan_tool_rejects_malformed_payload
测试给出无法反序列化的参数,断言没有 PlanUpdate、模型输出包含解析错误且 success=false。它证明错误发生在事件发送之前,不证明所有未知字段的错误文本完全相同。
9.3 配置与Code Mode
源码位置:codex-rs/core/src/tools/spec_plan_tests.rs :: update_plan_tool_respects_config_gate
测试分别构造默认和禁用配置,断言 visible 与 registered 两侧同步变化。另一个 Code Mode 测试验证 DirectModelOnly exposure 和 freeform 描述边界。它们证明工具计划阶段的可见性,不证明 PlanHandler 在每个平台 UI 上都可用。
9.4 客户端渲染
源码位置:codex-rs/tui/src/history_cell/plans.rs :: PlanUpdateCell::display_lines
TUI 测试覆盖不同状态的符号、删除线和换行渲染;App Server 测试覆盖 notification 中 explanation 与 steps 的映射。它们证明客户端对事件快照的投影,不证明 core 会维护跨事件的状态机。
9.5 可执行检查
rg -n "PlanHandler|EventMsg::PlanUpdate|UpdatePlanArgs|PlanUpdateCell" codex-rs/core/src codex-rs/protocol/src codex-rs/tui/src codex-rs/app-server/src
cargo test -p codex-core update_plan_tool_respects_config_gate
cargo test -p codex-core --test all suite::tool_harness::update_plan_tool_emits_plan_update_event
cargo test -p codex-core update_plan_tool_rejects_malformed_payload
cargo test -p codex-core code_mode_only_exposes_default_namespace_tools_directly
cargo test -p codex-tui plan_update这些检查覆盖规格注册、事件发送、错误路径和 TUI 投影;它们不会证明工具描述中的“最多一个进行中步骤”已经被运行时强制,也不会把 Plan mode 的 proposed plan 流程当作 update_plan 的消费者。
10. 阅读路径
遇到 update_plan 不可用,先查 Config::update_plan_enabled 和 tool plan 注册;遇到调用报错,先区分当前 turn 是否为 Plan mode,再查看 UpdatePlanArgs 的 serde 错误;遇到模型显示成功但界面没有 checklist,检查 EventMsg::PlanUpdate 是否发送,以及目标是 TUI history 还是 App Server notification。若需要增加计划不变量,最小可靠路径是 protocol/handler validation、错误测试、事件消费者测试三处同时补齐,而不是只修改工具 description。
