Skip to content

Plan工具

追踪 update_plan 的规格、模式门槛、PlanUpdate 事件、终端与 App Server 消费者及错误边界。

基于rust-v0.150.0
CodexRustToolsPlan

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

rust
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

rust
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

rust
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

rust
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

rust
#[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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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

rust
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 逻辑上可以通过:

json
{
  "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

rust
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

rust
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

rust
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 可执行检查 ​

bash
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。