Codex Tools

固定 tree 提交中的原生 tool handler、registry 与 spec_plan 实现;代码块内不删行、不改写,只做 HTML 转义。

原生 Rust tool 函数与 handler 实现

下面代码取自固定 tree 6e5f665feb9b71e1500a5721036fbdb8e871f4b0,目录入口见 codex-rs/core/src/tools。代码块内没有改写、重命名或删行,只做 HTML 转义;中文讲解放在代码块外。

原 Python 稿少了这些原生工具面:apply_patch、update_plan、request_permissions、list_mcp_resources、read_mcp_resource、list_mcp_resource_templates、tool_search、wait_for_environment、get_context_remaining、new_context_window、current_time、sleep、插件安装建议/审批,以及 Code Mode 的 execute/wait/delegate。有些默认不可见,受 feature flag 和模型能力控制。

Rust 端也没有 Python 稿里的 {"ok": true} 通用字典协议。成功走 typed ToolOutput;模型可见失败走 FunctionCallError;权限、沙盒、hook、取消和并行策略由 registry/spec_plan 统一处理。

Shell / 可恢复进程

这里能看到原生工具不是“本地函数直接执行”:handler 只解析参数和选择环境,实际进程/输出策略交给 UnifiedExec 运行时。exec_command 可启动 PTY 或 pipes,并在进程仍活着时返回 session_id;write_stdin 是继续已有会话或后台轮询。

codex-rs/core/src/tools/handlers/shell_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandToolOptions {
    pub include_login_parameter: bool,
    pub exec_permission_approvals_enabled: bool,
}

#[cfg(test)]
pub fn create_exec_command_tool(options: CommandToolOptions) -> ToolSpec {
    create_exec_command_tool_with_environment_id(
        options,
        /*include_environment_id*/ false,
        /*include_shell_parameter*/ true,
        /*include_windows_shell_guidance*/ cfg!(windows),
    )
}

pub(crate) fn create_exec_command_tool_with_environment_id(
    options: CommandToolOptions,
    include_environment_id: bool,
    include_shell_parameter: bool,
    include_windows_shell_guidance: bool,
) -> ToolSpec {
    let yield_time_ms_description = if cfg!(windows) {
        "Maximum time to wait before returning a session ID for a still-running command. Commands that finish sooner return immediately. For ordinary commands, omit this parameter to use the 10000 ms default. Effective range on Windows is 10000-30000 ms."
    } else {
        "Wait before yielding output. Defaults to 10000 ms; effective range is 250-30000 ms."
    };
    let mut properties = BTreeMap::from([
        (
            "cmd".to_string(),
            JsonSchema::string(Some("Shell command to execute.".to_string())),
        ),
        (
            "workdir".to_string(),
            JsonSchema::string(Some(
                "Working directory for the command. Defaults to the turn cwd."
                    .to_string(),
            )),
        ),
        (
            "tty".to_string(),
            JsonSchema::boolean(Some(
                "True allocates a PTY for the command; false or omitted uses plain pipes."
                    .to_string(),
            )),
        ),
        (
            "yield_time_ms".to_string(),
            JsonSchema::number(Some(yield_time_ms_description.to_string())),
        ),
        (
            "max_output_tokens".to_string(),
            JsonSchema::number(Some(
                "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
            )),
        ),
    ]);
    if include_shell_parameter {
        properties.insert(
            "shell".to_string(),
            JsonSchema::string(Some(
                "Shell binary to launch. Defaults to the user's default shell.".to_string(),
            )),
        );
    }
    if options.include_login_parameter {
        properties.insert(
            "login".to_string(),
            JsonSchema::boolean(Some(
                "True runs the shell with -l/-i semantics; false disables them. Defaults to true."
                    .to_string(),
            )),
        );
    }
    if include_environment_id {
        properties.insert(
            "environment_id".to_string(),
            JsonSchema::string(Some(
                "Environment id from <environment_context>. Omit to use the primary environment."
                    .to_string(),
            )),
        );
    }
    properties.extend(create_approval_parameters(
        options.exec_permission_approvals_enabled,
    ));

    ToolSpec::Function(ResponsesApiTool {
        name: "exec_command".to_string(),
        description: if include_windows_shell_guidance {
            format!(
                "Runs a command in a PTY, returning output or a session ID for ongoing interaction.\n\n{}",
                windows_shell_guidance()
            )
        } else {
            "Runs a command in a PTY, returning output or a session ID for ongoing interaction."
                .to_string()
        },
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["cmd".to_string()]),
            Some(false.into()),
        ),
        output_schema: Some(unified_exec_output_schema().into()),
    })
}

pub fn create_write_stdin_tool() -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "session_id".to_string(),
            JsonSchema::number(Some(
                "Identifier of the running unified exec session.".to_string(),
            )),
        ),
        (
            "chars".to_string(),
            JsonSchema::string(Some(
                "Bytes to write to stdin. Defaults to empty, which polls without writing.".to_string(),
            )),
        ),
        (
            "yield_time_ms".to_string(),
            JsonSchema::number(Some(
                "Wait before yielding output. Non-empty writes default to 250 ms and cap at 30000 ms; empty polls wait 5000-300000 ms by default.".to_string(),
            )),
        ),
        (
            "max_output_tokens".to_string(),
            JsonSchema::number(Some(
                "Output token budget. Defaults to 10000 tokens; larger requests may be capped by policy.".to_string(),
            )),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "write_stdin".to_string(),
        description:
            "Writes characters to an existing unified exec session and returns recent output."
                .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["session_id".to_string()]),
            Some(false.into()),
        ),
        output_schema: Some(unified_exec_output_schema().into()),
    })
}

pub fn create_request_permissions_tool(description: String) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "reason".to_string(),
            JsonSchema::string(Some(
                "Optional short explanation for why additional permissions are needed.".to_string(),
            )),
        ),
        (
            "environment_id".to_string(),
            JsonSchema::string(Some(
                "Environment id from <environment_context>. Omit to use the primary environment."
                    .to_string(),
            )),
        ),
        ("permissions".to_string(), permission_profile_schema()),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "request_permissions".to_string(),
        description,
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["permissions".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}

pub fn request_permissions_tool_description() -> String {
    "Request additional filesystem or network permissions from the user and wait for the client to grant a subset of the requested permission profile. Use environment_id to target a specific attached environment; omit it to use the primary environment. Relative filesystem paths resolve against the selected environment cwd. Granted permissions apply automatically to later shell-like commands in the current turn, or for the rest of the session if the client approves them at session scope."
        .to_string()
}

fn unified_exec_output_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "chunk_id": {
                "type": "string",
                "description": "Chunk identifier included when the response reports one."
            },
            "wall_time_seconds": {
                "type": "number",
                "description": "Elapsed wall time spent waiting for output in seconds."
            },
            "exit_code": {
                "type": "number",
                "description": "Process exit code when the command finished during this call."
            },
            "session_id": {
                "type": "number",
                "description": "Session identifier to pass to write_stdin when the process is still running."
            },
            "original_token_count": {
                "type": "number",
                "description": "Approximate token count before output truncation."
            },
            "output": {
                "type": "string",
                "description": "Command output text, possibly truncated."
            }
        },
        "required": ["wall_time_seconds", "output"],
        "additionalProperties": false
    })
}

fn create_approval_parameters(
    exec_permission_approvals_enabled: bool,
) -> BTreeMap<String, JsonSchema> {
    let mut sandbox_permission_values = vec![json!("use_default")];
    if exec_permission_approvals_enabled {
        sandbox_permission_values.push(json!("with_additional_permissions"));
    }
    sandbox_permission_values.push(json!("require_escalated"));
    let sandbox_permissions_description = if exec_permission_approvals_enabled {
        "Per-command sandbox override. Defaults to `use_default`; use `with_additional_permissions` with `additional_permissions`, or `require_escalated` for unsandboxed execution."
    } else {
        "Per-command sandbox override. Defaults to `use_default`; use `require_escalated` for unsandboxed execution."
    };

    let mut properties = BTreeMap::from([
        (
            "sandbox_permissions".to_string(),
            JsonSchema::string_enum(
                sandbox_permission_values,
                Some(sandbox_permissions_description.to_string()),
            ),
        ),
        (
            "justification".to_string(),
            JsonSchema::string(Some(
                "User-facing approval question for `require_escalated`; omit otherwise.".to_string(),
            )),
        ),
        (
            "prefix_rule".to_string(),
            JsonSchema::array(JsonSchema::string(/*description*/ None), Some(
                    r#"Reusable approval prefix for `cmd`, only with `sandbox_permissions: "require_escalated"`; for example ["git", "pull"]."#.to_string(),
                )),
        ),
    ]);

    if exec_permission_approvals_enabled {
        let mut additional_permissions = permission_profile_schema();
        additional_permissions.description = Some(
            "Sandboxed filesystem or network access for this command; only with `sandbox_permissions: \"with_additional_permissions\"`."
                .to_string(),
        );
        properties.insert("additional_permissions".to_string(), additional_permissions);
    }

    properties
}

fn permission_profile_schema() -> JsonSchema {
    let mut schema = JsonSchema::object(
        BTreeMap::from([
            ("network".to_string(), network_permissions_schema()),
            ("file_system".to_string(), file_system_permissions_schema()),
        ]),
        /*required*/ None,
        Some(false.into()),
    );
    schema.description = Some("Filesystem or network access request.".to_string());
    schema
}

fn network_permissions_schema() -> JsonSchema {
    let mut schema = JsonSchema::object(
        BTreeMap::from([(
            "enabled".to_string(),
            JsonSchema::boolean(Some(
                "True requests network access; false or omitted requests none.".to_string(),
            )),
        )]),
        /*required*/ None,
        Some(false.into()),
    );
    schema.description = Some("Network access request.".to_string());
    schema
}

fn file_system_permissions_schema() -> JsonSchema {
    let mut schema = JsonSchema::object(
        BTreeMap::from([
            (
                "read".to_string(),
                JsonSchema::array(
                    JsonSchema::string(/*description*/ None),
                    Some(
                        "Absolute paths to grant read access; omit when none are needed."
                            .to_string(),
                    ),
                ),
            ),
            (
                "write".to_string(),
                JsonSchema::array(
                    JsonSchema::string(/*description*/ None),
                    Some(
                        "Absolute paths to grant write access; omit when none are needed."
                            .to_string(),
                    ),
                ),
            ),
        ]),
        /*required*/ None,
        Some(false.into()),
    );
    schema.description = Some("Filesystem access request.".to_string());
    schema
}

fn windows_shell_guidance() -> &'static str {
    r#"Windows safety rules:
- Do not compose destructive filesystem commands across shells. Do not enumerate paths in PowerShell and then pass them to `cmd /c`, batch builtins, or another shell for deletion or moving. Use one shell end-to-end, prefer native PowerShell cmdlets such as `Remove-Item` / `Move-Item` with `-LiteralPath`, and avoid string-built shell commands for file operations.
- Before any recursive delete or move on Windows, verify the resolved absolute target paths stay within the intended workspace or explicitly named target directory. Never issue a recursive delete or move against a computed path if the final target has not been checked.
- When using `Start-Process` to launch a background helper or service, pass `-WindowStyle Hidden` unless the user explicitly asked for a visible interactive window. Use visible windows only for interactive tools the user needs to see or control."#
}

#[cfg(test)]
#[path = "shell_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/unified_exec.rs
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellInvocation;
use crate::shell::get_shell_by_model_provided_path;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::PostToolUsePayload;
use codex_protocol::models::AdditionalPermissionProfile;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;

#[cfg(test)]
use crate::tools::handlers::parse_arguments;

mod exec_command;
mod write_stdin;

pub use exec_command::ExecCommandHandler;
pub(crate) use exec_command::ExecCommandHandlerOptions;
pub use write_stdin::WriteStdinHandler;

#[derive(Debug, Deserialize)]
pub(crate) struct ExecCommandArgs {
    pub(crate) cmd: String,
    #[serde(default)]
    shell: Option<String>,
    #[serde(default)]
    login: Option<bool>,
    #[serde(default = "default_tty")]
    tty: bool,
    #[serde(default = "default_exec_yield_time_ms")]
    yield_time_ms: u64,
    #[serde(default)]
    timeout_ms: Option<u64>,
    #[serde(default)]
    max_output_tokens: Option<usize>,
    #[serde(default)]
    sandbox_permissions: Option<SandboxPermissions>,
    #[serde(default)]
    additional_permissions: Option<AdditionalPermissionProfile>,
    #[serde(default)]
    justification: Option<String>,
    #[serde(default)]
    prefix_rule: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
struct ExecCommandEnvironmentArgs {
    #[serde(default)]
    environment_id: Option<String>,
    // Keep this raw until after environment selection; relative paths must be
    // resolved against the selected environment cwd, not the process cwd.
    #[serde(default)]
    workdir: Option<String>,
}

fn default_exec_yield_time_ms() -> u64 {
    10_000
}

fn default_write_stdin_yield_time_ms() -> u64 {
    250
}

fn default_tty() -> bool {
    false
}

#[derive(Debug)]
pub(crate) struct ResolvedCommand {
    pub(crate) command: Vec<String>,
    pub(crate) shell: ShellInvocation,
}

fn post_unified_exec_tool_use_payload(
    invocation: &ToolInvocation,
    result: &dyn ToolOutput,
) -> Option<PostToolUsePayload> {
    let ToolPayload::Function { .. } = &invocation.payload else {
        return None;
    };

    let tool_input = result.post_tool_use_input(&invocation.payload)?;
    let tool_use_id = result.post_tool_use_id(&invocation.call_id);
    let tool_response = result.post_tool_use_response(&tool_use_id, &invocation.payload)?;
    Some(PostToolUsePayload {
        tool_name: HookToolName::bash(),
        tool_use_id,
        tool_input,
        tool_response,
    })
}

pub(crate) fn get_command(
    args: &ExecCommandArgs,
    session_shell: Arc<Shell>,
    allow_login_shell: bool,
) -> Result<ResolvedCommand, String> {
    let use_login_shell = match args.login {
        Some(true) if !allow_login_shell => {
            return Err(
                "login shell is disabled by config; omit `login` or set it to false.".to_string(),
            );
        }
        Some(use_login_shell) => use_login_shell,
        None => allow_login_shell,
    };

    let shell = args
        .shell
        .as_ref()
        .map(|shell_str| get_shell_by_model_provided_path(&PathBuf::from(shell_str)))
        .unwrap_or_else(|| session_shell.as_ref().clone());
    Ok(ResolvedCommand {
        command: shell.derive_exec_args(&args.cmd, use_login_shell),
        shell: ShellInvocation {
            shell,
            use_login_shell,
        },
    })
}

#[cfg(test)]
#[path = "unified_exec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;

use crate::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS;
use crate::exec_policy::prompt_is_rejected_by_policy;
use crate::function_tool::FunctionCallError;
use crate::maybe_emit_implicit_skill_invocation;
use crate::tools::context::ExecCommandToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch::intercept_apply_patch;
use crate::tools::handlers::file_system_sandbox_policy_context_for_cwd;
use crate::tools::handlers::implicit_granted_permissions;
use crate::tools::handlers::normalize_and_validate_additional_permissions;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::parse_arguments_with_base_path;
use crate::tools::handlers::resolve_sandbox_permissions;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::rewrite_function_string_argument;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::unified_exec::ExecCommandRequest;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::unified_exec::generate_chunk_id;
use codex_features::Feature;
use codex_otel::SessionTelemetry;
use codex_otel::TOOL_CALL_UNIFIED_EXEC_METRIC;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_shell_command::shell_detect::detect_shell_type;
use codex_tools::JsonSchema;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_path_uri::PathConvention;
use codex_utils_string::truncate_middle_chars;

use super::super::shell_spec::CommandToolOptions;
use super::super::shell_spec::create_exec_command_tool_with_environment_id;
use super::ExecCommandArgs;
use super::ExecCommandEnvironmentArgs;
use super::get_command;
use super::post_unified_exec_tool_use_payload;

// A byte limit is a conservative hard token bound even for byte-fallback tokenizers.
const EXEC_COMMAND_REJECTION_MAX_BYTES: usize = 900;

#[derive(Clone, Copy)]
pub(crate) struct ExecCommandHandlerOptions {
    pub(crate) include_login_parameter: bool,
    pub(crate) include_shell_parameter: bool,
    pub(crate) allow_tty: bool,
    pub(crate) exec_permission_approvals_enabled: bool,
    pub(crate) include_environment_id: bool,
    pub(crate) include_windows_shell_guidance: bool,
}

#[derive(Clone, Copy)]
enum ExecCommandLifetime {
    Interactive,
    OneShot,
}

pub struct ExecCommandHandler {
    options: ExecCommandHandlerOptions,
    lifetime: ExecCommandLifetime,
}

impl Default for ExecCommandHandler {
    fn default() -> Self {
        Self {
            lifetime: ExecCommandLifetime::Interactive,
            options: ExecCommandHandlerOptions {
                include_login_parameter: false,
                include_shell_parameter: true,
                allow_tty: true,
                exec_permission_approvals_enabled: false,
                include_environment_id: false,
                include_windows_shell_guidance: cfg!(windows),
            },
        }
    }
}

impl ExecCommandHandler {
    pub(crate) fn new(options: ExecCommandHandlerOptions) -> Self {
        Self {
            options,
            lifetime: ExecCommandLifetime::Interactive,
        }
    }

    pub(crate) fn one_shot(options: ExecCommandHandlerOptions) -> Self {
        Self {
            options,
            lifetime: ExecCommandLifetime::OneShot,
        }
    }
}

impl ToolExecutor<ToolInvocation> for ExecCommandHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("exec_command")
    }

    fn spec(&self) -> ToolSpec {
        let spec = create_exec_command_tool_with_environment_id(
            CommandToolOptions {
                include_login_parameter: self.options.include_login_parameter,
                exec_permission_approvals_enabled: self.options.exec_permission_approvals_enabled,
            },
            self.options.include_environment_id,
            self.options.include_shell_parameter,
            self.options.include_windows_shell_guidance,
        );
        let mut spec = match self.lifetime {
            ExecCommandLifetime::Interactive => spec,
            ExecCommandLifetime::OneShot => one_shot_exec_command_spec(spec),
        };
        if !self.options.allow_tty
            && let ToolSpec::Function(spec) = &mut spec
        {
            spec.parameters
                .properties
                .get_or_insert_default()
                .remove("tty");
        }
        spec
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ExecCommandHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            step_context,
            cancellation_token,
            tracker,
            call_id,
            payload,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "exec_command handler received unsupported payload".to_string(),
                ));
            }
        };

        let manager: &UnifiedExecProcessManager = &session.services.unified_exec_manager;
        let context = UnifiedExecContext::new(
            session.clone(),
            step_context.clone(),
            cancellation_token,
            call_id.clone(),
        );
        let environment_args: ExecCommandEnvironmentArgs = parse_arguments(&arguments)?;
        let turn_environment = resolve_tool_environment(
            &step_context,
            environment_args.environment_id.as_deref(),
            "unified exec is unavailable in this session",
        )?;
        let native_environment_cwd = turn_environment.cwd().clone();
        let cwd = environment_args
            .workdir
            .as_deref()
            .filter(|workdir| !workdir.is_empty())
            .map_or_else(
                || Ok(native_environment_cwd.clone()),
                |workdir| native_environment_cwd.join(workdir),
            )
            .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
        let environment = Arc::clone(&turn_environment.environment);
        let fs = environment.get_filesystem();

        // Remote executors enforce URI-native sandbox policy themselves. Only a host-local
        // sandbox needs a native cwd for resolving paths nested in the permissions config.
        let requires_host_native_cwd = !environment.is_remote()
            && SandboxManager::new().select_initial(
                turn_environment.permission_profile(),
                SandboxablePreference::Auto,
                codex_protocol::sandbox::effective_windows_sandbox_type(
                    turn_environment.config().windows_sandbox_type,
                    turn_environment.config().windows_sandbox_level,
                ),
                turn.network.is_some(),
            ) != SandboxType::None;
        // `to_abs_path()` alone cannot identify foreign drive paths: `file:///C:/repo` is
        // representable as `/C:/repo` on POSIX. Require the inferred convention to match too.
        let cwd_uses_native_convention =
            cwd.infer_path_convention() == Some(PathConvention::native());
        let native_cwd = match cwd.to_abs_path() {
            Ok(cwd) if cwd_uses_native_convention => Some(cwd),
            _ if !requires_host_native_cwd => None,
            Err(err) => return Err(FunctionCallError::RespondToModel(err.to_string())),
            Ok(_) => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "path URI `{cwd}` does not use the host's native {} path convention",
                    PathConvention::native()
                )));
            }
        };
        let mut args: ExecCommandArgs = match native_cwd.as_ref() {
            Some(native_cwd) => {
                // The base path only resolves paths nested in the permissions config types.
                parse_arguments_with_base_path(&arguments, native_cwd)?
            }
            None => {
                // Foreign executor cwd values cannot seed this host's AbsolutePathBufGuard.
                // Sandbox intent and URI-native roots are still sent to the executor.
                parse_arguments(&arguments)?
            }
        };
        if args.tty && !session.features().enabled(Feature::UnifiedExecTty) {
            return Err(FunctionCallError::RespondToModel(
                "TTY execution is disabled by config; omit `tty` or set it to false.".to_string(),
            ));
        }
        let sandbox_permissions =
            resolve_sandbox_permissions(args.sandbox_permissions, args.justification.as_deref())?;
        let hook_command = args.cmd.clone();
        maybe_emit_implicit_skill_invocation(
            session.as_ref(),
            context.step_context.turn.as_ref(),
            &hook_command,
            &cwd,
            native_cwd.as_ref(),
            &turn_environment.selection.environment_id,
        )
        .await;
        // Remote environments may use a different OS and must build commands with their native
        // shell; fall back to the session shell when the environment did not report one.
        let shell = turn_environment
            .shell
            .clone()
            .map(Arc::new)
            .unwrap_or_else(|| session.user_shell());
        // TODO(anp): Resolve requested shells in remote environments instead of restricting
        // commands to the reported default shell.
        if environment.is_remote()
            && let Some(requested_shell) = args.shell.take()
        {
            let Some(remote_shell) = turn_environment.shell.as_ref() else {
                return Err(FunctionCallError::RespondToModel(format!(
                    "environment `{}` does not report a shell",
                    turn_environment.selection.environment_id
                )));
            };
            if detect_shell_type(Path::new(&requested_shell)) != Some(remote_shell.shell_type) {
                return Err(FunctionCallError::RespondToModel(format!(
                    "environment `{}` only supports `{}`",
                    turn_environment.selection.environment_id,
                    remote_shell.name()
                )));
            }
        }
        let resolved_command =
            get_command(&args, shell, turn_environment.config().allow_login_shell)
                .map_err(FunctionCallError::RespondToModel)?;
        let command = resolved_command.command;
        let ExecCommandArgs {
            mut tty,
            yield_time_ms,
            timeout_ms,
            max_output_tokens,
            sandbox_permissions: _,
            additional_permissions,
            justification,
            prefix_rule,
            ..
        } = args;
        let completion_timeout = match self.lifetime {
            ExecCommandLifetime::Interactive => None,
            ExecCommandLifetime::OneShot => {
                tty = false;
                Some(Duration::from_millis(
                    timeout_ms.unwrap_or(DEFAULT_EXEC_COMMAND_TIMEOUT_MS),
                ))
            }
        };

        let exec_permission_approvals_enabled =
            session.features().enabled(Feature::ExecPermissionApprovals);
        let requested_additional_permissions = additional_permissions.clone();
        let sandbox_context =
            turn_environment.sandbox_context(/*additional_permissions*/ None);
        let permission_context = file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd);
        let effective_additional_permissions = apply_granted_turn_permissions(
            &context.step_context,
            turn_environment,
            &cwd,
            sandbox_permissions,
            additional_permissions,
        );
        let additional_permissions_allowed = exec_permission_approvals_enabled
            || (session.features().enabled(Feature::RequestPermissionsTool)
                && effective_additional_permissions.permissions_preapproved);

        // Sticky turn permissions have already been approved, so they should
        // continue through the normal exec approval flow for the command.
        let approval_policy = context.step_context.settings.approval_policy();
        if effective_additional_permissions
            .sandbox_permissions
            .requests_sandbox_override()
            && !effective_additional_permissions.permissions_preapproved
            && prompt_is_rejected_by_policy(approval_policy, /*prompt_is_rule*/ false).is_some()
        {
            return Err(FunctionCallError::RespondToModel(format!(
                "approval policy is {approval_policy:?}; reject command — you cannot ask for escalated permissions if the approval policy is {approval_policy:?}"
            )));
        }

        let normalized_additional_permissions = match implicit_granted_permissions(
            sandbox_permissions,
            requested_additional_permissions.as_ref(),
            &effective_additional_permissions,
        )
        .map_or_else(
            || {
                normalize_and_validate_additional_permissions(
                    additional_permissions_allowed,
                    approval_policy,
                    effective_additional_permissions.sandbox_permissions,
                    effective_additional_permissions.additional_permissions,
                    effective_additional_permissions.permissions_preapproved,
                    &permission_context,
                )
            },
            |permissions| Ok(Some(permissions)),
        ) {
            Ok(normalized) => normalized,
            Err(err) => {
                return Err(FunctionCallError::RespondToModel(err));
            }
        };

        let intercepted_patch = intercept_apply_patch(
            &command,
            &cwd,
            fs.as_ref(),
            turn_environment.clone(),
            context.session.clone(),
            Arc::clone(&context.step_context),
            context.cancellation_token.clone(),
            Some(&tracker),
            &context.call_id,
            "exec_command",
        )
        .await;
        if let Some(output) = intercepted_patch? {
            return Ok(boxed_tool_output(ExecCommandToolOutput {
                event_call_id: String::new(),
                chunk_id: String::new(),
                wall_time: std::time::Duration::ZERO,
                raw_output: output.into_text().into_bytes(),
                truncation_policy: step_context.settings.model_info.truncation_policy.into(),
                max_output_tokens,
                process_id: None,
                exit_code: None,
                original_token_count: None,
                output_omitted_bytes: None,
                hook_command: None,
            }));
        }

        emit_unified_exec_tty_metric(&step_context.session_telemetry, tty);
        crate::tools::lifecycle::notify_command_start(
            context.session.as_ref(),
            context.step_context.turn.as_ref(),
            &context.call_id,
            &command,
            &cwd,
            fs.as_ref(),
        )
        .await;
        // Preparation can be cancelled. Reserve a process only once it is done.
        let process_id = manager.allocate_process_id().await;
        let request = ExecCommandRequest {
            command,
            shell: resolved_command.shell,
            hook_command: hook_command.clone(),
            process_id,
            yield_time_ms,
            max_output_tokens,
            cwd,
            sandbox_cwd: native_environment_cwd,
            turn_environment: turn_environment.clone(),
            network: context.step_context.turn.network.clone(),
            tty,
            sandbox_permissions: effective_additional_permissions.sandbox_permissions,
            additional_permissions: normalized_additional_permissions,
            additional_permissions_preapproved: effective_additional_permissions
                .permissions_preapproved,
            justification,
            prefix_rule,
        };
        let result = match completion_timeout {
            Some(timeout) => {
                UnifiedExecProcessManager::exec_command_to_completion(request, &context, timeout)
                    .await
            }
            None => manager.exec_command(request, &context).await,
        };
        match result {
            Ok(response) => Ok(boxed_tool_output(response)),
            Err(UnifiedExecError::SandboxDenied {
                output,
                original_token_count,
                output_omitted_bytes,
                ..
            }) => {
                let output_text = output.aggregated_output.text;
                let original_token_count =
                    original_token_count.unwrap_or_else(|| approx_token_count(&output_text));
                Ok(boxed_tool_output(ExecCommandToolOutput {
                    event_call_id: context.call_id.clone(),
                    chunk_id: generate_chunk_id(),
                    wall_time: output.duration,
                    raw_output: output_text.into_bytes(),
                    truncation_policy: step_context.settings.model_info.truncation_policy.into(),
                    max_output_tokens,
                    // Sandbox denial is terminal, so there is no live
                    // process for write_stdin to resume.
                    process_id: None,
                    exit_code: Some(output.exit_code),
                    original_token_count: Some(original_token_count),
                    output_omitted_bytes,
                    hook_command: Some(hook_command),
                }))
            }
            Err(err) => {
                let message = format!("exec_command failed: {err:?}");
                Err(FunctionCallError::RespondToModel(truncate_middle_chars(
                    &message,
                    EXEC_COMMAND_REJECTION_MAX_BYTES,
                )))
            }
        }
    }
}

fn one_shot_exec_command_spec(spec: ToolSpec) -> ToolSpec {
    let ToolSpec::Function(mut spec) = spec else {
        unreachable!("exec_command has a function schema");
    };
    spec.description = spec.description.replacen(
        "Runs a command in a PTY, returning output or a session ID for ongoing interaction.",
        "Runs a command to completion and returns its output. The process is terminated on timeout or cancellation and cannot be resumed.",
        1,
    );
    let properties = spec.parameters.properties.get_or_insert_default();
    properties.remove("tty");
    properties.remove("yield_time_ms");
    properties.insert(
        "timeout_ms".to_string(),
        JsonSchema::number(Some(
            "Maximum command runtime. Defaults to 10000 ms.".to_string(),
        )),
    );
    spec.output_schema = spec.output_schema.map(|schema| {
        let mut schema = schema.into_value();
        if let Some(output_properties) = schema
            .get_mut("properties")
            .and_then(serde_json::Value::as_object_mut)
        {
            output_properties.remove("session_id");
        }
        schema.into()
    });
    ToolSpec::Function(spec)
}

impl CoreToolRuntime for ExecCommandHandler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }

    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        let ToolPayload::Function { arguments } = &invocation.payload else {
            return None;
        };

        parse_arguments::<ExecCommandArgs>(arguments)
            .ok()
            .map(|args| PreToolUsePayload {
                tool_name: HookToolName::bash(),
                tool_input: serde_json::json!({ "command": args.cmd }),
            })
    }

    fn with_updated_hook_input(
        &self,
        mut invocation: ToolInvocation,
        updated_input: serde_json::Value,
    ) -> Result<ToolInvocation, FunctionCallError> {
        let ToolPayload::Function { arguments } = invocation.payload else {
            return Err(FunctionCallError::RespondToModel(
                "hook input rewrite received unsupported exec_command payload".to_string(),
            ));
        };
        invocation.payload = ToolPayload::Function {
            arguments: rewrite_function_string_argument(
                &arguments,
                "exec_command",
                "cmd",
                updated_hook_command(&updated_input)?,
            )?,
        };
        Ok(invocation)
    }

    fn post_tool_use_payload(
        &self,
        invocation: &ToolInvocation,
        result: &dyn crate::tools::context::ToolOutput,
    ) -> Option<PostToolUsePayload> {
        post_unified_exec_tool_use_payload(invocation, result)
    }
}

fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) {
    session_telemetry.counter(
        TOOL_CALL_UNIFIED_EXEC_METRIC,
        /*inc*/ 1,
        &[("tty", if tty { "true" } else { "false" })],
    );
}
codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs
use crate::function_tool::FunctionCallError;
use codex_features::Feature;

use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::sandboxing::ToolError;
use crate::unified_exec::UnifiedExecContext;
use crate::unified_exec::UnifiedExecError;
use crate::unified_exec::WriteStdinInteractionEvent;
use crate::unified_exec::WriteStdinRequest;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;

use super::super::shell_spec::create_write_stdin_tool;
use super::post_unified_exec_tool_use_payload;

#[derive(Debug, Deserialize)]
struct WriteStdinArgs {
    // The model is trained on `session_id`.
    session_id: i32,
    #[serde(default)]
    chars: String,
    #[serde(default = "super::default_write_stdin_yield_time_ms")]
    yield_time_ms: u64,
    #[serde(default)]
    max_output_tokens: Option<usize>,
}

pub struct WriteStdinHandler;

impl ToolExecutor<ToolInvocation> for WriteStdinHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("write_stdin")
    }

    fn spec(&self) -> ToolSpec {
        create_write_stdin_tool()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl WriteStdinHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            step_context,
            cancellation_token,
            call_id,
            payload,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "write_stdin handler received unsupported payload".to_string(),
                ));
            }
        };

        let args: WriteStdinArgs = parse_arguments(&arguments)?;
        if turn
            .config
            .features
            .get()
            .enabled(Feature::StableEnvironmentTools)
        {
            resolve_tool_environment(
                &step_context,
                /*environment_id*/ None,
                "unified exec is unavailable in this session",
            )?;
        }
        let context =
            UnifiedExecContext::new(session.clone(), step_context, cancellation_token, call_id);
        let response = session
            .services
            .unified_exec_manager
            .write_stdin(
                &context,
                WriteStdinRequest {
                    process_id: args.session_id,
                    input: &args.chars,
                    yield_time_ms: args.yield_time_ms,
                    max_output_tokens: args.max_output_tokens,
                    truncation_policy: context
                        .step_context
                        .settings
                        .model_info
                        .truncation_policy
                        .into(),
                    interaction_event: Some(WriteStdinInteractionEvent {
                        session: &session,
                        turn: &turn,
                    }),
                },
            )
            .await
            .map_err(|err| {
                let message = match err {
                    UnifiedExecError::StdinApproval(ToolError::Rejected(reason)) => {
                        format!("write_stdin rejected: {reason}")
                    }
                    UnifiedExecError::StdinApproval(ToolError::Codex(err)) => {
                        format!("write_stdin approval failed: {err}")
                    }
                    err => format!("write_stdin failed: {err}"),
                };
                FunctionCallError::RespondToModel(message)
            })?;

        Ok(boxed_tool_output(response))
    }
}

impl CoreToolRuntime for WriteStdinHandler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }

    fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        // `write_stdin` is transport for an existing exec session. Empty writes
        // are background polls, and non-empty writes continue a command that
        // already ran PreToolUse as Bash, so do not emit a second pre hook here.
        None
    }

    fn post_tool_use_payload(
        &self,
        invocation: &ToolInvocation,
        result: &dyn crate::tools::context::ToolOutput,
    ) -> Option<PostToolUsePayload> {
        // A `write_stdin` poll can observe final completion for the original
        // `exec_command`; emit that command's matching Bash PostToolUse.
        post_unified_exec_tool_use_payload(invocation, result)
    }
}

补丁 / 图片 / 用户输入

原 Python 稿把 apply_patch 并入 shell,但 Rust core 里有独立 handler;view_image 也不只是 base64,还带路径校验和错误语义。request_user_input 是模型只读、等待 UI 返回的交互工具,不是普通 shell 提示。

codex-rs/core/src/tools/handlers/apply_patch_spec.rs
use codex_tools::FreeformTool;
use codex_tools::FreeformToolFormat;
use codex_tools::ToolSpec;

const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("../../../assets/tools/apply_patch.lark");

/// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models
/// https://platform.openai.com/docs/guides/function-calling#custom-tools
pub fn create_apply_patch_freeform_tool(include_environment_id: bool) -> ToolSpec {
    let definition = if include_environment_id {
        APPLY_PATCH_LARK_GRAMMAR.replace(
            "start: begin_patch hunk+ end_patch",
            "start: begin_patch environment_id? hunk+ end_patch\nenvironment_id: \"*** Environment ID: \" filename LF",
        )
    } else {
        APPLY_PATCH_LARK_GRAMMAR.to_string()
    };
    ToolSpec::Freeform(FreeformTool {
        name: "apply_patch".to_string(),
        description: "The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.".to_string(),
        defer_loading: None,
        format: FreeformToolFormat {
            r#type: "grammar".to_string(),
            syntax: "lark".to_string(),
            definition,
        },
    })
}

#[cfg(test)]
#[path = "apply_patch_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/apply_patch.rs
use std::collections::HashMap;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio_util::sync::CancellationToken;

use crate::apply_patch;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::function_tool::FunctionCallError;
use crate::safety::PatchPolicyMatcher;
use crate::safety::PatchSandboxRoute;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext;
use crate::session::turn_context::TurnEnvironment;
use crate::tools::context::ApplyPatchToolOutput;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::events::ToolEmitter;
use crate::tools::events::ToolEventCtx;
use crate::tools::handlers::apply_granted_turn_permissions;
use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool;
use crate::tools::handlers::file_system_sandbox_policy_context_for_cwd;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::updated_hook_command;
use crate::tools::hook_names::HookToolName;
use crate::tools::orchestrator::ToolOrchestrator;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolArgumentDiffConsumer;
use crate::tools::registry::ToolExecutor;
use crate::tools::runtimes::apply_patch::ApplyPatchRequest;
use crate::tools::runtimes::apply_patch::ApplyPatchRuntime;
use crate::tools::sandboxing::ToolCtx;
use crate::windows_sandbox::windows_sandbox_level_for_legacy_checks;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::Hunk;
use codex_apply_patch::StreamingPatchParser;
use codex_exec_server::ExecutorFileSystem;
use codex_features::Feature;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::models::FileSystemPermissions;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::PatchApplyUpdatedEvent;
use codex_sandboxing::policy_transforms::effective_file_system_sandbox_policy;
use codex_sandboxing::policy_transforms::normalize_additional_permissions;
use codex_sandboxing::policy_transforms::normalize_additional_permissions_with_context;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_utils_path_uri::PathUri;

const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500);

/// Handles freeform `apply_patch` requests and routes verified patches to the
/// selected environment filesystem.
#[derive(Default)]
pub struct ApplyPatchHandler {
    multi_environment: bool,
}

impl ApplyPatchHandler {
    pub(crate) fn new(multi_environment: bool) -> Self {
        Self { multi_environment }
    }
}

#[derive(Default)]
struct ApplyPatchArgumentDiffConsumer {
    parser: StreamingPatchParser,
    last_sent_at: Option<Instant>,
    pending: Option<PatchApplyUpdatedEvent>,
}

impl ToolArgumentDiffConsumer for ApplyPatchArgumentDiffConsumer {
    fn consume_diff(
        &mut self,
        turn: &TurnContext,
        call_id: String,
        diff: &str,
    ) -> Option<EventMsg> {
        if !turn
            .config
            .features
            .enabled(Feature::ApplyPatchStreamingEvents)
        {
            return None;
        }

        self.push_delta(call_id, diff)
            .map(EventMsg::PatchApplyUpdated)
    }

    fn finish(&mut self) -> Result<Option<EventMsg>, FunctionCallError> {
        self.finish_update_on_complete()
            .map(|event| event.map(EventMsg::PatchApplyUpdated))
    }
}

impl ApplyPatchArgumentDiffConsumer {
    fn push_delta(&mut self, call_id: String, delta: &str) -> Option<PatchApplyUpdatedEvent> {
        let hunks = self.parser.push_delta(delta).ok()?;
        if hunks.is_empty() {
            return None;
        }
        let changes = convert_apply_patch_hunks_to_protocol(&hunks);
        let event = PatchApplyUpdatedEvent { call_id, changes };
        let now = Instant::now();
        match self.last_sent_at {
            Some(last_sent_at)
                if now.duration_since(last_sent_at) < APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL =>
            {
                self.pending = Some(event);
                None
            }
            Some(_) | None => {
                self.pending = None;
                self.last_sent_at = Some(now);
                Some(event)
            }
        }
    }

    fn finish_update_on_complete(
        &mut self,
    ) -> Result<Option<PatchApplyUpdatedEvent>, FunctionCallError> {
        self.parser.finish().map_err(|err| {
            FunctionCallError::RespondToModel(format!("failed to parse apply_patch: {err}"))
        })?;

        let event = self.pending.take();
        if event.is_some() {
            self.last_sent_at = Some(Instant::now());
        }
        Ok(event)
    }
}

fn convert_apply_patch_hunks_to_protocol(hunks: &[Hunk]) -> HashMap<PathBuf, FileChange> {
    hunks
        .iter()
        .map(|hunk| {
            let path = hunk_source_path(hunk).to_path_buf();
            let change = match hunk {
                Hunk::AddFile { contents, .. } => FileChange::Add {
                    content: contents.clone(),
                },
                Hunk::DeleteFile { .. } => FileChange::Delete {
                    content: String::new(),
                },
                Hunk::UpdateFile {
                    chunks, move_path, ..
                } => FileChange::Update {
                    unified_diff: format_update_chunks_for_progress(chunks),
                    move_path: move_path.clone(),
                },
            };
            (path, change)
        })
        .collect()
}

fn hunk_source_path(hunk: &Hunk) -> &Path {
    match hunk {
        Hunk::AddFile { path, .. } | Hunk::DeleteFile { path } | Hunk::UpdateFile { path, .. } => {
            path
        }
    }
}

fn format_update_chunks_for_progress(chunks: &[codex_apply_patch::UpdateFileChunk]) -> String {
    let mut unified_diff = String::new();
    for chunk in chunks {
        match &chunk.change_context {
            Some(context) => {
                unified_diff.push_str("@@ ");
                unified_diff.push_str(context);
                unified_diff.push('\n');
            }
            None => {
                unified_diff.push_str("@@");
                unified_diff.push('\n');
            }
        }
        for line in &chunk.old_lines {
            unified_diff.push('-');
            unified_diff.push_str(line);
            unified_diff.push('\n');
        }
        for line in &chunk.new_lines {
            unified_diff.push('+');
            unified_diff.push_str(line);
            unified_diff.push('\n');
        }
        if chunk.is_end_of_file {
            unified_diff.push_str("*** End of File");
            unified_diff.push('\n');
        }
    }
    unified_diff
}

fn file_paths_for_action(action: &ApplyPatchAction) -> Vec<PathUri> {
    let mut keys = Vec::new();
    for (path, change) in action.changes() {
        keys.push(path.clone());

        if let ApplyPatchFileChange::Update { move_path, .. } = change
            && let Some(dest) = move_path
        {
            keys.push(dest.clone());
        }
    }

    keys
}

fn write_permissions_for_paths(
    file_paths: &[PathUri],
    matching: &PatchPolicyMatcher<'_>,
) -> io::Result<Option<AdditionalPermissionProfile>> {
    let sandbox_route = matching.sandbox_route;
    let context = &matching.context;
    let mut write_paths = Vec::new();
    for path in file_paths {
        // Skip already-writable targets before deriving parent permissions.
        // Otherwise, a writable directory could grant access to its parent.
        if matching.can_write_path(path)? {
            continue;
        }
        let parent = path
            .parent()
            .or_else(|| match sandbox_route {
                PatchSandboxRoute::Platform(_) => {
                    // Host path rules can recover parents of opaque local paths.
                    // `to_abs_path` verifies that the target round-trips losslessly.
                    path.to_abs_path().ok()?.parent().map(PathUri::from)
                }
                PatchSandboxRoute::ExecutorManaged => None,
            })
            .unwrap_or_else(|| path.clone());
        if !matching.can_write_path(&parent)? {
            write_paths.push(parent);
        }
    }
    write_paths.sort_by_key(PathUri::to_string);
    write_paths.dedup();

    if write_paths.is_empty() {
        return Ok(None);
    }
    let permissions = AdditionalPermissionProfile {
        file_system: Some(FileSystemPermissions::from_read_write_path_uris(
            Some(vec![]),
            Some(write_paths),
        )),
        ..Default::default()
    };

    Ok(match sandbox_route {
        PatchSandboxRoute::Platform(_) => normalize_additional_permissions(permissions).ok(),
        PatchSandboxRoute::ExecutorManaged => {
            normalize_additional_permissions_with_context(permissions, context).ok()
        }
    })
}

/// Extracts the raw patch text used as the command-shaped hook input for apply_patch.
fn apply_patch_payload_command(payload: &ToolPayload) -> Option<String> {
    match payload {
        ToolPayload::Custom { input } => Some(input.clone()),
        _ => None,
    }
}

impl ToolExecutor<ToolInvocation> for ApplyPatchHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("apply_patch")
    }

    fn spec(&self) -> ToolSpec {
        create_apply_patch_freeform_tool(self.multi_environment)
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ApplyPatchHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            step_context,
            cancellation_token,
            tracker,
            call_id,
            tool_name,
            payload,
            ..
        } = invocation;

        let ToolPayload::Custom { input: patch_input } = payload else {
            return Err(FunctionCallError::RespondToModel(
                "apply_patch handler received unsupported payload".to_string(),
            ));
        };
        let args = match codex_apply_patch::parse_patch(&patch_input) {
            Ok(args) => args,
            Err(parse_error) => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "apply_patch verification failed: {parse_error}"
                )));
            }
        };
        let selected_environment_id =
            require_environment_id(args.environment_id.as_deref(), self.multi_environment)?;

        // Verify the parsed patch against the selected environment filesystem.
        let turn_environment = resolve_tool_environment(
            &step_context,
            selected_environment_id.as_deref(),
            "apply_patch is unavailable in this session",
        )?;
        let fs = turn_environment.environment.get_filesystem();
        let sandbox = turn_environment.sandbox_context(/*additional_permissions*/ None);
        match codex_apply_patch::verify_apply_patch_args(
            args,
            turn_environment.cwd(),
            fs.as_ref(),
            Some(&sandbox),
        )
        .await
        {
            codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
                let tool_ctx = ToolCtx {
                    session,
                    step_context: Arc::clone(&step_context),
                    cancellation_token,
                    call_id,
                    tool_name,
                };
                let content = execute_verified_patch(
                    changes,
                    turn_environment.clone(),
                    Some(&tracker),
                    tool_ctx,
                )
                .await?;
                Ok(boxed_tool_output(ApplyPatchToolOutput::from_text(content)))
            }
            codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
                Err(FunctionCallError::RespondToModel(format!(
                    "apply_patch verification failed: {parse_error}"
                )))
            }
            codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
                tracing::trace!("Failed to parse apply_patch input, {error:?}");
                Err(FunctionCallError::RespondToModel(
                    "apply_patch handler received invalid patch input".to_string(),
                ))
            }
            codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => {
                Err(FunctionCallError::RespondToModel(
                    "apply_patch handler received non-apply_patch input".to_string(),
                ))
            }
        }
    }
}

impl CoreToolRuntime for ApplyPatchHandler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Custom { .. })
    }

    fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
        Some(Box::<ApplyPatchArgumentDiffConsumer>::default())
    }

    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        apply_patch_payload_command(&invocation.payload).map(|command| PreToolUsePayload {
            tool_name: HookToolName::apply_patch(),
            tool_input: serde_json::json!({ "command": command }),
        })
    }

    fn with_updated_hook_input(
        &self,
        mut invocation: ToolInvocation,
        updated_input: serde_json::Value,
    ) -> Result<ToolInvocation, FunctionCallError> {
        let patch = updated_hook_command(&updated_input)?;
        invocation.payload = match invocation.payload {
            ToolPayload::Custom { .. } => ToolPayload::Custom {
                input: patch.to_string(),
            },
            payload => payload,
        };
        Ok(invocation)
    }

    fn post_tool_use_payload(
        &self,
        invocation: &ToolInvocation,
        result: &dyn crate::tools::context::ToolOutput,
    ) -> Option<PostToolUsePayload> {
        let tool_response =
            result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
        Some(PostToolUsePayload {
            tool_name: HookToolName::apply_patch(),
            tool_use_id: invocation.call_id.clone(),
            tool_input: serde_json::json!({
                "command": apply_patch_payload_command(&invocation.payload)?,
            }),
            tool_response,
        })
    }
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn intercept_apply_patch(
    command: &[String],
    cwd: &PathUri,
    fs: &dyn ExecutorFileSystem,
    turn_environment: TurnEnvironment,
    session: Arc<Session>,
    step_context: Arc<StepContext>,
    cancellation_token: CancellationToken,
    tracker: Option<&SharedTurnDiffTracker>,
    call_id: &str,
    tool_name: &str,
) -> Result<Option<FunctionToolOutput>, FunctionCallError> {
    let sandbox = turn_environment.sandbox_context(/*additional_permissions*/ None);
    match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox))
        .await
    {
        codex_apply_patch::MaybeApplyPatchVerified::Body(changes) => {
            let tool_ctx = ToolCtx {
                session,
                step_context,
                cancellation_token,
                call_id: call_id.to_string(),
                tool_name: ToolName::plain(tool_name),
            };
            let content =
                execute_verified_patch(changes, turn_environment, tracker, tool_ctx).await?;
            Ok(Some(FunctionToolOutput::from_text(content, Some(true))))
        }
        codex_apply_patch::MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
            Err(FunctionCallError::RespondToModel(format!(
                "apply_patch verification failed: {parse_error}"
            )))
        }
        codex_apply_patch::MaybeApplyPatchVerified::ShellParseError(error) => {
            tracing::trace!("Failed to parse apply_patch input, {error:?}");
            Ok(None)
        }
        codex_apply_patch::MaybeApplyPatchVerified::NotApplyPatch => Ok(None),
    }
}

async fn execute_verified_patch(
    action: ApplyPatchAction,
    turn_environment: TurnEnvironment,
    tracker: Option<&SharedTurnDiffTracker>,
    tool_ctx: ToolCtx,
) -> Result<String, FunctionCallError> {
    let cwd = action.cwd.clone();
    let sandbox_context = turn_environment.sandbox_context(/*additional_permissions*/ None);
    let policy_context = file_system_sandbox_policy_context_for_cwd(&sandbox_context, &cwd);
    let sandbox_route = if turn_environment.environment.is_remote() {
        PatchSandboxRoute::ExecutorManaged
    } else {
        PatchSandboxRoute::Platform(windows_sandbox_level_for_legacy_checks(
            turn_environment.config().windows_sandbox_type,
            turn_environment.config().windows_sandbox_level,
        ))
    };
    let environment_id = turn_environment.selection.environment_id.as_str();
    let file_paths = file_paths_for_action(&action);
    let granted_permissions = tool_ctx
        .step_context
        .turn
        .granted_permissions(environment_id);
    let base_file_system_sandbox_policy = turn_environment
        .permission_profile()
        .file_system_sandbox_policy();
    let file_system_sandbox_policy = effective_file_system_sandbox_policy(
        &base_file_system_sandbox_policy,
        granted_permissions.as_ref(),
    );
    let matching = sandbox_route
        .prepare_matching(&file_system_sandbox_policy, &policy_context)
        .map_err(|error| {
            FunctionCallError::RespondToModel(format!(
                "failed to prepare patch permissions: {error}"
            ))
        })?;
    let additional_permissions =
        write_permissions_for_paths(&file_paths, &matching).map_err(|error| {
            FunctionCallError::RespondToModel(format!("failed to check patch permissions: {error}"))
        })?;
    let effective_additional_permissions = apply_granted_turn_permissions(
        &tool_ctx.step_context,
        &turn_environment,
        &cwd,
        crate::sandboxing::SandboxPermissions::UseDefault,
        additional_permissions,
    );
    let apply = apply_patch::prepare_apply_patch(
        &tool_ctx.step_context,
        &turn_environment,
        &matching,
        action,
    )?;
    let changes = convert_apply_patch_to_protocol(&apply.action);
    let emitter = ToolEmitter::apply_patch_for_environment(
        changes.clone(),
        apply.auto_approved,
        turn_environment.selection.environment_id.clone(),
    );
    let event_ctx = ToolEventCtx::new(
        tool_ctx.session.as_ref(),
        tool_ctx.step_context.turn.as_ref(),
        &tool_ctx.step_context.settings.model_info,
        &tool_ctx.call_id,
        tracker,
    );
    emitter.begin(event_ctx).await;

    let request = ApplyPatchRequest {
        turn_environment,
        action: apply.action,
        file_paths,
        changes: Arc::new(changes),
        exec_approval_requirement: apply.exec_approval_requirement,
        additional_permissions: effective_additional_permissions.additional_permissions,
        permissions_preapproved: effective_additional_permissions.permissions_preapproved,
    };
    let mut orchestrator = ToolOrchestrator::new();
    let mut runtime = ApplyPatchRuntime::new();
    let result = orchestrator
        .run(&mut runtime, &request, &tool_ctx)
        .await
        .map(|result| result.output);
    let (result, delta) = match result {
        Ok(output) => (Ok(output.exec_output), Some(output.delta)),
        Err(error) => (Err(error), Some(runtime.committed_delta().clone())),
    };
    let event_ctx = ToolEventCtx::new(
        tool_ctx.session.as_ref(),
        tool_ctx.step_context.turn.as_ref(),
        &tool_ctx.step_context.settings.model_info,
        &tool_ctx.call_id,
        tracker,
    );
    emitter.finish(event_ctx, result, delta.as_ref()).await
}

fn require_environment_id(
    parsed_environment_id: Option<&str>,
    allow_environment_id: bool,
) -> Result<Option<String>, FunctionCallError> {
    match parsed_environment_id {
        Some(_) if !allow_environment_id => Err(FunctionCallError::RespondToModel(
            "apply_patch environment selection is unavailable for this turn".to_string(),
        )),
        Some(environment_id) => Ok(Some(environment_id.to_string())),
        None => Ok(None),
    }
}

#[cfg(test)]
#[path = "apply_patch_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/view_image_spec.rs
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ViewImageToolOptions {
    pub can_request_original_image_detail: bool,
    pub unified_image_budget: bool,
    pub include_environment_id: bool,
}

pub fn create_view_image_tool(options: ViewImageToolOptions) -> ToolSpec {
    let mut properties = BTreeMap::from([(
        "path".to_string(),
        JsonSchema::string(Some("Local filesystem path to an image file.".to_string())),
    )]);
    if options.can_request_original_image_detail && !options.unified_image_budget {
        properties.insert(
            "detail".to_string(),
            JsonSchema::string_enum(
                vec![json!("high"), json!("original")],
                Some(
                    "Image detail level. Defaults to `high`; use `original` to preserve exact resolution.".to_string(),
                ),
            ),
        );
    }
    if options.include_environment_id {
        properties.insert(
            "environment_id".to_string(),
            JsonSchema::string(Some(
                "Environment id from <environment_context>. Omit to use the primary environment."
                    .to_string(),
            )),
        );
    }

    ToolSpec::Function(ResponsesApiTool {
        name: VIEW_IMAGE_TOOL_NAME.to_string(),
        description: "View a local image file from the filesystem when visual inspection is needed. Use this for images already available on disk."
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, Some(vec!["path".to_string()]), Some(false.into())),
        output_schema: Some(view_image_output_schema(options).into()),
    })
}

fn view_image_output_schema(options: ViewImageToolOptions) -> Value {
    let mut schema = json!({
        "type": "object",
        "properties": {
            "image_url": {
                "type": "string",
                "description": "Data URL for the loaded image."
            }
        },
        "required": ["image_url"],
        "additionalProperties": false
    });
    if !options.unified_image_budget {
        schema["properties"]["detail"] = json!({
            "type": "string",
            "enum": ["high", "original"],
            "description": "Image detail hint returned by view_image. Returns `high` for default resized behavior or `original` when original resolution is preserved."
        });
        schema["required"] = json!(["image_url", "detail"]);
    }
    schema
}
codex-rs/core/src/tools/handlers/view_image.rs
use codex_exec_server::GetMetadataOptions;
use codex_exec_server::ReadFileOptions;
use codex_protocol::items::ImageViewItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ImageReference;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::InputModality;
use codex_utils_image::data_url_from_bytes;
use serde::Deserialize;

use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::handlers::view_image_spec::create_view_image_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

pub struct ViewImageHandler {
    options: ViewImageToolOptions,
}

impl Default for ViewImageHandler {
    fn default() -> Self {
        Self {
            options: ViewImageToolOptions {
                can_request_original_image_detail: false,
                unified_image_budget: false,
                include_environment_id: false,
            },
        }
    }
}

impl ViewImageHandler {
    pub(crate) fn new(options: ViewImageToolOptions) -> Self {
        Self { options }
    }
}

const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str =
    "view_image is not allowed because you do not support image inputs";
const VIEW_IMAGE_INVALID_MESSAGE: &str =
    "unable to process image: invalid or unsupported image data";

#[derive(Deserialize)]
struct ViewImageArgs {
    path: String,
    #[serde(default)]
    environment_id: Option<String>,
    detail: Option<String>,
}

#[derive(Clone, Copy, Eq, PartialEq)]
enum ViewImageDetail {
    High,
    Original,
}

impl ToolExecutor<ToolInvocation> for ViewImageHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("view_image")
    }

    fn spec(&self) -> ToolSpec {
        create_view_image_tool(self.options)
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ViewImageHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        if !invocation
            .step_context
            .settings
            .model_info
            .input_modalities
            .contains(&InputModality::Image)
        {
            return Err(FunctionCallError::RespondToModel(
                VIEW_IMAGE_UNSUPPORTED_MESSAGE.to_string(),
            ));
        }

        let ToolInvocation {
            session,
            turn,
            step_context,
            payload,
            call_id,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "view_image handler received unsupported payload".to_string(),
                ));
            }
        };

        let ViewImageArgs {
            path,
            environment_id,
            detail,
        } = parse_arguments(&arguments)?;
        // Keep accepting previously supported detail hints after they disappear from the schema.
        let detail = match detail.as_deref() {
            None => None,
            Some("high") => Some(ViewImageDetail::High),
            Some("original") => Some(ViewImageDetail::Original),
            Some(detail) => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `{detail}`"
                )));
            }
        };

        let turn_environment = resolve_tool_environment(
            &step_context,
            environment_id.as_deref(),
            "view_image is unavailable in this session",
        )?;
        let path_uri = turn_environment.cwd().join(&path).map_err(|err| {
            FunctionCallError::RespondToModel(format!(
                "unable to resolve image path `{path}` against environment cwd `{}`: {err}",
                turn_environment.cwd(),
            ))
        })?;
        let model_visible_path = path_uri.inferred_native_path_string();
        let sandbox = turn_environment.sandbox_context(/*additional_permissions*/ None);
        let fs = turn_environment.environment.get_filesystem();

        let metadata = fs
            .get_metadata(&path_uri, GetMetadataOptions::default(), Some(&sandbox))
            .await
            .map_err(|error| {
                FunctionCallError::RespondToModel(format!(
                    "unable to locate image at `{model_visible_path}`: {error}"
                ))
            })?;

        if !metadata.is_file {
            return Err(FunctionCallError::RespondToModel(format!(
                "image path `{model_visible_path}` is not a file"
            )));
        }
        let file_bytes = fs
            .read_file(&path_uri, ReadFileOptions::default(), Some(&sandbox))
            .await
            .map_err(|error| {
                FunctionCallError::RespondToModel(format!(
                    "unable to read image at `{model_visible_path}`: {error}"
                ))
            })?;
        // Reject non-images before their bytes can reach code mode without changing
        // valid image bytes, metadata, or centralized image preparation.
        image::load_from_memory(&file_bytes).map_err(|_| {
            FunctionCallError::RespondToModel(VIEW_IMAGE_INVALID_MESSAGE.to_string())
        })?;

        let can_request_original_detail =
            can_request_original_image_detail(&step_context.settings.model_info);
        let use_original_detail = self.options.unified_image_budget
            || can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original));
        let image_detail = if use_original_detail {
            ImageDetail::Original
        } else {
            DEFAULT_IMAGE_DETAIL
        };

        // The history insertion path owns image preparation and resizing.
        let image_url = data_url_from_bytes("application/octet-stream", &file_bytes);

        let item = TurnItem::ImageView(ImageViewItem {
            id: call_id,
            path: path_uri,
        });
        session.emit_turn_item_started(turn.as_ref(), &item).await;
        session.emit_turn_item_completed(turn.as_ref(), item).await;

        Ok(boxed_tool_output(ViewImageOutput {
            image_url,
            image_detail,
            unified_image_budget: self.options.unified_image_budget,
        }))
    }
}

impl CoreToolRuntime for ViewImageHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}

pub struct ViewImageOutput {
    image_url: String,
    image_detail: ImageDetail,
    unified_image_budget: bool,
}

impl ToolOutput for ViewImageOutput {
    fn log_output(&self) -> String {
        format!("<image data URL omitted: {} bytes>", self.image_url.len())
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
        let body =
            FunctionCallOutputBody::ContentItems(vec![FunctionCallOutputContentItem::InputImage {
                image: ImageReference::Inline {
                    image_url: self.image_url.clone(),
                },
                detail: Some(self.image_detail),
            }]);
        let output = FunctionCallOutputPayload {
            body,
            success: Some(true),
        };

        ResponseInputItem::FunctionCallOutput {
            call_id: call_id.to_string(),
            output,
        }
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> serde_json::Value {
        if self.unified_image_budget {
            serde_json::json!({ "image_url": self.image_url })
        } else {
            serde_json::json!({
                "image_url": self.image_url,
                "detail": self.image_detail
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::PermissionProfileSnapshot;
    use crate::environment_selection::TurnEnvironmentState;
    use crate::session::step_context::StepContext;
    use crate::session::tests::make_session_and_context;
    use crate::session::turn_context::TurnEnvironment;
    use crate::tools::context::ToolCallSource;
    use crate::tools::context::ToolInvocation;
    use crate::turn_diff_tracker::TurnDiffTracker;
    use codex_protocol::models::PermissionProfile;
    use codex_protocol::permissions::FileSystemAccessMode;
    use codex_protocol::permissions::FileSystemSandboxEntry;
    use codex_protocol::permissions::FileSystemSandboxPolicy;
    use codex_protocol::permissions::NetworkSandboxPolicy;
    use codex_utils_absolute_path::AbsolutePathBuf;
    use codex_utils_path_uri::PathUri;
    use core_test_support::TempDirExt;
    use image::ImageBuffer;
    use image::ImageFormat;
    use image::Rgba;
    use pretty_assertions::assert_eq;
    use serde_json::json;
    use std::io::Cursor;
    use std::sync::Arc;
    use tokio::sync::Mutex;

    fn replace_primary_environment_cwd(turn: &mut crate::TurnContext, cwd: AbsolutePathBuf) {
        let mut current = turn
            .initial_environments
            .turn_environments()
            .next()
            .cloned()
            .expect("default local turn environment");
        current.config_mut().workspace_roots.clear();
        let mut selection = current.selection;
        selection.cwd = PathUri::from_abs_path(&cwd);
        selection.workspace_roots.clear();
        turn.initial_environments.environments[0] =
            TurnEnvironmentState::Ready(TurnEnvironment::new(
                selection,
                current.config_origin,
                current.environment,
                current.shell,
            ));
    }

    fn tiny_png() -> Vec<u8> {
        let image = ImageBuffer::from_pixel(
            /*width*/ 1,
            /*height*/ 1,
            Rgba([255u8, 0, 0, 255]),
        );
        let mut bytes = Vec::new();
        image
            .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png)
            .expect("encode test image");
        bytes
    }

    #[test]
    fn log_preview_omits_image_data() {
        let output = ViewImageOutput {
            image_url: "data:image/png;base64,AAA".to_string(),
            image_detail: DEFAULT_IMAGE_DETAIL,
            unified_image_budget: false,
        };

        assert_eq!(output.log_output(), "<image data URL omitted: 25 bytes>");
    }

    #[test]
    fn code_mode_result_returns_image_url_object() {
        let output = ViewImageOutput {
            image_url: "data:image/png;base64,AAA".to_string(),
            image_detail: DEFAULT_IMAGE_DETAIL,
            unified_image_budget: false,
        };

        let result = output.code_mode_result(&ToolPayload::Function {
            arguments: "{}".to_string(),
        });

        assert_eq!(
            result,
            json!({
                "image_url": "data:image/png;base64,AAA",
                "detail": "high",
            })
        );
    }

    #[tokio::test]
    async fn handle_passes_sandbox_context_for_local_filesystem_reads() {
        let (session, mut turn) = make_session_and_context().await;
        let image_dir = tempfile::tempdir().expect("create image temp dir");
        let image_cwd = image_dir.abs();

        replace_primary_environment_cwd(&mut turn, image_cwd.clone());
        let image_path = image_cwd.join("image.png");
        std::fs::write(image_path.as_path(), tiny_png()).expect("write test image");
        Arc::make_mut(&mut turn.config)
            .permissions
            .set_permission_profile(PermissionProfile::Disabled)
            .expect("set thread permission profile");
        let TurnEnvironmentState::Ready(environment) =
            &mut turn.initial_environments.environments[0]
        else {
            panic!("primary environment should be ready");
        };
        environment.config_mut().permission_profile =
            PermissionProfileSnapshot::legacy(PermissionProfile::from_runtime_permissions(
                &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new(
                    image_cwd.into(),
                    FileSystemAccessMode::Read,
                )]),
                NetworkSandboxPolicy::Restricted,
            ));
        let turn = Arc::new(turn);

        let result = ViewImageHandler::default()
            .handle(ToolInvocation {
                session: Arc::new(session),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-view-image".to_string(),
                tool_name: codex_tools::ToolName::plain("view_image"),
                source: ToolCallSource::Direct,
                payload: ToolPayload::Function {
                    arguments: json!({ "path": "image.png" }).to_string(),
                },
            })
            .await;

        let Err(FunctionCallError::RespondToModel(message)) = result else {
            panic!("expected sandboxed filesystem error");
        };
        assert!(
            message.contains("sandboxed filesystem operations require configured runtime paths"),
            "{message}"
        );
    }

    #[tokio::test]
    async fn handle_rejects_unsupported_detail() {
        let (session, turn) = make_session_and_context().await;
        let turn = Arc::new(turn);

        let result = ViewImageHandler::default()
            .handle(ToolInvocation {
                session: Arc::new(session),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-view-image".to_string(),
                tool_name: codex_tools::ToolName::plain("view_image"),
                source: ToolCallSource::Direct,
                payload: ToolPayload::Function {
                    arguments: json!({ "path": "image.png", "detail": "low" }).to_string(),
                },
            })
            .await;

        let Err(FunctionCallError::RespondToModel(message)) = result else {
            panic!("expected unsupported detail error");
        };
        assert_eq!(
            message,
            "view_image.detail only supports `high` or `original`; omit `detail` for default high resized behavior, got `low`"
        );
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn handle_accepts_explicit_high_detail() {
        let (session, mut turn) = make_session_and_context().await;
        let image_dir = tempfile::tempdir().expect("create image temp dir");
        let image_cwd = image_dir.abs();

        replace_primary_environment_cwd(&mut turn, image_cwd.clone());
        let image_path = image_cwd.join("image.png");
        std::fs::write(image_path.as_path(), tiny_png()).expect("write test image");
        let TurnEnvironmentState::Ready(environment) =
            &mut turn.initial_environments.environments[0]
        else {
            panic!("primary environment should be ready");
        };
        environment.config_mut().permission_profile =
            PermissionProfileSnapshot::legacy(PermissionProfile::Disabled);
        let turn = Arc::new(turn);

        let result = ViewImageHandler::default()
            .handle(ToolInvocation {
                session: Arc::new(session),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-view-image".to_string(),
                tool_name: codex_tools::ToolName::plain("view_image"),
                source: ToolCallSource::Direct,
                payload: ToolPayload::Function {
                    arguments: json!({ "path": "image.png", "detail": "high" }).to_string(),
                },
            })
            .await;

        result.expect("explicit high detail should be accepted");
    }

    #[tokio::test(flavor = "multi_thread")]
    async fn handle_rejects_invalid_image_before_returning_output_to_code_mode() {
        let (session, mut turn) = make_session_and_context().await;
        let image_dir = tempfile::tempdir().expect("create image temp dir");
        let image_cwd = image_dir.abs();

        replace_primary_environment_cwd(&mut turn, image_cwd.clone());
        let image_path = image_cwd.join("not-an-image.txt");
        std::fs::write(image_path.as_path(), b"arbitrary file contents")
            .expect("write invalid image");
        let TurnEnvironmentState::Ready(environment) =
            &mut turn.initial_environments.environments[0]
        else {
            panic!("primary environment should be ready");
        };
        environment.config_mut().permission_profile =
            PermissionProfileSnapshot::legacy(PermissionProfile::Disabled);
        let turn = Arc::new(turn);

        let result = ViewImageHandler::default()
            .handle(ToolInvocation {
                session: Arc::new(session),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-view-image".to_string(),
                tool_name: codex_tools::ToolName::plain("view_image"),
                source: ToolCallSource::CodeMode {
                    cell_id: "cell-1".to_string(),
                    runtime_tool_call_id: "tool-1".to_string(),
                },
                payload: ToolPayload::Function {
                    arguments: json!({ "path": "not-an-image.txt" }).to_string(),
                },
            })
            .await;

        let Err(FunctionCallError::RespondToModel(message)) = result else {
            panic!("expected invalid image error");
        };
        assert_eq!(message, VIEW_IMAGE_INVALID_MESSAGE);
    }
}
codex-rs/core/src/tools/handlers/request_user_input_spec.rs
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputQuestion;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap;

pub const REQUEST_USER_INPUT_TOOL_NAME: &str = "request_user_input";

#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub(crate) struct RequestUserInputToolArgs {
    pub questions: Vec<RequestUserInputQuestion>,
}

pub fn create_request_user_input_tool(description: String) -> ToolSpec {
    let option_props = BTreeMap::from([
        (
            "label".to_string(),
            JsonSchema::string(Some("User-facing label (1-5 words).".to_string())),
        ),
        (
            "description".to_string(),
            JsonSchema::string(Some(
                "One short sentence explaining impact/tradeoff if selected.".to_string(),
            )),
        ),
    ]);

    let options_schema = JsonSchema::array(JsonSchema::object(
            option_props,
            Some(vec!["label".to_string(), "description".to_string()]),
            Some(false.into()),
        ), Some(
            "Provide 2-3 mutually exclusive choices. Put the recommended option first and suffix its label with \"(Recommended)\". Do not include an \"Other\" option in this list; the client will add a free-form \"Other\" option automatically."
                .to_string(),
        ));

    let question_props = BTreeMap::from([
        (
            "id".to_string(),
            JsonSchema::string(Some(
                "Stable identifier for mapping answers (snake_case).".to_string(),
            )),
        ),
        (
            "header".to_string(),
            JsonSchema::string(Some(
                "Short header label shown in the UI (12 or fewer chars).".to_string(),
            )),
        ),
        (
            "question".to_string(),
            JsonSchema::string(Some(
                "Single-sentence prompt shown to the user.".to_string(),
            )),
        ),
        ("options".to_string(), options_schema),
    ]);

    let questions_schema = JsonSchema::array(
        JsonSchema::object(
            question_props,
            Some(vec![
                "id".to_string(),
                "header".to_string(),
                "question".to_string(),
                "options".to_string(),
            ]),
            Some(false.into()),
        ),
        Some("Questions to show the user. Prefer 1 and do not exceed 3".to_string()),
    );

    let properties = BTreeMap::from([("questions".to_string(), questions_schema)]);

    ToolSpec::Function(ResponsesApiTool {
        name: REQUEST_USER_INPUT_TOOL_NAME.to_string(),
        description,
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["questions".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}

pub fn request_user_input_unavailable_message(
    mode: ModeKind,
    available_modes: &[ModeKind],
) -> Option<String> {
    if available_modes.contains(&mode) {
        None
    } else {
        let mode_name = mode.display_name();
        Some(format!(
            "request_user_input is unavailable in {mode_name} mode"
        ))
    }
}

pub(crate) fn normalize_request_user_input_tool_args(
    mut args: RequestUserInputToolArgs,
) -> Result<RequestUserInputToolArgs, String> {
    let missing_options = args
        .questions
        .iter()
        .any(|question| question.options.as_ref().is_none_or(Vec::is_empty));
    if missing_options {
        return Err("request_user_input requires non-empty options for every question".to_string());
    }

    for question in &mut args.questions {
        question.is_other = true;
    }

    Ok(args)
}

pub fn request_user_input_tool_description(available_modes: &[ModeKind]) -> String {
    let allowed_modes = format_allowed_modes(available_modes);
    format!(
        "Request user input for one to three short questions and wait for the response. This tool is only available in {allowed_modes}."
    )
}

fn format_allowed_modes(available_modes: &[ModeKind]) -> String {
    let mode_names: Vec<&str> = available_modes
        .iter()
        .map(|mode| mode.display_name())
        .collect();

    match mode_names.as_slice() {
        [] => "no modes".to_string(),
        [mode] => format!("{mode} mode"),
        [first, second] => format!("{first} or {second} mode"),
        [..] => format!("modes: {}", mode_names.join(",")),
    }
}

#[cfg(test)]
#[path = "request_user_input_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/request_user_input.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::request_user_input_spec::REQUEST_USER_INPUT_TOOL_NAME;
use crate::tools::handlers::request_user_input_spec::RequestUserInputToolArgs;
use crate::tools::handlers::request_user_input_spec::create_request_user_input_tool;
use crate::tools::handlers::request_user_input_spec::normalize_request_user_input_tool_args;
use crate::tools::handlers::request_user_input_spec::request_user_input_tool_description;
use crate::tools::handlers::request_user_input_spec::request_user_input_unavailable_message;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_features::Feature;
use codex_history::RetainedContextEvent;
use codex_history::VerifiedAnswer;
use codex_history::VerifiedQuestionAnswer;
use codex_protocol::config_types::ModeKind;
use codex_protocol::request_user_input::RequestUserInputArgs;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

pub struct RequestUserInputHandler {
    pub available_modes: Vec<ModeKind>,
}

impl ToolExecutor<ToolInvocation> for RequestUserInputHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(REQUEST_USER_INPUT_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_request_user_input_tool(request_user_input_tool_description(&self.available_modes))
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl RequestUserInputHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            call_id,
            payload,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "{REQUEST_USER_INPUT_TOOL_NAME} handler received unsupported payload"
                )));
            }
        };

        if turn.session_source.is_non_root_agent() {
            return Err(FunctionCallError::RespondToModel(
                "request_user_input can only be used by the root thread".to_string(),
            ));
        }

        let mode = turn.collaboration_mode().mode;
        if let Some(message) = request_user_input_unavailable_message(mode, &self.available_modes) {
            return Err(FunctionCallError::RespondToModel(message));
        }

        let args: RequestUserInputToolArgs = parse_arguments(&arguments)?;
        let args = normalize_request_user_input_tool_args(args)
            .map_err(FunctionCallError::RespondToModel)?;
        let args = RequestUserInputArgs {
            questions: args.questions,
            is_blocking: mode == ModeKind::Plan,
            auto_resolution_ms: None,
        };
        let questions = args.questions.clone();
        let accepted = session
            .request_user_input(turn.as_ref(), call_id.clone(), args)
            .await
            .ok_or_else(|| {
                FunctionCallError::RespondToModel(format!(
                    "{REQUEST_USER_INPUT_TOOL_NAME} was cancelled before receiving a response"
                ))
            })?;

        let response = accepted.response;

        let content = serde_json::to_string(&response).map_err(|err| {
            FunctionCallError::Fatal(format!(
                "failed to serialize {REQUEST_USER_INPUT_TOOL_NAME} response: {err}"
            ))
        })?;
        // Persist answers even while an older checkpoint needs compatibility review.
        if turn.config.features.enabled(Feature::GuardianApproval) {
            let user_input = questions
                .iter()
                .filter_map(|question| {
                    let response = response.answers.get(&question.id)?;
                    let answers = response
                        .answers
                        .iter()
                        .filter(|answer| !answer.trim().is_empty())
                        .cloned()
                        .collect::<Vec<_>>();
                    if answers.is_empty() {
                        return None;
                    }
                    let mut question_text = question.question.clone();
                    for option in question
                        .options
                        .iter()
                        .flatten()
                        .filter(|option| response.answers.contains(&option.label))
                    {
                        question_text
                            .push_str(&format!("\n{}: {}", option.label, option.description));
                    }
                    Some(VerifiedQuestionAnswer {
                        question: question_text,
                        answer: answers.join("\n"),
                    })
                })
                .collect::<Vec<_>>();
            if !user_input.is_empty() {
                session
                    .record_retained_context(RetainedContextEvent::VerifiedAnswer {
                        answer: VerifiedAnswer {
                            turn_id: turn.sub_id.clone(),
                            call_id,
                            questions: user_input,
                        },
                        acceptance_order: Some(accepted.acceptance_order),
                    })
                    .await;
            }
        }

        Ok(boxed_tool_output(FunctionToolOutput::from_text(
            content,
            /*success*/ Some(true),
        )))
    }
}

impl CoreToolRuntime for RequestUserInputHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}

#[cfg(test)]
#[path = "request_user_input_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/request_user_input_async.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageDelivery;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::AsyncUserInputQuestion;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap;

const TOOL_NAME: &str = "request_user_input_async";

pub struct RequestUserInputAsyncHandler {
    pub description: String,
    pub parameters: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RequestUserInputAsyncArgs {
    questions: Vec<AsyncUserInputQuestion>,
}

impl ToolExecutor<ToolInvocation> for RequestUserInputAsyncHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        let mut options = JsonSchema::array(
            JsonSchema::string(/*description*/ None),
            Some("Suggested answers, in display order. Put the recommended answer first; the first option is preselected by default. The user can select one option or enter a free-text answer. Do not include an Other option or a free-text placeholder; the UI provides free-text input automatically. Omit options for a free-text-only question.".to_string()),
        );
        options.min_items = Some(1);
        let question = JsonSchema::object(
            BTreeMap::from([
                ("title".to_string(), JsonSchema::string(Some("The complete question shown to the user, including any context needed to answer it.".to_string()))),
                ("options".to_string(), options),
            ]),
            Some(vec!["title".to_string()]),
            /*additional_properties*/ Some(false.into()),
        );
        let mut questions = JsonSchema::array(
            question,
            Some(
                "One or more self-contained questions to present together, in display order."
                    .to_string(),
            ),
        );
        questions.min_items = Some(1);
        let properties = BTreeMap::from([("questions".to_string(), questions)]);
        let mut parameters = JsonSchema::object(
            properties,
            Some(vec!["questions".to_string()]),
            /*additional_properties*/ Some(false.into()),
        );
        if let Some(parameters_override) = &self.parameters {
            match crate::tools::catalog_parameters::parse(parameters_override) {
                Ok(schema) => parameters = schema,
                Err(reason) => tracing::warn!(
                    tool = TOOL_NAME,
                    reason,
                    "Invalid catalog tool parameters; using bundled parameters"
                ),
            }
        }

        ToolSpec::Function(ResponsesApiTool {
            name: TOOL_NAME.to_string(),
            description: self.description.clone(),
            strict: false,
            defer_loading: None,
            parameters,
            output_schema: None,
        })
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let ToolInvocation {
                session,
                turn,
                call_id,
                payload,
                ..
            } = invocation;
            let ToolPayload::Function { arguments } = payload else {
                return Err(FunctionCallError::RespondToModel(format!(
                    "{TOOL_NAME} handler received unsupported payload"
                )));
            };
            let args: RequestUserInputAsyncArgs = parse_arguments(&arguments)?;
            if args.questions.is_empty() {
                return Err(FunctionCallError::RespondToModel(
                    "questions must not be empty".to_string(),
                ));
            }
            let mut messages = Vec::with_capacity(args.questions.len());
            for question in &args.questions {
                if question.title.trim().is_empty() {
                    return Err(FunctionCallError::RespondToModel(
                        "question titles must not be empty".to_string(),
                    ));
                }
                let mut lines = vec![question.title.clone()];
                if let Some(options) = &question.options {
                    if options.is_empty() || options.iter().any(|option| option.trim().is_empty()) {
                        return Err(FunctionCallError::RespondToModel(
                            "options must contain at least one non-empty answer".to_string(),
                        ));
                    }
                    lines.extend(options.iter().map(|option| format!("- {option}")));
                }
                messages.push(lines.join("\n"));
            }

            let item = TurnItem::AgentMessage(AgentMessageItem {
                id: call_id,
                content: vec![AgentMessageContent::Text {
                    text: messages.join("\n\n"),
                }],
                phase: Some(MessagePhase::FinalAnswer),
                memory_citation: None,
                delivery: Some(AgentMessageDelivery::Async),
                questions: Some(args.questions),
            });
            session.emit_turn_item_started(turn.as_ref(), &item).await;
            session.emit_turn_item_completed(turn.as_ref(), item).await;

            Ok(boxed_tool_output(FunctionToolOutput::from_text(
                r#"{"accepted":true}"#.to_string(),
                /*success*/ Some(true),
            )))
        })
    }
}

impl CoreToolRuntime for RequestUserInputAsyncHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}
codex-rs/core/src/tools/handlers/send_message_to_user_async.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageDelivery;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::MessagePhase;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap;

const TOOL_NAME: &str = "send_message_to_user_async";

pub struct SendMessageToUserAsyncHandler;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SendMessageToUserAsyncArgs {
    message: String,
}

impl ToolExecutor<ToolInvocation> for SendMessageToUserAsyncHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        let properties = BTreeMap::from([(
            "message".to_string(),
            JsonSchema::string(Some(
                "The concise question or update to send to the user.".to_string(),
            )),
        )]);

        ToolSpec::Function(ResponsesApiTool {
            name: TOOL_NAME.to_string(),
            description: "Send a concise message that needs the user's attention during ongoing work. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message. Use this tool to report a critical blocker or a finding that may change the task's direction, or to answer a user question or status request received while work is still in progress. Use this tool when a message needs the user's immediate attention; use commentary for routine progress and intermediate context. Use clear formatting, such as bolding questions, to make requests easy to notice and answer."
                .to_string(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(
                properties,
                Some(vec!["message".to_string()]),
                /*additional_properties*/ Some(false.into()),
            ),
            output_schema: None,
        })
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let ToolInvocation {
                session,
                turn,
                call_id,
                payload,
                ..
            } = invocation;
            let ToolPayload::Function { arguments } = payload else {
                return Err(FunctionCallError::RespondToModel(format!(
                    "{TOOL_NAME} handler received unsupported payload"
                )));
            };
            let args: SendMessageToUserAsyncArgs = parse_arguments(&arguments)?;
            let message = args.message.trim();
            if message.is_empty() {
                return Err(FunctionCallError::RespondToModel(
                    "message must not be empty".to_string(),
                ));
            }

            let item = TurnItem::AgentMessage(AgentMessageItem {
                id: call_id,
                content: vec![AgentMessageContent::Text {
                    text: message.to_string(),
                }],
                phase: Some(MessagePhase::FinalAnswer),
                memory_citation: None,
                delivery: Some(AgentMessageDelivery::Async),
                questions: None,
            });
            session.emit_turn_item_started(turn.as_ref(), &item).await;
            session.emit_turn_item_completed(turn.as_ref(), item).await;

            Ok(boxed_tool_output(FunctionToolOutput::from_text(
                r#"{"accepted":true}"#.to_string(),
                /*success*/ Some(true),
            )))
        })
    }
}

impl CoreToolRuntime for SendMessageToUserAsyncHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}

计划 / 环境 / 上下文 / 控制工具

这些是原 Python 稿完全省掉的内置控制面。它们多数受 feature flag 或 model capability 控制,所以不同环境可见性不同;Rust registry 的注册处才是准确来源。

codex-rs/core/src/tools/handlers/plan_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::json;
use std::collections::BTreeMap;

pub fn create_update_plan_tool() -> ToolSpec {
    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()),
            ),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "update_plan".to_string(),
        description: r#"Updates the task plan.
Provide an optional explanation and a list of plan items, each with a step and status.
At most one step can be in_progress at a time.
"#
        .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["plan".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}
codex-rs/core/src/tools/handlers/plan.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::plan_spec::create_update_plan_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::config_types::ModeKind;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::EventMsg;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value as JsonValue;

pub struct PlanHandler;

pub struct 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())
    }
}

impl ToolExecutor<ToolInvocation> for PlanHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("update_plan")
    }

    fn spec(&self) -> ToolSpec {
        create_update_plan_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl PlanHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            call_id: _,
            payload,
            ..
        } = invocation;

        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)?;
        session
            .send_event(turn.as_ref(), EventMsg::PlanUpdate(args))
            .await;

        Ok(boxed_tool_output(PlanToolOutput))
    }
}

impl CoreToolRuntime for PlanHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}

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}"))
    })
}
codex-rs/core/src/tools/handlers/current_time.rs
use crate::context::ContextualUserFragment;
use crate::context::CurrentTimeReminder;
use crate::context::CurrentTimeUnavailable;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_features::Feature;
use codex_protocol::models::ResponseInputItem;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;

const NAMESPACE: &str = "clock";
const TOOL_NAME: &str = "curr_time";

struct CurrentTimeOutput(CurrentTimeReminder);

impl ToolOutput for CurrentTimeOutput {
    fn log_output(&self) -> String {
        self.0.body()
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        FunctionToolOutput::from_text(self.0.body(), Some(true)).to_response_item(call_id, payload)
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        json!({
            "current_time": self.0.formatted_time(),
        })
    }
}

pub struct CurrentTimeHandler;

impl ToolExecutor<ToolInvocation> for CurrentTimeHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(NAMESPACE, TOOL_NAME)
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn spec(&self) -> ToolSpec {
        ToolSpec::Namespace(ResponsesApiNamespace {
            name: NAMESPACE.to_string(),
            description: "Tools for reading and waiting on time.".to_string(),
            tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
                name: TOOL_NAME.to_string(),
                description: "Return the current time in UTC.".to_string(),
                strict: false,
                defer_loading: None,
                parameters: JsonSchema::object(
                    BTreeMap::new(),
                    /*required*/ None,
                    /*additional_properties*/ Some(false.into()),
                ),
                output_schema: Some(json!({
                    "type": "object",
                    "properties": {
                        "current_time": {
                            "type": "string",
                            "description": "Current UTC time formatted as YYYY-MM-DD HH:MM:SS UTC."
                        }
                    },
                    "required": ["current_time"],
                    "additionalProperties": false
                }).into()),
            })],
        })
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            if !matches!(invocation.payload, ToolPayload::Function { .. }) {
                return Err(FunctionCallError::RespondToModel(format!(
                    "{TOOL_NAME} handler received unsupported payload"
                )));
            }

            let current_time = invocation
                .session
                .services
                .time_provider
                .current_time(invocation.session.thread_id)
                .await
                .map_err(|err| {
                    if invocation
                        .turn
                        .config
                        .features
                        .enabled(Feature::NonfatalClockReadErrors)
                    {
                        tracing::error!(
                            thread_id = %invocation.session.thread_id,
                            turn_id = %invocation.turn.sub_id,
                            "failed to read current time for the clock tool; the clock provider may be stalled"
                        );
                        FunctionCallError::RespondToModel(CurrentTimeUnavailable::MESSAGE.to_string())
                    } else {
                        FunctionCallError::Fatal(format!("failed to read current time: {err:#}"))
                    }
                })?;
            Ok(boxed_tool_output(CurrentTimeOutput(
                CurrentTimeReminder::new(current_time),
            )))
        })
    }
}

impl CoreToolRuntime for CurrentTimeHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}
codex-rs/core/src/tools/handlers/get_context_remaining_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;

pub(crate) const GET_CONTEXT_REMAINING_TOOL_NAME: &str = "get_context_remaining";

pub fn create_get_context_remaining_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: GET_CONTEXT_REMAINING_TOOL_NAME.to_string(),
        description: "Get the remaining tokens in the current context window.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
        output_schema: Some(get_context_remaining_output_schema().into()),
    })
}

fn get_context_remaining_output_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "tokens_left": {
                "anyOf": [
                    { "type": "integer" },
                    { "type": "null" }
                ],
                "description": "Remaining tokens in the current context window, or null when unavailable."
            }
        },
        "required": ["tokens_left"],
        "additionalProperties": false
    })
}
codex-rs/core/src/tools/handlers/get_context_remaining.rs
use crate::context::ContextualUserFragment;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::get_context_remaining_spec::GET_CONTEXT_REMAINING_TOOL_NAME;
use crate::tools::handlers::get_context_remaining_spec::create_get_context_remaining_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::models::ResponseInputItem;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde_json::Value as JsonValue;
use serde_json::json;

#[derive(Debug, Clone)]
struct GetContextRemainingOutput {
    tokens_left: Option<i64>,
}

impl GetContextRemainingOutput {
    fn new(tokens_left: Option<i64>) -> Self {
        Self { tokens_left }
    }

    fn fragment(&self) -> String {
        match self.tokens_left {
            Some(tokens_left) => {
                crate::context::TokenBudgetRemainingContext::new(tokens_left).render()
            }
            None => crate::context::TokenBudgetRemainingContext::unknown().render(),
        }
    }
}

impl ToolOutput for GetContextRemainingOutput {
    fn log_output(&self) -> String {
        self.fragment()
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        FunctionToolOutput::from_text(self.fragment(), Some(true))
            .to_response_item(call_id, payload)
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        json!({
            "tokens_left": self.tokens_left,
        })
    }
}

pub struct GetContextRemainingHandler;

impl ToolExecutor<ToolInvocation> for GetContextRemainingHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(GET_CONTEXT_REMAINING_TOOL_NAME)
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn spec(&self) -> ToolSpec {
        create_get_context_remaining_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            if !matches!(invocation.payload, ToolPayload::Function { .. }) {
                return Err(FunctionCallError::RespondToModel(
                    "get_context_remaining handler received unsupported payload".to_string(),
                ));
            }

            let token_status = crate::session::context_window::context_window_token_status(
                invocation.session.as_ref(),
                invocation.turn.as_ref(),
            )
            .await;

            Ok(boxed_tool_output(GetContextRemainingOutput::new(
                token_status.base_window_tokens_remaining,
            )))
        })
    }
}

impl CoreToolRuntime for GetContextRemainingHandler {}
codex-rs/core/src/tools/handlers/new_context_window_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;

pub(crate) const NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "new_context";

pub fn create_new_context_window_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
        description: "Start a new context window. Does not clear, reset, or otherwise affect environment state.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
        output_schema: None,
    })
}
codex-rs/core/src/tools/handlers/new_context_window.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::new_context_window_spec::NEW_CONTEXT_WINDOW_TOOL_NAME;
use crate::tools::handlers::new_context_window_spec::create_new_context_window_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

pub(crate) const NEW_CONTEXT_WINDOW_MESSAGE: &str =
    "A new context window will start without summarizing conversation history.";

pub struct NewContextWindowHandler;

impl ToolExecutor<ToolInvocation> for NewContextWindowHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(NEW_CONTEXT_WINDOW_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_new_context_window_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            if !matches!(invocation.payload, ToolPayload::Function { .. }) {
                return Err(FunctionCallError::RespondToModel(
                    "new_context handler received unsupported payload".to_string(),
                ));
            }

            invocation.session.request_new_context_window().await;

            Ok(boxed_tool_output(FunctionToolOutput::from_text(
                NEW_CONTEXT_WINDOW_MESSAGE.to_string(),
                Some(true),
            )))
        })
    }
}

impl CoreToolRuntime for NewContextWindowHandler {}
codex-rs/core/src/tools/handlers/wait_for_environment.rs
use codex_tools::JsonSchema;
use codex_tools::JsonToolOutput;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use serde_json::json;
use std::collections::BTreeMap;

use crate::environment_selection::TurnEnvironmentState;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;

const WAIT_FOR_ENVIRONMENT_TOOL_NAME: &str = "wait_for_environment";
const DEFAULT_TOOL_DESCRIPTION: &str = "Wait for a selected execution environment marked as `starting` to become available. Use this when the current task needs that environment's files, commands, or installed capabilities. Do not wait if the task can be completed using tools already available, such as connectors. Waiting may take several minutes and blocks other tool calls. If startup fails, continue without that environment.";
const DEFAULT_ENVIRONMENT_ID_DESCRIPTION: &str =
    "The exact environment ID marked as `starting` in `<environment_context>`.";
const MAX_COMBINED_DESCRIPTION_BYTES: usize = 1_024;
const MAX_SERIALIZED_TOOL_SPEC_BYTES: usize = 1_000;

/// Model-visible descriptions supplied by a host that supports deferred environments.
///
/// The two tool-schema descriptions must not exceed 1,024 UTF-8 bytes in total, and Core
/// also limits the complete serialized tool specification to 1,000 bytes. Oversized
/// descriptions fall back to Core defaults.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WaitForEnvironmentToolConfig {
    /// Explains when and why the model should call `wait_for_environment`.
    pub tool_description: String,
    /// Explains how the model should select the `environment_id` argument.
    pub environment_id_description: String,
}

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitForEnvironmentArgs {
    environment_id: String,
}

pub(crate) struct WaitForEnvironmentHandler {
    tool_description: String,
    environment_id_description: String,
}

impl WaitForEnvironmentHandler {
    pub(crate) fn new(config: &WaitForEnvironmentToolConfig) -> Self {
        let combined_description_bytes = config
            .tool_description
            .len()
            .saturating_add(config.environment_id_description.len());
        if combined_description_bytes <= MAX_COMBINED_DESCRIPTION_BYTES {
            let handler = Self {
                tool_description: config.tool_description.clone(),
                environment_id_description: config.environment_id_description.clone(),
            };
            if serde_json::to_vec(&handler.spec())
                .is_ok_and(|serialized| serialized.len() <= MAX_SERIALIZED_TOOL_SPEC_BYTES)
            {
                return handler;
            }
        }

        tracing::warn!(
            "oversized wait_for_environment tool configuration; falling back to Core defaults"
        );
        Self::default()
    }
}

impl Default for WaitForEnvironmentHandler {
    fn default() -> Self {
        Self {
            tool_description: DEFAULT_TOOL_DESCRIPTION.to_string(),
            environment_id_description: DEFAULT_ENVIRONMENT_ID_DESCRIPTION.to_string(),
        }
    }
}

impl ToolExecutor<ToolInvocation> for WaitForEnvironmentHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(WAIT_FOR_ENVIRONMENT_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        ToolSpec::Function(ResponsesApiTool {
            name: WAIT_FOR_ENVIRONMENT_TOOL_NAME.to_string(),
            description: self.tool_description.clone(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(
                BTreeMap::from([(
                    "environment_id".to_string(),
                    JsonSchema::string(Some(self.environment_id_description.clone())),
                )]),
                /*required*/ Some(vec!["environment_id".to_string()]),
                /*additional_properties*/ Some(false.into()),
            ),
            output_schema: None,
        })
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let ToolInvocation {
                payload,
                step_context,
                ..
            } = invocation;
            let arguments = match payload {
                ToolPayload::Function { arguments } => arguments,
                _ => {
                    return Err(FunctionCallError::Fatal(format!(
                        "{WAIT_FOR_ENVIRONMENT_TOOL_NAME} handler received unsupported payload"
                    )));
                }
            };
            let args: WaitForEnvironmentArgs = parse_arguments(&arguments)?;
            let environment_id = args.environment_id;
            let already_ready = step_context
                .environments
                .turn_environments()
                .any(|environment| environment.selection.environment_id == environment_id);
            if !already_ready {
                if let Some(error) =
                    step_context
                        .environments
                        .environments
                        .iter()
                        .find_map(|state| match state {
                            TurnEnvironmentState::Failed {
                                selection, error, ..
                            } if selection.environment_id == environment_id => Some(error),
                            _ => None,
                        })
                {
                    return Err(environment_failure(&environment_id, error));
                }
                let Some(environment) = step_context
                    .environments
                    .starting()
                    .find(|environment| environment.selection.environment_id == environment_id)
                    .cloned()
                else {
                    return Err(FunctionCallError::RespondToModel(format!(
                        "environment `{environment_id}` is neither ready nor starting"
                    )));
                };

                environment
                    .wait_until_ready()
                    .await
                    .map_err(|error| environment_failure(&environment_id, &error.to_string()))?;
            }

            Ok(boxed_tool_output(JsonToolOutput::new(json!({
                "environment_id": environment_id,
                "status": "ready",
            }))))
        })
    }
}

impl CoreToolRuntime for WaitForEnvironmentHandler {}

// Keep failure output small while preserving a UTF-8 boundary.
fn environment_failure(environment_id: &str, error: &str) -> FunctionCallError {
    const MAX_ERROR_BYTES: usize = 256;
    let reason = &error[..error.floor_char_boundary(MAX_ERROR_BYTES)];
    FunctionCallError::RespondToModel(format!(
        "Environment `{environment_id}` failed to start: {reason}"
    ))
}
codex-rs/core/src/tools/handlers/request_permissions.rs
use codex_protocol::permissions::FileSystemSandboxPolicyContext;
use codex_protocol::request_permissions::RequestPermissionsArgs;
use codex_sandboxing::policy_transforms::normalize_additional_permissions_with_context;
use codex_utils_path_uri::LegacyAppPathString;
use serde_json::Value;

use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::resolve_tool_environment;
use crate::tools::handlers::shell_spec::create_request_permissions_tool;
use crate::tools::handlers::shell_spec::request_permissions_tool_description;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;

pub struct RequestPermissionsHandler;

#[derive(Deserialize)]
pub(crate) struct RequestPermissionsEnvironmentArgs {
    #[serde(default, rename = "environment_id", alias = "environmentId")]
    pub(crate) environment_id: Option<String>,
}

impl ToolExecutor<ToolInvocation> for RequestPermissionsHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("request_permissions")
    }

    fn spec(&self) -> ToolSpec {
        create_request_permissions_tool(request_permissions_tool_description())
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl RequestPermissionsHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            step_context,
            cancellation_token,
            call_id,
            payload,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "request_permissions handler received unsupported payload".to_string(),
                ));
            }
        };

        let environment_args: RequestPermissionsEnvironmentArgs = parse_arguments(&arguments)?;
        let turn_environment = resolve_tool_environment(
            &step_context,
            environment_args.environment_id.as_deref(),
            "request_permissions requires a primary environment",
        )?;
        let sandbox_context =
            turn_environment.sandbox_context(/*additional_permissions*/ None);
        let context = sandbox_context.policy_context();
        let mut arguments: Value = parse_arguments(&arguments)?;
        resolve_permission_path_strings(&mut arguments, &context)?;
        let mut args: RequestPermissionsArgs =
            serde_json::from_value(arguments).map_err(|err| {
                FunctionCallError::RespondToModel(format!(
                    "failed to parse function arguments: {err}"
                ))
            })?;
        args.permissions =
            normalize_additional_permissions_with_context(args.permissions.into(), &context)
                .map(codex_protocol::request_permissions::RequestPermissionProfile::from)
                .map_err(FunctionCallError::RespondToModel)?;
        if args.permissions.is_empty() {
            return Err(FunctionCallError::RespondToModel(
                "request_permissions requires at least one permission".to_string(),
            ));
        }

        let response = session
            .request_permissions_for_environment(
                &step_context,
                call_id,
                args,
                turn_environment.selection(),
                cancellation_token,
            )
            .await
            .ok_or_else(|| {
                FunctionCallError::RespondToModel(
                    "request_permissions was cancelled before receiving a response".to_string(),
                )
            })?;

        let content = serde_json::to_string(&response).map_err(|err| {
            FunctionCallError::Fatal(format!(
                "failed to serialize request_permissions response: {err}"
            ))
        })?;

        Ok(boxed_tool_output(FunctionToolOutput::from_text(
            content,
            Some(true),
        )))
    }
}

impl CoreToolRuntime for RequestPermissionsHandler {}

fn resolve_permission_path_strings(
    arguments: &mut Value,
    context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), FunctionCallError> {
    let Some(file_system) = arguments
        .pointer_mut("/permissions/file_system")
        .and_then(Value::as_object_mut)
    else {
        return Ok(());
    };
    let mut legacy_entries = Vec::new();
    for (field, access) in [("read", "read"), ("write", "write")] {
        if let Some(paths) = file_system.remove(field) {
            if paths.is_null() {
                continue;
            }
            let Value::Array(paths) = paths else {
                file_system.insert(field.to_string(), paths);
                continue;
            };
            for mut path in paths {
                resolve_permission_path_string(&mut path, context)?;
                legacy_entries.push(serde_json::json!({
                    "path": {"type": "path", "path": path},
                    "access": access,
                }));
            }
        }
    }
    if !legacy_entries.is_empty() {
        file_system
            .entry("entries".to_string())
            .or_insert_with(|| Value::Array(Vec::new()))
            .as_array_mut()
            .ok_or_else(|| {
                FunctionCallError::RespondToModel(
                    "request_permissions file_system.entries must be an array".to_string(),
                )
            })?
            .extend(legacy_entries);
    }
    if let Some(entries) = file_system.get_mut("entries").and_then(Value::as_array_mut) {
        for entry in entries {
            if entry.pointer("/path/type").and_then(Value::as_str) == Some("path")
                && let Some(path) = entry.pointer_mut("/path/path")
            {
                resolve_permission_path_string(path, context)?;
            }
        }
    }
    Ok(())
}

fn resolve_permission_path_string(
    value: &mut Value,
    context: &FileSystemSandboxPolicyContext<'_>,
) -> Result<(), FunctionCallError> {
    let Some(path) = value.as_str() else {
        return Ok(());
    };
    let cwd = context.cwd;
    let convention = cwd.infer_path_convention().ok_or_else(|| {
        FunctionCallError::RespondToModel(format!(
            "request_permissions cwd `{cwd}` has no path convention"
        ))
    })?;
    let path = LegacyAppPathString::from_string(path)
        .resolve_against(cwd, context.user_home_dir)
        .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
    if path.is_opaque() || std::str::from_utf8(&path.decoded_path_bytes()).is_err() {
        return Err(FunctionCallError::RespondToModel(
            "permission path cannot be represented losslessly".to_string(),
        ));
    }
    let path = LegacyAppPathString::from_path_uri(&path, convention)
        .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))?;
    *value = Value::String(path.into_string());
    Ok(())
}
codex-rs/core/src/tools/handlers/sleep.rs
use crate::context::CurrentTimeUnavailable;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_extension_items::ExtensionItem;
use codex_extension_items::sleep::SleepItem;
use codex_features::Feature;
use codex_protocol::items::TurnItem;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolExposure;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::time::Duration;
use std::time::Instant;

const NAMESPACE: &str = "clock";
const TOOL_NAME: &str = "sleep";
const MAX_SLEEP_DURATION_MS: u64 = 12 * 60 * 60 * 1000;

pub struct SleepHandler;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SleepArgs {
    duration_ms: u64,
}

fn create_sleep_tool() -> ToolSpec {
    let properties = BTreeMap::from([(
        "duration_ms".to_string(),
        JsonSchema::number(Some(format!(
            "How long to sleep in milliseconds. Must be between 1 and {MAX_SLEEP_DURATION_MS}."
        ))),
    )]);

    ToolSpec::Namespace(ResponsesApiNamespace {
        name: NAMESPACE.to_string(),
        description: "Tools for reading and waiting on time.".to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: TOOL_NAME.to_string(),
            description: "Pause execution for a specified duration. The sleep ends early when new input arrives for the active turn. Returns the elapsed wall-clock time."
                .to_string(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(
                properties,
                Some(vec!["duration_ms".to_string()]),
                /*additional_properties*/ Some(false.into()),
            ),
            output_schema: None,
        })],
    })
}

impl ToolExecutor<ToolInvocation> for SleepHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(NAMESPACE, TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_sleep_tool()
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::DirectModelOnly
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let ToolInvocation {
                session,
                turn,
                call_id,
                payload,
                ..
            } = invocation;
            let ToolPayload::Function { arguments } = payload else {
                return Err(FunctionCallError::RespondToModel(format!(
                    "{TOOL_NAME} handler received unsupported payload"
                )));
            };
            let args: SleepArgs = parse_arguments(&arguments)?;
            if !(1..=MAX_SLEEP_DURATION_MS).contains(&args.duration_ms) {
                return Err(FunctionCallError::RespondToModel(format!(
                    "duration_ms must be between 1 and {MAX_SLEEP_DURATION_MS}"
                )));
            }

            let started = Instant::now();
            let item = TurnItem::Extension(ExtensionItem::Sleep(SleepItem {
                id: call_id,
                duration_ms: args.duration_ms,
            }));
            session.emit_turn_item_started(turn.as_ref(), &item).await;
            let turn_state = session
                .input_queue
                .turn_state_for_sub_id(&session.active_turn, &turn.sub_id)
                .await;
            let (mut activity_rx, pending_activity) = session
                .input_queue
                .subscribe_activity(turn_state.as_deref())
                .await;
            let sleep_result = if pending_activity.is_some() {
                Ok(true)
            } else {
                let sleep = session
                    .services
                    .time_provider
                    .sleep(session.thread_id, Duration::from_millis(args.duration_ms));
                tokio::pin!(sleep);
                tokio::select! {
                    result = &mut sleep => result.map(|()| false),
                    result = activity_rx.changed() => {
                        if result.is_ok() {
                            Ok(true)
                        } else {
                            sleep.await.map(|()| false)
                        }
                    }
                }
            };
            session.emit_turn_item_completed(turn.as_ref(), item).await;
            let interrupted = sleep_result.map_err(|err| {
                if turn.config.features.enabled(Feature::NonfatalClockReadErrors) {
                    tracing::error!(
                        thread_id = %session.thread_id,
                        turn_id = %turn.sub_id,
                        "failed to read current time for the sleep tool; the clock provider may be stalled"
                    );
                    FunctionCallError::RespondToModel(CurrentTimeUnavailable::MESSAGE.to_string())
                } else {
                    FunctionCallError::Fatal(format!("failed to sleep: {err:#}"))
                }
            })?;

            let message = if interrupted {
                "Sleep interrupted by new input."
            } else {
                "Sleep completed."
            };
            let wall_time_seconds = started.elapsed().as_secs_f64();
            Ok(boxed_tool_output(FunctionToolOutput::from_text(
                format!("Wall time: {wall_time_seconds:.4} seconds\n{message}"),
                /*success*/ Some(true),
            )))
        })
    }
}

impl CoreToolRuntime for SleepHandler {
    fn is_builtin_control_tool(&self) -> bool {
        true
    }
}
codex-rs/core/src/tools/handlers/test_sync_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;

pub fn create_test_sync_tool() -> ToolSpec {
    let barrier_properties = BTreeMap::from([
        (
            "id".to_string(),
            JsonSchema::string(Some(
                "Identifier shared by concurrent calls that should rendezvous".to_string(),
            )),
        ),
        (
            "participants".to_string(),
            JsonSchema::number(Some(
                "Number of tool calls that must arrive before the barrier opens".to_string(),
            )),
        ),
        (
            "timeout_ms".to_string(),
            JsonSchema::number(Some(
                "Maximum barrier wait in milliseconds. Defaults to 1000.".to_string(),
            )),
        ),
    ]);

    let properties = BTreeMap::from([
        (
            "sleep_before_ms".to_string(),
            JsonSchema::number(Some(
                "Delay before any other action. Defaults to no delay.".to_string(),
            )),
        ),
        (
            "sleep_after_ms".to_string(),
            JsonSchema::number(Some(
                "Delay after completing the barrier. Defaults to no delay.".to_string(),
            )),
        ),
        (
            "barrier".to_string(),
            JsonSchema::object(
                barrier_properties,
                Some(vec!["id".to_string(), "participants".to_string()]),
                Some(false.into()),
            ),
        ),
        (
            "wait_for_git_enrichment".to_string(),
            JsonSchema::boolean(Some(
                "Wait for Git enrichment for the current turn to finish, subject to a timeout."
                    .to_string(),
            )),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "test_sync_tool".to_string(),
        description: "Internal synchronization helper used by Codex integration tests.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
        output_schema: None,
    })
}

#[cfg(test)]
#[path = "test_sync_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/test_sync.rs
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;

use serde::Deserialize;
use tokio::sync::Barrier;
use tokio::time::sleep;

use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::test_sync_spec::create_test_sync_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

pub struct TestSyncHandler;

const DEFAULT_TIMEOUT_MS: u64 = 1_000;
const GIT_ENRICHMENT_TIMEOUT: Duration = Duration::from_secs(10);

static BARRIERS: OnceLock<tokio::sync::Mutex<HashMap<String, BarrierState>>> = OnceLock::new();

struct BarrierState {
    barrier: Arc<Barrier>,
    participants: usize,
}

#[derive(Debug, Deserialize)]
struct BarrierArgs {
    id: String,
    participants: usize,
    #[serde(default = "default_timeout_ms")]
    timeout_ms: u64,
}

#[derive(Debug, Deserialize)]
struct TestSyncArgs {
    #[serde(default)]
    sleep_before_ms: Option<u64>,
    #[serde(default)]
    sleep_after_ms: Option<u64>,
    #[serde(default)]
    barrier: Option<BarrierArgs>,
    #[serde(default)]
    wait_for_git_enrichment: bool,
}

fn default_timeout_ms() -> u64 {
    DEFAULT_TIMEOUT_MS
}

fn barrier_map() -> &'static tokio::sync::Mutex<HashMap<String, BarrierState>> {
    BARRIERS.get_or_init(|| tokio::sync::Mutex::new(HashMap::new()))
}

impl ToolExecutor<ToolInvocation> for TestSyncHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("test_sync_tool")
    }

    fn spec(&self) -> ToolSpec {
        create_test_sync_tool()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl TestSyncHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation { payload, turn, .. } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "test_sync_tool handler received unsupported payload".to_string(),
                ));
            }
        };

        let args: TestSyncArgs = parse_arguments(&arguments)?;

        if let Some(delay) = args.sleep_before_ms
            && delay > 0
        {
            sleep(Duration::from_millis(delay)).await;
        }

        if let Some(barrier) = args.barrier {
            wait_on_barrier(barrier).await?;
        }

        if args.wait_for_git_enrichment {
            tokio::time::timeout(
                GIT_ENRICHMENT_TIMEOUT,
                turn.turn_metadata_state.wait_for_git_enrichment(),
            )
            .await
            .map_err(|_| {
                FunctionCallError::RespondToModel(format!(
                    "test_sync_tool git enrichment wait timed out after {} seconds",
                    GIT_ENRICHMENT_TIMEOUT.as_secs()
                ))
            })?;
        }

        if let Some(delay) = args.sleep_after_ms
            && delay > 0
        {
            sleep(Duration::from_millis(delay)).await;
        }

        Ok(boxed_tool_output(FunctionToolOutput::from_text(
            "ok".to_string(),
            Some(true),
        )))
    }
}

impl CoreToolRuntime for TestSyncHandler {}

async fn wait_on_barrier(args: BarrierArgs) -> Result<(), FunctionCallError> {
    if args.participants == 0 {
        return Err(FunctionCallError::RespondToModel(
            "barrier participants must be greater than zero".to_string(),
        ));
    }

    if args.timeout_ms == 0 {
        return Err(FunctionCallError::RespondToModel(
            "barrier timeout must be greater than zero".to_string(),
        ));
    }

    let barrier_id = args.id.clone();
    let barrier = {
        let mut map = barrier_map().lock().await;
        match map.entry(barrier_id.clone()) {
            Entry::Occupied(entry) => {
                let state = entry.get();
                if state.participants != args.participants {
                    let existing = state.participants;
                    return Err(FunctionCallError::RespondToModel(format!(
                        "barrier {barrier_id} already registered with {existing} participants"
                    )));
                }
                state.barrier.clone()
            }
            Entry::Vacant(entry) => {
                let barrier = Arc::new(Barrier::new(args.participants));
                entry.insert(BarrierState {
                    barrier: barrier.clone(),
                    participants: args.participants,
                });
                barrier
            }
        }
    };

    let timeout = Duration::from_millis(args.timeout_ms);
    let wait_result = tokio::time::timeout(timeout, barrier.wait())
        .await
        .map_err(|_| {
            FunctionCallError::RespondToModel("test_sync_tool barrier wait timed out".to_string())
        })?;

    if wait_result.is_leader() {
        let mut map = barrier_map().lock().await;
        if let Some(state) = map.get(&barrier_id)
            && Arc::ptr_eq(&state.barrier, &barrier)
        {
            map.remove(&barrier_id);
        }
    }

    Ok(())
}
codex-rs/core/src/tools/handlers/list_available_plugins_to_install_spec.rs
use codex_tools::JsonSchema;
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::ResponsesApiTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSpec;
pub(crate) fn create_list_available_plugins_to_install_tool() -> ToolSpec {
    let description = format!(
        "# List plugin/connector install candidates\n\nUse this tool only when both are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `{TOOL_SEARCH_TOOL_NAME}` is not available, or it has already been called and did not find or make the requested tool callable.\n\nReturns known plugins and connectors that can be passed to `{REQUEST_PLUGIN_INSTALL_TOOL_NAME}`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n"
    );

    ToolSpec::Function(ResponsesApiTool {
        name: LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME.to_string(),
        description,
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(Default::default(), Some(Vec::new()), Some(false.into())),
        output_schema: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn create_list_available_plugins_to_install_tool_uses_expected_wire_shape() {
        assert_eq!(
            create_list_available_plugins_to_install_tool(),
            ToolSpec::Function(ResponsesApiTool {
                name: "list_available_plugins_to_install".to_string(),
                description: "# List plugin/connector install candidates\n\nUse this tool only when both are true:\n- The user explicitly asks to use a specific plugin or connector that is not already available in the current context or active `tools` list.\n- `tool_search` is not available, or it has already been called and did not find or make the requested tool callable.\n\nReturns known plugins and connectors that can be passed to `request_plugin_install`. When both a plugin and a connector match, prefer the plugin; use the connector only when its corresponding plugin is already installed.\n".to_string(),
                strict: false,
                defer_loading: None,
                parameters: JsonSchema::object(
                    Default::default(),
                    Some(Vec::new()),
                    Some(false.into()),
                ),
                output_schema: None,
            })
        );
    }
}
codex-rs/core/src/tools/handlers/list_available_plugins_to_install.rs
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
use codex_tools::ListAvailablePluginsToInstallResult;
use codex_tools::RequestPluginInstallEntry;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::list_available_plugins_to_install_spec::create_list_available_plugins_to_install_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;

const MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS: usize = 240;

pub struct ListAvailablePluginsToInstallHandler {
    tools: Vec<RequestPluginInstallEntry>,
}

impl ListAvailablePluginsToInstallHandler {
    pub(crate) fn new(mut tools: Vec<RequestPluginInstallEntry>) -> Self {
        tools.sort_by(|left, right| {
            left.name
                .cmp(&right.name)
                .then_with(|| left.id.cmp(&right.id))
        });
        Self { tools }
    }

    fn result(&self) -> ListAvailablePluginsToInstallResult {
        ListAvailablePluginsToInstallResult {
            tools: self
                .tools
                .iter()
                .map(|tool| RequestPluginInstallEntry {
                    id: tool.id.clone(),
                    name: tool.name.clone(),
                    description: tool.description.as_ref().map(|description| {
                        truncate_to_char_boundary(
                            description,
                            MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS,
                        )
                        .to_string()
                    }),
                    tool_type: tool.tool_type,
                    has_skills: tool.has_skills,
                    mcp_server_names: tool.mcp_server_names.clone(),
                    app_connector_ids: tool.app_connector_ids.clone(),
                })
                .collect(),
        }
    }
}

impl ToolExecutor<ToolInvocation> for ListAvailablePluginsToInstallHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_list_available_plugins_to_install_tool()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ListAvailablePluginsToInstallHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation { payload, .. } = invocation;
        match payload {
            ToolPayload::Function { .. } => {}
            _ => {
                return Err(FunctionCallError::Fatal(format!(
                    "{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} handler received unsupported payload"
                )));
            }
        }

        let content = serde_json::to_string(&self.result()).map_err(|err| {
            FunctionCallError::Fatal(format!(
                "failed to serialize {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME} response: {err}"
            ))
        })?;

        Ok(boxed_tool_output(FunctionToolOutput::from_text(
            content,
            Some(true),
        )))
    }
}

impl CoreToolRuntime for ListAvailablePluginsToInstallHandler {}

fn truncate_to_char_boundary(value: &str, max_chars: usize) -> &str {
    match value.char_indices().nth(max_chars) {
        Some((index, _)) => &value[..index],
        None => value,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use codex_tools::DiscoverableToolType;
    use pretty_assertions::assert_eq;

    #[test]
    fn result_truncates_candidate_descriptions() {
        let handler = ListAvailablePluginsToInstallHandler::new(vec![
            RequestPluginInstallEntry {
                id: "sample@openai-curated".to_string(),
                name: "Sample Plugin".to_string(),
                description: Some(
                    "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS + 1),
                ),
                tool_type: DiscoverableToolType::Plugin,
                has_skills: true,
                mcp_server_names: vec!["sample-mcp".to_string()],
                app_connector_ids: vec!["connector-sample".to_string()],
            },
            RequestPluginInstallEntry {
                id: "calendar@openai-curated".to_string(),
                name: "Calendar".to_string(),
                description: Some("calendar".to_string()),
                tool_type: DiscoverableToolType::Plugin,
                has_skills: false,
                mcp_server_names: Vec::new(),
                app_connector_ids: Vec::new(),
            },
        ]);

        assert_eq!(
            handler.result(),
            ListAvailablePluginsToInstallResult {
                tools: vec![
                    RequestPluginInstallEntry {
                        id: "calendar@openai-curated".to_string(),
                        name: "Calendar".to_string(),
                        description: Some("calendar".to_string()),
                        tool_type: DiscoverableToolType::Plugin,
                        has_skills: false,
                        mcp_server_names: Vec::new(),
                        app_connector_ids: Vec::new(),
                    },
                    RequestPluginInstallEntry {
                        id: "sample@openai-curated".to_string(),
                        name: "Sample Plugin".to_string(),
                        description: Some(
                            "x".repeat(MAX_LIST_AVAILABLE_PLUGINS_TO_INSTALL_DESCRIPTION_CHARS,)
                        ),
                        tool_type: DiscoverableToolType::Plugin,
                        has_skills: true,
                        mcp_server_names: vec!["sample-mcp".to_string()],
                        app_connector_ids: vec!["connector-sample".to_string()],
                    },
                ],
            }
        );
    }
}
codex-rs/core/src/tools/handlers/request_plugin_install_spec.rs
use codex_tools::JsonSchema;
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;

use crate::tools::router::ToolSuggestPresentation;

pub(crate) fn create_request_plugin_install_tool(
    presentation: ToolSuggestPresentation,
) -> ToolSpec {
    let (properties, required, description) = match presentation {
        ToolSuggestPresentation::ListTool => (
            BTreeMap::from([
                (
                    "tool_type".to_string(),
                    JsonSchema::string(Some(
                        "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
                            .to_string(),
                    )),
                ),
                (
                    "action_type".to_string(),
                    JsonSchema::string(Some(
                        "Suggested action for the tool. Use \"install\".".to_string(),
                    )),
                ),
                (
                    "tool_id".to_string(),
                    JsonSchema::string(Some("Connector or plugin id to suggest.".to_string())),
                ),
                (
                    "suggest_reason".to_string(),
                    JsonSchema::string(Some(
                        "Concise one-line user-facing reason why this plugin or connector can help with the current request."
                            .to_string(),
                    )),
                ),
            ]),
            vec![
                "tool_type".to_string(),
                "action_type".to_string(),
                "tool_id".to_string(),
                "suggest_reason".to_string(),
            ],
            format!(
                "# Request plugin/connector install\n\nUse this tool only after `{LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}` returns a plugin or connector that exactly matches the user's explicit request.\n\nDo not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools."
            ),
        ),
        ToolSuggestPresentation::RecommendationContext => (
            BTreeMap::from([
                (
                    "plugin_id".to_string(),
                    JsonSchema::string(Some(
                        "The parenthesized plugin ID from the `<recommended_plugins>` list."
                            .to_string(),
                    )),
                ),
                (
                    "suggest_reason".to_string(),
                    JsonSchema::string(Some(
                        "Concise one-line user-facing reason why this plugin can help with the current request."
                            .to_string(),
                    )),
                ),
            ]),
            vec!["plugin_id".to_string(), "suggest_reason".to_string()],
            "# Suggest a recommended plugin installation\n\nUse this tool only when all of the following are true:\n- The user explicitly asks to use a specific plugin that is not already available in the current context or active `tools` list.\n- Tool search has already been exhausted and did not find or make the requested tool callable.\n- The plugin is listed in `<recommended_plugins>`.\n\nDo not use it for adjacent capabilities, broad recommendations, or plugins that merely seem useful. Briefly explain why the plugin can help with the current request in `suggest_reason`.\n\nIMPORTANT: DO NOT call this tool in parallel with other tools.".to_string(),
        ),
    };

    ToolSpec::Function(ResponsesApiTool {
        name: REQUEST_PLUGIN_INSTALL_TOOL_NAME.to_string(),
        description,
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, Some(required), Some(false.into())),
        output_schema: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use codex_tools::JsonSchema;
    use pretty_assertions::assert_eq;
    use std::collections::BTreeMap;

    #[test]
    fn create_request_plugin_install_tool_uses_expected_legacy_wire_shape() {
        let expected_description = concat!(
            "# Request plugin/connector install\n\n",
            "Use this tool only after `list_available_plugins_to_install` returns a plugin or connector that exactly matches the user's explicit request.\n\n",
            "Do not use it for adjacent capabilities, broad recommendations, or tools that merely seem useful. Pass the returned `tool_type` through directly, and pass the returned `id` as `tool_id`.\n\n",
            "IMPORTANT: DO NOT call this tool in parallel with other tools.",
        );

        assert_eq!(
            create_request_plugin_install_tool(ToolSuggestPresentation::ListTool),
            ToolSpec::Function(ResponsesApiTool {
                name: "request_plugin_install".to_string(),
                description: expected_description.to_string(),
                strict: false,
                defer_loading: None,
                parameters: JsonSchema::object(BTreeMap::from([
                        (
                            "action_type".to_string(),
                            JsonSchema::string(Some(
                                    "Suggested action for the tool. Use \"install\"."
                                        .to_string(),
                                ),),
                        ),
                        (
                            "suggest_reason".to_string(),
                            JsonSchema::string(Some(
                                    "Concise one-line user-facing reason why this plugin or connector can help with the current request."
                                        .to_string(),
                                ),),
                        ),
                        (
                            "tool_id".to_string(),
                            JsonSchema::string(Some(
                                    "Connector or plugin id to suggest."
                                        .to_string(),
                                ),),
                        ),
                        (
                            "tool_type".to_string(),
                            JsonSchema::string(Some(
                                    "Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
                                        .to_string(),
                                ),),
                        ),
                    ]), Some(vec![
                        "tool_type".to_string(),
                        "action_type".to_string(),
                        "tool_id".to_string(),
                        "suggest_reason".to_string(),
                    ]), Some(false.into())),
                output_schema: None,
            })
        );
    }

    #[test]
    fn recommendation_context_uses_simplified_plugin_wire_shape() {
        let expected_description = concat!(
            "# Suggest a recommended plugin installation\n\n",
            "Use this tool only when all of the following are true:\n",
            "- The user explicitly asks to use a specific plugin that is not already available in the current context or active `tools` list.\n",
            "- Tool search has already been exhausted and did not find or make the requested tool callable.\n",
            "- The plugin is listed in `<recommended_plugins>`.\n\n",
            "Do not use it for adjacent capabilities, broad recommendations, or plugins that merely seem useful. Briefly explain why the plugin can help with the current request in `suggest_reason`.\n\n",
            "IMPORTANT: DO NOT call this tool in parallel with other tools.",
        );

        assert_eq!(
            create_request_plugin_install_tool(ToolSuggestPresentation::RecommendationContext),
            ToolSpec::Function(ResponsesApiTool {
                name: "request_plugin_install".to_string(),
                description: expected_description.to_string(),
                strict: false,
                defer_loading: None,
                parameters: JsonSchema::object(
                    BTreeMap::from([
                        (
                            "plugin_id".to_string(),
                            JsonSchema::string(Some(
                                "The parenthesized plugin ID from the `<recommended_plugins>` list."
                                    .to_string(),
                            )),
                        ),
                        (
                            "suggest_reason".to_string(),
                            JsonSchema::string(Some(
                                "Concise one-line user-facing reason why this plugin can help with the current request."
                                    .to_string(),
                            )),
                        ),
                    ]),
                    Some(vec!["plugin_id".to_string(), "suggest_reason".to_string()]),
                    Some(false.into()),
                ),
                output_schema: None,
            })
        );
    }
}
codex-rs/core/src/tools/handlers/request_plugin_install.rs
use std::collections::HashSet;
use std::sync::Arc;

use codex_analytics::PluginInstallRequestSource;
use codex_analytics::PluginInstallRequested;
use codex_analytics::PluginInstallRequestedPlugin;
use codex_analytics::build_track_events_context;
use codex_config::types::ToolSuggestDisabledTool;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
use codex_tools::DiscoverableTool;
use codex_tools::DiscoverableToolAction;
use codex_tools::DiscoverableToolType;
use codex_tools::LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE;
use codex_tools::REQUEST_PLUGIN_INSTALL_PERSIST_KEY;
use codex_tools::REQUEST_PLUGIN_INSTALL_TOOL_NAME;
use codex_tools::RequestPluginInstallArgs;
use codex_tools::RequestPluginInstallResult;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::all_requested_connectors_picked_up;
use codex_tools::build_request_plugin_install_elicitation_request;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_tools::verified_connector_install_completed;
use rmcp::model::RequestId;
use serde::Deserialize;
use serde_json::Value;
use tracing::warn;

use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::connectors;
use crate::connectors::AppInfo;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::handlers::request_plugin_install_spec::create_request_plugin_install_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use crate::tools::router::ToolSuggestPresentation;

#[derive(Debug, Deserialize, PartialEq, Eq)]
struct RecommendedPluginInstallArgs {
    #[serde(alias = "tool_id")]
    plugin_id: String,
    suggest_reason: String,
}

pub struct RequestPluginInstallHandler {
    discoverable_tools: Vec<DiscoverableTool>,
    presentation: ToolSuggestPresentation,
}

impl RequestPluginInstallHandler {
    pub(crate) fn new(
        discoverable_tools: Vec<DiscoverableTool>,
        presentation: ToolSuggestPresentation,
    ) -> Self {
        Self {
            discoverable_tools,
            presentation,
        }
    }
}

impl ToolExecutor<ToolInvocation> for RequestPluginInstallHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(REQUEST_PLUGIN_INSTALL_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        create_request_plugin_install_tool(self.presentation)
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        false
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl RequestPluginInstallHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            payload,
            session,
            step_context,
            call_id,
            ..
        } = invocation;
        let turn = Arc::clone(&step_context.turn);
        let mcp = &step_context.mcp;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::Fatal(format!(
                    "{REQUEST_PLUGIN_INSTALL_TOOL_NAME} handler received unsupported payload"
                )));
            }
        };

        if turn.session_source.is_non_root_agent() {
            return Err(FunctionCallError::RespondToModel(
                "request_plugin_install can only be used by the root thread".to_string(),
            ));
        }

        let (requested_tool_id, requested_tool_type, suggest_reason) = match self.presentation {
            ToolSuggestPresentation::ListTool => {
                let args: RequestPluginInstallArgs = parse_arguments(&arguments)?;
                if args.action_type != DiscoverableToolAction::Install {
                    return Err(FunctionCallError::RespondToModel(
                        "plugin install requests currently support only action_type=\"install\""
                            .to_string(),
                    ));
                }
                (args.tool_id, Some(args.tool_type), args.suggest_reason)
            }
            ToolSuggestPresentation::RecommendationContext => {
                let args: RecommendedPluginInstallArgs = parse_arguments(&arguments)?;
                (args.plugin_id, None, args.suggest_reason)
            }
        };
        let suggest_reason = suggest_reason.trim();
        if suggest_reason.is_empty() {
            return Err(FunctionCallError::RespondToModel(
                "suggest_reason must not be empty".to_string(),
            ));
        }
        if (requested_tool_type == Some(DiscoverableToolType::Plugin)
            || self.presentation == ToolSuggestPresentation::RecommendationContext)
            && turn.app_server_client_name.as_deref() == Some("codex-tui")
        {
            return Err(FunctionCallError::RespondToModel(
                "plugin install requests are not available in codex-tui yet".to_string(),
            ));
        }

        let discoverable_tools = filter_request_plugin_install_discoverable_tools_for_client(
            self.discoverable_tools.clone(),
            turn.app_server_client_name.as_deref(),
        );

        let tool = discoverable_tools
            .into_iter()
            .find(|tool| {
                tool.id() == requested_tool_id
                    && match self.presentation {
                        ToolSuggestPresentation::ListTool => {
                            Some(tool.tool_type()) == requested_tool_type
                        }
                        ToolSuggestPresentation::RecommendationContext => {
                            matches!(tool, DiscoverableTool::Plugin(_))
                        }
                    }
            })
            .ok_or_else(|| {
                let (argument_name, source) = match self.presentation {
                    ToolSuggestPresentation::ListTool => (
                        "tool_id",
                        format!(
                            "the discoverable tools returned by {LIST_AVAILABLE_PLUGINS_TO_INSTALL_TOOL_NAME}"
                        ),
                    ),
                    ToolSuggestPresentation::RecommendationContext => (
                        "plugin_id",
                        "the entries in the <recommended_plugins> list".to_string(),
                    ),
                };
                FunctionCallError::RespondToModel(format!(
                    "{argument_name} must match one of {source}"
                ))
            })?;
        let tool = if self.presentation == ToolSuggestPresentation::RecommendationContext {
            let plugin_id = tool.id().to_string();
            let auth = session.services.auth_manager.auth().await;
            let plugins_config = turn.config.plugins_config_input();
            match codex_core_plugins::hydrate_selected_recommended_plugin_install_metadata(
                &plugins_config,
                auth.as_ref(),
                tool,
            )
            .await
            {
                Ok(Some(tool)) => tool,
                Ok(None) => return Err(recommended_plugins_no_longer_available()),
                Err(err) => {
                    warn!(
                        plugin_id,
                        error = %err,
                        "failed to hydrate selected recommended plugin install metadata"
                    );
                    return Err(recommended_plugin_metadata_retryable());
                }
            }
        } else {
            tool
        };
        let tool_type = tool.tool_type();

        let suggestion_id = format!("request_plugin_install_{call_id}");
        if let DiscoverableTool::Plugin(plugin) = &tool {
            let source = match self.presentation {
                ToolSuggestPresentation::ListTool => PluginInstallRequestSource::LegacyDiscovery,
                ToolSuggestPresentation::RecommendationContext => {
                    PluginInstallRequestSource::EndpointRecommendation
                }
            };
            session
                .services
                .analytics_events_client
                .track_plugin_install_requested(
                    build_track_events_context(
                        step_context.settings.model_info.slug.clone(),
                        session.thread_id.to_string(),
                        turn.sub_id.clone(),
                        turn.originator.clone(),
                        /*turn_metadata*/ None,
                    ),
                    PluginInstallRequested {
                        suggestion_id: suggestion_id.clone(),
                        plugins: vec![PluginInstallRequestedPlugin {
                            plugin_id: plugin.id.clone(),
                            remote_plugin_id: plugin.remote_plugin_id.clone(),
                            plugin_name: plugin.name.clone(),
                            connector_ids: plugin.app_connector_ids.clone(),
                        }],
                        source,
                    },
                );
        }

        let request =
            build_request_plugin_install_elicitation_request(suggest_reason, &tool, &suggestion_id);
        let request_id = RequestId::String(suggestion_id.into());
        let elicitation = session
            .request_mcp_server_elicitation(
                turn.as_ref(),
                CODEX_APPS_MCP_SERVER_NAME.to_string(),
                request_id,
                request,
            )
            .await;
        let response = elicitation.response;
        if let Some(response) = response.as_ref() {
            maybe_persist_disabled_install_request(&session, &turn, &tool, response).await;
        }
        let user_confirmed = response
            .as_ref()
            .is_some_and(|response| response.action == ElicitationAction::Accept);

        let auth = session.services.auth_manager.auth().await;
        let completed = if user_confirmed {
            verify_request_plugin_install_completed(&session, &turn, mcp, &tool, auth.as_ref())
                .await
        } else {
            false
        };

        if completed && let DiscoverableTool::Connector(connector) = &tool {
            session
                .merge_connector_selection(HashSet::from([connector.id.clone()]))
                .await;
        }

        if elicitation.sent {
            let tool_type = match tool_type {
                DiscoverableToolType::Connector => "connector",
                DiscoverableToolType::Plugin => "plugin",
            };
            let response_action = match response.as_ref().map(|response| &response.action) {
                Some(ElicitationAction::Accept) => "accept",
                Some(ElicitationAction::Decline) => "decline",
                Some(ElicitationAction::Cancel) => "cancel",
                Some(_) => "unknown",
                None => "unavailable",
            };
            step_context
                .session_telemetry
                .record_plugin_install_suggestion(
                    tool_type,
                    tool.id(),
                    tool.name(),
                    response_action,
                    user_confirmed,
                    completed,
                );
        }

        let content = serde_json::to_string(&RequestPluginInstallResult {
            completed,
            user_confirmed,
            tool_type,
            action_type: DiscoverableToolAction::Install,
            tool_id: tool.id().to_string(),
            tool_name: tool.name().to_string(),
            suggest_reason: suggest_reason.to_string(),
        })
        .map_err(|err| {
            FunctionCallError::Fatal(format!(
                "failed to serialize {REQUEST_PLUGIN_INSTALL_TOOL_NAME} response: {err}"
            ))
        })?;

        Ok(boxed_tool_output(FunctionToolOutput::from_text(
            content,
            Some(true),
        )))
    }
}

fn recommended_plugins_no_longer_available() -> FunctionCallError {
    FunctionCallError::RespondToModel(
        "The recommended plugins for this turn are no longer available.".to_string(),
    )
}

fn recommended_plugin_metadata_retryable() -> FunctionCallError {
    FunctionCallError::RespondToModel(
        "The selected recommended plugin could not be verified right now. Retry request_plugin_install with the same plugin_id.".to_string(),
    )
}

impl CoreToolRuntime for RequestPluginInstallHandler {}

async fn maybe_persist_disabled_install_request(
    session: &crate::session::session::Session,
    turn: &crate::session::turn_context::TurnContext,
    tool: &DiscoverableTool,
    response: &ElicitationResponse,
) {
    if !request_plugin_install_response_requests_persistent_disable(response) {
        return;
    }

    if let Err(err) = persist_disabled_install_request(&turn.config.codex_home, tool).await {
        warn!(
            error = %err,
            tool_id = tool.id(),
            "failed to persist disabled tool suggestion"
        );
        return;
    }

    session.reload_user_config_layer().await;
}

fn request_plugin_install_response_requests_persistent_disable(
    response: &ElicitationResponse,
) -> bool {
    if response.action != ElicitationAction::Decline {
        return false;
    }

    response
        .meta
        .as_ref()
        .and_then(Value::as_object)
        .and_then(|meta| meta.get(REQUEST_PLUGIN_INSTALL_PERSIST_KEY))
        .and_then(Value::as_str)
        == Some(REQUEST_PLUGIN_INSTALL_PERSIST_ALWAYS_VALUE)
}

async fn persist_disabled_install_request(
    codex_home: &codex_utils_absolute_path::AbsolutePathBuf,
    tool: &DiscoverableTool,
) -> anyhow::Result<()> {
    ConfigEditsBuilder::new(codex_home)
        .with_edits([ConfigEdit::AddToolSuggestDisabledTool(
            disabled_install_request(tool),
        )])
        .apply()
        .await
}

fn disabled_install_request(tool: &DiscoverableTool) -> ToolSuggestDisabledTool {
    match tool {
        DiscoverableTool::Connector(connector) => {
            ToolSuggestDisabledTool::connector(connector.id.as_str())
        }
        DiscoverableTool::Plugin(plugin) => ToolSuggestDisabledTool::plugin(plugin.id.as_str()),
    }
}

async fn verify_request_plugin_install_completed(
    session: &Arc<crate::session::session::Session>,
    turn: &crate::session::turn_context::TurnContext,
    mcp: &codex_mcp::McpBinding,
    tool: &DiscoverableTool,
    auth: Option<&codex_login::CodexAuth>,
) -> bool {
    match tool {
        DiscoverableTool::Connector(connector) => refresh_missing_requested_connectors(
            session,
            turn,
            mcp,
            auth,
            std::slice::from_ref(&connector.id),
            connector.id.as_str(),
        )
        .await
        .is_some_and(|accessible_connectors| {
            verified_connector_install_completed(connector.id.as_str(), &accessible_connectors)
        }),
        DiscoverableTool::Plugin(plugin) => {
            if is_remote_plugin_install_suggestion(&plugin.id) {
                let (_, accessible_connectors) = tokio::join!(
                    refresh_remote_installed_plugins_cache_after_install(
                        session,
                        turn,
                        auth,
                        plugin.id.as_str(),
                    ),
                    refresh_missing_requested_connectors(
                        session,
                        turn,
                        mcp,
                        auth,
                        &plugin.app_connector_ids,
                        plugin.id.as_str(),
                    )
                );
                return accessible_connectors.is_some_and(|accessible_connectors| {
                    all_requested_connectors_picked_up(
                        &plugin.app_connector_ids,
                        &accessible_connectors,
                    )
                });
            }

            session.reload_user_config_layer().await;
            let config = session.get_config().await;
            let completed = verified_plugin_install_completed(
                plugin.id.as_str(),
                config.as_ref(),
                session.services.plugins_manager.as_ref(),
            );
            let _ = refresh_missing_requested_connectors(
                session,
                turn,
                mcp,
                auth,
                &plugin.app_connector_ids,
                plugin.id.as_str(),
            )
            .await;
            completed
        }
    }
}

async fn refresh_remote_installed_plugins_cache_after_install(
    session: &crate::session::session::Session,
    turn: &crate::session::turn_context::TurnContext,
    auth: Option<&codex_login::CodexAuth>,
    tool_id: &str,
) {
    let plugins_manager = &session.services.plugins_manager;
    let plugins_config = turn.config.plugins_config_input();
    if let Err(err) = plugins_manager
        .build_and_cache_remote_installed_plugin_marketplaces(
            &plugins_config,
            auth,
            &[REMOTE_GLOBAL_MARKETPLACE_NAME],
            /*on_effective_plugins_changed*/ None,
        )
        .await
    {
        warn!(
            "failed to refresh remote installed plugins cache after plugin install request for {tool_id}: {err:#}"
        );
    }
}

fn is_remote_plugin_install_suggestion(plugin_id: &str) -> bool {
    plugin_id
        .rsplit_once('@')
        .is_some_and(|(_, marketplace_name)| marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME)
}

async fn refresh_missing_requested_connectors(
    session: &Arc<crate::session::session::Session>,
    turn: &crate::session::turn_context::TurnContext,
    mcp: &codex_mcp::McpBinding,
    auth: Option<&codex_login::CodexAuth>,
    expected_connector_ids: &[String],
    tool_id: &str,
) -> Option<Vec<AppInfo>> {
    if expected_connector_ids.is_empty() {
        return Some(Vec::new());
    }

    let mcp_tools = mcp.tools();
    let accessible_connectors = connectors::accessible_connectors_from_mcp_tools(mcp_tools);
    if all_requested_connectors_picked_up(expected_connector_ids, &accessible_connectors) {
        return Some(accessible_connectors);
    }

    match session.hard_refresh_latest_codex_apps_tools().await {
        Ok(mcp_tools) => {
            let accessible_connectors =
                connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
            connectors::refresh_accessible_connectors_cache_from_mcp_tools(
                &turn.config,
                auth,
                &mcp_tools,
            );
            Some(accessible_connectors)
        }
        Err(err) => {
            warn!(
                "failed to refresh codex apps tools cache after plugin install request for {tool_id}: {err:#}"
            );
            None
        }
    }
}

fn verified_plugin_install_completed(
    tool_id: &str,
    config: &crate::config::Config,
    plugins_manager: &codex_core_plugins::PluginsManager,
) -> bool {
    let plugins_input = config.plugins_config_input();
    plugins_manager
        .list_marketplaces_for_config(&plugins_input, &[], /*include_openai_curated*/ true)
        .ok()
        .into_iter()
        .flat_map(|outcome| outcome.marketplaces)
        .flat_map(|marketplace| marketplace.plugins.into_iter())
        .any(|plugin| plugin.id == tool_id && plugin.installed)
}

#[cfg(test)]
#[path = "request_plugin_install_tests.rs"]
mod tests;

工具搜索 / 动态 / 扩展工具

工具数量多时,Codex 会把部分工具转入 deferred search;hosted web_search 只生成 provider 端工具声明,Codex 本地没有搜索后端。

codex-rs/core/src/tools/handlers/tool_search_spec.rs
use codex_tools::JsonSchema;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_utils_string::take_bytes_at_char_boundary;
use std::collections::BTreeMap;

const MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES: usize = 512 * 1024;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ToolSearchSourceListing {
    Include,
    Omit,
}

pub(crate) fn create_tool_search_tool(
    searchable_sources: &[ToolSearchSourceInfo],
    default_limit: usize,
    source_listing: ToolSearchSourceListing,
) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "query".to_string(),
            JsonSchema::string(Some("Search query for deferred tools.".to_string())),
        ),
        (
            "limit".to_string(),
            JsonSchema::number(Some(format!(
                "Maximum number of tools to return. Defaults to {default_limit}."
            ))),
        ),
    ]);

    let source_section = match source_listing {
        ToolSearchSourceListing::Include => {
            let mut source_descriptions = BTreeMap::new();
            for source in searchable_sources {
                source_descriptions
                    .entry(source.name.clone())
                    .and_modify(|existing: &mut Option<String>| {
                        if existing.is_none() {
                            *existing = source.description.clone();
                        }
                    })
                    .or_insert(source.description.clone());
            }

            let source_descriptions = if source_descriptions.is_empty() {
                "None currently enabled.".to_string()
            } else {
                let reserved_name_bytes = source_descriptions.keys().fold(
                    source_descriptions.len().saturating_sub(1),
                    |reserved, name| reserved.saturating_add(2).saturating_add(name.len()),
                );
                let mut description_budget =
                    MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(reserved_name_bytes);
                let mut rendered = String::new();
                for (name, description) in source_descriptions {
                    let separator_bytes = usize::from(!rendered.is_empty());
                    let required = separator_bytes.saturating_add(2).saturating_add(name.len());
                    if required
                        > MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES.saturating_sub(rendered.len())
                    {
                        continue;
                    }

                    if !rendered.is_empty() {
                        rendered.push('\n');
                    }
                    rendered.push_str("- ");
                    rendered.push_str(&name);

                    if let Some(description) = description
                        && description_budget >= 2
                    {
                        rendered.push_str(": ");
                        description_budget -= 2;
                        let bounded_description =
                            take_bytes_at_char_boundary(&description, description_budget);
                        rendered.push_str(bounded_description);
                        description_budget -= bounded_description.len();
                    }
                }
                rendered
            };
            format!(
                "\n\nYou have access to tools from the following sources:\n{source_descriptions}\n"
            )
        }
        ToolSearchSourceListing::Omit => "\n\n".to_string(),
    };

    let description = format!(
        "# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.{source_section}Some of the tools may not have been provided to you upfront, and you should use this tool (`{TOOL_SEARCH_TOOL_NAME}`) to search for the required tools. For MCP tool discovery, always use `{TOOL_SEARCH_TOOL_NAME}` instead of `list_mcp_resources` or `list_mcp_resource_templates`."
    );

    ToolSpec::ToolSearch {
        execution: "client".to_string(),
        description,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["query".to_string()]),
            Some(false.into()),
        ),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use codex_tools::JsonSchema;
    use pretty_assertions::assert_eq;
    use std::collections::BTreeMap;

    #[test]
    fn create_tool_search_tool_deduplicates_and_renders_enabled_sources() {
        assert_eq!(
            create_tool_search_tool(
                &[
                    ToolSearchSourceInfo {
                        name: "Google Drive".to_string(),
                        description: Some(
                            "Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work."
                                .to_string(),
                        ),
                    },
                    ToolSearchSourceInfo {
                        name: "Google Drive".to_string(),
                        description: None,
                    },
                    ToolSearchSourceInfo {
                        name: "docs".to_string(),
                        description: None,
                    },
                ],
                /*default_limit*/ 8,
                ToolSearchSourceListing::Include,
            ),
            ToolSpec::ToolSearch {
                execution: "client".to_string(),
                description: "# Tool discovery\n\nSearches over deferred tool metadata with BM25 and exposes matching tools for the next model call.\n\nYou have access to tools from the following sources:\n- Google Drive: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.\n- docs\nSome of the tools may not have been provided to you upfront, and you should use this tool (`tool_search`) to search for the required tools. For MCP tool discovery, always use `tool_search` instead of `list_mcp_resources` or `list_mcp_resource_templates`.".to_string(),
                parameters: JsonSchema::object(BTreeMap::from([
                        (
                            "limit".to_string(),
                            JsonSchema::number(Some(
                                    "Maximum number of tools to return. Defaults to 8."
                                        .to_string(),
                                ),),
                        ),
                        (
                            "query".to_string(),
                            JsonSchema::string(Some("Search query for deferred tools.".to_string()),),
                        ),
                    ]), Some(vec!["query".to_string()]), Some(false.into())),
            }
        );
    }

    #[test]
    fn create_tool_search_tool_omits_sources_when_world_state_advertises_them() {
        let ToolSpec::ToolSearch { description, .. } = create_tool_search_tool(
            &[ToolSearchSourceInfo {
                name: "Google Drive".to_string(),
                description: Some("Search files and documents.".to_string()),
            }],
            /*default_limit*/ 8,
            ToolSearchSourceListing::Omit,
        ) else {
            panic!("expected tool search spec");
        };

        assert!(!description.contains("You have access to tools from the following sources"));
        assert!(!description.contains("Google Drive"));
        assert!(description.contains("use this tool (`tool_search`) to search"));
    }

    #[test]
    fn create_tool_search_tool_bounds_aggregate_source_descriptions() {
        let long_description = "🦀".repeat(20_000);
        let sources = (0..8)
            .map(|index| ToolSearchSourceInfo {
                name: format!("source-{index:02}"),
                description: Some(long_description.clone()),
            })
            .collect::<Vec<_>>();
        let ToolSpec::ToolSearch { description, .. } = create_tool_search_tool(
            &sources,
            /*default_limit*/ 8,
            ToolSearchSourceListing::Include,
        ) else {
            panic!("expected tool search spec");
        };

        let (_, source_section) = description
            .split_once("You have access to tools from the following sources:\n")
            .expect("tool search should retain its source introduction");
        let (source_descriptions, _) = source_section
            .split_once("\nSome of the tools may not have been provided to you upfront")
            .expect("tool search should retain its discovery instructions");
        assert!(source_descriptions.len() <= MAX_TOOL_SEARCH_SOURCE_DESCRIPTION_BYTES);
        assert!(source_descriptions.starts_with("- source-00: 🦀"));
        assert!(source_descriptions.contains(&long_description));
        let advertised_names = source_descriptions
            .lines()
            .map(|line| {
                let source = line
                    .strip_prefix("- ")
                    .expect("each source should be a complete list item");
                source
                    .split_once(": ")
                    .map_or(source, |(name, _)| name)
                    .to_string()
            })
            .collect::<Vec<_>>();
        let expected_names = (0..8)
            .map(|index| format!("source-{index:02}"))
            .collect::<Vec<_>>();
        assert_eq!(advertised_names, expected_names);
        assert!(description.contains("always use `tool_search`"));
    }
}
codex-rs/core/src/tools/handlers/tool_search.rs
use crate::function_tool::FunctionCallError;
use crate::tools::code_mode::prepare_code_mode_tool_definitions;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::tool_search_spec::ToolSearchSourceListing;
use crate::tools::handlers::tool_search_spec::create_tool_search_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolRegistry;
use crate::tools::router::ToolRouter;
use bm25::Embedder;
use bm25::EmbedderBuilder;
use bm25::Language;
use bm25::Scorer;
use codex_features::Feature;
use codex_prompts::ResolvedModelMessages;
use codex_tools::IndirectNamespacePrefixes;
use codex_tools::JsonToolOutput;
use codex_tools::LoadableToolSpec;
use codex_tools::TOOL_SEARCH_DEFAULT_LIMIT;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolName;
use codex_tools::ToolSearchEntry;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use codex_tools::coalesce_loadable_tool_specs;
use serde::Serialize;
use std::borrow::Cow;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::Weak;
use tracing::instrument;

#[derive(Serialize)]
struct CodeModeSearchResult {
    name: String,
    description: String,
}

pub struct ToolSearchHandler {
    search_infos: Vec<ToolSearchInfo>,
    source_listing: ToolSearchSourceListing,
    spec: ToolSpec,
    search_embedder: Embedder,
    search_scorer: Scorer<usize>,
}

#[derive(Default)]
pub(crate) struct ToolSearchHandlerCache {
    cached: Mutex<Option<CachedToolSearchHandler>>,
}

struct CachedToolSearchHandler {
    handler: Arc<ToolSearchHandler>,
    sources: Vec<ToolSearchSource>,
}

enum ToolSearchSource {
    Immutable(Weak<dyn CoreToolRuntime>),
    Dynamic(Box<ToolSearchInfo>),
}

impl ToolSearchHandlerCache {
    #[instrument(level = "trace", skip_all)]
    pub(crate) fn get_or_build(
        &self,
        registry: &ToolRegistry,
        source_listing: ToolSearchSourceListing,
    ) -> Arc<ToolSearchHandler> {
        let sources = registry
            .entries()
            .filter(|tool| tool.exposure.is_deferred())
            .filter_map(|tool| {
                if let Some(runtime) = tool.cached_runtime() {
                    Some(ToolSearchSource::Immutable(Arc::downgrade(runtime)))
                } else {
                    registry
                        .search_info(tool)
                        .map(Box::new)
                        .map(ToolSearchSource::Dynamic)
                }
            })
            .collect::<Vec<_>>();

        {
            let cached = self.cached();
            if let Some(cached) = cached.as_ref()
                && cached.handler.source_listing == source_listing
                && Self::sources_match(&cached.sources, &sources)
            {
                return Arc::clone(&cached.handler);
            }
        }

        let search_infos = sources
            .iter()
            .filter_map(|source| match source {
                ToolSearchSource::Immutable(runtime) => {
                    runtime.upgrade().and_then(|runtime| runtime.search_info())
                }
                ToolSearchSource::Dynamic(search_info) => Some(search_info.as_ref().clone()),
            })
            .collect();

        let handler = Arc::new(ToolSearchHandler::new(search_infos, source_listing));
        let mut cached = self.cached();
        if let Some(cached) = cached.as_ref()
            && cached.handler.source_listing == source_listing
            && Self::sources_match(&cached.sources, &sources)
        {
            return Arc::clone(&cached.handler);
        }
        *cached = Some(CachedToolSearchHandler {
            handler: Arc::clone(&handler),
            sources,
        });
        handler
    }

    fn sources_match(cached_sources: &[ToolSearchSource], sources: &[ToolSearchSource]) -> bool {
        cached_sources.len() == sources.len()
            && cached_sources
                .iter()
                .zip(sources)
                .all(|(cached, current)| match (cached, current) {
                    (ToolSearchSource::Immutable(cached), ToolSearchSource::Immutable(current)) => {
                        Weak::ptr_eq(cached, current)
                    }
                    (ToolSearchSource::Dynamic(cached), ToolSearchSource::Dynamic(current)) => {
                        cached == current
                    }
                    (ToolSearchSource::Immutable(_), ToolSearchSource::Dynamic(_))
                    | (ToolSearchSource::Dynamic(_), ToolSearchSource::Immutable(_)) => false,
                })
    }

    fn cached(&self) -> std::sync::MutexGuard<'_, Option<CachedToolSearchHandler>> {
        match self.cached.lock() {
            Ok(cached) => cached,
            Err(poisoned) => poisoned.into_inner(),
        }
    }
}

impl ToolSearchHandler {
    #[instrument(
        level = "trace",
        skip_all,
        fields(search_info_count = search_infos.len())
    )]
    pub(crate) fn new(
        search_infos: Vec<ToolSearchInfo>,
        source_listing: ToolSearchSourceListing,
    ) -> Self {
        let search_source_infos = search_infos
            .iter()
            .filter_map(|search_info| search_info.source_info.clone())
            .collect::<Vec<_>>();
        let spec = create_tool_search_tool(
            &search_source_infos,
            TOOL_SEARCH_DEFAULT_LIMIT,
            source_listing,
        );
        let corpus = search_infos
            .iter()
            .map(|search_info| search_info.entry.search_text.as_str())
            .collect::<Vec<_>>();
        let search_embedder =
            EmbedderBuilder::with_fit_to_corpus(Language::English, &corpus).build();
        let mut search_scorer = Scorer::new();
        for (id, text) in corpus.into_iter().enumerate() {
            search_scorer.upsert(&id, search_embedder.embed(text));
        }

        Self {
            search_infos,
            source_listing,
            spec,
            search_embedder,
            search_scorer,
        }
    }
}

impl ToolExecutor<ToolInvocation> for ToolSearchHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(TOOL_SEARCH_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ToolSearchHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            payload,
            source,
            step_context,
            ..
        } = invocation;
        let nested = matches!(source, ToolCallSource::CodeMode { .. });

        let args = match payload {
            ToolPayload::ToolSearch { arguments } => arguments,
            ToolPayload::Function { arguments } if nested => super::parse_arguments(&arguments)?,
            _ => {
                return Err(FunctionCallError::Fatal(format!(
                    "{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
                )));
            }
        };

        let query = args.query.trim();
        if query.is_empty() {
            return Err(FunctionCallError::RespondToModel(
                "query must not be empty".to_string(),
            ));
        }
        let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT);

        if limit == 0 {
            return Err(FunctionCallError::RespondToModel(
                "limit must be greater than zero".to_string(),
            ));
        }

        let model_messages = ResolvedModelMessages::from_model(&step_context.settings.model_info);
        let indirect_prefixes = IndirectNamespacePrefixes::new(
            model_messages.indirect_description_prefixes(),
            step_context.tool_router.mcp_namespaces(),
        )
        .map_err(|error| FunctionCallError::Fatal(error.to_string()))?;

        if nested {
            let tools = self.search_code_mode(
                query,
                limit,
                &step_context.tool_router,
                step_context
                    .turn
                    .config
                    .code_mode
                    .tool_input_schema_max_bytes,
                step_context
                    .turn
                    .config
                    .features
                    .enabled(Feature::CodeModeToolDescriptionFirst),
                &indirect_prefixes,
            );
            return Ok(boxed_tool_output(JsonToolOutput::new(serde_json::json!({
                "tools": tools,
            }))));
        }

        if self.search_infos.is_empty() {
            return Ok(boxed_tool_output(ToolSearchOutput { tools: Vec::new() }));
        }

        let mut tools = self.search(query, limit);
        indirect_prefixes.apply_search(&mut tools);

        Ok(boxed_tool_output(ToolSearchOutput { tools }))
    }
}

impl CoreToolRuntime for ToolSearchHandler {}

impl ToolSearchHandler {
    fn search_code_mode(
        &self,
        query: &str,
        limit: usize,
        router: &ToolRouter,
        code_mode_input_schema_max_bytes: Option<usize>,
        tool_description_first: bool,
        indirect_prefixes: &IndirectNamespacePrefixes<'_>,
    ) -> Vec<CodeModeSearchResult> {
        let mut tools = Vec::new();
        // Apply the exact step's nested-tool policy before limiting ranked results.
        for entry in self.search_entries(query) {
            for candidate_name in entry.tool_names() {
                let candidate_name = candidate_name.with_default_namespace();
                let name = codex_code_mode::normalize_code_mode_identifier(
                    &codex_tools::code_mode_name_for_tool_name(&candidate_name),
                );
                let Some(tool_name) =
                    router
                        .code_mode_tool_names()
                        .get(&name)
                        .filter(|tool_name| {
                            (*tool_name).clone().with_default_namespace() == candidate_name
                        })
                else {
                    continue;
                };
                let Some(tool) = router.registered_tool(tool_name) else {
                    continue;
                };
                let definitions = prepare_code_mode_tool_definitions(
                    tool.cached_runtime().map(Arc::as_ref),
                    || Cow::Owned(Arc::unwrap_or_clone(tool.spec())),
                    code_mode_input_schema_max_bytes,
                    tool_description_first,
                );
                let Some(definition) = definitions.iter().find(|definition| {
                    definition.tool_name.clone().with_default_namespace() == candidate_name
                }) else {
                    continue;
                };
                let mut description = definition.description.clone();
                indirect_prefixes
                    .apply_code_mode_description(&definition.tool_name, &mut description);
                tools.push(CodeModeSearchResult { name, description });
                if tools.len() == limit {
                    return tools;
                }
            }
        }
        tools
    }

    fn search(&self, query: &str, limit: usize) -> Vec<LoadableToolSpec> {
        coalesce_loadable_tool_specs(
            self.search_entries(query)
                .take(limit)
                .map(ToolSearchEntry::to_loadable_spec),
        )
    }

    fn search_entries(&self, query: &str) -> impl Iterator<Item = &ToolSearchEntry> + '_ {
        self.search_scorer
            .matches(&self.search_embedder.embed(query))
            .into_iter()
            .filter_map(|result| self.search_infos.get(result.id))
            .map(|search_info| &search_info.entry)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::handlers::DynamicToolHandler;
    use crate::tools::handlers::McpHandler;
    use crate::tools::registry::ToolExposure;
    use codex_mcp::ToolInfo;
    use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
    use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
    use codex_tools::ResponsesApiNamespace;
    use codex_tools::ResponsesApiNamespaceTool;
    use codex_tools::ResponsesApiTool;
    use pretty_assertions::assert_eq;
    use rmcp::model::Tool;
    use std::sync::Arc;

    #[test]
    fn cache_reuses_immutable_handlers_and_rebuilds_for_current_registry_changes() {
        let cache = ToolSearchHandlerCache::default();
        let mut info = tool_info("calendar", "create_event", "Create events");
        info.callable_namespace = "functions".to_string();
        let runtime: Arc<dyn CoreToolRuntime> =
            Arc::new(McpHandler::new(info.clone()).expect("MCP tool should convert"));
        let mut registry = ToolRegistry::default();
        registry.register_trusted_with_exposure(Arc::clone(&runtime), ToolExposure::Deferred);

        let first = cache.get_or_build(&registry, ToolSearchSourceListing::Include);
        let second = cache.get_or_build(&registry, ToolSearchSourceListing::Include);
        assert!(Arc::ptr_eq(&first, &second));

        let without_sources = cache.get_or_build(&registry, ToolSearchSourceListing::Omit);
        assert!(!Arc::ptr_eq(&first, &without_sources));

        let mut replacement_registry = ToolRegistry::default();
        let replacement =
            Arc::new(McpHandler::new(info).expect("replacement MCP tool should convert"));
        replacement_registry.register_trusted_with_exposure(replacement, ToolExposure::Deferred);
        let replacement = cache.get_or_build(&replacement_registry, ToolSearchSourceListing::Omit);
        assert!(!Arc::ptr_eq(&without_sources, &replacement));

        registry
            .apply_functions_namespace_function_prefixes(
                Some(&std::collections::BTreeMap::from([(
                    "create_event".to_string(),
                    "Guidance.".to_string(),
                )])),
                [ToolName::plain("create_event")],
            )
            .expect("valid function prefixes");
        let prefixed = cache.get_or_build(&registry, ToolSearchSourceListing::Include);
        let reused = cache.get_or_build(&registry, ToolSearchSourceListing::Include);
        assert!(Arc::ptr_eq(&prefixed, &reused));
        assert_eq!(
            prefixed.search_infos[0].entry.search_text,
            first.search_infos[0].entry.search_text
        );
        let mut expected = first.search("Create events", /*limit*/ 1);
        let LoadableToolSpec::Namespace(namespace) = &mut expected[0] else {
            panic!("namespace")
        };
        let ResponsesApiNamespaceTool::Function(tool) = &mut namespace.tools[0] else {
            panic!("function")
        };
        tool.description = format!("Guidance.\n\n{}", tool.description);
        assert_eq!(prefixed.search("Create events", /*limit*/ 1), expected);
        registry
            .apply_functions_namespace_function_prefixes(/*prefixes*/ None, [])
            .expect("clear prefixes");
        let cleared = cache.get_or_build(&registry, ToolSearchSourceListing::Include);
        assert_eq!(
            cleared.search("Create events", /*limit*/ 1),
            first.search("Create events", /*limit*/ 1)
        );

        let mut disabled_registry = ToolRegistry::default();
        disabled_registry.register_trusted_with_exposure(runtime, ToolExposure::Direct);
        let disabled = cache.get_or_build(&disabled_registry, ToolSearchSourceListing::Omit);
        assert!(!Arc::ptr_eq(&replacement, &disabled));
        assert!(disabled.search_infos.is_empty());
    }

    #[test]
    fn cache_rechecks_dynamic_tool_metadata_while_reusing_immutable_mcp_handlers() {
        let cache = ToolSearchHandlerCache::default();
        let mcp_runtime: Arc<dyn CoreToolRuntime> = Arc::new(
            McpHandler::new(tool_info("calendar", "create_event", "Create events"))
                .expect("MCP tool should convert"),
        );
        let mut dynamic_tool = DynamicToolFunctionSpec {
            name: "lookup".to_string(),
            description: "Search current records".to_string(),
            input_schema: serde_json::json!({"type": "object", "properties": {}}),
            defer_loading: true,
        };

        let mut first_registry = ToolRegistry::default();
        first_registry
            .register_trusted_with_exposure(Arc::clone(&mcp_runtime), ToolExposure::Deferred);
        first_registry.register_external_with_exposure(
            Arc::new(DynamicToolHandler::new(&dynamic_tool).expect("dynamic tool should convert")),
            ToolExposure::Deferred,
        );
        let first = cache.get_or_build(&first_registry, ToolSearchSourceListing::Include);

        let mut equivalent_registry = ToolRegistry::default();
        equivalent_registry
            .register_trusted_with_exposure(Arc::clone(&mcp_runtime), ToolExposure::Deferred);
        equivalent_registry.register_external_with_exposure(
            Arc::new(DynamicToolHandler::new(&dynamic_tool).expect("dynamic tool should convert")),
            ToolExposure::Deferred,
        );
        let equivalent = cache.get_or_build(&equivalent_registry, ToolSearchSourceListing::Include);
        assert!(Arc::ptr_eq(&first, &equivalent));

        dynamic_tool.description = "Search refreshed records".to_string();
        let mut refreshed_registry = ToolRegistry::default();
        refreshed_registry.register_trusted_with_exposure(mcp_runtime, ToolExposure::Deferred);
        refreshed_registry.register_external_with_exposure(
            Arc::new(DynamicToolHandler::new(&dynamic_tool).expect("dynamic tool should convert")),
            ToolExposure::Deferred,
        );
        let refreshed = cache.get_or_build(&refreshed_registry, ToolSearchSourceListing::Include);
        assert!(!Arc::ptr_eq(&first, &refreshed));
        assert!(
            refreshed.search_infos[1]
                .entry
                .search_text
                .contains("refreshed")
        );
    }

    #[test]
    fn mixed_search_results_coalesce_mcp_namespaces() {
        let dynamic_namespace = DynamicToolNamespaceSpec {
            name: "codex_app".to_string(),
            description: "Tools in the codex_app namespace.".to_string(),
            tools: Vec::new(),
        };
        let dynamic_tools = [DynamicToolFunctionSpec {
            name: "automation_update".to_string(),
            description: "Create, update, view, or delete recurring automations.".to_string(),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {
                    "mode": { "type": "string" },
                },
                "required": ["mode"],
                "additionalProperties": false,
            }),
            defer_loading: true,
        }];
        let mcp_tools = [
            tool_info("calendar", "create_event", "Create events"),
            tool_info("calendar", "list_events", "List events"),
        ];
        let mut search_infos = mcp_tools
            .iter()
            .map(|tool| {
                McpHandler::new(tool.clone())
                    .expect("MCP tool should convert")
                    .search_info()
                    .expect("MCP handler should return search info")
            })
            .collect::<Vec<_>>();
        search_infos.extend(dynamic_tools.iter().map(|tool| {
            DynamicToolHandler::new_in_namespace(&dynamic_namespace, tool)
                .expect("dynamic tool should convert")
                .search_info()
                .expect("dynamic handler should return search info")
        }));
        let handler = ToolSearchHandler::new(search_infos, ToolSearchSourceListing::Include);
        let results = [
            &handler.search_infos[0].entry,
            &handler.search_infos[2].entry,
            &handler.search_infos[1].entry,
        ];

        let tools = coalesce_loadable_tool_specs(
            results.into_iter().map(ToolSearchEntry::to_loadable_spec),
        );

        assert_eq!(
            tools,
            vec![
                LoadableToolSpec::Namespace(ResponsesApiNamespace {
                    name: "mcp__calendar".to_string(),
                    description: "Tools in the mcp__calendar namespace.".to_string(),
                    tools: vec![
                        ResponsesApiNamespaceTool::Function(ResponsesApiTool {
                            name: "create_event".to_string(),
                            description: "Create events desktop tool".to_string(),
                            strict: false,
                            defer_loading: Some(true),
                            parameters: codex_tools::JsonSchema::object(
                                Default::default(),
                                /*required*/ None,
                                Some(false.into()),
                            ),
                            output_schema: None,
                        }),
                        ResponsesApiNamespaceTool::Function(ResponsesApiTool {
                            name: "list_events".to_string(),
                            description: "List events desktop tool".to_string(),
                            strict: false,
                            defer_loading: Some(true),
                            parameters: codex_tools::JsonSchema::object(
                                Default::default(),
                                /*required*/ None,
                                Some(false.into()),
                            ),
                            output_schema: None,
                        }),
                    ],
                }),
                LoadableToolSpec::Namespace(ResponsesApiNamespace {
                    name: "codex_app".to_string(),
                    description: "Tools in the codex_app namespace.".to_string(),
                    tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
                        name: "automation_update".to_string(),
                        description: "Create, update, view, or delete recurring automations."
                            .to_string(),
                        strict: false,
                        defer_loading: Some(true),
                        parameters: codex_tools::JsonSchema::object(
                            std::collections::BTreeMap::from([(
                                "mode".to_string(),
                                codex_tools::JsonSchema::string(/*description*/ None),
                            )]),
                            Some(vec!["mode".to_string()]),
                            Some(false.into()),
                        ),
                        output_schema: None,
                    })],
                }),
            ],
        );
    }

    fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {
        ToolInfo {
            server_name: server_name.to_string(),
            supports_parallel_tool_calls: false,
            server_origin: None,
            callable_name: tool_name.to_string(),
            callable_namespace: format!("mcp__{server_name}"),
            namespace_description: None,
            tool: Tool::new(
                tool_name.to_string(),
                format!("{description_prefix} desktop tool"),
                Arc::new(rmcp::model::object(serde_json::json!({
                    "type": "object",
                    "properties": {},
                    "additionalProperties": false,
                }))),
            ),
            openai_file_input_optional_fields: Default::default(),
            connector_id: None,
            connector_name: None,
            plugin_display_names: Vec::new(),
        }
    }
}
codex-rs/core/src/tools/handlers/dynamic.rs
use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolExposure;
use codex_async_utils::OrCancelExt;
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::items::DynamicToolCallItem;
use codex_protocol::items::DynamicToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::default_namespace_description;
use codex_tools::dynamic_tool_to_responses_api_tool;
use serde_json::Value;
use std::time::Instant;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use tracing::warn;

pub struct DynamicToolHandler {
    tool_name: ToolName,
    spec: ToolSpec,
    exposure: ToolExposure,
}

impl DynamicToolHandler {
    pub fn new(tool: &DynamicToolFunctionSpec) -> Option<Self> {
        Self::from_parts(tool, /*namespace*/ None)
    }

    pub fn new_in_namespace(
        namespace: &DynamicToolNamespaceSpec,
        tool: &DynamicToolFunctionSpec,
    ) -> Option<Self> {
        Self::from_parts(tool, Some(namespace))
    }

    fn from_parts(
        tool: &DynamicToolFunctionSpec,
        namespace: Option<&DynamicToolNamespaceSpec>,
    ) -> Option<Self> {
        let tool_name = ToolName::new(
            namespace.map(|namespace| namespace.name.clone()),
            tool.name.clone(),
        );
        let mut output_tool = dynamic_tool_to_responses_api_tool(tool).ok()?;
        // Exposure controls deferral; tool search restores this marker for deferred results.
        output_tool.defer_loading = None;
        let spec = match namespace {
            Some(namespace) => ToolSpec::Namespace(ResponsesApiNamespace {
                name: namespace.name.clone(),
                description: if namespace.description.trim().is_empty() {
                    default_namespace_description(&namespace.name)
                } else {
                    namespace.description.clone()
                },
                tools: vec![ResponsesApiNamespaceTool::Function(output_tool)],
            }),
            None => ToolSpec::Function(output_tool),
        };
        Some(Self {
            tool_name,
            spec,
            exposure: if tool.defer_loading {
                ToolExposure::Deferred
            } else {
                ToolExposure::Direct
            },
        })
    }
}

impl ToolExecutor<ToolInvocation> for DynamicToolHandler {
    fn tool_name(&self) -> ToolName {
        self.tool_name.clone()
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn exposure(&self) -> ToolExposure {
        self.exposure
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        ToolSearchInfo::from_tool_spec(
            self.spec(),
            Some(ToolSearchSourceInfo {
                name: "Dynamic tools".to_string(),
                description: Some("Tools provided by the current Codex thread.".to_string()),
            }),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl DynamicToolHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            call_id,
            payload,
            cancellation_token,
            ..
        } = invocation;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "dynamic tool handler received unsupported payload".to_string(),
                ));
            }
        };

        let args: Value = parse_arguments(&arguments)?;
        let response = request_dynamic_tool(
            &session,
            turn.as_ref(),
            call_id,
            self.tool_name.clone(),
            args,
            cancellation_token,
        )
        .await
        .ok_or_else(|| {
            FunctionCallError::RespondToModel(
                "dynamic tool call was cancelled before receiving a response".to_string(),
            )
        })?;

        let DynamicToolResponse {
            content_items,
            success,
        } = response;
        let body = content_items
            .into_iter()
            .map(FunctionCallOutputContentItem::from)
            .collect::<Vec<_>>();
        Ok(boxed_tool_output(FunctionToolOutput::from_content(
            body,
            Some(success),
        )))
    }
}

impl CoreToolRuntime for DynamicToolHandler {
    fn finishes_on_cancellation(&self) -> bool {
        true
    }

    fn is_third_party_tool(&self) -> bool {
        true
    }
}

#[expect(
    clippy::await_holding_invalid_type,
    reason = "active turn checks and dynamic tool response registration must remain atomic"
)]
async fn request_dynamic_tool(
    session: &Session,
    turn_context: &TurnContext,
    call_id: String,
    tool_name: ToolName,
    arguments: Value,
    cancellation_token: CancellationToken,
) -> Option<DynamicToolResponse> {
    if cancellation_token.is_cancelled() {
        return None;
    }
    let namespace = tool_name.namespace;
    let tool = tool_name.name;
    let (tx_response, rx_response) = oneshot::channel();
    let event_id = call_id.clone();
    let prev_entry = {
        let mut active = session.active_turn.lock().await;
        if cancellation_token.is_cancelled() {
            return None;
        }
        match active.as_mut() {
            Some(at) => {
                let mut ts = at.turn_state.lock().await;
                ts.insert_pending_dynamic_tool(call_id.clone(), tx_response)
            }
            None => return None,
        }
    };
    if prev_entry.is_some() {
        warn!("Overwriting existing pending dynamic tool call for call_id: {event_id}");
    }

    let started_at = Instant::now();
    session
        .emit_turn_item_started(
            turn_context,
            &TurnItem::DynamicToolCall(DynamicToolCallItem {
                id: call_id.clone(),
                namespace: namespace.clone(),
                tool: tool.clone(),
                arguments: arguments.clone(),
                status: DynamicToolCallStatus::InProgress,
                content_items: None,
                success: None,
                error: None,
                duration: None,
            }),
        )
        .await;
    let response = rx_response
        .or_cancel(&cancellation_token)
        .await
        .ok()
        .and_then(Result::ok);

    let item = match &response {
        Some(response) => DynamicToolCallItem {
            id: call_id,
            namespace,
            tool,
            arguments,
            status: if response.success {
                DynamicToolCallStatus::Completed
            } else {
                DynamicToolCallStatus::Failed
            },
            content_items: Some(response.content_items.clone()),
            success: Some(response.success),
            error: None,
            duration: Some(started_at.elapsed()),
        },
        None => DynamicToolCallItem {
            id: call_id,
            namespace,
            tool,
            arguments,
            status: DynamicToolCallStatus::Failed,
            content_items: Some(Vec::new()),
            success: Some(false),
            error: Some("dynamic tool call was cancelled before receiving a response".to_string()),
            duration: Some(started_at.elapsed()),
        },
    };
    session
        .emit_turn_item_completed(turn_context, TurnItem::DynamicToolCall(item))
        .await;

    response
}

#[cfg(test)]
#[path = "dynamic_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/extension_tools.rs
use std::sync::Arc;
use std::sync::Weak;

use codex_history::ResponseItemEnvelope;
use codex_protocol::items::TurnItem;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_tools::ConversationHistory;
use codex_tools::ExtensionTurnItem;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolEnvironment;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use codex_tools::TurnItemEmissionFuture;
use codex_tools::TurnItemEmitter;
use codex_utils_string::to_ascii_json_string;

use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::lifecycle::extension_tool_call_source;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use crate::turn_metadata::ExecutionMetadata;

pub(crate) struct ExtensionToolAdapter(
    Arc<dyn for<'call> codex_tools::ToolExecutor<ExtensionToolCall<'call>>>,
);

impl ExtensionToolAdapter {
    pub(crate) fn new(
        executor: Arc<dyn for<'call> codex_tools::ToolExecutor<ExtensionToolCall<'call>>>,
    ) -> Self {
        Self(executor)
    }
}

impl ToolExecutor<ToolInvocation> for ExtensionToolAdapter {
    fn tool_name(&self) -> ToolName {
        self.0.tool_name()
    }

    fn spec(&self) -> ToolSpec {
        self.0.spec()
    }

    fn exposure(&self) -> crate::tools::registry::ToolExposure {
        self.0.exposure()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        self.0.supports_parallel_tool_calls()
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        self.0.search_info()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let environment_accessors = invocation.step_context.environments();
            let environments = environment_accessors
                .iter()
                .map(|(environment, fs)| {
                    ToolEnvironment::new(
                        environment.selection.environment_id.clone(),
                        environment.cwd().clone(),
                        fs,
                    )
                })
                .collect();
            self.0
                .handle(to_extension_call(&invocation, environments).await)
                .await
        })
    }
}

impl CoreToolRuntime for ExtensionToolAdapter {
    fn is_builtin_control_tool(&self) -> bool {
        let tool_name = self.0.tool_name();
        if tool_name.is_default_namespace() {
            return matches!(
                tool_name.name.as_str(),
                "get_goal" | "create_goal" | "update_goal"
            );
        }
        matches!(
            (tool_name.namespace.as_deref(), tool_name.name.as_str()),
            (
                Some("notes"),
                "list_files_by_prefix"
                    | "read_file"
                    | "search_contents"
                    | "append_to_file"
                    | "write_file"
            ) | (
                Some("history"),
                "list_windows" | "list_items" | "read_item" | "search_contents"
            )
        )
    }

    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        match payload {
            ToolPayload::Function { .. } => true,
            ToolPayload::Custom { .. } => match self.0.spec() {
                ToolSpec::Freeform(_) => true,
                ToolSpec::Namespace(namespace) => namespace.tools.iter().any(|tool| {
                    matches!(
                        tool,
                        ResponsesApiNamespaceTool::Custom(tool)
                            if tool.name == self.0.tool_name().name
                    )
                }),
                ToolSpec::Function(_)
                | ToolSpec::ToolSearch { .. }
                | ToolSpec::WebSearch { .. } => false,
            },
            ToolPayload::ToolSearch { .. } => false,
        }
    }
}

struct CoreTurnItemEmitter {
    session: Weak<Session>,
    turn: Weak<TurnContext>,
}

async fn emit_legacy_events(session: &Session, turn: &TurnContext, legacy_events: Vec<EventMsg>) {
    for msg in legacy_events {
        session
            .send_event_raw(Event {
                id: turn.sub_id.clone(),
                msg,
            })
            .await;
    }
}

impl TurnItemEmitter for CoreTurnItemEmitter {
    fn emit_started<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> {
        Box::pin(async move {
            let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
                return;
            };
            let ExtensionTurnItem {
                item,
                legacy_events,
            } = item;
            let item = TurnItem::Extension(item);
            session.emit_turn_item_started(turn.as_ref(), &item).await;
            emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await;
        })
    }

    fn emit_completed<'a>(&'a self, item: ExtensionTurnItem) -> TurnItemEmissionFuture<'a> {
        Box::pin(async move {
            let (Some(session), Some(turn)) = (self.session.upgrade(), self.turn.upgrade()) else {
                return;
            };
            let ExtensionTurnItem {
                item,
                legacy_events,
            } = item;
            let item = TurnItem::Extension(item);
            session.emit_turn_item_completed(turn.as_ref(), item).await;
            emit_legacy_events(session.as_ref(), turn.as_ref(), legacy_events).await;
        })
    }
}

async fn to_extension_call<'a>(
    invocation: &ToolInvocation,
    environments: Vec<ToolEnvironment<'a>>,
) -> ExtensionToolCall<'a> {
    let history = invocation
        .session
        .clone_history()
        .await
        .into_shared_annotated_items();
    let conversation_history = ConversationHistory::new_deferred(move || {
        Arc::unwrap_or_clone(history)
            .into_iter()
            .map(ResponseItemEnvelope::into_item)
            .collect()
    });
    let settings = &invocation.step_context.settings;
    let codex_turn_metadata = invocation
        .turn
        .turn_metadata_state
        .current_meta_value_for_mcp_request(ExecutionMetadata::from_settings(
            &invocation.step_context.settings,
        ))
        .and_then(|metadata| to_ascii_json_string(&metadata).ok());
    ExtensionToolCall {
        turn_id: invocation.turn.sub_id.clone(),
        call_id: invocation.call_id.clone(),
        tool_name: invocation.tool_name.clone(),
        model: settings.model_info.slug.clone(),
        codex_turn_metadata,
        truncation_policy: settings.model_info.truncation_policy.into(),
        source: extension_tool_call_source(invocation.source.clone()),
        conversation_history,
        turn_item_emitter: Arc::new(CoreTurnItemEmitter {
            session: Arc::downgrade(&invocation.session),
            turn: Arc::downgrade(&invocation.turn),
        }),
        environments,
        payload: invocation.payload.clone(),
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use codex_extension_items::ExtensionItem;
    use codex_extension_items::image_generation::ImageGenerationItem;
    use codex_extension_items::web_search::WebSearchItem;
    use codex_protocol::items::TurnItem;
    use codex_protocol::models::ContentItem;
    use codex_protocol::models::ResponseItem;
    use codex_protocol::protocol::EventMsg;
    use codex_protocol::protocol::ImageGenerationBeginEvent;
    use codex_protocol::protocol::ImageGenerationEndEvent;
    use codex_tools::ExtensionTurnItem;
    use codex_tools::ToolCallSource as ExtensionToolCallSource;
    use codex_utils_absolute_path::test_support::PathExt;
    use codex_utils_absolute_path::test_support::test_path_buf;
    use codex_utils_path_uri::PathUri;
    use core_test_support::responses::strip_response_item_id;
    use core_test_support::responses::strip_response_item_ids;
    use pretty_assertions::assert_eq;
    use serde_json::json;
    use tokio::sync::Mutex;

    use super::CoreTurnItemEmitter;
    use super::ExtensionToolAdapter;
    use crate::session::step_context::StepContext;
    use crate::tools::context::ToolCallSource;
    use crate::tools::context::ToolInvocation;
    use crate::tools::context::ToolPayload;
    use crate::tools::hook_names::HookToolName;
    use crate::tools::registry::CoreToolRuntime;
    use crate::tools::registry::PostToolUsePayload;
    use crate::tools::registry::PreToolUsePayload;
    use crate::turn_diff_tracker::TurnDiffTracker;

    struct StubExtensionExecutor;

    impl<'call> codex_extension_api::ToolExecutor<codex_tools::ToolCall<'call>>
        for StubExtensionExecutor
    {
        fn tool_name(&self) -> codex_tools::ToolName {
            codex_tools::ToolName::plain("extension_echo")
        }

        fn spec(&self) -> codex_tools::ToolSpec {
            codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
                name: "extension_echo".to_string(),
                description: "Echoes arguments.".to_string(),
                strict: true,
                parameters: codex_tools::parse_tool_input_schema(&json!({
                    "type": "object",
                    "properties": {
                        "message": { "type": "string" },
                    },
                    "required": ["message"],
                    "additionalProperties": false,
                }))
                .expect("extension schema should parse"),
                output_schema: None,
                defer_loading: None,
            })
        }

        fn handle<'a>(
            &'a self,
            _call: codex_tools::ToolCall<'call>,
        ) -> codex_tools::ToolExecutorFuture<'a>
        where
            'call: 'a,
        {
            Box::pin(async {
                Ok(
                    Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
                        as Box<dyn codex_tools::ToolOutput>,
                )
            })
        }
    }

    struct CapturingExtensionExecutor {
        captured_call: Arc<Mutex<Option<codex_tools::ToolCall<'static>>>>,
        captured_sandbox_cwds: Arc<Mutex<Vec<PathUri>>>,
    }

    impl<'call> codex_extension_api::ToolExecutor<codex_tools::ToolCall<'call>>
        for CapturingExtensionExecutor
    {
        fn tool_name(&self) -> codex_tools::ToolName {
            codex_tools::ToolName::plain("extension_echo")
        }

        fn spec(&self) -> codex_tools::ToolSpec {
            codex_tools::ToolSpec::Function(codex_tools::ResponsesApiTool {
                name: "extension_echo".to_string(),
                description: "Captures arguments.".to_string(),
                strict: false,
                parameters: codex_tools::JsonSchema::default(),
                output_schema: None,
                defer_loading: None,
            })
        }

        fn handle<'a>(
            &'a self,
            call: codex_tools::ToolCall<'call>,
        ) -> codex_tools::ToolExecutorFuture<'a>
        where
            'call: 'a,
        {
            Box::pin(self.handle_call(call))
        }
    }

    impl CapturingExtensionExecutor {
        async fn handle_call(
            &self,
            call: codex_tools::ToolCall<'_>,
        ) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
            call.turn_item_emitter
                .emit_started(ExtensionTurnItem {
                    item: ExtensionItem::WebSearch(WebSearchItem {
                        id: call.call_id.clone(),
                        query: String::new(),
                        action: None,
                        results: None,
                    }),
                    legacy_events: Vec::new(),
                })
                .await;
            // Record owned metadata only; the invocation lifetime belongs to this callback.
            *self.captured_sandbox_cwds.lock().await = call
                .environments
                .iter()
                .map(|environment| environment.cwd.clone())
                .collect();
            let call = codex_tools::ToolCall {
                environments: Vec::new(),
                ..call
            };
            *self.captured_call.lock().await = Some(call);
            Ok(
                Box::new(codex_tools::JsonToolOutput::new(json!({ "ok": true })))
                    as Box<dyn codex_tools::ToolOutput>,
            )
        }
    }

    #[test]
    fn function_extensions_reject_custom_payloads() {
        let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor));

        assert!(handler.matches_kind(&ToolPayload::Function {
            arguments: "{}".to_string(),
        }));
        assert!(!handler.matches_kind(&ToolPayload::Custom {
            input: "raw input".to_string(),
        }));
    }

    #[tokio::test]
    async fn exposes_generic_hook_payloads() {
        let handler = ExtensionToolAdapter::new(Arc::new(StubExtensionExecutor));
        let (session, turn) = crate::session::tests::make_session_and_context().await;
        let turn = Arc::new(turn);
        let invocation = ToolInvocation {
            session: session.into(),
            step_context: StepContext::for_test(Arc::clone(&turn)),
            turn,
            cancellation_token: tokio_util::sync::CancellationToken::new(),
            tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
            call_id: "call-extension".to_string(),
            tool_name: codex_tools::ToolName::plain("extension_echo"),
            source: ToolCallSource::Direct,
            payload: ToolPayload::Function {
                arguments: json!({ "message": "hello" }).to_string(),
            },
        };
        let output = codex_tools::JsonToolOutput::new(json!({ "ok": true }));

        assert_eq!(
            CoreToolRuntime::pre_tool_use_payload(&handler, &invocation),
            Some(PreToolUsePayload {
                tool_name: HookToolName::new("extension_echo"),
                tool_input: json!({ "message": "hello" }),
            })
        );
        assert_eq!(
            CoreToolRuntime::post_tool_use_payload(&handler, &invocation, &output),
            Some(PostToolUsePayload {
                tool_name: HookToolName::new("extension_echo"),
                tool_use_id: "call-extension".to_string(),
                tool_input: json!({ "message": "hello" }),
                tool_response: json!({ "ok": true }),
            })
        );
    }

    #[tokio::test]
    async fn passes_step_node_repl_flag_and_scoped_turn_item_emitter_to_extension_call() {
        let captured_call = Arc::new(Mutex::new(None));
        let captured_sandbox_cwds = Arc::new(Mutex::new(Vec::new()));
        let handler = ExtensionToolAdapter::new(Arc::new(CapturingExtensionExecutor {
            captured_call: Arc::clone(&captured_call),
            captured_sandbox_cwds: Arc::clone(&captured_sandbox_cwds),
        }));
        let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
        let weak_session = Arc::downgrade(&session);
        let weak_turn = Arc::downgrade(&turn);
        let turn_id = turn.sub_id.clone();
        let model = turn.model_info().slug.clone();
        let truncation_policy = turn.model_info().truncation_policy.into();
        let expected_sandbox_cwds = turn
            .initial_environments
            .turn_environments()
            .map(|environment| environment.cwd().clone())
            .collect::<Vec<_>>();
        let history_item = ResponseItem::Message {
            status: None,
            encrypted_content: None,
            id: None,
            role: "user".to_string(),
            content: vec![ContentItem::InputText {
                text: "extension history".to_string(),
            }],
            phase: None,
            internal_chat_message_metadata_passthrough: None,
        };
        session
            .record_conversation_items(
                &turn,
                turn.model_info(),
                std::slice::from_ref(&history_item),
            )
            .await;
        let expected_history_item = strip_response_item_id(
            session
                .clone_history()
                .await
                .raw_items()
                .next()
                .expect("history item")
                .clone(),
        );
        let raw_history_event = rx.recv().await.expect("history raw response item event");
        let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else {
            panic!("expected raw response item event");
        };
        assert_eq!(
            strip_response_item_id(raw_history_item.item),
            expected_history_item
        );
        let mut step_context = StepContext::for_test(Arc::clone(&turn));
        let settings = Arc::make_mut(
            &mut Arc::get_mut(&mut step_context)
                .expect("unshared step")
                .settings,
        );
        let model_info = Arc::make_mut(&mut settings.model_info);
        model_info.node_repl_disabled = !turn.model_info().node_repl_disabled;
        let node_repl_disabled = model_info.node_repl_disabled;
        let invocation = ToolInvocation {
            session,
            step_context,
            turn,
            cancellation_token: tokio_util::sync::CancellationToken::new(),
            tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
            call_id: "call-extension".to_string(),
            tool_name: codex_tools::ToolName::plain("extension_echo"),
            source: ToolCallSource::CodeMode {
                cell_id: "cell-1".to_string(),
                runtime_tool_call_id: "nested-call-1".to_string(),
            },
            payload: ToolPayload::Function {
                arguments: json!({ "message": "hello" }).to_string(),
            },
        };

        crate::tools::registry::ToolExecutor::handle(&handler, invocation)
            .await
            .expect("extension call should succeed");

        let captured_call = captured_call.lock().await.clone().expect("captured call");
        assert!(weak_session.upgrade().is_none());
        assert!(weak_turn.upgrade().is_none());
        assert_eq!(captured_call.turn_id, turn_id);
        assert_eq!(captured_call.call_id, "call-extension");
        assert_eq!(
            captured_call.tool_name,
            codex_tools::ToolName::plain("extension_echo")
        );
        assert_eq!(captured_call.model, model);
        assert_eq!(captured_call.truncation_policy, truncation_policy);
        let metadata: serde_json::Value = serde_json::from_str(
            captured_call
                .codex_turn_metadata
                .as_deref()
                .expect("turn metadata"),
        )
        .expect("metadata JSON");
        assert_eq!(metadata["node_repl_disabled"], json!(node_repl_disabled));
        assert_eq!(
            captured_call.source,
            ExtensionToolCallSource::CodeMode {
                cell_id: "cell-1".to_string(),
                runtime_tool_call_id: "nested-call-1".to_string(),
            }
        );
        assert_eq!(*captured_sandbox_cwds.lock().await, expected_sandbox_cwds);
        assert_eq!(
            strip_response_item_ids(captured_call.conversation_history.items()),
            vec![expected_history_item]
        );
        match captured_call.payload {
            ToolPayload::Function { arguments } => {
                assert_eq!(arguments, json!({ "message": "hello" }).to_string());
            }
            payload => panic!("expected function payload, got {payload:?}"),
        }

        let started = rx.recv().await.expect("item started event");
        let EventMsg::ItemStarted(started) = started.msg else {
            panic!("expected item started event");
        };
        let TurnItem::Extension(ExtensionItem::WebSearch(started_item)) = started.item else {
            panic!("expected extension web search item");
        };
        assert_eq!(
            started_item,
            WebSearchItem {
                id: "call-extension".to_string(),
                query: String::new(),
                action: None,
                results: None,
            }
        );
    }

    #[tokio::test]
    async fn image_generation_publication_preserves_extension_saved_path() {
        let (session, turn, rx) = crate::session::tests::make_session_and_context_with_rx().await;
        let expected_path = test_path_buf("/tmp/extension-claimed.png").abs();
        let emitter = CoreTurnItemEmitter {
            session: Arc::downgrade(&session),
            turn: Arc::downgrade(&turn),
        };
        let expected_started_item = ExtensionItem::ImageGeneration(ImageGenerationItem {
            id: "call-image".to_string(),
            status: "in_progress".to_string(),
            revised_prompt: None,
            result: String::new(),
            transparent_background: None,
            failure: None,
            saved_path: None,
            imagegen_request_id: None,
            generation_id: None,
        });
        let expected_completed_item = ExtensionItem::ImageGeneration(ImageGenerationItem {
            id: "call-image".to_string(),
            status: "completed".to_string(),
            revised_prompt: Some("A tiny blue square".to_string()),
            result: "cG5n".to_string(),
            transparent_background: Some(true),
            failure: None,
            saved_path: Some(expected_path.clone()),
            imagegen_request_id: None,
            generation_id: None,
        });
        codex_tools::TurnItemEmitter::emit_started(
            &emitter,
            ExtensionTurnItem {
                item: expected_started_item.clone(),
                legacy_events: vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent {
                    call_id: "call-image".to_string(),
                })],
            },
        )
        .await;
        codex_tools::TurnItemEmitter::emit_completed(
            &emitter,
            ExtensionTurnItem {
                item: expected_completed_item.clone(),
                legacy_events: vec![EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
                    call_id: "call-image".to_string(),
                    status: "completed".to_string(),
                    revised_prompt: Some("A tiny blue square".to_string()),
                    result: "cG5n".to_string(),
                    transparent_background: Some(true),
                    failure: None,
                    saved_path: Some(expected_path.clone()),
                })],
            },
        )
        .await;

        let started = rx.recv().await.expect("item started event");
        let EventMsg::ItemStarted(started) = started.msg else {
            panic!("expected item started event");
        };
        let TurnItem::Extension(started_item) = started.item else {
            panic!("expected extension item");
        };
        let begin = rx.recv().await.expect("legacy image start event");
        assert!(matches!(begin.msg, EventMsg::ImageGenerationBegin(_)));
        let completed = rx.recv().await.expect("item completed event");
        let EventMsg::ItemCompleted(completed) = completed.msg else {
            panic!("expected item completed event");
        };
        let TurnItem::Extension(completed_item) = completed.item else {
            panic!("expected extension item");
        };
        let end = rx.recv().await.expect("legacy image end event");
        assert!(matches!(end.msg, EventMsg::ImageGenerationEnd(_)));

        assert_eq!(started_item, expected_started_item);
        assert_eq!(completed_item, expected_completed_item);
    }
}
codex-rs/core/src/tools/hosted_spec.rs
use codex_protocol::config_types::WebSearchConfig;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::openai_models::WebSearchToolType;
use codex_tools::ToolSpec;

const WEB_SEARCH_TEXT_AND_IMAGE_CONTENT_TYPES: [&str; 2] = ["text", "image"];

pub struct WebSearchToolOptions<'a> {
    pub web_search_mode: Option<WebSearchMode>,
    pub web_search_config: Option<&'a WebSearchConfig>,
    pub web_search_tool_type: WebSearchToolType,
}

pub fn create_web_search_tool(options: WebSearchToolOptions<'_>) -> Option<ToolSpec> {
    let (external_web_access, indexed_web_access) = match options.web_search_mode {
        Some(WebSearchMode::Cached) => (false, None),
        Some(WebSearchMode::Indexed) => (true, Some(true)),
        Some(WebSearchMode::Live) => (true, None),
        Some(WebSearchMode::Disabled) | None => return None,
    };

    let search_content_types = match options.web_search_tool_type {
        WebSearchToolType::Text => None,
        WebSearchToolType::TextAndImage => Some(
            WEB_SEARCH_TEXT_AND_IMAGE_CONTENT_TYPES
                .into_iter()
                .map(str::to_string)
                .collect(),
        ),
    };

    Some(ToolSpec::WebSearch {
        external_web_access: Some(external_web_access),
        indexed_web_access,
        filters: options
            .web_search_config
            .and_then(|config| config.filters.clone().map(Into::into)),
        user_location: options
            .web_search_config
            .and_then(|config| config.user_location.clone().map(Into::into)),
        search_context_size: options
            .web_search_config
            .and_then(|config| config.search_context_size),
        search_content_types,
    })
}

#[cfg(test)]
#[path = "hosted_spec_tests.rs"]
mod tests;

MCP / MCP 资源

MCP 工具不是 Codex 的私有函数,而是外部 server 通过协议导入;Rust 侧维护缓存、权限、错误映射和响应包装。

codex-rs/core/src/tools/handlers/mcp.rs
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Instant;

use crate::context::NodeReplReviewEvidence;
use crate::context::NodeReplReviewEvidenceMode;
use crate::context::node_repl_review_evidence_mode;
use crate::function_tool::FunctionCallError;
use crate::mcp_tool_call::handle_mcp_tool_call;
use crate::original_image_detail::can_request_original_image_detail;
use crate::session::session::Session;
use crate::tools::context::McpToolOutput;
use crate::tools::context::ToolCallSource;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::flat_tool_name;
use crate::tools::hook_names::HookToolName;
use crate::tools::lifecycle::notify_tool_start;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use crate::tools::registry::ToolTelemetryTags;
use codex_extension_api::McpToolContext;
use codex_mcp::ToolInfo;
use codex_protocol::mcp::is_node_repl_backed_connector;
use codex_protocol::user_input::UserInput;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSearchSourceInfo;
use codex_tools::ToolSpec;
use codex_tools::agent_plugin_mcp_tool_to_responses_api_tool;
use codex_tools::mcp_tool_to_responses_api_tool;
use codex_utils_image::PromptImageMode;
use codex_utils_image::load_data_url_for_prompt_uncached;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_string::take_bytes_at_char_boundary;
use futures::future::BoxFuture;
use serde_json::Map;
use serde_json::Value;

const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";
const MCP_TOOL_NAME_DELIMITER: &str = "__";
const MAX_AGENT_PLUGIN_MCP_NAMESPACE_DESCRIPTION_BYTES: usize = 1_000;
const MAX_MCP_NAMESPACE_DESCRIPTION_BYTES: usize = 512 * 1024;

pub struct McpHandler {
    tool_info: ToolInfo,
    agent_plugin: bool,
    spec: Arc<ToolSpec>,
    code_mode_tool_definitions:
        OnceLock<(Option<usize>, bool, Vec<codex_code_mode::ToolDefinition>)>,
}

impl McpHandler {
    pub fn new(tool_info: ToolInfo) -> Result<Self, serde_json::Error> {
        Self::with_agent_plugin(
            tool_info, /*agent_plugin*/ false, /*schema_max_bytes*/ None,
        )
    }

    pub fn new_with_schema_max_bytes(
        tool_info: ToolInfo,
        schema_max_bytes: usize,
    ) -> Result<Self, serde_json::Error> {
        Self::with_agent_plugin(
            tool_info,
            /*agent_plugin*/ false,
            Some(schema_max_bytes),
        )
    }

    pub fn new_agent_plugin(tool_info: ToolInfo) -> Result<Self, serde_json::Error> {
        Self::with_agent_plugin(
            tool_info, /*agent_plugin*/ true, /*schema_max_bytes*/ None,
        )
    }

    fn with_agent_plugin(
        tool_info: ToolInfo,
        agent_plugin: bool,
        schema_max_bytes: Option<usize>,
    ) -> Result<Self, serde_json::Error> {
        let spec = Arc::new(create_tool_spec(
            &tool_info,
            agent_plugin,
            schema_max_bytes,
        )?);
        Ok(Self {
            tool_info,
            agent_plugin,
            spec,
            code_mode_tool_definitions: OnceLock::new(),
        })
    }

    pub(crate) fn tool_info(&self) -> &ToolInfo {
        &self.tool_info
    }

    pub(crate) fn model_spec_bytes(&self) -> Result<usize, serde_json::Error> {
        serde_json::to_vec(&self.spec).map(|spec| spec.len())
    }

    fn hook_tool_name(&self) -> HookToolName {
        HookToolName::new(ensure_mcp_prefix(&join_tool_name(&self.tool_name())))
    }
}

fn join_tool_name(tool_name: &ToolName) -> String {
    match tool_name.namespace.as_deref() {
        Some(namespace) => {
            let namespace = namespace.trim_end_matches('_');
            let name = tool_name.name.trim_start_matches('_');
            format!("{namespace}{MCP_TOOL_NAME_DELIMITER}{name}")
        }
        None => tool_name.name.clone(),
    }
}

fn ensure_mcp_prefix(name: &str) -> String {
    if name.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {
        name.to_string()
    } else {
        format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{name}")
    }
}

impl ToolExecutor<ToolInvocation> for McpHandler {
    fn tool_name(&self) -> ToolName {
        self.tool_info.canonical_tool_name()
    }

    fn spec(&self) -> ToolSpec {
        self.spec.as_ref().clone()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        // Correctly implemented MCP servers should tolerate parallel calls to
        // tools that advertise themselves as read-only.
        self.tool_info.supports_parallel_tool_calls
            || self
                .tool_info
                .tool
                .annotations
                .as_ref()
                .and_then(|annotations| annotations.read_only_hint)
                .unwrap_or(false)
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        let source_name = self
            .tool_info
            .connector_name
            .as_deref()
            .map(str::trim)
            .filter(|connector_name| !connector_name.is_empty())
            .unwrap_or_else(|| self.tool_info.server_name.trim());
        let source_info = (!source_name.is_empty()).then(|| ToolSearchSourceInfo {
            name: source_name.to_string(),
            description: effective_namespace_description(&self.tool_info, self.agent_plugin)
                .map(str::trim)
                .filter(|description| !description.is_empty())
                .map(str::to_string),
        });

        ToolSearchInfo::from_shared_spec(
            build_mcp_search_text(&self.tool_info, self.agent_plugin),
            Arc::clone(&self.spec),
            source_info,
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl McpHandler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let prepared_mcp_call = invocation.session.prepare_mcp_call(&self.tool_info).await;
        // Use the executed call's binding; a later catalog refresh must not change eligibility.
        let result_metadata_capture_allowed = invocation
            .session
            .services
            .analytics_events_client
            .is_enabled()
            && prepared_mcp_call
                .as_ref()
                .is_some_and(codex_mcp::PreparedMcpCall::is_host_owned_apps);
        let mcp_tool = prepared_mcp_call.as_ref().map(|call| {
            McpToolContext::from_prepared_call(
                call,
                invocation
                    .turn
                    .config
                    .mcp_servers
                    .get()
                    .get(call.server_name()),
            )
        });
        notify_tool_start(&invocation, mcp_tool.as_ref()).await;

        let originating_call = invocation.originating_call().await;
        let ToolInvocation {
            session,
            step_context,
            cancellation_token,
            call_id,
            tool_name,
            source,
            payload,
            ..
        } = invocation;

        let payload = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "mcp handler received unsupported payload".to_string(),
                ));
            }
        };

        // Capture presentation policy from the same config snapshot used for execution.
        let truncation_policy = prepared_mcp_call
            .as_ref()
            .and_then(codex_mcp::PreparedMcpCall::output_token_limit)
            .map(TruncationPolicy::Tokens)
            .unwrap_or(step_context.settings.model_info.truncation_policy.into());
        let started = Instant::now();
        let result = handle_mcp_tool_call(
            Arc::clone(&session),
            &step_context,
            &cancellation_token,
            call_id.clone(),
            originating_call,
            &self.tool_info,
            prepared_mcp_call,
            self.hook_tool_name(),
            tool_name,
            &source,
            payload,
        )
        .await;

        Ok(boxed_tool_output(McpToolOutput {
            result: result.result,
            tool_input: result.tool_input,
            result_metadata_capture_allowed,
            wall_time: started.elapsed(),
            original_image_detail_supported: can_request_original_image_detail(
                &step_context.settings.model_info,
            ),
            truncation_policy,
        }))
    }
}

impl CoreToolRuntime for McpHandler {
    fn immutable_spec(&self) -> Option<&Arc<ToolSpec>> {
        Some(&self.spec)
    }

    fn cached_code_mode_definitions(
        &self,
        code_mode_input_schema_max_bytes: Option<usize>,
        tool_description_first: bool,
    ) -> Option<&[codex_code_mode::ToolDefinition]> {
        let (cached_budget, cached_tool_description_first, definitions) =
            self.code_mode_tool_definitions.get_or_init(|| {
                let mut definitions = codex_tools::collect_code_mode_tool_definitions(
                    std::iter::once(self.spec.as_ref()),
                    code_mode_input_schema_max_bytes,
                    tool_description_first,
                );
                for definition in &mut definitions {
                    definition.input_schema = None;
                    definition.output_schema = None;
                }
                (
                    code_mode_input_schema_max_bytes,
                    tool_description_first,
                    definitions,
                )
            });
        // Reuse definitions only when both rendering inputs match.
        (*cached_budget == code_mode_input_schema_max_bytes
            && *cached_tool_description_first == tool_description_first)
            .then_some(definitions.as_slice())
    }

    fn wait_until_ready<'a>(&'a self, session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> {
        Some(Box::pin(async move {
            session
                .wait_for_mcp_server(&self.tool_info.server_name)
                .await;
        }))
    }

    fn is_third_party_tool(&self) -> bool {
        true
    }

    fn mcp_server_name(&self) -> Option<&str> {
        Some(&self.tool_info.server_name)
    }

    fn on_tool_result_accepted(&self, invocation: &ToolInvocation, result: &dyn ToolOutput) {
        invocation
            .session
            .services
            .executed_tool_calls
            .record_accepted_result(&invocation.source, &invocation.call_id, result);
        let ToolCallSource::CodeMode { cell_id, .. } = &invocation.source else {
            return;
        };
        let evidence_mode = node_repl_review_evidence_mode(&invocation.turn);
        let image_capture_enabled = invocation
            .session
            .services
            .thread_extension_data
            .get::<NodeReplReviewEvidence>()
            .is_some_and(|evidence| evidence.image_capture_enabled());
        if !is_node_repl_backed_connector(
            &self.tool_info.server_name,
            self.tool_info.connector_id.as_deref(),
        ) || !result.success_for_logging()
            || evidence_mode == NodeReplReviewEvidenceMode::Disabled && !image_capture_enabled
        {
            return;
        }

        let result = result.code_mode_result(&invocation.payload);
        let Some(content) = result.get("content").and_then(Value::as_array) else {
            return;
        };
        let is_encrypted = |item: &Value| {
            item.get("_meta")
                .and_then(|meta| meta.get("codex/encryptedContent"))
                .and_then(Value::as_bool)
                == Some(true)
        };
        let mut captured_image_bytes = 0_usize;
        let mut items = content
            .iter()
            .filter_map(|item| {
                if is_encrypted(item) {
                    return None;
                }
                match item.get("type").and_then(Value::as_str) {
                    Some("text") => item
                        .get("text")
                        .and_then(Value::as_str)
                        .filter(|text| !text.trim().is_empty())
                        .map(|text| UserInput::Text {
                            text: text.to_string(),
                            text_elements: Vec::new(),
                        }),
                    Some("image")
                        if evidence_mode == NodeReplReviewEvidenceMode::Multimodal
                            || image_capture_enabled =>
                    {
                        let payload = item.get("data").and_then(Value::as_str)?;
                        let mime_type = item.get("mimeType").and_then(Value::as_str)?;
                        if payload.is_empty()
                            || !mime_type
                                .get(..6)
                                .is_some_and(|prefix| prefix.eq_ignore_ascii_case("image/"))
                        {
                            return None;
                        }
                        let image_bytes = "data:;base64,"
                            .len()
                            .saturating_add(mime_type.len())
                            .saturating_add(payload.len());
                        let next_image_bytes = captured_image_bytes.saturating_add(image_bytes);
                        if next_image_bytes > NodeReplReviewEvidence::MAX_RETAINED_BYTES {
                            return None;
                        }
                        let detail = item
                            .get("_meta")
                            .and_then(|meta| meta.get("codex/imageDetail"))
                            .and_then(|detail| serde_json::from_value(detail.clone()).ok());
                        let image_url =
                            format!("data:{};base64,{payload}", mime_type.to_ascii_lowercase());
                        load_data_url_for_prompt_uncached(&image_url, PromptImageMode::Original)
                            .ok()?;
                        captured_image_bytes = next_image_bytes;
                        Some(UserInput::Image {
                            image: codex_protocol::models::ImageReference::Inline { image_url },
                            detail,
                        })
                    }
                    _ => None,
                }
            })
            .collect::<Vec<_>>();
        if !items
            .iter()
            .any(|item| matches!(item, UserInput::Text { .. }))
            && !content.iter().any(is_encrypted)
            && let Some(content) = result.get("structuredContent")
            && !content.is_null()
            && let Ok(text) = serde_json::to_string(content)
        {
            items.insert(
                /*index*/ 0,
                UserInput::Text {
                    text,
                    text_elements: Vec::new(),
                },
            );
        }
        invocation
            .session
            .services
            .thread_extension_data
            .get_or_init(NodeReplReviewEvidence::default)
            .record(
                &format!(
                    "{}.{}",
                    self.tool_info.server_name, self.tool_info.tool.name
                ),
                cell_id,
                &invocation.call_id,
                items,
            );
    }

    fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags {
        let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())];
        if let Some(origin) = &self.tool_info.server_origin {
            tags.push(("mcp_server_origin", origin.clone()));
        }
        tags
    }

    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        let ToolPayload::Function { arguments } = &invocation.payload else {
            return None;
        };

        Some(PreToolUsePayload {
            tool_name: self.hook_tool_name(),
            tool_input: mcp_hook_tool_input(arguments),
        })
    }

    fn with_updated_hook_input(
        &self,
        mut invocation: ToolInvocation,
        updated_input: Value,
    ) -> Result<ToolInvocation, FunctionCallError> {
        invocation.payload = match invocation.payload {
            ToolPayload::Function { .. } => ToolPayload::Function {
                arguments: serde_json::to_string(&updated_input).map_err(|err| {
                    FunctionCallError::RespondToModel(format!(
                        "failed to serialize rewritten MCP arguments: {err}"
                    ))
                })?,
            },
            payload => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "tool {} does not support hook input rewriting for payload {payload:?}",
                    self.tool_name()
                )));
            }
        };
        Ok(invocation)
    }
    fn post_tool_use_payload(
        &self,
        invocation: &ToolInvocation,
        result: &dyn crate::tools::context::ToolOutput,
    ) -> Option<PostToolUsePayload> {
        let ToolPayload::Function { .. } = &invocation.payload else {
            return None;
        };

        let tool_response =
            result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;
        Some(PostToolUsePayload {
            tool_name: self.hook_tool_name(),
            tool_use_id: invocation.call_id.clone(),
            tool_input: result.post_tool_use_input(&invocation.payload)?,
            tool_response,
        })
    }
}

fn create_tool_spec(
    tool_info: &ToolInfo,
    agent_plugin: bool,
    schema_max_bytes: Option<usize>,
) -> Result<ToolSpec, serde_json::Error> {
    let tool_name = tool_info.canonical_tool_name();
    let tool = if agent_plugin {
        agent_plugin_mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)?
    } else {
        mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool, schema_max_bytes)?
    };
    let description = effective_namespace_description(tool_info, agent_plugin)
        .map(str::trim)
        .filter(|description| !description.is_empty())
        .map(str::to_string)
        .or_else(|| {
            tool_info
                .connector_name
                .as_deref()
                .map(str::trim)
                .filter(|connector_name| !connector_name.is_empty())
                .map(|connector_name| format!("Tools for working with {connector_name}."))
        })
        .unwrap_or_default();

    Ok(ToolSpec::Namespace(ResponsesApiNamespace {
        name: tool_info.callable_namespace.clone(),
        description: take_bytes_at_char_boundary(&description, MAX_MCP_NAMESPACE_DESCRIPTION_BYTES)
            .to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(tool)],
    }))
}

fn effective_namespace_description(tool_info: &ToolInfo, agent_plugin: bool) -> Option<&str> {
    let description = tool_info.namespace_description.as_deref()?;
    Some(if agent_plugin {
        take_bytes_at_char_boundary(
            description,
            MAX_AGENT_PLUGIN_MCP_NAMESPACE_DESCRIPTION_BYTES,
        )
    } else {
        description
    })
}

fn mcp_hook_tool_input(raw_arguments: &str) -> Value {
    if raw_arguments.trim().is_empty() {
        return Value::Object(Map::new());
    }

    serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))
}

fn build_mcp_search_text(info: &ToolInfo, agent_plugin: bool) -> String {
    let tool_name = info.canonical_tool_name();
    let mut schema_properties = info
        .tool
        .input_schema
        .get("properties")
        .and_then(serde_json::Value::as_object)
        .map(|map| map.keys().cloned().collect::<Vec<_>>())
        .unwrap_or_default();
    schema_properties.sort();
    let mut parts = vec![
        flat_tool_name(&tool_name).into_owned(),
        info.callable_name.clone(),
        info.tool.name.to_string(),
        info.server_name.clone(),
    ];
    if let Some(title) = info.tool.title.as_deref().map(str::trim)
        && !title.is_empty()
    {
        parts.push(title.to_string());
    }
    if let Some(description) = info.tool.description.as_deref().map(str::trim)
        && !description.is_empty()
    {
        parts.push(description.to_string());
    }
    if let Some(connector_name) = info.connector_name.as_deref().map(str::trim)
        && !connector_name.is_empty()
    {
        parts.push(connector_name.to_string());
    }
    if let Some(namespace_description) =
        effective_namespace_description(info, agent_plugin).map(str::trim)
        && !namespace_description.is_empty()
    {
        parts.push(namespace_description.to_string());
    }
    parts.extend(
        info.plugin_display_names
            .iter()
            .map(String::as_str)
            .map(str::trim)
            .filter(|display_name| !display_name.is_empty())
            .map(str::to_string),
    );
    parts.extend(schema_properties);
    parts.join(" ")
}

#[cfg(test)]
#[path = "mcp_search_tests.rs"]
mod search_tests;

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::step_context::StepContext;
    use crate::session::tests::make_session_and_context;
    use crate::tools::context::ToolCallSource;
    use crate::tools::hook_names::HookToolName;
    use crate::tools::registry::PostToolUsePayload;
    use crate::tools::registry::PreToolUsePayload;
    use crate::turn_diff_tracker::TurnDiffTracker;
    use codex_features::Feature;
    use pretty_assertions::assert_eq;
    use serde_json::json;
    use std::time::Duration;
    use tokio::sync::Mutex;

    #[tokio::test]
    async fn mcp_pre_tool_use_payload_uses_prefixed_tool_name_and_raw_args() {
        let payload = ToolPayload::Function {
            arguments: json!({
                "entities": [{
                    "name": "Ada",
                    "entityType": "person"
                }]
            })
            .to_string(),
        };
        let (session, turn) = make_session_and_context().await;
        let turn = Arc::new(turn);
        let handler = McpHandler::new(tool_info("memory", "memory", "create_entities"))
            .expect("MCP tool spec should build");
        assert_eq!(
            handler.pre_tool_use_payload(&ToolInvocation {
                session: session.into(),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-mcp-pre".to_string(),
                tool_name: codex_tools::ToolName::namespaced("memory", "create_entities"),
                source: ToolCallSource::Direct,
                payload,
            }),
            Some(PreToolUsePayload {
                tool_name: HookToolName::new("mcp__memory__create_entities"),
                tool_input: json!({
                    "entities": [{
                        "name": "Ada",
                        "entityType": "person"
                    }]
                }),
            })
        );
    }

    #[tokio::test]
    async fn mcp_pre_tool_use_payload_keeps_builtin_like_tool_names_namespaced() {
        let payload = ToolPayload::Function {
            arguments: json!({ "message": "hello" }).to_string(),
        };
        let (session, turn) = make_session_and_context().await;
        let turn = Arc::new(turn);
        let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))
            .expect("MCP tool spec should build");

        assert_eq!(
            handler.pre_tool_use_payload(&ToolInvocation {
                session: session.into(),
                step_context: StepContext::for_test(Arc::clone(&turn)),
                turn,
                cancellation_token: tokio_util::sync::CancellationToken::new(),
                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                call_id: "call-mcp-pre-builtin-like".to_string(),
                tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),
                source: ToolCallSource::Direct,
                payload,
            }),
            Some(PreToolUsePayload {
                tool_name: HookToolName::new("mcp__foo__exec_command"),
                tool_input: json!({ "message": "hello" }),
            })
        );
    }

    #[tokio::test]
    async fn mcp_updated_input_rewrites_builtin_like_tool_names_as_mcp() {
        let payload = ToolPayload::Function {
            arguments: json!({ "message": "hello" }).to_string(),
        };
        let (session, turn) = make_session_and_context().await;
        let turn = Arc::new(turn);
        let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))
            .expect("MCP tool spec should build");

        let invocation = handler
            .with_updated_hook_input(
                ToolInvocation {
                    session: session.into(),
                    step_context: StepContext::for_test(Arc::clone(&turn)),
                    turn,
                    cancellation_token: tokio_util::sync::CancellationToken::new(),
                    tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
                    call_id: "call-mcp-rewrite-builtin-like".to_string(),
                    tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),
                    source: ToolCallSource::Direct,
                    payload,
                },
                json!({ "message": "rewritten" }),
            )
            .expect("MCP rewrite should succeed");

        let ToolPayload::Function { arguments } = invocation.payload else {
            panic!("builtin-like MCP tool should stay function-shaped");
        };
        assert_eq!(arguments, json!({ "message": "rewritten" }).to_string());
    }

    #[tokio::test]
    async fn mcp_post_tool_use_payload_uses_prefixed_tool_name_args_and_result() {
        let payload = ToolPayload::Function {
            arguments: json!({ "path": "/tmp/notes.txt" }).to_string(),
        };
        let output = McpToolOutput {
            result: codex_protocol::mcp::CallToolResult {
                content: vec![json!({
                    "type": "text",
                    "text": "notes"
                })],
                structured_content: Some(json!({ "bytes": 5 })),
                is_error: None,
                meta: None,
            },
            tool_input: json!({
                "path": {
                    "file_id": "file_123"
                }
            }),
            result_metadata_capture_allowed: false,
            wall_time: Duration::from_millis(42),
            original_image_detail_supported: true,
            truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),
        };
        let (session, turn) = make_session_and_context().await;
        let mut session = session;
        let mut turn = turn;
        Arc::make_mut(&mut turn.config)
            .features
            .enable(Feature::ExecutedToolCallMetadata)
            .expect("test feature must be configurable");
        let recorder = crate::tools::executed_tool_calls::ExecutedToolCalls::new(
            &turn.config.features,
            &codex_history::InitialHistory::New,
        );
        session.services.executed_tool_calls = recorder.clone();
        let turn = Arc::new(turn);
        let handler = McpHandler::new(tool_info("filesystem", "filesystem", "read_file"))
            .expect("MCP tool spec should build");
        let invocation = ToolInvocation {
            session: session.into(),
            step_context: StepContext::for_test(Arc::clone(&turn)),
            turn,
            cancellation_token: tokio_util::sync::CancellationToken::new(),
            tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),
            call_id: "call-mcp-post".to_string(),
            tool_name: codex_tools::ToolName::namespaced("filesystem", "read_file"),
            source: ToolCallSource::Direct,
            payload,
        };
        assert_eq!(
            handler.post_tool_use_payload(&invocation, &output),
            Some(PostToolUsePayload {
                tool_name: HookToolName::new("mcp__filesystem__read_file"),
                tool_use_id: "call-mcp-post".to_string(),
                tool_input: json!({
                    "path": {
                        "file_id": "file_123"
                    }
                }),
                tool_response: json!({
                    "content": [{
                        "type": "text",
                        "text": "notes"
                    }],
                    "structuredContent": { "bytes": 5 }
                }),
            })
        );

        // Nested MCP result metadata still reaches the owning Code Mode cell.
        let cell_id = codex_code_mode::CellId::new("mcp-cell".to_string());
        recorder.start_cell(&cell_id, "exec-mcp-post");
        let mut invocation = invocation;
        invocation.source = ToolCallSource::CodeMode {
            cell_id: cell_id.as_str().to_string(),
            runtime_tool_call_id: "mcp-runtime-call".to_string(),
        };
        let mut output = output;
        output.result.meta = Some(json!({ "provider/custom": { "items": [1, null] } }));
        output.result_metadata_capture_allowed = true;
        recorder.record_tool_call(
            &crate::tools::router::ToolCall {
                tool_name: invocation.tool_name.clone(),
                call_id: invocation.call_id.clone(),
                payload: invocation.payload.clone(),
                encrypted_function_args: None,
            },
            &invocation.source,
            &invocation.step_context,
        );
        handler.on_tool_result_accepted(&invocation, &output);
        let mut items = [serde_json::from_value(json!({
            "type": "custom_tool_call_output", "call_id": "exec-mcp-post", "output": "notes",
        }))
        .expect("Code Mode output")];
        recorder.attach_to_prompt(&mut items, &mut Default::default());
        assert_eq!(
            serde_json::to_value(items[0].executed_tool_call_metadata()).unwrap()["executed_tool_calls"],
            json!([{
                "name": codex_tools::code_mode_name_for_tool_name(&invocation.tool_name),
                "arguments": { "path": "/tmp/notes.txt" },
                "tool_result_metadata": { "provider/custom": { "items": [1, null] } },
            }]),
        );
    }

    #[test]
    fn mcp_code_mode_definitions_are_cached_lazily() {
        let handler = McpHandler::new(tool_info("filesystem", "mcp__filesystem", "read_file"))
            .expect("MCP tool spec should build");

        assert!(handler.code_mode_tool_definitions.get().is_none());
        assert!(Arc::ptr_eq(
            handler
                .immutable_spec()
                .expect("MCP spec should be immutable"),
            &handler.spec,
        ));

        let first = handler
            .cached_code_mode_definitions(
                /*code_mode_input_schema_max_bytes*/ None,
                /*tool_description_first*/ false,
            )
            .expect("MCP definitions should be cached");
        assert_eq!(first.len(), 1);
        assert!(first[0].input_schema.is_none());
        assert!(first[0].output_schema.is_none());

        assert!(
            handler
                .cached_code_mode_definitions(
                    /*code_mode_input_schema_max_bytes*/ None,
                    /*tool_description_first*/ true,
                )
                .is_none()
        );

        let second = handler
            .cached_code_mode_definitions(
                /*code_mode_input_schema_max_bytes*/ None,
                /*tool_description_first*/ false,
            )
            .expect("MCP definitions should be cached");
        assert!(std::ptr::eq(first, second));
        assert!(
            handler
                .cached_code_mode_definitions(Some(32_000), /*tool_description_first*/ false)
                .is_none()
        );
    }

    #[test]
    fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() {
        let mut read_only_info = tool_info("foo", "mcp__foo__", "read");
        read_only_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));

        assert!(
            McpHandler::new(read_only_info)
                .expect("MCP tool spec should build")
                .supports_parallel_tool_calls()
        );
    }

    #[test]
    fn mcp_parallel_calls_require_read_only_hint_or_server_opt_in() {
        let missing_hint_info = tool_info("foo", "mcp__foo__", "unannotated");
        assert!(
            !McpHandler::new(missing_hint_info)
                .expect("MCP tool spec should build")
                .supports_parallel_tool_calls()
        );

        let mut writable_info = tool_info("foo", "mcp__foo__", "write");
        writable_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(false));
        assert!(
            !McpHandler::new(writable_info)
                .expect("MCP tool spec should build")
                .supports_parallel_tool_calls()
        );

        let mut server_opt_in_info = tool_info("foo", "mcp__foo__", "server_opt_in");
        server_opt_in_info.supports_parallel_tool_calls = true;
        assert!(
            McpHandler::new(server_opt_in_info)
                .expect("MCP tool spec should build")
                .supports_parallel_tool_calls()
        );
    }

    fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo {
        ToolInfo {
            server_name: server_name.to_string(),
            supports_parallel_tool_calls: false,
            server_origin: None,
            callable_name: tool_name.to_string(),
            callable_namespace: callable_namespace.to_string(),
            namespace_description: None,
            tool: rmcp::model::Tool::new_with_raw(
                tool_name.to_string(),
                None,
                Arc::new(rmcp::model::object(serde_json::json!({
                    "type": "object",
                }))),
            ),
            openai_file_input_optional_fields: Default::default(),
            connector_id: None,
            connector_name: None,
            plugin_display_names: Vec::new(),
        }
    }
}
codex-rs/core/src/tools/handlers/mcp_resource_spec.rs
use codex_protocol::openai_models::ToolMessage;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;

pub fn create_list_mcp_resources_tool(messages: Option<&ToolMessage>) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "server".to_string(),
            JsonSchema::string(Some(
                "MCP server name. Omit to list resources from every configured server.".to_string(),
            )),
        ),
        (
            "cursor".to_string(),
            JsonSchema::string(Some(
                "Opaque cursor from a previous list_mcp_resources call; omit for the first page."
                    .to_string(),
            )),
        ),
    ]);

    let tool = ResponsesApiTool {
        name: "list_mcp_resources".to_string(),
        description: "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
        output_schema: None,
    };
    with_model_messages(tool, messages)
}

pub fn create_list_mcp_resource_templates_tool(messages: Option<&ToolMessage>) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "server".to_string(),
            JsonSchema::string(Some(
                "MCP server name. Omit to list resource templates from every configured server."
                    .to_string(),
            )),
        ),
        (
            "cursor".to_string(),
            JsonSchema::string(Some(
                "Opaque cursor from a previous list_mcp_resource_templates call; omit for the first page."
                    .to_string(),
            )),
        ),
    ]);

    let tool = ResponsesApiTool {
        name: "list_mcp_resource_templates".to_string(),
        description: "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
        output_schema: None,
    };
    with_model_messages(tool, messages)
}

pub fn create_read_mcp_resource_tool(messages: Option<&ToolMessage>) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "server".to_string(),
            JsonSchema::string(Some(
                "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."
                    .to_string(),
            )),
        ),
        (
            "uri".to_string(),
            JsonSchema::string(Some(
                "Resource URI to read. Must be one of the URIs returned by list_mcp_resources."
                    .to_string(),
            )),
        ),
    ]);

    let tool = ResponsesApiTool {
        name: "read_mcp_resource".to_string(),
        description:
            "Read a specific resource from an MCP server given the server name and resource URI."
                .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["server".to_string(), "uri".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    };
    with_model_messages(tool, messages)
}

fn with_model_messages(mut tool: ResponsesApiTool, messages: Option<&ToolMessage>) -> ToolSpec {
    if let Some(messages) = messages {
        if let Some(description) = &messages.description {
            tool.description.clone_from(description);
        }
        if let Some(parameters) = &messages.parameters {
            match crate::tools::catalog_parameters::parse(parameters) {
                Ok(parameters) => tool.parameters = parameters,
                Err(reason) => tracing::warn!(
                    tool = %tool.name,
                    reason,
                    "Invalid catalog tool parameters; using bundled parameters"
                ),
            }
        }
    }
    ToolSpec::Function(tool)
}

#[cfg(test)]
#[path = "mcp_resource_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/mcp_resource.rs
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;

use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_protocol::items::McpToolCallError;
use codex_protocol::items::McpToolCallItem;
use codex_protocol::items::McpToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::function_call_output_content_items_to_text;
use codex_protocol::protocol::TruncationPolicy;
use codex_utils_output_truncation::truncate_text;
use codex_utils_output_truncation::with_serialization_allowance;
use rmcp::model::ListResourceTemplatesResult;
use rmcp::model::ListResourcesResult;
use rmcp::model::PaginatedRequestParams;
use rmcp::model::ReadResourceResult;
use rmcp::model::Resource;
use rmcp::model::ResourceTemplate;
use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;

use crate::function_tool::FunctionCallError;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolOutput;
use crate::tools::context::boxed_tool_output;
use codex_protocol::protocol::McpInvocation;

mod list_mcp_resource_templates;
mod list_mcp_resources;
mod read_mcp_resource;

pub use list_mcp_resource_templates::ListMcpResourceTemplatesHandler;
pub use list_mcp_resources::ListMcpResourcesHandler;
pub use read_mcp_resource::ReadMcpResourceHandler;

fn model_can_access_mcp_server(turn: &TurnContext, server: &str) -> bool {
    turn.config.orchestrator_mcp_enabled || server != CODEX_APPS_MCP_SERVER_NAME
}

fn ensure_model_can_access_mcp_server(
    turn: &TurnContext,
    server: &str,
) -> Result<(), FunctionCallError> {
    if model_can_access_mcp_server(turn, server) {
        Ok(())
    } else {
        Err(FunctionCallError::RespondToModel(format!(
            "MCP server '{server}' is disabled by `orchestrator.mcp.enabled`"
        )))
    }
}

#[derive(Debug, Deserialize, Default, PartialEq, Eq)]
struct ListResourceArgs {
    #[serde(default)]
    server: Option<String>,
    #[serde(default)]
    cursor: Option<String>,
}

impl ListResourceArgs {
    fn normalized(self) -> Self {
        Self {
            server: normalize_optional_string(self.server),
            cursor: normalize_optional_string(self.cursor),
        }
    }

    fn target(
        &self,
        turn: &TurnContext,
    ) -> Result<Option<(String, Option<PaginatedRequestParams>)>, FunctionCallError> {
        match &self.server {
            Some(server) => {
                ensure_model_can_access_mcp_server(turn, server)?;
                let params = self
                    .cursor
                    .clone()
                    .map(|cursor| PaginatedRequestParams::default().with_cursor(Some(cursor)));
                Ok(Some((server.clone(), params)))
            }
            None if self.cursor.is_some() => Err(FunctionCallError::RespondToModel(
                "cursor can only be used when a server is specified".to_string(),
            )),
            None => Ok(None),
        }
    }
}

#[derive(Debug, Deserialize)]
struct ReadResourceArgs {
    server: String,
    uri: String,
}

#[derive(Debug, Serialize)]
struct ResourceWithServer<T> {
    server: String,
    #[serde(flatten)]
    resource: T,
}

impl<T> ResourceWithServer<T> {
    fn new(server: String, resource: T) -> Self {
        Self { server, resource }
    }

    fn from_server(server: &str, resources: Vec<T>) -> Vec<Self> {
        resources
            .into_iter()
            .map(|resource| Self::new(server.to_string(), resource))
            .collect()
    }

    fn from_all_servers(resources_by_server: HashMap<String, Vec<T>>) -> Vec<Self> {
        let mut entries: Vec<_> = resources_by_server.into_iter().collect();
        entries.sort_by(|(left, _), (right, _)| left.cmp(right));
        entries
            .into_iter()
            .flat_map(|(server, resources)| Self::from_server(&server, resources))
            .collect()
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ListResourcesPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    server: Option<String>,
    resources: Vec<ResourceWithServer<Resource>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    next_cursor: Option<String>,
}

impl ListResourcesPayload {
    fn from_single_server(server: String, result: ListResourcesResult) -> Self {
        Self {
            resources: ResourceWithServer::from_server(&server, result.resources),
            server: Some(server),
            next_cursor: result.next_cursor,
        }
    }

    fn from_all_servers(resources_by_server: HashMap<String, Vec<Resource>>) -> Self {
        Self {
            server: None,
            resources: ResourceWithServer::from_all_servers(resources_by_server),
            next_cursor: None,
        }
    }
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ListResourceTemplatesPayload {
    #[serde(skip_serializing_if = "Option::is_none")]
    server: Option<String>,
    resource_templates: Vec<ResourceWithServer<ResourceTemplate>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    next_cursor: Option<String>,
}

impl ListResourceTemplatesPayload {
    fn from_single_server(server: String, result: ListResourceTemplatesResult) -> Self {
        Self {
            resource_templates: ResourceWithServer::from_server(&server, result.resource_templates),
            server: Some(server),
            next_cursor: result.next_cursor,
        }
    }

    fn from_all_servers(templates_by_server: HashMap<String, Vec<ResourceTemplate>>) -> Self {
        Self {
            server: None,
            resource_templates: ResourceWithServer::from_all_servers(templates_by_server),
            next_cursor: None,
        }
    }
}

#[derive(Debug, Serialize)]
struct ReadResourcePayload {
    server: String,
    uri: String,
    #[serde(flatten)]
    result: ReadResourceResult,
}

fn call_tool_result_from_content(content: &str, success: Option<bool>) -> CallToolResult {
    CallToolResult {
        content: vec![serde_json::json!({"type": "text", "text": content})],
        structured_content: None,
        is_error: success.map(|value| !value),
        meta: None,
    }
}

async fn emit_tool_call_begin(
    session: &Arc<Session>,
    turn: &TurnContext,
    call_id: &str,
    invocation: McpInvocation,
) {
    let McpInvocation {
        server,
        tool,
        arguments,
    } = invocation;
    let item = TurnItem::McpToolCall(McpToolCallItem {
        id: call_id.to_string(),
        server,
        tool,
        arguments: arguments.unwrap_or(Value::Null),
        connector_id: None,
        mcp_app_resource_uri: None,
        mcp_app_ui: None,
        link_id: None,
        app_name: None,
        action_name: None,
        plugin_id: None,
        read_only_hint: None,
        status: McpToolCallStatus::InProgress,
        result: None,
        error: None,
        duration: None,
    });
    session.emit_turn_item_started(turn, &item).await;
}

async fn emit_tool_call_end(
    session: &Arc<Session>,
    turn: &TurnContext,
    call_id: &str,
    invocation: McpInvocation,
    duration: Duration,
    result: Result<CallToolResult, String>,
) {
    let (status, result, error) = match result {
        Ok(result) if result.is_error.unwrap_or(false) => {
            (McpToolCallStatus::Failed, Some(result), None)
        }
        Ok(result) => (McpToolCallStatus::Completed, Some(result), None),
        Err(message) => (
            McpToolCallStatus::Failed,
            None,
            Some(McpToolCallError { message }),
        ),
    };
    let McpInvocation {
        server,
        tool,
        arguments,
    } = invocation;
    let item = TurnItem::McpToolCall(McpToolCallItem {
        id: call_id.to_string(),
        server,
        tool,
        arguments: arguments.unwrap_or(Value::Null),
        connector_id: None,
        mcp_app_resource_uri: None,
        mcp_app_ui: None,
        link_id: None,
        app_name: None,
        action_name: None,
        plugin_id: None,
        read_only_hint: None,
        status,
        result,
        error,
        duration: Some(duration),
    });
    session.emit_turn_item_completed(turn, item).await;
}

async fn run_resource_operation<T>(
    session: &Arc<Session>,
    step_context: &StepContext,
    call_id: &str,
    invocation: McpInvocation,
    operation: impl Future<Output = Result<T, FunctionCallError>>,
) -> Result<Box<dyn ToolOutput>, FunctionCallError>
where
    T: Serialize,
{
    let turn = &step_context.turn;
    emit_tool_call_begin(session, turn, call_id, invocation.clone()).await;
    let start = Instant::now();
    let result = operation.await.and_then(|payload| {
        serialize_function_output(
            payload,
            step_context.settings.model_info.truncation_policy.into(),
        )
    });

    match result {
        Ok(output) => {
            let content =
                function_call_output_content_items_to_text(&output.body).unwrap_or_default();
            emit_tool_call_end(
                session,
                turn,
                call_id,
                invocation,
                start.elapsed(),
                Ok(call_tool_result_from_content(&content, output.success)),
            )
            .await;
            Ok(boxed_tool_output(output))
        }
        Err(error) => {
            emit_tool_call_end(
                session,
                turn,
                call_id,
                invocation,
                start.elapsed(),
                Err(error.to_string()),
            )
            .await;
            Err(error)
        }
    }
}

fn normalize_optional_string(input: Option<String>) -> Option<String> {
    input.and_then(|value| {
        let trimmed = value.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    })
}

fn normalize_required_string(field: &str, value: String) -> Result<String, FunctionCallError> {
    match normalize_optional_string(Some(value)) {
        Some(normalized) => Ok(normalized),
        None => Err(FunctionCallError::RespondToModel(format!(
            "{field} must be provided"
        ))),
    }
}

fn serialize_function_output<T>(
    payload: T,
    truncation_policy: TruncationPolicy,
) -> Result<FunctionToolOutput, FunctionCallError>
where
    T: Serialize,
{
    let content = serde_json::to_string(&payload).map_err(|err| {
        FunctionCallError::RespondToModel(format!(
            "failed to serialize MCP resource response: {err}"
        ))
    })?;
    // Match regular MCP tool outputs by bounding the copy persisted to the
    // rollout and injected into model context.
    let content = truncate_text(&content, with_serialization_allowance(truncation_policy));

    Ok(FunctionToolOutput::from_text(content, Some(true)))
}

fn parse_arguments(raw_args: &str) -> Result<Option<Value>, FunctionCallError> {
    if raw_args.trim().is_empty() {
        Ok(None)
    } else {
        let value: Value = serde_json::from_str(raw_args).map_err(|err| {
            FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}"))
        })?;
        if value.is_null() {
            Ok(None)
        } else {
            Ok(Some(value))
        }
    }
}

fn parse_args<T>(arguments: Option<Value>) -> Result<T, FunctionCallError>
where
    T: DeserializeOwned,
{
    match arguments {
        Some(value) => serde_json::from_value(value).map_err(|err| {
            FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}"))
        }),
        None => Err(FunctionCallError::RespondToModel(
            "failed to parse function arguments: expected value".to_string(),
        )),
    }
}

fn parse_args_with_default<T>(arguments: Option<Value>) -> Result<T, FunctionCallError>
where
    T: DeserializeOwned + Default,
{
    match arguments {
        Some(value) => parse_args(Some(value)),
        None => Ok(T::default()),
    }
}

#[cfg(test)]
#[path = "mcp_resource_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resource_templates.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resource_templates_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::protocol::McpInvocation;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

use super::ListResourceArgs;
use super::ListResourceTemplatesPayload;
use super::model_can_access_mcp_server;
use super::parse_args_with_default;
use super::parse_arguments;
use super::run_resource_operation;

pub struct ListMcpResourceTemplatesHandler {
    spec: ToolSpec,
}

impl ToolExecutor<ToolInvocation> for ListMcpResourceTemplatesHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("list_mcp_resource_templates")
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ListMcpResourceTemplatesHandler {
    pub fn new(messages: Option<&ToolMessage>) -> Self {
        Self {
            spec: create_list_mcp_resource_templates_tool(messages),
        }
    }

    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            step_context,
            call_id,
            payload,
            ..
        } = invocation;
        let turn = std::sync::Arc::clone(&step_context.turn);
        let mcp = &step_context.mcp;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "list_mcp_resource_templates handler received unsupported payload".to_string(),
                ));
            }
        };

        let arguments = parse_arguments(arguments.as_str())?;
        let args: ListResourceArgs = parse_args_with_default(arguments.clone())?;
        let args = args.normalized();

        let invocation = McpInvocation {
            server: args.server.clone().unwrap_or_else(|| "codex".to_string()),
            tool: "list_mcp_resource_templates".to_string(),
            arguments: arguments.clone(),
        };

        run_resource_operation(&session, &step_context, &call_id, invocation, async {
            if let Some((server_name, params)) = args.target(turn.as_ref())? {
                let result = mcp
                    .list_resource_templates(&server_name, params)
                    .await
                    .map_err(|err| {
                        FunctionCallError::RespondToModel(format!(
                            "resources/templates/list failed: {err:#}"
                        ))
                    })?;
                Ok(ListResourceTemplatesPayload::from_single_server(
                    server_name,
                    result,
                ))
            } else {
                let templates = mcp
                    .list_all_resource_templates(|server_name| {
                        model_can_access_mcp_server(turn.as_ref(), server_name)
                    })
                    .await;
                Ok(ListResourceTemplatesPayload::from_all_servers(templates))
            }
        })
        .await
    }
}

impl CoreToolRuntime for ListMcpResourceTemplatesHandler {}
codex-rs/core/src/tools/handlers/mcp_resource/list_mcp_resources.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::mcp_resource_spec::create_list_mcp_resources_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::protocol::McpInvocation;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

use super::ListResourceArgs;
use super::ListResourcesPayload;
use super::model_can_access_mcp_server;
use super::parse_args_with_default;
use super::parse_arguments;
use super::run_resource_operation;

pub struct ListMcpResourcesHandler {
    spec: ToolSpec,
}

impl ToolExecutor<ToolInvocation> for ListMcpResourcesHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("list_mcp_resources")
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ListMcpResourcesHandler {
    pub fn new(messages: Option<&ToolMessage>) -> Self {
        Self {
            spec: create_list_mcp_resources_tool(messages),
        }
    }

    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            step_context,
            call_id,
            payload,
            ..
        } = invocation;
        let turn = std::sync::Arc::clone(&step_context.turn);
        let mcp = &step_context.mcp;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "list_mcp_resources handler received unsupported payload".to_string(),
                ));
            }
        };

        let arguments = parse_arguments(arguments.as_str())?;
        let args: ListResourceArgs = parse_args_with_default(arguments.clone())?;
        let args = args.normalized();

        let invocation = McpInvocation {
            server: args.server.clone().unwrap_or_else(|| "codex".to_string()),
            tool: "list_mcp_resources".to_string(),
            arguments: arguments.clone(),
        };

        run_resource_operation(&session, &step_context, &call_id, invocation, async {
            if let Some((server_name, params)) = args.target(turn.as_ref())? {
                let result = mcp
                    .list_resources(&server_name, params)
                    .await
                    .map_err(|err| {
                        FunctionCallError::RespondToModel(format!("resources/list failed: {err:#}"))
                    })?;
                Ok(ListResourcesPayload::from_single_server(
                    server_name,
                    result,
                ))
            } else {
                let resources = mcp
                    .list_all_resources(|server_name| {
                        model_can_access_mcp_server(turn.as_ref(), server_name)
                    })
                    .await;
                Ok(ListResourcesPayload::from_all_servers(resources))
            }
        })
        .await
    }
}

impl CoreToolRuntime for ListMcpResourcesHandler {}
codex-rs/core/src/tools/handlers/mcp_resource/read_mcp_resource.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::mcp_resource_spec::create_read_mcp_resource_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::openai_models::ToolMessage;
use codex_protocol::protocol::McpInvocation;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

use rmcp::model::ReadResourceRequestParams;

use super::ReadResourceArgs;
use super::ReadResourcePayload;
use super::ensure_model_can_access_mcp_server;
use super::normalize_required_string;
use super::parse_args;
use super::parse_arguments;
use super::run_resource_operation;

pub struct ReadMcpResourceHandler {
    spec: ToolSpec,
}

impl ToolExecutor<ToolInvocation> for ReadMcpResourceHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("read_mcp_resource")
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl ReadMcpResourceHandler {
    pub fn new(messages: Option<&ToolMessage>) -> Self {
        Self {
            spec: create_read_mcp_resource_tool(messages),
        }
    }

    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            step_context,
            call_id,
            payload,
            ..
        } = invocation;
        let turn = std::sync::Arc::clone(&step_context.turn);
        let mcp = &step_context.mcp;

        let arguments = match payload {
            ToolPayload::Function { arguments } => arguments,
            _ => {
                return Err(FunctionCallError::RespondToModel(
                    "read_mcp_resource handler received unsupported payload".to_string(),
                ));
            }
        };

        let arguments = parse_arguments(arguments.as_str())?;
        let args: ReadResourceArgs = parse_args(arguments.clone())?;
        let ReadResourceArgs { server, uri } = args;
        let server = normalize_required_string("server", server)?;
        let uri = normalize_required_string("uri", uri)?;

        let invocation = McpInvocation {
            server: server.clone(),
            tool: "read_mcp_resource".to_string(),
            arguments: arguments.clone(),
        };

        run_resource_operation(&session, &step_context, &call_id, invocation, async {
            ensure_model_can_access_mcp_server(turn.as_ref(), &server)?;
            let result = mcp
                .read_resource(&server, ReadResourceRequestParams::new(uri.clone()))
                .await
                .map_err(|err| {
                    FunctionCallError::RespondToModel(format!("resources/read failed: {err:#}"))
                })?;

            Ok(ReadResourcePayload {
                server,
                uri,
                result,
            })
        })
        .await
    }
}

impl CoreToolRuntime for ReadMcpResourceHandler {}

multi_agent v1 / v2

Python 稿把 multi_agent 简化成 worker thread;原生实现是 namespace 化的 thread registry,包含状态机、输入/等待/恢复/关闭和并发上限。

codex-rs/core/src/tools/handlers/multi_agents_spec.rs
use crate::agent::child_config::MAX_SPAWN_AGENT_MODEL_OVERRIDES;
use crate::agent::child_config::model_supports_multi_agent_backend;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiNamespace;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;

pub const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1";
const MULTI_AGENT_V1_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents.";

const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE: &str = "Spawned agents inherit your current model by default. Omit `model` to use that preferred default; set `model` only when an explicit override is needed.";
const SPAWN_AGENT_INHERITED_MODEL_GUIDANCE_V2: &str = "Spawned agents inherit your current model by default. Do not set the `model` field unless the user explicitly asks for a different model.";
const SPAWN_AGENT_MODEL_CATALOG_GUIDANCE: &str =
    "Pick model overrides from the latest <model_catalog> listing.";
const SPAWN_AGENT_TYPE_OVERRIDE_DESCRIPTION_V1: &str = "Agent type override for the new agent. Omit to inherit the parent agent type with a full-history fork; otherwise, `default` is used.";
const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str =
    "Model override for the new agent. Omit unless an explicit override is needed.";
const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64;

#[derive(Debug, Clone)]
pub struct SpawnAgentToolOptions {
    pub available_models: Vec<ModelPreset>,
    pub agent_type_description: String,
    pub expose_agent_type: bool,
    pub hide_agent_type_model_reasoning: bool,
    pub expose_spawn_agent_model_overrides: bool,
    pub multi_agent_version: MultiAgentVersion,
    pub model_catalog_in_context: bool,
    pub usage_hint_text: Option<String>,
}

impl Default for SpawnAgentToolOptions {
    fn default() -> Self {
        Self {
            available_models: Vec::new(),
            agent_type_description: String::new(),
            expose_agent_type: true,
            hide_agent_type_model_reasoning: false,
            expose_spawn_agent_model_overrides: false,
            multi_agent_version: MultiAgentVersion::Disabled,
            model_catalog_in_context: false,
            usage_hint_text: None,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WaitAgentTimeoutOptions {
    pub default_timeout_ms: i64,
    pub min_timeout_ms: i64,
    pub max_timeout_ms: i64,
}

impl Default for WaitAgentTimeoutOptions {
    fn default() -> Self {
        Self {
            default_timeout_ms: super::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS,
            min_timeout_ms: super::multi_agents_common::MIN_WAIT_TIMEOUT_MS,
            max_timeout_ms: super::multi_agents_common::MAX_WAIT_TIMEOUT_MS,
        }
    }
}

pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec {
    let available_models_description = (!options.model_catalog_in_context
        && !options.hide_agent_type_model_reasoning)
        .then(|| {
            spawn_agent_models_description(&options.available_models, options.multi_agent_version)
        });
    let inherited_model_guidance =
        (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE);
    let return_value_description =
        "Returns the spawned agent id plus the user-facing nickname when available.";
    let mut properties = spawn_agent_common_properties_v1(&options.agent_type_description);
    if !options.expose_agent_type {
        properties.remove("agent_type");
    }
    if options.hide_agent_type_model_reasoning {
        hide_spawn_agent_metadata_options(&mut properties);
    }

    ToolSpec::Namespace(ResponsesApiNamespace {
        name: MULTI_AGENT_V1_NAMESPACE.to_string(),
        description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: "spawn_agent".to_string(),
            description: spawn_agent_tool_description(
                available_models_description.as_deref(),
                options.model_catalog_in_context,
                inherited_model_guidance,
                return_value_description,
                options.usage_hint_text,
            ),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
            output_schema: Some(spawn_agent_output_schema_v1().into()),
        })],
    })
}

pub fn create_spawn_agent_tool_v2(
    options: SpawnAgentToolOptions,
    description_override: Option<&str>,
) -> ToolSpec {
    let available_models_description = (!options.model_catalog_in_context
        && options.expose_spawn_agent_model_overrides)
        .then(|| {
            spawn_agent_models_description(&options.available_models, options.multi_agent_version)
        });
    let inherited_model_guidance = if options.model_catalog_in_context {
        options
            .expose_spawn_agent_model_overrides
            .then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE_V2)
    } else {
        (options.expose_spawn_agent_model_overrides && !options.hide_agent_type_model_reasoning)
            .then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)
    };
    let mut properties = spawn_agent_common_properties_v2(&options.agent_type_description);
    if !options.expose_agent_type {
        properties.remove("agent_type");
    }
    if !options.expose_spawn_agent_model_overrides {
        properties.remove("model");
        properties.remove("reasoning_effort");
    }
    properties.insert(
        "task_name".to_string(),
        JsonSchema::string(Some(
            "Task name for the new agent. Use lowercase letters, digits, and underscores."
                .to_string(),
        )),
    );

    ToolSpec::Function(ResponsesApiTool {
        name: "spawn_agent".to_string(),
        description: spawn_agent_tool_description_v2(
            available_models_description.as_deref(),
            options.model_catalog_in_context,
            options
                .expose_spawn_agent_model_overrides
                .then_some(SPAWN_AGENT_MODEL_CATALOG_GUIDANCE),
            inherited_model_guidance,
            options.usage_hint_text,
            description_override,
        ),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["task_name".to_string(), "message".to_string()]),
            Some(false.into()),
        ),
        output_schema: Some(
            spawn_agent_output_schema_v2(options.hide_agent_type_model_reasoning).into(),
        ),
    })
}

pub fn create_send_input_tool_v1() -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "target".to_string(),
            JsonSchema::string(Some("Agent id to message (from spawn_agent).".to_string())),
        ),
        (
            "message".to_string(),
            JsonSchema::string(Some(
                "Legacy plain-text message to send to the agent. Use either message or items."
                    .to_string(),
            )),
        ),
        ("items".to_string(), create_collab_input_items_schema()),
        (
            "interrupt".to_string(),
            JsonSchema::boolean(Some(
                "True interrupts the current task and handles this message immediately; false or omitted queues it."
                    .to_string(),
            )),
        ),
    ]);

    ToolSpec::Namespace(ResponsesApiNamespace {
        name: MULTI_AGENT_V1_NAMESPACE.to_string(),
        description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: "send_input".to_string(),
            description: "Send a message to an existing agent. Use interrupt=true to redirect work immediately. You should reuse the agent by send_input if you believe your assigned task is highly dependent on the context of a previous task."
                .to_string(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
            output_schema: Some(send_input_output_schema().into()),
        })],
    })
}

pub fn create_send_message_tool() -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "target".to_string(),
            JsonSchema::string(Some(
                "Relative or canonical task name to message (from spawn_agent).".to_string(),
            )),
        ),
        (
            "message".to_string(),
            JsonSchema::string(Some(
                "Message text to queue on the target agent.".to_string(),
            ))
            .with_encrypted(),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "send_message".to_string(),
        description: "Send a message to an existing agent. The message will be delivered promptly. Does not trigger a new turn."
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            Some(vec!["target".to_string(), "message".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}

pub fn create_followup_task_tool() -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "target".to_string(),
            JsonSchema::string(Some(
                "Agent id or canonical task name to send a follow-up task to (from spawn_agent)."
                    .to_string(),
            )),
        ),
        (
            "message".to_string(),
            JsonSchema::string(Some(
                "Message text to send to the target agent.".to_string(),
            ))
            .with_encrypted(),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: "followup_task".to_string(),
        description: "Send a follow-up task to an existing non-root target agent and trigger a turn if it is idle. If the target is already running, deliver the task promptly at message boundaries while sampling, or after the pending tool call completes."
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, Some(vec!["target".to_string(), "message".to_string()]), Some(false.into())),
        output_schema: None,
    })
}

pub fn create_resume_agent_tool() -> ToolSpec {
    let properties = BTreeMap::from([(
        "id".to_string(),
        JsonSchema::string(Some("Agent id to resume.".to_string())),
    )]);

    ToolSpec::Namespace(ResponsesApiNamespace {
        name: MULTI_AGENT_V1_NAMESPACE.to_string(),
        description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: "resume_agent".to_string(),
            description:
                "Resume a previously closed agent by id so it can receive send_input and wait_agent calls."
                    .to_string(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(properties, Some(vec!["id".to_string()]), Some(false.into())),
            output_schema: Some(resume_agent_output_schema().into()),
        })],
    })
}

pub fn create_wait_agent_tool_v1(options: WaitAgentTimeoutOptions) -> ToolSpec {
    ToolSpec::Namespace(ResponsesApiNamespace {
        name: MULTI_AGENT_V1_NAMESPACE.to_string(),
        description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: "wait_agent".to_string(),
            description: "Wait for agents to reach a final status. Completed statuses may include the agent's final message. Returns empty status when timed out. Once the agent reaches a final status, a notification message will be received containing the same completed status."
                .to_string(),
            strict: false,
            defer_loading: None,
            parameters: wait_agent_tool_parameters_v1(options),
            output_schema: Some(wait_output_schema_v1().into()),
        })],
    })
}

pub fn create_wait_agent_tool_v2(options: WaitAgentTimeoutOptions) -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: "wait_agent".to_string(),
        description: "Wait for a mailbox update from any live agent, including queued messages and final-status notifications. The wait also ends early when new user input is steered into the active turn. Does not return the content; returns either a summary of which agents have updates (if any), an interruption summary for steered input, or a timeout summary if no activity arrives before the deadline."
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: wait_agent_tool_parameters_v2(options),
        output_schema: Some(wait_output_schema_v2().into()),
    })
}

pub fn create_list_agents_tool() -> ToolSpec {
    let properties = BTreeMap::from([(
        "path_prefix".to_string(),
        JsonSchema::string(Some(
            "Task-path prefix filter without a trailing slash. Omit to list all live agents."
                .to_string(),
        )),
    )]);

    ToolSpec::Function(ResponsesApiTool {
        name: "list_agents".to_string(),
        description:
            "List live agents in the current root thread tree. Optionally filter by task-path prefix."
                .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
        output_schema: Some(list_agents_output_schema().into()),
    })
}

pub fn create_close_agent_tool_v1() -> ToolSpec {
    let properties = BTreeMap::from([(
        "target".to_string(),
        JsonSchema::string(Some("Agent id to close (from spawn_agent).".to_string())),
    )]);

    ToolSpec::Namespace(ResponsesApiNamespace {
        name: MULTI_AGENT_V1_NAMESPACE.to_string(),
        description: MULTI_AGENT_V1_NAMESPACE_DESCRIPTION.to_string(),
        tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
            name: "close_agent".to_string(),
            description: "Close an agent and any open descendants when they are no longer needed, and return the target agent's previous status before shutdown was requested. Completed agents remain open and count toward the concurrency limit until closed. Don't keep agents open for too long if they are not needed anymore.".to_string(),
            strict: false,
            defer_loading: None,
            parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
            output_schema: Some(
                agent_previous_status_output_schema(
                    "The agent status observed before shutdown was requested.",
                )
                .into(),
            ),
        })],
    })
}

pub fn create_interrupt_agent_tool_v2() -> ToolSpec {
    let properties = BTreeMap::from([(
        "target".to_string(),
        JsonSchema::string(Some(
            "Agent id or canonical task name to interrupt (from spawn_agent).".to_string(),
        )),
    )]);

    ToolSpec::Function(ResponsesApiTool {
        name: "interrupt_agent".to_string(),
        description: "Interrupt an agent's current turn, if any, and return its previous status. The agent remains available for messages and follow-up tasks.".to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(properties, Some(vec!["target".to_string()]), Some(false.into())),
        output_schema: Some(
            agent_previous_status_output_schema(
                "The agent status observed before the interrupt request was handled.",
            )
            .into(),
        ),
    })
}

fn agent_status_output_schema() -> Value {
    json!({
        "oneOf": [
            {
                "type": "string",
                "enum": ["pending_init", "running", "interrupted", "shutdown", "not_found"]
            },
            {
                "type": "object",
                "properties": {
                    "completed": {
                        "type": ["string", "null"]
                    }
                },
                "required": ["completed"],
                "additionalProperties": false
            },
            {
                "type": "object",
                "properties": {
                    "errored": {
                        "type": "string"
                    }
                },
                "required": ["errored"],
                "additionalProperties": false
            }
        ]
    })
}

fn spawn_agent_output_schema_v1() -> Value {
    json!({
        "type": "object",
        "properties": {
            "agent_id": {
                "type": "string",
                "description": "Thread identifier for the spawned agent."
            },
            "nickname": {
                "type": ["string", "null"],
                "description": "User-facing nickname for the spawned agent when available."
            }
        },
        "required": ["agent_id", "nickname"],
        "additionalProperties": false
    })
}

fn spawn_agent_output_schema_v2(hide_agent_metadata: bool) -> Value {
    if hide_agent_metadata {
        return json!({
            "type": "object",
            "properties": {
                "task_name": {
                    "type": "string",
                    "description": "Canonical task name for the spawned agent."
                }
            },
            "required": ["task_name"],
            "additionalProperties": false
        });
    }

    json!({
        "type": "object",
        "properties": {
            "task_name": {
                "type": "string",
                "description": "Canonical task name for the spawned agent."
            },
            "nickname": {
                "type": ["string", "null"],
                "description": "User-facing nickname for the spawned agent when available."
            }
        },
        "required": ["task_name", "nickname"],
        "additionalProperties": false
    })
}

fn send_input_output_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "submission_id": {
                "type": "string",
                "description": "Identifier for the queued input submission."
            }
        },
        "required": ["submission_id"],
        "additionalProperties": false
    })
}

fn list_agents_output_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "agents": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "agent_name": {
                            "type": "string",
                            "description": "Canonical task name for the agent when available, otherwise the agent id."
                        },
                        "agent_status": {
                            "description": "Last known status of the agent.",
                            "allOf": [agent_status_output_schema()]
                        }
                    },
                    "required": ["agent_name", "agent_status"],
                    "additionalProperties": false
                },
                "description": "Live agents visible in the current root thread tree."
            }
        },
        "required": ["agents"],
        "additionalProperties": false
    })
}

fn resume_agent_output_schema() -> Value {
    json!({
        "type": "object",
        "properties": {
            "status": agent_status_output_schema()
        },
        "required": ["status"],
        "additionalProperties": false
    })
}

fn wait_output_schema_v1() -> Value {
    json!({
        "type": "object",
        "properties": {
            "status": {
                "type": "object",
                "description": "Final statuses keyed by agent id.",
                "additionalProperties": agent_status_output_schema()
            },
            "timed_out": {
                "type": "boolean",
                "description": "Whether the wait call returned due to timeout before any agent reached a final status."
            }
        },
        "required": ["status", "timed_out"],
        "additionalProperties": false
    })
}

fn wait_output_schema_v2() -> Value {
    json!({
        "type": "object",
        "properties": {
            "message": {
                "type": "string",
                "description": "Brief wait summary without the agent's final content, including any timeout adjustment."
            },
            "timed_out": {
                "type": "boolean",
                "description": "Whether the wait call returned because no mailbox update arrived before the timeout."
            }
        },
        "required": ["message", "timed_out"],
        "additionalProperties": false
    })
}

fn agent_previous_status_output_schema(previous_status_description: &str) -> Value {
    json!({
        "type": "object",
        "properties": {
            "previous_status": {
                "description": previous_status_description,
                "allOf": [agent_status_output_schema()]
            }
        },
        "required": ["previous_status"],
        "additionalProperties": false
    })
}

fn create_collab_input_items_schema() -> JsonSchema {
    let properties = BTreeMap::from([
        (
            "type".to_string(),
            JsonSchema::string(Some(
                "Input item type: text, image, local_image, audio, local_audio, skill, or mention."
                    .to_string(),
            )),
        ),
        (
            "text".to_string(),
            JsonSchema::string(Some("Text content when type is text.".to_string())),
        ),
        (
            "image_url".to_string(),
            JsonSchema::string(Some("Image URL when type is image.".to_string())),
        ),
        (
            "audio_url".to_string(),
            JsonSchema::string(Some("Audio data URL when type is audio.".to_string())),
        ),
        (
            "path".to_string(),
            JsonSchema::string(Some(
                "Path when type is local_image/local_audio/skill, or structured mention target such as app://<connector-id> or plugin://<plugin-name>@<marketplace-name> when type is mention."
                    .to_string(),
            )),
        ),
        (
            "name".to_string(),
            JsonSchema::string(Some("Display name when type is skill or mention.".to_string())),
        ),
    ]);

    JsonSchema::array(JsonSchema::object(properties, /*required*/ None, Some(false.into())), Some(
            "Structured input items. Use this to pass explicit mentions (for example app:// connector paths)."
                .to_string(),
        ))
}

fn spawn_agent_common_properties_v1(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
    BTreeMap::from([
        (
            "message".to_string(),
            JsonSchema::string(Some(
                "Initial plain-text task for the new agent. Use either message or items."
                    .to_string(),
            )),
        ),
        ("items".to_string(), create_collab_input_items_schema()),
        (
            "agent_type".to_string(),
            JsonSchema::string(Some(format!(
                "{SPAWN_AGENT_TYPE_OVERRIDE_DESCRIPTION_V1}\n{agent_type_description}"
            ))),
        ),
        (
            "fork_context".to_string(),
            JsonSchema::boolean(Some(
                "True forks the current thread history into the new agent; false or omitted starts with only the initial prompt."
                    .to_string(),
            )),
        ),
        (
            "model".to_string(),
            JsonSchema::string(Some(
                SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION.to_string(),
            )),
        ),
        (
            "reasoning_effort".to_string(),
            JsonSchema::string(Some(
                "Reasoning effort override for the new agent. Omit to inherit the parent effort."
                    .to_string(),
            )),
        ),
    ])
}

fn spawn_agent_common_properties_v2(agent_type_description: &str) -> BTreeMap<String, JsonSchema> {
    BTreeMap::from([
        (
            "message".to_string(),
            JsonSchema::string(Some(
                "Initial plain-text task for the new agent.".to_string(),
            ))
            .with_encrypted(),
        ),
        (
            "agent_type".to_string(),
            JsonSchema::string(Some(format!(
                "Agent type override for the new agent. Omit unless explicitly asked. The selected role applies regardless of how much parent history is inherited.\n{agent_type_description}"
            ))),
        ),
        (
            "fork_turns".to_string(),
            JsonSchema::string(Some(
                "Parent history to inherit. Defaults to `all`; use `none` to start without parent history. Only `all` and `none` are supported.".to_string(),
            )),
        ),
        (
            "model".to_string(),
            JsonSchema::string(Some(
                SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION.to_string(),
            )),
        ),
        (
            "reasoning_effort".to_string(),
            JsonSchema::string(Some(
                "Reasoning effort override for the new agent. Omit to inherit the parent effort."
                    .to_string(),
            )),
        ),
    ])
}

fn hide_spawn_agent_metadata_options(properties: &mut BTreeMap<String, JsonSchema>) {
    properties.remove("agent_type");
    properties.remove("model");
    properties.remove("reasoning_effort");
}

fn spawn_agent_tool_description(
    available_models_description: Option<&str>,
    model_catalog_in_context: bool,
    inherited_model_guidance: Option<&str>,
    return_value_description: &str,
    usage_hint_text: Option<String>,
) -> String {
    let model_catalog_guidance = inherited_model_guidance
        .map(|_| SPAWN_AGENT_MODEL_CATALOG_GUIDANCE)
        .unwrap_or_default();
    let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();

    let tool_description = if model_catalog_in_context {
        format!(
            r#"
        Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}
{model_catalog_guidance}"#
        )
    } else {
        let available_models_description = available_models_description.unwrap_or_default();
        format!(
            r#"
        {available_models_description}
        Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}"#
        )
    };

    if let Some(usage_hint_text) = usage_hint_text {
        return format!(
            r#"
        {tool_description}
{usage_hint_text}"#
        );
    }
    let agent_role_usage_hint = if model_catalog_in_context {
        ""
    } else if available_models_description.is_some() {
        "\nAgent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself."
    } else {
        "\n"
    };
    format!(
        r#"
        {tool_description}
This spawn_agent tool provides you access to sub-agents that inherit your current model by default. Do not set the `model` field unless the user explicitly asks for a different model. You should follow the rules and guidelines below to use this tool.

Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work.
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.{agent_role_usage_hint}

### When to delegate vs. do the subtask yourself
- First, quickly analyze the overall user task and form a succinct high-level plan. Identify which tasks are immediate blockers on the critical path, and which tasks are sidecar tasks that are needed but can run in parallel without blocking the next local step. As part of that plan, explicitly decide what immediate task you should do locally right now. Do this planning step before delegating to agents so you do not hand off the immediate blocking task to a submodel and then waste time waiting on it.
- Use a subagent when a subtask is easy enough for it to handle and can run in parallel with your local work. Prefer delegating concrete, bounded sidecar tasks that materially advance the main task without blocking your immediate next local step.
- Do not delegate urgent blocking work when your immediate next step depends on that result. If the very next action is blocked on that task, the main rollout should usually do it locally to keep the critical path moving.
- Keep work local when the subtask is too difficult to delegate well and when it is tightly coupled, urgent, or likely to block your immediate next step.

### Designing delegated subtasks
- Subtasks must be concrete, well-defined, and self-contained.
- Delegated subtasks must materially advance the main task.
- Do not duplicate work between the main rollout and delegated subtasks.
- Avoid issuing multiple delegate calls on the same unresolved thread unless the new delegated task is genuinely different and necessary.
- Narrow the delegated ask to the concrete output you need next.
- For coding tasks, prefer delegating concrete code-change worker subtasks over read-only explorer analysis when the subagent can make a bounded patch in a clear write scope.
- When delegating coding work, instruct the submodel to edit files directly in its forked workspace and list the file paths it changed in the final answer.
- For code-edit subtasks, decompose work so each delegated task has a disjoint write set.

### After you delegate
- Call wait_agent very sparingly. Only call wait_agent when you need the result immediately for the next critical-path step and you are blocked until it returns.
- Do not redo delegated subagent tasks yourself; focus on integrating results or tackling non-overlapping work.
- While the subagent is running in the background, do meaningful non-overlapping work immediately.
- Do not repeatedly wait by reflex.
- When a delegated coding task returns, quickly review the uploaded changes, then integrate or refine them.

### Parallel delegation patterns
- Run multiple independent information-seeking subtasks in parallel when you have distinct questions that can be answered independently.
- Split implementation into disjoint codebase slices and spawn multiple agents for them in parallel when the write scopes do not overlap.
- Delegate verification only when it can run in parallel with ongoing implementation and is likely to catch a concrete risk before final integration.
- The key is to find opportunities to spawn multiple independent subtasks in parallel within the same round, while ensuring each subtask is well-defined, self-contained, and materially advances the main task."#
    )
}

fn spawn_agent_tool_description_v2(
    available_models_description: Option<&str>,
    model_catalog_in_context: bool,
    model_catalog_guidance: Option<&str>,
    inherited_model_guidance: Option<&str>,
    usage_hint_text: Option<String>,
    description: Option<&str>,
) -> String {
    let model_catalog_guidance = model_catalog_guidance.unwrap_or_default();
    let inherited_model_guidance = inherited_model_guidance.unwrap_or_default();

    let (catalog_prefix, catalog_suffix) = if model_catalog_in_context {
        (String::new(), format!("\n{model_catalog_guidance}"))
    } else {
        let available_models_description = available_models_description.unwrap_or_default();
        (
            format!("        {available_models_description}\n"),
            String::new(),
        )
    };
    let tool_description = if let Some(description) = description {
        format!(
            r#"
{catalog_prefix}        {description}
{inherited_model_guidance}{catalog_suffix}"#
        )
    } else {
        format!(
            r#"
{catalog_prefix}        Spawns an agent to work on the specified task. If your current task is `/root/task1` and you spawn_agent with task_name "task_3" the agent will have canonical task name `/root/task1/task_3`.
You are then able to refer to this agent as `task_3` or `/root/task1/task_3` interchangeably. However an agent `/root/task2/task_3` would only be able to communicate with this agent via its canonical name `/root/task1/task_3`.
The spawned agent will have the same tools as you and the ability to spawn its own subagents.
{inherited_model_guidance}{catalog_suffix}
It will be able to send you and other running agents messages, and its final answer will be provided to you when it finishes.
The new agent's canonical task name will be provided to it along with the message.

Note that passing `fork_turns="none"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns="all"` will provide the subagent with all surrounding context."#
        )
    };

    if let Some(usage_hint_text) = usage_hint_text {
        return format!(
            r#"
        {tool_description}
{usage_hint_text}"#
        );
    }
    tool_description
}

fn spawn_agent_models_description(
    models: &[ModelPreset],
    multi_agent_version: MultiAgentVersion,
) -> String {
    let visible_models: Vec<&ModelPreset> = models
        .iter()
        .filter(|model| model.show_in_picker)
        .filter(|model| model_supports_multi_agent_backend(model, multi_agent_version))
        .take(MAX_SPAWN_AGENT_MODEL_OVERRIDES)
        .collect();
    if visible_models.is_empty() {
        return "No picker-visible model overrides are currently loaded.".to_string();
    }

    let model_descriptions = visible_models
        .into_iter()
        .map(|model| {
            let default_reasoning_effort = &model.default_reasoning_effort;
            let efforts = model
                .supported_reasoning_efforts
                .iter()
                .map(|preset| {
                    let effort = preset.effort.as_str();
                    let effort = match effort
                        .char_indices()
                        .nth(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION)
                    {
                        Some((index, _)) => &effort[..index],
                        None => effort,
                    };
                    if &preset.effort == default_reasoning_effort {
                        format!("{effort} (default)")
                    } else {
                        effort.to_string()
                    }
                })
                .collect::<Vec<_>>()
                .join(", ");
            let reasoning_efforts_suffix = if efforts.is_empty() {
                String::new()
            } else {
                format!(" Reasoning efforts: {efforts}.")
            };
            let service_tiers = model
                .service_tiers
                .iter()
                .map(|tier| tier.id.as_str())
                .collect::<Vec<_>>()
                .join(", ");
            let service_tiers_suffix = if service_tiers.is_empty() {
                String::new()
            } else {
                format!(" Service tiers: {service_tiers}.")
            };
            let model_slug = &model.model;
            let description = &model.description;
            format!(
                "- `{model_slug}`: {description}{reasoning_efforts_suffix}{service_tiers_suffix}"
            )
        })
        .collect::<Vec<_>>()
        .join("\n");
    format!(
        "Available model overrides (optional; inherited parent model is preferred):\n{model_descriptions}"
    )
}

fn wait_agent_tool_parameters_v1(options: WaitAgentTimeoutOptions) -> JsonSchema {
    let properties = BTreeMap::from([
        (
            "targets".to_string(),
            JsonSchema::array(
                JsonSchema::string(/*description*/ None),
                Some(
                    "Agent ids to wait on. Pass multiple ids to wait for whichever finishes first."
                        .to_string(),
                ),
            ),
        ),
        (
            "timeout_ms".to_string(),
            JsonSchema::number(Some(format!(
                "Timeout in milliseconds. Defaults to {}, min {}, max {}. Prefer longer waits (minutes) to avoid busy polling.",
                options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
            ))),
        ),
    ]);

    JsonSchema::object(
        properties,
        Some(vec!["targets".to_string()]),
        Some(false.into()),
    )
}

fn wait_agent_tool_parameters_v2(options: WaitAgentTimeoutOptions) -> JsonSchema {
    let properties = BTreeMap::from([(
        "timeout_ms".to_string(),
        JsonSchema::number(Some(format!(
            "Timeout in milliseconds. Defaults to {}, min {}, max {}.",
            options.default_timeout_ms, options.min_timeout_ms, options.max_timeout_ms,
        ))),
    )]);

    JsonSchema::object(properties, /*required*/ None, Some(false.into()))
}

#[cfg(test)]
#[path = "multi_agents_spec_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/multi_agents_common.rs
use crate::agent::types::SpawnAgentForkMode;
use crate::config::DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
use crate::config::HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use codex_otel::SessionTelemetry;
use codex_protocol::AgentPath;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
use serde::Serialize;
use serde_json::Value as JsonValue;

/// Minimum wait timeout to prevent tight polling loops from burning CPU.
pub(crate) const MIN_WAIT_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS;
pub(crate) const DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000;
pub(crate) const MAX_WAIT_TIMEOUT_MS: i64 = HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS;
pub(crate) fn function_arguments(payload: ToolPayload) -> Result<String, FunctionCallError> {
    match payload {
        ToolPayload::Function { arguments } => Ok(arguments),
        _ => Err(FunctionCallError::RespondToModel(
            "collab handler received unsupported payload".to_string(),
        )),
    }
}

pub(crate) fn tool_output_json_text<T>(value: &T, tool_name: &str) -> String
where
    T: Serialize,
{
    serde_json::to_string(value).unwrap_or_else(|err| {
        JsonValue::String(format!("failed to serialize {tool_name} result: {err}")).to_string()
    })
}

pub(crate) fn tool_output_response_item<T>(
    call_id: &str,
    payload: &ToolPayload,
    value: &T,
    success: Option<bool>,
    tool_name: &str,
) -> ResponseInputItem
where
    T: Serialize,
{
    FunctionToolOutput::from_text(tool_output_json_text(value, tool_name), success)
        .to_response_item(call_id, payload)
}

pub(crate) fn tool_output_code_mode_result<T>(value: &T, tool_name: &str) -> JsonValue
where
    T: Serialize,
{
    serde_json::to_value(value).unwrap_or_else(|err| {
        JsonValue::String(format!("failed to serialize {tool_name} result: {err}"))
    })
}

pub(crate) fn collab_spawn_error(err: CodexErr) -> FunctionCallError {
    match err.details() {
        CodexErrorDetails::UnsupportedOperation(message) if message == "thread manager dropped" => {
            FunctionCallError::RespondToModel("collab manager unavailable".to_string())
        }
        CodexErrorDetails::UnsupportedOperation(message) => {
            FunctionCallError::RespondToModel(message.clone())
        }
        _ => FunctionCallError::RespondToModel(format!("collab spawn failed: {err}")),
    }
}

pub(crate) fn record_collab_spawn_failure(
    telemetry: &SessionTelemetry,
    product_sku: Option<&str>,
    err: &CodexErr,
    call_id: &str,
    turn_id: &str,
    fork_mode: Option<&SpawnAgentForkMode>,
    multi_agent_version: MultiAgentVersion,
) {
    let reason = match err.details() {
        CodexErrorDetails::AgentLimitReached { .. } => "limit_reached",
        CodexErrorDetails::InvalidRequest(_) => "invalid_request",
        CodexErrorDetails::ThreadNotFound(_) => "thread_not_found",
        CodexErrorDetails::UnsupportedOperation(_) => "unsupported_operation",
        _ => "internal",
    };
    let fork_mode = match fork_mode {
        None => "none",
        Some(SpawnAgentForkMode::FullHistory) => "all",
    };
    let multi_agent_version = match multi_agent_version {
        MultiAgentVersion::Disabled => "disabled",
        MultiAgentVersion::V1 => "v1",
        MultiAgentVersion::V2 => "v2",
    };
    telemetry
        .clone()
        .with_product_sku(product_sku)
        .record_multi_agent_spawn_failure(
            reason,
            err,
            call_id,
            turn_id,
            fork_mode,
            multi_agent_version,
        );
}

pub(crate) fn collab_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
    match err.details() {
        CodexErrorDetails::ThreadNotFound(id) => {
            FunctionCallError::RespondToModel(format!("agent with id {id} not found"))
        }
        CodexErrorDetails::InternalAgentDied => {
            FunctionCallError::RespondToModel(format!("agent with id {agent_id} is closed"))
        }
        CodexErrorDetails::UnsupportedOperation(_) => {
            FunctionCallError::RespondToModel("collab manager unavailable".to_string())
        }
        _ => FunctionCallError::RespondToModel(format!("collab tool failed: {err}")),
    }
}

/// Keeps V2 request validation distinct from a dropped local manager.
pub(crate) fn collab_v2_agent_error(agent_id: ThreadId, err: CodexErr) -> FunctionCallError {
    match err.details() {
        CodexErrorDetails::UnsupportedOperation(message) if message != "thread manager dropped" => {
            FunctionCallError::RespondToModel(message.clone())
        }
        _ => collab_agent_error(agent_id, err),
    }
}

pub(crate) fn thread_spawn_source(
    parent_thread_id: ThreadId,
    parent_session_source: &SessionSource,
    depth: i32,
    agent_role: Option<&str>,
    task_name: Option<String>,
) -> Result<SessionSource, FunctionCallError> {
    let agent_path = task_name
        .as_deref()
        .map(|task_name| {
            parent_session_source
                .get_agent_path()
                .unwrap_or_else(AgentPath::root)
                .join(task_name)
                .map_err(FunctionCallError::RespondToModel)
        })
        .transpose()?;
    Ok(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
        parent_thread_id,
        depth,
        agent_path,
        agent_nickname: None,
        agent_role: agent_role.map(str::to_string),
    }))
}

pub(crate) fn parse_collab_input(
    message: Option<String>,
    items: Option<Vec<UserInput>>,
) -> Result<Vec<UserInput>, FunctionCallError> {
    match (message, items) {
        (Some(_), Some(_)) => Err(FunctionCallError::RespondToModel(
            "Provide either message or items, but not both".to_string(),
        )),
        (None, None) => Err(FunctionCallError::RespondToModel(
            "Provide one of: message or items".to_string(),
        )),
        (Some(message), None) => {
            if message.trim().is_empty() {
                return Err(FunctionCallError::RespondToModel(
                    "Empty message can't be sent to an agent".to_string(),
                ));
            }
            Ok(vec![UserInput::Text {
                text: message,
                text_elements: Vec::new(),
            }])
        }
        (None, Some(items)) => {
            if items.is_empty() {
                return Err(FunctionCallError::RespondToModel(
                    "Items can't be empty".to_string(),
                ));
            }
            Ok(items)
        }
    }
}
codex-rs/core/src/tools/handlers/multi_agents.rs
//! Implements the collaboration tool surface for spawning and managing sub-agents.
//!
//! This handler translates model tool calls into `AgentControl` operations and keeps spawned
//! agents aligned with the live turn that created them. Sub-agents start from the turn's effective
//! config, inherit runtime-only state such as provider, approval policy, sandbox, and cwd, and
//! then optionally layer role-specific config on top.

use crate::agent::AgentStatus;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
pub(crate) use crate::tools::handlers::multi_agents_common::*;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::CollabAgentRef;
use codex_protocol::user_input::UserInput;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSearchSourceInfo;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;

const MULTI_AGENT_TOOL_SEARCH_SOURCE_NAME: &str = "Multi-agent tools";
const MULTI_AGENT_TOOL_SEARCH_SOURCE_DESCRIPTION: &str = "Spawn and manage sub-agents.";

pub(crate) fn parse_agent_id_target(target: &str) -> Result<ThreadId, FunctionCallError> {
    ThreadId::from_string(target).map_err(|err| {
        FunctionCallError::RespondToModel(format!("invalid agent id {target}: {err:?}"))
    })
}

pub(crate) fn parse_agent_id_targets(
    targets: Vec<String>,
) -> Result<Vec<ThreadId>, FunctionCallError> {
    if targets.is_empty() {
        return Err(FunctionCallError::RespondToModel(
            "agent ids must be non-empty".to_string(),
        ));
    }

    targets
        .into_iter()
        .map(|target| parse_agent_id_target(&target))
        .collect()
}

fn multi_agent_tool_search_info(
    search_text: &str,
    spec: codex_tools::ToolSpec,
) -> Option<ToolSearchInfo> {
    ToolSearchInfo::from_spec(
        search_text.to_string(),
        spec,
        Some(ToolSearchSourceInfo {
            name: MULTI_AGENT_TOOL_SEARCH_SOURCE_NAME.to_string(),
            description: Some(MULTI_AGENT_TOOL_SEARCH_SOURCE_DESCRIPTION.to_string()),
        }),
    )
}

pub(crate) use close_agent::Handler as CloseAgentHandler;
pub(crate) use resume_agent::Handler as ResumeAgentHandler;
pub(crate) use send_input::Handler as SendInputHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;

pub(crate) mod close_agent;
mod resume_agent;
mod send_input;
mod spawn;
pub(crate) mod wait;

pub(crate) fn collab_tool_call_status(
    status: &AgentStatus,
    receiver_thread_id: Option<ThreadId>,
) -> CollabAgentToolCallStatus {
    match status {
        AgentStatus::Errored(_) | AgentStatus::NotFound => CollabAgentToolCallStatus::Failed,
        _ if receiver_thread_id.is_some() => CollabAgentToolCallStatus::Completed,
        _ => CollabAgentToolCallStatus::Failed,
    }
}

#[cfg(test)]
#[path = "multi_agents_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/multi_agents/spawn.rs
use super::*;
use crate::agent::api::AgentInput;
use crate::agent::api::SpawnRequest;
use crate::agent::child_config::SpawnConfigOptions;
use crate::agent::child_config::SpawnConfigVersion;
use crate::agent::child_config::prepare_agent_spawn_config;
use crate::agent::control::render_input_preview;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::types::SpawnAgentForkMode;
use crate::agent::types::SpawnAgentOptions;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v1;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ToolSpec;

#[derive(Default)]
pub(crate) struct Handler {
    options: SpawnAgentToolOptions,
}

impl Handler {
    pub(crate) fn new(options: SpawnAgentToolOptions) -> Self {
        Self { options }
    }
}

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "spawn_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_spawn_agent_tool_v1(self.options.clone())
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        multi_agent_tool_search_info(
            "spawn_agent spawn agent subagent sub-agent delegate delegation parallel work worker explorer no-apps fork model reasoning",
            self.spec(),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move { handle_spawn_agent(invocation).await.map(boxed_tool_output) })
    }
}

async fn handle_spawn_agent(
    invocation: ToolInvocation,
) -> Result<SpawnAgentResult, FunctionCallError> {
    let ToolInvocation {
        session,
        step_context,
        payload,
        call_id,
        ..
    } = invocation;
    let turn = &step_context.turn;
    let arguments = function_arguments(payload)?;
    let args: SpawnAgentArgs = parse_arguments(&arguments)?;
    let role_name = args
        .agent_type
        .as_deref()
        .map(str::trim)
        .filter(|role| !role.is_empty());
    let input_items = parse_collab_input(args.message, args.items)?;
    let prompt = render_input_preview(&input_items);
    let session_source = turn.session_source.clone();
    let child_depth = next_thread_spawn_depth(&session_source);
    let max_depth = turn.config.agent_max_depth;
    if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
        return Err(FunctionCallError::RespondToModel(
            "Agent depth limit reached. Solve the task yourself.".to_string(),
        ));
    }
    session
        .emit_turn_item_started(
            turn,
            &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id.clone(),
                tool: CollabAgentTool::SpawnAgent,
                status: CollabAgentToolCallStatus::InProgress,
                sender_thread_id: session.thread_id,
                receiver_thread_ids: Vec::new(),
                receiver_agents: Vec::new(),
                prompt: Some(prompt.clone()),
                model: Some(args.model.clone().unwrap_or_default()),
                reasoning_effort: Some(args.reasoning_effort.clone().unwrap_or_default()),
                agents_states: Default::default(),
            }),
        )
        .await;
    let prepared = prepare_agent_spawn_config(
        &session,
        step_context.as_ref(),
        SpawnConfigOptions {
            version: SpawnConfigVersion::V1,
            full_history_fork: args.fork_context,
            role_name,
            model: args.model.as_deref(),
            reasoning_effort: args.reasoning_effort.clone(),
        },
    )
    .await
    .map_err(FunctionCallError::RespondToModel)?;
    let config = prepared.config;
    let fork_mode = args.fork_context.then_some(SpawnAgentForkMode::FullHistory);
    let result = session
        .services
        .agent_control
        .spawn(SpawnRequest {
            caller: session.thread_id,
            config,
            input: AgentInput::UserInput(input_items),
            source: thread_spawn_source(
                session.thread_id,
                &turn.session_source,
                child_depth,
                prepared.role_name.as_deref(),
                /*task_name*/ None,
            )?,
            options: SpawnAgentOptions {
                fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
                fork_mode: fork_mode.clone(),
                parent_thread_id: Some(session.thread_id),
                parent_turn_id: Some(turn.sub_id.clone()),
                root_turn_id: turn.turn_metadata_state.root_turn_id(),
                turn_trigger: turn.turn_metadata_state.current_turn_trigger(),
                environments: Some(step_context.environments.clone()),
                disabled_plugin_ids: Some(turn.disabled_plugin_ids.clone()),
                multi_agent_v2_usage_hints: None,
                cyber_access_program: turn.cyber_access_program,
            },
        })
        .await
        .map_err(|err| {
            record_collab_spawn_failure(
                &turn.session_telemetry,
                turn.config.apps_mcp_product_sku.as_deref(),
                &err,
                &call_id,
                &turn.sub_id,
                fork_mode.as_ref(),
                MultiAgentVersion::V1,
            );
            collab_spawn_error(err)
        });
    let (new_thread_id, status) = match &result {
        Ok((spawned_agent, _)) => (Some(spawned_agent.thread_id), spawned_agent.status.clone()),
        Err(_) => (None, AgentStatus::NotFound),
    };
    let agent_snapshot = result.as_ref().ok().map(|(_, config)| config);
    let new_agent_nickname =
        agent_snapshot.and_then(|snapshot| snapshot.session_source.get_nickname());
    let new_agent_role =
        agent_snapshot.and_then(|snapshot| snapshot.session_source.get_agent_role());
    let effective_model = agent_snapshot
        .map(|snapshot| snapshot.model.clone())
        .unwrap_or_else(|| args.model.clone().unwrap_or_default());
    let effective_reasoning_effort = agent_snapshot
        .and_then(|snapshot| snapshot.reasoning_effort.clone())
        .unwrap_or(args.reasoning_effort.unwrap_or_default());
    let nickname = new_agent_nickname.clone();
    let receiver_thread_ids = new_thread_id.into_iter().collect();
    let receiver_agents = new_thread_id
        .map(|thread_id| CollabAgentRef {
            thread_id,
            agent_nickname: new_agent_nickname,
            agent_role: new_agent_role,
        })
        .into_iter()
        .collect();
    let agents_states = new_thread_id
        .map(|thread_id| [(thread_id, status.clone())].into_iter().collect())
        .unwrap_or_default();
    session
        .emit_turn_item_completed(
            turn,
            TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id,
                tool: CollabAgentTool::SpawnAgent,
                status: collab_tool_call_status(&status, new_thread_id),
                sender_thread_id: session.thread_id,
                receiver_thread_ids,
                receiver_agents,
                prompt: Some(prompt),
                model: Some(effective_model),
                reasoning_effort: Some(effective_reasoning_effort),
                agents_states,
            }),
        )
        .await;
    let (spawned_agent, _) = result?;
    let new_thread_id = spawned_agent.thread_id;
    let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
    turn.session_telemetry.counter(
        "codex.multi_agent.spawn",
        /*inc*/ 1,
        &[("role", role_tag), ("version", "v1")],
    );

    Ok(SpawnAgentResult {
        agent_id: new_thread_id.to_string(),
        nickname,
    })
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
struct SpawnAgentArgs {
    message: Option<String>,
    items: Option<Vec<UserInput>>,
    agent_type: Option<String>,
    model: Option<String>,
    reasoning_effort: Option<ReasoningEffort>,
    #[serde(default)]
    fork_context: bool,
}

#[derive(Debug, Serialize)]
pub(crate) struct SpawnAgentResult {
    agent_id: String,
    nickname: Option<String>,
}

impl ToolOutput for SpawnAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "spawn_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "spawn_agent")
    }
}
codex-rs/core/src/tools/handlers/multi_agents/send_input.rs
use super::*;
use crate::agent::api::AgentTarget;
use crate::agent::child_config::build_agent_resume_config;
use crate::agent::control::render_input_preview;
use crate::tools::handlers::multi_agents_spec::create_send_input_tool_v1;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "send_input")
    }

    fn spec(&self) -> ToolSpec {
        create_send_input_tool_v1()
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        multi_agent_tool_search_info(
            "send_input send message existing agent subagent follow up interrupt redirect queue target",
            self.spec(),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            payload,
            call_id,
            ..
        } = invocation;
        let arguments = function_arguments(payload)?;
        let args: SendInputArgs = parse_arguments(&arguments)?;
        let receiver_thread_id = parse_agent_id_target(&args.target)?;
        let input_items = parse_collab_input(args.message, args.items)?;
        let prompt = render_input_preview(&input_items);
        let local_agent_control = session
            .services
            .local_agent_runtime
            .control(session.session_id());
        let receiver_agent = local_agent_control.get_agent_metadata(receiver_thread_id);
        let residency_pin = if receiver_agent.is_some() {
            let resume_config = build_agent_resume_config(turn.as_ref())
                .map_err(FunctionCallError::RespondToModel)?;
            local_agent_control
                .ensure_v2_agent_loaded(resume_config, receiver_thread_id, /*parent*/ None)
                .await
                .map_err(|err| collab_agent_error(receiver_thread_id, err))?
        } else {
            None
        };
        let receiver_agent = receiver_agent.unwrap_or_default();
        if args.interrupt {
            session
                .services
                .agent_control
                .interrupt(
                    session.thread_id,
                    AgentTarget::Id(receiver_thread_id),
                    MultiAgentVersion::V1,
                )
                .await
                .map_err(|err| collab_agent_error(receiver_thread_id, err))?;
        }
        session
            .emit_turn_item_started(
                &turn,
                &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id.clone(),
                    tool: CollabAgentTool::SendInput,
                    status: CollabAgentToolCallStatus::InProgress,
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: vec![receiver_thread_id],
                    receiver_agents: Vec::new(),
                    prompt: Some(prompt.clone()),
                    model: None,
                    reasoning_effort: None,
                    agents_states: Default::default(),
                }),
            )
            .await;
        let result = local_agent_control
            .send_input(
                receiver_thread_id,
                input_items,
                crate::TurnStartOptions {
                    parent_turn_id: Some(turn.sub_id.clone()),
                    root_turn_id: turn.turn_metadata_state.root_turn_id(),
                    turn_trigger: turn.turn_metadata_state.current_turn_trigger(),
                    cyber_access_program: turn.cyber_access_program,
                    ..Default::default()
                },
            )
            .await
            .map_err(|err| collab_agent_error(receiver_thread_id, err));
        drop(residency_pin);
        let status = local_agent_control.get_status(receiver_thread_id).await;
        session
            .emit_turn_item_completed(
                &turn,
                TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id,
                    tool: CollabAgentTool::SendInput,
                    status: collab_tool_call_status(&status, Some(receiver_thread_id)),
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: vec![receiver_thread_id],
                    receiver_agents: vec![CollabAgentRef {
                        thread_id: receiver_thread_id,
                        agent_nickname: receiver_agent.agent_nickname,
                        agent_role: receiver_agent.agent_role,
                    }],
                    prompt: Some(prompt),
                    model: None,
                    reasoning_effort: None,
                    agents_states: [(receiver_thread_id, status)].into_iter().collect(),
                }),
            )
            .await;
        let submission_id = result?;

        Ok(boxed_tool_output(SendInputResult { submission_id }))
    }
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
struct SendInputArgs {
    target: String,
    message: Option<String>,
    items: Option<Vec<UserInput>>,
    #[serde(default)]
    interrupt: bool,
}

#[derive(Debug, Serialize)]
pub(crate) struct SendInputResult {
    submission_id: String,
}

impl ToolOutput for SendInputResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "send_input")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "send_input")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "send_input")
    }
}
codex-rs/core/src/tools/handlers/multi_agents/wait.rs
use super::*;
use crate::agent::control::StatusSubscription;
use crate::agent::status::is_final;
use crate::session::session::Session;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v1;
use codex_protocol::error::CodexErr;
use codex_protocol::error::CodexErrorDetails;
use codex_tools::ToolSpec;
use futures::FutureExt;
use futures::StreamExt;
use futures::stream::FuturesUnordered;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;

use tokio::time::timeout_at;

#[derive(Default)]
pub(crate) struct Handler {
    options: WaitAgentTimeoutOptions,
}

impl Handler {
    pub(crate) fn new(options: WaitAgentTimeoutOptions) -> Self {
        Self { options }
    }
}

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "wait_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_wait_agent_tool_v1(self.options)
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        multi_agent_tool_search_info(
            "wait_agent wait agent subagent status final result complete timeout targets",
            self.spec(),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            payload,
            call_id,
            ..
        } = invocation;
        let arguments = function_arguments(payload)?;
        let args: WaitArgs = parse_arguments(&arguments)?;
        let receiver_thread_ids = parse_agent_id_targets(args.targets)?;
        let local_agent_control = session
            .services
            .local_agent_runtime
            .control(session.session_id());
        let mut receiver_agents = Vec::with_capacity(receiver_thread_ids.len());
        let mut target_by_thread_id = HashMap::with_capacity(receiver_thread_ids.len());
        for receiver_thread_id in &receiver_thread_ids {
            let agent_metadata = local_agent_control
                .get_agent_metadata(*receiver_thread_id)
                .unwrap_or_default();
            target_by_thread_id.insert(
                *receiver_thread_id,
                agent_metadata
                    .agent_path
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| receiver_thread_id.to_string()),
            );
            receiver_agents.push(CollabAgentRef {
                thread_id: *receiver_thread_id,
                agent_nickname: agent_metadata.agent_nickname,
                agent_role: agent_metadata.agent_role,
            });
        }

        let timeout_ms = args.timeout_ms.unwrap_or(DEFAULT_WAIT_TIMEOUT_MS);
        let timeout_ms = match timeout_ms {
            ms if ms <= 0 => {
                return Err(FunctionCallError::RespondToModel(
                    "timeout_ms must be greater than zero".to_owned(),
                ));
            }
            ms => ms.clamp(MIN_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
        };

        session
            .emit_turn_item_started(
                &turn,
                &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id.clone(),
                    tool: CollabAgentTool::Wait,
                    status: CollabAgentToolCallStatus::InProgress,
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: receiver_thread_ids.clone(),
                    receiver_agents: receiver_agents.clone(),
                    prompt: None,
                    model: None,
                    reasoning_effort: None,
                    agents_states: Default::default(),
                }),
            )
            .await;

        let mut status_rxs = Vec::with_capacity(receiver_thread_ids.len());
        let mut initial_final_statuses = Vec::new();
        for id in &receiver_thread_ids {
            let subscription = async {
                let mut updates = local_agent_control.subscribe_status(*id).await?;
                let initial = updates
                    .next()
                    .await
                    .transpose()?
                    .ok_or(CodexErr::InternalAgentDied)?;
                Ok::<_, CodexErr>((initial, updates))
            }
            .await;
            match subscription {
                Ok((initial, updates)) => {
                    let status = initial.status().cloned().unwrap_or(AgentStatus::NotFound);
                    if is_final(&status) {
                        initial_final_statuses.push((*id, status));
                    }
                    status_rxs.push((*id, updates));
                }
                Err(err) if matches!(err.details(), CodexErrorDetails::ThreadNotFound(_)) => {
                    initial_final_statuses.push((*id, AgentStatus::NotFound));
                }
                Err(err) => {
                    let mut statuses = HashMap::with_capacity(1);
                    statuses.insert(*id, local_agent_control.get_status(*id).await);
                    session
                        .emit_turn_item_completed(
                            &turn,
                            TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                                id: call_id.clone(),
                                tool: CollabAgentTool::Wait,
                                status: wait_tool_call_status(&statuses),
                                sender_thread_id: session.thread_id,
                                receiver_thread_ids: statuses.keys().copied().collect(),
                                receiver_agents: wait_receiver_agents(&statuses, &receiver_agents),
                                prompt: None,
                                model: None,
                                reasoning_effort: None,
                                agents_states: statuses,
                            }),
                        )
                        .await;
                    return Err(collab_agent_error(*id, err));
                }
            }
        }

        let statuses = if !initial_final_statuses.is_empty() {
            initial_final_statuses
        } else {
            let mut futures = FuturesUnordered::new();
            for (id, rx) in status_rxs.into_iter() {
                let session = session.clone();
                futures.push(wait_for_final_status(session, id, rx));
            }
            let mut results = Vec::new();
            let deadline = Instant::now() + Duration::from_millis(timeout_ms as u64);
            loop {
                match timeout_at(deadline, futures.next()).await {
                    Ok(Some(Ok(Some(result)))) => {
                        results.push(result);
                        break;
                    }
                    Ok(Some(Ok(None))) => continue,
                    Ok(Some(Err(err))) => return Err(err),
                    Ok(None) | Err(_) => break,
                }
            }
            if !results.is_empty() {
                loop {
                    match futures.next().now_or_never() {
                        Some(Some(Ok(Some(result)))) => results.push(result),
                        Some(Some(Ok(None))) => continue,
                        Some(Some(Err(err))) => return Err(err),
                        Some(None) | None => break,
                    }
                }
            }
            results
        };

        let timed_out = statuses.is_empty();
        let statuses_by_id = statuses.clone().into_iter().collect::<HashMap<_, _>>();
        let result = WaitAgentResult {
            status: statuses
                .into_iter()
                .filter_map(|(thread_id, status)| {
                    target_by_thread_id
                        .get(&thread_id)
                        .cloned()
                        .map(|target| (target, status))
                })
                .collect(),
            timed_out,
        };

        session
            .emit_turn_item_completed(
                &turn,
                TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id,
                    tool: CollabAgentTool::Wait,
                    status: wait_tool_call_status(&statuses_by_id),
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: statuses_by_id.keys().copied().collect(),
                    receiver_agents: wait_receiver_agents(&statuses_by_id, &receiver_agents),
                    prompt: None,
                    model: None,
                    reasoning_effort: None,
                    agents_states: statuses_by_id,
                }),
            )
            .await;

        Ok(boxed_tool_output(result))
    }
}

fn wait_tool_call_status(statuses: &HashMap<ThreadId, AgentStatus>) -> CollabAgentToolCallStatus {
    if statuses
        .values()
        .any(|status| matches!(status, AgentStatus::Errored(_) | AgentStatus::NotFound))
    {
        CollabAgentToolCallStatus::Failed
    } else {
        CollabAgentToolCallStatus::Completed
    }
}

fn wait_receiver_agents(
    statuses: &HashMap<ThreadId, AgentStatus>,
    receiver_agents: &[CollabAgentRef],
) -> Vec<CollabAgentRef> {
    if statuses.is_empty() {
        return Vec::new();
    }

    let mut agents = Vec::with_capacity(statuses.len());
    let mut seen = HashMap::with_capacity(receiver_agents.len());
    for receiver_agent in receiver_agents {
        seen.insert(receiver_agent.thread_id, ());
        if statuses.contains_key(&receiver_agent.thread_id) {
            agents.push(receiver_agent.clone());
        }
    }

    let mut extras = statuses
        .keys()
        .filter(|thread_id| !seen.contains_key(thread_id))
        .map(|thread_id| CollabAgentRef {
            thread_id: *thread_id,
            agent_nickname: None,
            agent_role: None,
        })
        .collect::<Vec<_>>();
    extras.sort_by_key(|agent| agent.thread_id.to_string());
    agents.extend(extras);
    agents
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
struct WaitArgs {
    #[serde(default)]
    targets: Vec<String>,
    timeout_ms: Option<i64>,
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitAgentResult {
    pub(crate) status: HashMap<String, AgentStatus>,
    pub(crate) timed_out: bool,
}

impl ToolOutput for WaitAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "wait_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "wait_agent")
    }
}

async fn wait_for_final_status(
    session: Arc<Session>,
    thread_id: ThreadId,
    mut updates: StatusSubscription,
) -> Result<Option<(ThreadId, AgentStatus)>, FunctionCallError> {
    while let Some(snapshot) = updates.next().await {
        let snapshot = snapshot.map_err(|err| collab_agent_error(thread_id, err))?;
        let status = snapshot.status().cloned().unwrap_or(AgentStatus::NotFound);
        if is_final(&status) {
            return Ok(Some((thread_id, status)));
        }
    }
    let latest = session
        .services
        .local_agent_runtime
        .control(session.session_id())
        .get_status(thread_id)
        .await;
    Ok(is_final(&latest).then_some((thread_id, latest)))
}
codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs
use super::*;
use crate::agent::child_config::build_agent_resume_config;
use crate::agent::next_thread_spawn_depth;
use crate::tools::handlers::multi_agents_spec::create_resume_agent_tool;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "resume_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_resume_agent_tool()
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        multi_agent_tool_search_info(
            "resume_agent resume reopen closed agent subagent thread id target",
            self.spec(),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move { handle_resume_agent(invocation).await.map(boxed_tool_output) })
    }
}

async fn handle_resume_agent(
    invocation: ToolInvocation,
) -> Result<ResumeAgentResult, FunctionCallError> {
    let ToolInvocation {
        session,
        turn,
        payload,
        call_id,
        ..
    } = invocation;
    let arguments = function_arguments(payload)?;
    let args: ResumeAgentArgs = parse_arguments(&arguments)?;
    let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| {
        FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id))
    })?;
    let local_agent_control = session
        .services
        .local_agent_runtime
        .control(session.session_id());
    let receiver_agent = local_agent_control
        .get_agent_metadata(receiver_thread_id)
        .unwrap_or_default();
    let child_depth = next_thread_spawn_depth(&turn.session_source);
    let max_depth = turn.config.agent_max_depth;
    if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
        return Err(FunctionCallError::RespondToModel(
            "Agent depth limit reached. Solve the task yourself.".to_string(),
        ));
    }

    session
        .emit_turn_item_started(
            &turn,
            &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id.clone(),
                tool: CollabAgentTool::ResumeAgent,
                status: CollabAgentToolCallStatus::InProgress,
                sender_thread_id: session.thread_id,
                receiver_thread_ids: vec![receiver_thread_id],
                receiver_agents: vec![CollabAgentRef {
                    thread_id: receiver_thread_id,
                    agent_nickname: receiver_agent.agent_nickname.clone(),
                    agent_role: receiver_agent.agent_role.clone(),
                }],
                prompt: None,
                model: None,
                reasoning_effort: None,
                agents_states: Default::default(),
            }),
        )
        .await;

    let result = async {
        let config = build_agent_resume_config(&turn).map_err(FunctionCallError::RespondToModel)?;
        let source = thread_spawn_source(
            session.thread_id(),
            &turn.session_source,
            child_depth,
            /*agent_role*/ None,
            /*task_name*/ None,
        )?;
        local_agent_control
            .resume_agent(config, receiver_thread_id, source)
            .await
            .map_err(|err| collab_agent_error(receiver_thread_id, err))
    }
    .await;
    let (status, receiver_agent, error) = match result {
        Ok((agent, _)) => (agent.status, agent.metadata, None),
        Err(err) => (
            local_agent_control.get_status(receiver_thread_id).await,
            receiver_agent,
            Some(err),
        ),
    };
    session
        .emit_turn_item_completed(
            &turn,
            TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id,
                tool: CollabAgentTool::ResumeAgent,
                status: collab_tool_call_status(&status, Some(receiver_thread_id)),
                sender_thread_id: session.thread_id(),
                receiver_thread_ids: vec![receiver_thread_id],
                receiver_agents: vec![CollabAgentRef {
                    thread_id: receiver_thread_id,
                    agent_nickname: receiver_agent.agent_nickname,
                    agent_role: receiver_agent.agent_role,
                }],
                prompt: None,
                model: None,
                reasoning_effort: None,
                agents_states: [(receiver_thread_id, status.clone())].into_iter().collect(),
            }),
        )
        .await;

    if let Some(err) = error {
        return Err(err);
    }
    turn.session_telemetry
        .counter("codex.multi_agent.resume", /*inc*/ 1, &[]);

    Ok(ResumeAgentResult { status })
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
struct ResumeAgentArgs {
    id: String,
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct ResumeAgentResult {
    pub(crate) status: AgentStatus,
}

impl ToolOutput for ResumeAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "resume_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "resume_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "resume_agent")
    }
}
codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs
use super::*;
use crate::tools::handlers::multi_agents_spec::create_close_agent_tool_v1;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::namespaced(MULTI_AGENT_V1_NAMESPACE, "close_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_close_agent_tool_v1()
    }

    fn search_info(&self) -> Option<ToolSearchInfo> {
        multi_agent_tool_search_info(
            "close_agent close shutdown stop agent subagent thread status target",
            self.spec(),
        )
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move { handle_close_agent(invocation).await.map(boxed_tool_output) })
    }
}

async fn handle_close_agent(
    invocation: ToolInvocation,
) -> Result<CloseAgentResult, FunctionCallError> {
    let ToolInvocation {
        session,
        turn,
        payload,
        call_id,
        ..
    } = invocation;
    let arguments = function_arguments(payload)?;
    let args: CloseAgentArgs = parse_arguments(&arguments)?;
    let agent_id = parse_agent_id_target(&args.target)?;
    let local_agent_control = session
        .services
        .local_agent_runtime
        .control(session.session_id());
    let receiver_agent = local_agent_control.get_agent_metadata(agent_id);
    let receiver_agent = receiver_agent.unwrap_or_default();
    session
        .emit_turn_item_started(
            &turn,
            &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id.clone(),
                tool: CollabAgentTool::CloseAgent,
                status: CollabAgentToolCallStatus::InProgress,
                sender_thread_id: session.thread_id,
                receiver_thread_ids: vec![agent_id],
                receiver_agents: Vec::new(),
                prompt: None,
                model: None,
                reasoning_effort: None,
                agents_states: Default::default(),
            }),
        )
        .await;
    // Shutdown may remove the target before a descendant fails to close.
    let previous_status = local_agent_control.get_status(agent_id).await;
    let result = local_agent_control.close_agent(agent_id).await;
    let (status, receiver_agent) = match &result {
        Ok(snapshot) => (
            snapshot.status().cloned().unwrap_or(AgentStatus::NotFound),
            snapshot.metadata().clone(),
        ),
        Err(_) => (previous_status, receiver_agent),
    };
    session
        .emit_turn_item_completed(
            &turn,
            TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                id: call_id,
                tool: CollabAgentTool::CloseAgent,
                status: collab_tool_call_status(&status, Some(agent_id)),
                sender_thread_id: session.thread_id,
                receiver_thread_ids: vec![agent_id],
                receiver_agents: vec![CollabAgentRef {
                    thread_id: agent_id,
                    agent_nickname: receiver_agent.agent_nickname,
                    agent_role: receiver_agent.agent_role,
                }],
                prompt: None,
                model: None,
                reasoning_effort: None,
                agents_states: [(agent_id, status.clone())].into_iter().collect(),
            }),
        )
        .await;
    result.map_err(|err| collab_agent_error(agent_id, err))?;

    Ok(CloseAgentResult {
        previous_status: status,
    })
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct CloseAgentResult {
    pub(crate) previous_status: AgentStatus,
}

impl ToolOutput for CloseAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "close_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "close_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "close_agent")
    }
}

#[derive(Debug, Deserialize)]
struct CloseAgentArgs {
    target: String,
}
codex-rs/core/src/tools/handlers/multi_agents_v2.rs
//! Implements the MultiAgentV2 collaboration tool surface.

use crate::agent::AgentStatus;
use crate::agent::agent_resolver::resolve_agent_target;
use crate::agent::types::AgentMessage;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::multi_agents_common::*;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_protocol::items::CollabAgentTool;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus;
use codex_protocol::items::SubAgentActivityItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::SubAgentActivityKind;
use codex_tools::ToolName;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;

pub(crate) use followup_task::Handler as FollowupTaskHandler;
pub(crate) use interrupt_agent::Handler as InterruptAgentHandler;
pub(crate) use list_agents::Handler as ListAgentsHandler;
pub(crate) use send_message::Handler as SendMessageHandler;
pub(crate) use spawn::Handler as SpawnAgentHandler;
pub(crate) use wait::Handler as WaitAgentHandler;

mod analytics;
mod followup_task;
mod interrupt_agent;
mod list_agents;
mod message_tool;
mod send_message;
mod spawn;
pub(crate) mod wait;

pub(crate) async fn emit_sub_agent_activity(
    session: &crate::session::session::Session,
    turn: &crate::session::turn_context::TurnContext,
    item: SubAgentActivityItem,
) {
    let item = TurnItem::SubAgentActivity(item);
    session.emit_turn_item_started(turn, &item).await;
    session.emit_turn_item_completed(turn, item).await;
}

fn agent_message_from_tool(
    message: String,
    source: &crate::tools::context::ToolCallSource,
) -> AgentMessage {
    if matches!(
        source,
        crate::tools::context::ToolCallSource::DirectPlaintextMessage
    ) {
        AgentMessage::Plaintext(message)
    } else {
        AgentMessage::Encrypted(message)
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs
//! Shared argument parsing and dispatch for the v2 agent messaging tools.
//!
//! `send_message` and `followup_task` share the same submission path and differ only in whether the
//! resulting `InterAgentCommunication` should wake the target immediately.

use super::analytics::ToolCallAnalytics;
use super::*;
use crate::TurnStartOptions;
use crate::agent::api::AgentInput;
use crate::agent::api::AgentTarget;
use crate::agent::api::SendRequest;
use crate::agent::child_config::build_agent_resume_config;
use crate::agent::types::MessageDeliveryMode;
use crate::tools::context::FunctionToolOutput;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
/// Input for the MultiAgentV2 `send_message` tool.
pub(crate) struct SendMessageArgs {
    pub(crate) target: String,
    pub(crate) message: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
/// Input for the MultiAgentV2 `followup_task` tool.
pub(crate) struct FollowupTaskArgs {
    pub(crate) target: String,
    pub(crate) message: String,
}

pub(super) fn message_content(message: String) -> Result<String, FunctionCallError> {
    if message.trim().is_empty() {
        return Err(FunctionCallError::RespondToModel(
            "Empty message can't be sent to an agent".to_string(),
        ));
    }
    Ok(message)
}

/// Handles the shared MultiAgentV2 message flow for both `send_message` and `followup_task`.
pub(super) async fn handle_message_string_tool(
    invocation: ToolInvocation,
    mode: MessageDeliveryMode,
    target: String,
    message: String,
    analytics: &mut ToolCallAnalytics,
) -> Result<FunctionToolOutput, FunctionCallError> {
    let message = message_content(message)?;
    let ToolInvocation {
        session,
        turn,
        call_id,
        source,
        ..
    } = invocation;
    let receiver_thread_id = resolve_agent_target(&session, &turn, &target).await?;
    analytics.set_receiver(receiver_thread_id);
    let resume_config =
        build_agent_resume_config(&turn).map_err(FunctionCallError::RespondToModel)?;
    let receipt = session
        .services
        .agent_control
        .send(SendRequest {
            caller: session.thread_id,
            target: AgentTarget::Id(receiver_thread_id),
            resume_config,
            input: AgentInput::Message {
                message: agent_message_from_tool(message, &source),
                mode,
            },
            start_options: TurnStartOptions {
                parent_turn_id: (mode == MessageDeliveryMode::TriggerTurn)
                    .then(|| turn.sub_id.clone()),
                root_turn_id: turn.turn_metadata_state.root_turn_id(),
                turn_trigger: turn.turn_metadata_state.current_turn_trigger(),
                cyber_access_program: turn.cyber_access_program,
                ..Default::default()
            },
        })
        .await
        .map_err(|err| collab_v2_agent_error(receiver_thread_id, err))?;
    let receiver_agent_path = receipt.metadata.agent_path.ok_or_else(|| {
        FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
    })?;
    emit_sub_agent_activity(
        &session,
        &turn,
        SubAgentActivityItem {
            model: None,
            reasoning_effort: None,
            id: call_id,
            agent_thread_id: receiver_thread_id,
            agent_path: receiver_agent_path,
            kind: SubAgentActivityKind::Interacted,
        },
    )
    .await;

    Ok(FunctionToolOutput::from_text(String::new(), Some(true)))
}
codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs
use super::*;
use crate::agent::api::AgentInput;
use crate::agent::api::SpawnRequest;
use crate::agent::child_config::SpawnConfigOptions;
use crate::agent::child_config::SpawnConfigVersion;
use crate::agent::child_config::prepare_agent_spawn_config;
use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::types::MessageDeliveryMode;
use crate::agent::types::SpawnAgentForkMode;
use crate::agent::types::SpawnAgentOptions;
use crate::codex_thread::ThreadConfigSnapshot;
use crate::session::multi_agents::resolve_usage_hints;
use crate::tools::handlers::multi_agents::collab_tool_call_status;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2;
use crate::tools::handlers::multi_agents_v2::message_tool::message_content;
use crate::turn_timing::now_unix_timestamp_ms;
use codex_prompts::ResolvedModelMessages;
use codex_protocol::ThreadId;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ToolSpec;
use std::num::NonZeroUsize;

#[derive(Default)]
pub(crate) struct Handler {
    options: SpawnAgentToolOptions,
    description_override: Option<String>,
}

impl Handler {
    pub(crate) fn new(
        options: SpawnAgentToolOptions,
        description_override: Option<String>,
    ) -> Self {
        Self {
            options,
            description_override,
        }
    }
}

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("spawn_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_spawn_agent_tool_v2(self.options.clone(), self.description_override.as_deref())
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let analytics = invocation.session.services.analytics_events_client.clone();
            let sender_thread_id = invocation.session.thread_id;
            let turn_id = invocation.step_context.turn.sub_id.clone();
            let call_id = invocation.call_id.clone();
            let started_at_ms = now_unix_timestamp_ms();
            let result = handle_spawn_agent(invocation).await;
            let completed_at_ms = now_unix_timestamp_ms();
            let (status, receiver_thread_ids, agents_states) = match &result {
                Ok((_, thread_id, agent_status, _)) => (
                    collab_tool_call_status(agent_status, Some(*thread_id)),
                    vec![*thread_id],
                    [(*thread_id, agent_status.clone())].into_iter().collect(),
                ),
                Err(_) => (
                    CollabAgentToolCallStatus::Failed,
                    Vec::new(),
                    Default::default(),
                ),
            };
            let agent_snapshot = result.as_ref().ok().map(|(_, _, _, snapshot)| snapshot);

            analytics.track_collab_tool_call(
                turn_id,
                CollabAgentToolCallItem {
                    id: call_id,
                    tool: CollabAgentTool::SpawnAgent,
                    status,
                    sender_thread_id,
                    receiver_thread_ids,
                    receiver_agents: Vec::new(),
                    prompt: None,
                    model: agent_snapshot.map(|snapshot| snapshot.model.clone()),
                    reasoning_effort: agent_snapshot
                        .and_then(|snapshot| snapshot.reasoning_effort.clone()),
                    agents_states,
                },
                started_at_ms,
                completed_at_ms,
            );

            result.map(|(output, _, _, _)| boxed_tool_output(output))
        })
    }
}

async fn handle_spawn_agent(
    invocation: ToolInvocation,
) -> Result<
    (
        SpawnAgentResult,
        ThreadId,
        AgentStatus,
        ThreadConfigSnapshot,
    ),
    FunctionCallError,
> {
    let ToolInvocation {
        session,
        step_context,
        payload,
        call_id,
        source,
        ..
    } = invocation;
    let turn = &step_context.turn;
    let arguments = function_arguments(payload)?;
    let args: SpawnAgentArgs = parse_arguments(&arguments)?;
    let fork_mode = args.fork_mode()?;
    let message = message_content(args.message)?;
    let role_name = args
        .agent_type
        .as_deref()
        .map(str::trim)
        .filter(|role| !role.is_empty());

    let session_source = turn.session_source.clone();
    let child_depth = next_thread_spawn_depth(&session_source);
    let prepared = prepare_agent_spawn_config(
        &session,
        step_context.as_ref(),
        SpawnConfigOptions {
            version: SpawnConfigVersion::V2,
            full_history_fork: matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)),
            role_name,
            model: args.model.as_deref(),
            reasoning_effort: args.reasoning_effort.clone(),
        },
    )
    .await
    .map_err(FunctionCallError::RespondToModel)?;
    let config = prepared.config;
    let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
    let spawn_source = thread_spawn_source(
        session.thread_id,
        &turn.session_source,
        child_depth,
        prepared.role_name.as_deref(),
        Some(args.task_name.clone()),
    )?;
    let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| {
        FunctionCallError::RespondToModel(
            "spawned agent is missing a canonical task name".to_string(),
        )
    })?;
    let multi_agent_v2_usage_hints =
        if is_full_history_fork && turn.multi_agent_version == MultiAgentVersion::V2 {
            let child_model_info = match config.model.as_deref() {
                Some(model) if model != turn.model_info().slug => Some(
                    session
                        .services
                        .models_manager
                        .get_model_info(model, &config.to_models_manager_config())
                        .await,
                ),
                _ => None,
            };
            let child_multi_agent_messages = ResolvedModelMessages::from_model(
                child_model_info.as_ref().unwrap_or(turn.model_info()),
            )
            .multi_agent();
            Some(resolve_usage_hints(
                &config.multi_agent_v2,
                child_multi_agent_messages,
                !config.update_plan_enabled && config.model_catalog.is_none(),
            ))
        } else {
            None
        };
    let (spawned_agent, agent_snapshot) = session
        .services
        .agent_control
        .spawn(SpawnRequest {
            caller: session.thread_id,
            config,
            input: AgentInput::Message {
                message: agent_message_from_tool(message, &source),
                mode: MessageDeliveryMode::TriggerTurn,
            },
            source: spawn_source,
            options: SpawnAgentOptions {
                fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
                fork_mode: fork_mode.clone(),
                parent_thread_id: Some(session.thread_id),
                parent_turn_id: Some(turn.sub_id.clone()),
                root_turn_id: turn.turn_metadata_state.root_turn_id(),
                turn_trigger: turn.turn_metadata_state.current_turn_trigger(),
                environments: Some(step_context.environments.clone()),
                disabled_plugin_ids: Some(turn.disabled_plugin_ids.clone()),
                multi_agent_v2_usage_hints,
                cyber_access_program: turn.cyber_access_program,
            },
        })
        .await
        .map_err(|err| {
            record_collab_spawn_failure(
                &turn.session_telemetry,
                turn.config.apps_mcp_product_sku.as_deref(),
                &err,
                &call_id,
                &turn.sub_id,
                fork_mode.as_ref(),
                MultiAgentVersion::V2,
            );
            collab_spawn_error(err)
        })?;
    let new_thread_id = spawned_agent.thread_id;
    let agent_status = spawned_agent.status;
    let nickname = agent_snapshot
        .session_source
        .get_nickname()
        .or(spawned_agent.metadata.agent_nickname);
    emit_sub_agent_activity(
        &session,
        turn,
        SubAgentActivityItem {
            model: Some(agent_snapshot.model.clone()),
            reasoning_effort: agent_snapshot.reasoning_effort.clone(),
            id: call_id,
            agent_thread_id: new_thread_id,
            agent_path: new_agent_path.clone(),
            kind: SubAgentActivityKind::Started,
        },
    )
    .await;
    let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME);
    turn.session_telemetry.counter(
        "codex.multi_agent.spawn",
        /*inc*/ 1,
        &[("role", role_tag), ("version", "v2")],
    );
    let task_name = String::from(new_agent_path);

    let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata;
    let output = if hide_agent_metadata {
        SpawnAgentResult::HiddenMetadata { task_name }
    } else {
        SpawnAgentResult::WithNickname {
            task_name,
            nickname,
        }
    };
    Ok((output, new_thread_id, agent_status, agent_snapshot))
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SpawnAgentArgs {
    message: String,
    task_name: String,
    agent_type: Option<String>,
    model: Option<String>,
    reasoning_effort: Option<ReasoningEffort>,
    fork_turns: Option<String>,
    fork_context: Option<bool>,
}

impl SpawnAgentArgs {
    fn fork_mode(&self) -> Result<Option<SpawnAgentForkMode>, FunctionCallError> {
        if self.fork_context.is_some() {
            return Err(FunctionCallError::RespondToModel(
                "fork_context is not supported in MultiAgentV2; use fork_turns instead".to_string(),
            ));
        }

        let fork_turns = self
            .fork_turns
            .as_deref()
            .map(str::trim)
            .filter(|fork_turns| !fork_turns.is_empty())
            .unwrap_or("all");

        if fork_turns.eq_ignore_ascii_case("none") {
            return Ok(None);
        }
        // Accept legacy turn counts without limiting the inherited history.
        if fork_turns.eq_ignore_ascii_case("all") || fork_turns.parse::<NonZeroUsize>().is_ok() {
            return Ok(Some(SpawnAgentForkMode::FullHistory));
        }

        Err(FunctionCallError::RespondToModel(
            "fork_turns must be `none` or `all`".to_string(),
        ))
    }
}

#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum SpawnAgentResult {
    WithNickname {
        task_name: String,
        nickname: Option<String>,
    },
    HiddenMetadata {
        task_name: String,
    },
}

impl ToolOutput for SpawnAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "spawn_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "spawn_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "spawn_agent")
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/send_message.rs
use super::analytics::ToolCallAnalytics;
use super::message_tool::SendMessageArgs;
use super::message_tool::handle_message_string_tool;
use super::*;
use crate::agent::types::MessageDeliveryMode;
use crate::tools::handlers::multi_agents_spec::create_send_message_tool;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("send_message")
    }

    fn spec(&self) -> ToolSpec {
        create_send_message_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::SendMessage);
            let result = self.handle_call(invocation, &mut analytics).await;
            analytics.finish(&result);
            result
        })
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
        analytics: &mut ToolCallAnalytics,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let arguments = function_arguments(invocation.payload.clone())?;
        let args: SendMessageArgs = parse_arguments(&arguments)?;
        handle_message_string_tool(
            invocation,
            MessageDeliveryMode::QueueOnly,
            args.target,
            args.message,
            analytics,
        )
        .await
        .map(boxed_tool_output)
    }
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/followup_task.rs
use super::analytics::ToolCallAnalytics;
use super::message_tool::FollowupTaskArgs;
use super::message_tool::handle_message_string_tool;
use super::*;
use crate::agent::types::MessageDeliveryMode;
use crate::tools::handlers::multi_agents_spec::create_followup_task_tool;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("followup_task")
    }

    fn spec(&self) -> ToolSpec {
        create_followup_task_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let mut analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::FollowupTask);
            let result = self.handle_call(invocation, &mut analytics).await;
            analytics.finish(&result);
            result
        })
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
        analytics: &mut ToolCallAnalytics,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let arguments = function_arguments(invocation.payload.clone())?;
        let args: FollowupTaskArgs = parse_arguments(&arguments)?;
        handle_message_string_tool(
            invocation,
            MessageDeliveryMode::TriggerTurn,
            args.target,
            args.message,
            analytics,
        )
        .await
        .map(boxed_tool_output)
    }
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/wait.rs
use super::*;
use crate::session::InputQueueActivity;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::handlers::multi_agents_spec::create_wait_agent_tool_v2;
use codex_tools::ToolSpec;
use std::collections::HashMap;
use std::time::Duration;
use tokio::time::Instant;
use tokio::time::timeout_at;

#[derive(Default)]
pub(crate) struct Handler {
    options: WaitAgentTimeoutOptions,
}

impl Handler {
    pub(crate) fn new(options: WaitAgentTimeoutOptions) -> Self {
        Self { options }
    }
}

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("wait_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_wait_agent_tool_v2(self.options)
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            payload,
            call_id,
            ..
        } = invocation;
        let arguments = function_arguments(payload)?;
        let args: WaitArgs = parse_arguments(&arguments)?;
        let min_timeout_ms = turn.config.multi_agent_v2.min_wait_timeout_ms;
        let max_timeout_ms = turn.config.multi_agent_v2.max_wait_timeout_ms;
        let default_timeout_ms = turn.config.multi_agent_v2.default_wait_timeout_ms;
        let requested_timeout_ms = args.timeout_ms;
        let timeout_ms = match requested_timeout_ms {
            Some(ms) if ms > max_timeout_ms => {
                return Err(FunctionCallError::RespondToModel(format!(
                    "timeout_ms must be at most {max_timeout_ms}"
                )));
            }
            Some(ms) => ms.max(min_timeout_ms),
            None => default_timeout_ms,
        };

        let turn_state = session
            .input_queue
            .turn_state_for_sub_id(&session.active_turn, &turn.sub_id)
            .await;
        let (mut activity_rx, pending_activity) = session
            .input_queue
            .subscribe_activity(turn_state.as_deref())
            .await;

        session
            .emit_turn_item_started(
                &turn,
                &TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id.clone(),
                    tool: CollabAgentTool::Wait,
                    status: CollabAgentToolCallStatus::InProgress,
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: Vec::new(),
                    receiver_agents: Vec::new(),
                    prompt: None,
                    model: None,
                    reasoning_effort: None,
                    agents_states: Default::default(),
                }),
            )
            .await;

        let wait_started = Instant::now();
        let deadline = wait_started + Duration::from_millis(timeout_ms as u64);
        let outcome = wait_for_activity(&mut activity_rx, pending_activity, deadline).await;
        // A completed wait may wake for a message, user input, or its timeout.
        // Dropped waits do not have an observed outcome and are not included.
        turn.session_telemetry.record_duration(
            "codex.multi_agent.wait.duration_ms",
            wait_started.elapsed(),
            &[(
                "outcome",
                match outcome {
                    WaitOutcome::MailboxActivity => "mailbox",
                    WaitOutcome::Steered => "steered",
                    WaitOutcome::TimedOut => "timed_out",
                },
            )],
        );
        let result = WaitAgentResult::from_outcome(outcome, requested_timeout_ms, timeout_ms);

        session
            .emit_turn_item_completed(
                &turn,
                TurnItem::CollabAgentToolCall(CollabAgentToolCallItem {
                    id: call_id,
                    tool: CollabAgentTool::Wait,
                    status: CollabAgentToolCallStatus::Completed,
                    sender_thread_id: session.thread_id,
                    receiver_thread_ids: Vec::new(),
                    receiver_agents: Vec::new(),
                    prompt: None,
                    model: None,
                    reasoning_effort: None,
                    agents_states: HashMap::new(),
                }),
            )
            .await;

        Ok(boxed_tool_output(result))
    }
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitArgs {
    timeout_ms: Option<i64>,
}

#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
pub(crate) struct WaitAgentResult {
    pub(crate) message: String,
    pub(crate) timed_out: bool,
}

impl WaitAgentResult {
    fn from_outcome(
        outcome: WaitOutcome,
        requested_timeout_ms: Option<i64>,
        timeout_ms: i64,
    ) -> Self {
        let message = match outcome {
            WaitOutcome::MailboxActivity => "Wait completed.",
            WaitOutcome::Steered => "Wait interrupted by new input.",
            WaitOutcome::TimedOut => "Wait timed out.",
        };
        let message = match requested_timeout_ms {
            Some(requested_timeout_ms) if requested_timeout_ms < timeout_ms => format!(
                "{message}\n\nRequested timeout of {requested_timeout_ms}ms was clamped to the minimum of {timeout_ms}ms."
            ),
            Some(_) | None => message.to_string(),
        };
        Self {
            message,
            timed_out: outcome == WaitOutcome::TimedOut,
        }
    }
}

impl ToolOutput for WaitAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "wait_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, /*success*/ None, "wait_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "wait_agent")
    }
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum WaitOutcome {
    MailboxActivity,
    Steered,
    TimedOut,
}

async fn wait_for_activity(
    activity_rx: &mut tokio::sync::watch::Receiver<InputQueueActivity>,
    pending_activity: Option<InputQueueActivity>,
    deadline: Instant,
) -> WaitOutcome {
    if let Some(activity) = pending_activity {
        return match activity {
            InputQueueActivity::Mailbox => WaitOutcome::MailboxActivity,
            InputQueueActivity::Steer => WaitOutcome::Steered,
        };
    }
    match timeout_at(deadline, activity_rx.changed()).await {
        Ok(Ok(())) => match *activity_rx.borrow_and_update() {
            InputQueueActivity::Mailbox => WaitOutcome::MailboxActivity,
            InputQueueActivity::Steer => WaitOutcome::Steered,
        },
        Ok(Err(_)) | Err(_) => WaitOutcome::TimedOut,
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/interrupt_agent.rs
use super::analytics::ToolCallAnalytics;
use super::*;
use crate::agent::api::AgentTarget;
use crate::tools::handlers::multi_agents_spec::create_interrupt_agent_tool_v2;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("interrupt_agent")
    }

    fn spec(&self) -> ToolSpec {
        create_interrupt_agent_tool_v2()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let mut analytics =
                ToolCallAnalytics::new(&invocation, CollabAgentTool::InterruptAgent);
            let result = handle_interrupt_agent(invocation, &mut analytics).await;
            analytics.finish(&result);
            result.map(boxed_tool_output)
        })
    }
}

async fn handle_interrupt_agent(
    invocation: ToolInvocation,
    analytics: &mut ToolCallAnalytics,
) -> Result<InterruptAgentResult, FunctionCallError> {
    let ToolInvocation {
        session,
        turn,
        payload,
        call_id,
        ..
    } = invocation;
    let arguments = function_arguments(payload)?;
    let args: InterruptAgentArgs = parse_arguments(&arguments)?;
    let agent_id = resolve_agent_target(&session, &turn, &args.target).await?;
    analytics.set_receiver(agent_id);
    let snapshot = session
        .services
        .agent_control
        .interrupt(
            session.thread_id,
            AgentTarget::Id(agent_id),
            MultiAgentVersion::V2,
        )
        .await
        .map_err(|err| collab_v2_agent_error(agent_id, err))?;
    let agent_path = snapshot.metadata().agent_path.clone().ok_or_else(|| {
        FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string())
    })?;
    let previous_status = snapshot.status().cloned().unwrap_or(AgentStatus::NotFound);
    emit_sub_agent_activity(
        &session,
        &turn,
        SubAgentActivityItem {
            model: None,
            reasoning_effort: None,
            id: call_id,
            agent_thread_id: agent_id,
            agent_path,
            kind: SubAgentActivityKind::Interrupted,
        },
    )
    .await;

    Ok(InterruptAgentResult { previous_status })
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct InterruptAgentArgs {
    target: String,
}

#[derive(Debug, Deserialize, Serialize)]
pub(crate) struct InterruptAgentResult {
    pub(crate) previous_status: AgentStatus,
}

impl ToolOutput for InterruptAgentResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "interrupt_agent")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "interrupt_agent")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "interrupt_agent")
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs
use super::analytics::ToolCallAnalytics;
use super::*;
use crate::tools::handlers::multi_agents_spec::create_list_agents_tool;
use codex_tools::ToolSpec;

pub(crate) struct Handler;

impl ToolExecutor<ToolInvocation> for Handler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain("list_agents")
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        true
    }

    fn spec(&self) -> ToolSpec {
        create_list_agents_tool()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(async move {
            let analytics = ToolCallAnalytics::new(&invocation, CollabAgentTool::ListAgents);
            let result = self.handle_call(invocation).await;
            analytics.finish(&result);
            result
        })
    }
}

impl Handler {
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let ToolInvocation {
            session,
            turn,
            payload,
            ..
        } = invocation;
        let arguments = function_arguments(payload)?;
        let args: ListAgentsArgs = parse_arguments(&arguments)?;
        let agents = session
            .services
            .agent_control
            .list(
                session.thread_id,
                turn.parent_thread_id,
                &turn.session_source,
                args.path_prefix.as_deref(),
            )
            .await
            .map_err(collab_spawn_error)?;

        let agents = agents
            .into_iter()
            .map(|agent| ListedAgent {
                agent_name: agent
                    .metadata
                    .agent_path
                    .as_ref()
                    .map(ToString::to_string)
                    .unwrap_or_else(|| agent.thread_id.to_string()),
                agent_status: agent.status,
            })
            .collect();
        Ok(boxed_tool_output(ListAgentsResult { agents }))
    }
}

impl CoreToolRuntime for Handler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Function { .. })
    }
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListAgentsArgs {
    path_prefix: Option<String>,
}

#[derive(Debug, Serialize)]
struct ListedAgent {
    agent_name: String,
    agent_status: AgentStatus,
}

#[derive(Debug, Serialize)]
pub(crate) struct ListAgentsResult {
    agents: Vec<ListedAgent>,
}

impl ToolOutput for ListAgentsResult {
    fn log_output(&self) -> String {
        tool_output_json_text(self, "list_agents")
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        tool_output_response_item(call_id, payload, self, Some(true), "list_agents")
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        tool_output_code_mode_result(self, "list_agents")
    }
}
codex-rs/core/src/tools/handlers/multi_agents_v2/analytics.rs
use codex_analytics::AnalyticsEventsClient;
use codex_protocol::ThreadId;
use codex_protocol::items::CollabAgentTool;
use codex_protocol::items::CollabAgentToolCallItem;
use codex_protocol::items::CollabAgentToolCallStatus;

use crate::tools::context::ToolInvocation;
use crate::turn_timing::now_unix_timestamp_ms;

/// Records private collaborator analytics without emitting another public tool item.
pub(super) struct ToolCallAnalytics {
    client: AnalyticsEventsClient,
    turn_id: String,
    item: CollabAgentToolCallItem,
    started_at_ms: i64,
}

impl ToolCallAnalytics {
    pub(super) fn new(invocation: &ToolInvocation, tool: CollabAgentTool) -> Self {
        Self {
            client: invocation.session.services.analytics_events_client.clone(),
            turn_id: invocation.turn.sub_id.clone(),
            item: CollabAgentToolCallItem {
                // Activity and analytics must use the same ID for turn-count deduplication.
                id: invocation.call_id.clone(),
                tool,
                status: CollabAgentToolCallStatus::Interrupted,
                sender_thread_id: invocation.session.thread_id,
                receiver_thread_ids: Vec::new(),
                receiver_agents: Vec::new(),
                prompt: None,
                model: None,
                reasoning_effort: None,
                agents_states: Default::default(),
            },
            started_at_ms: now_unix_timestamp_ms(),
        }
    }

    pub(super) fn set_receiver(&mut self, thread_id: ThreadId) {
        self.item.receiver_thread_ids = vec![thread_id];
    }

    pub(super) fn finish<T, E>(mut self, result: &Result<T, E>) {
        self.item.status = if result.is_ok() {
            CollabAgentToolCallStatus::Completed
        } else {
            CollabAgentToolCallStatus::Failed
        };
    }
}

impl Drop for ToolCallAnalytics {
    fn drop(&mut self) {
        self.client.track_collab_tool_call(
            self.turn_id.clone(),
            self.item.clone(),
            self.started_at_ms,
            now_unix_timestamp_ms(),
        );
    }
}

Code Mode / 调用追踪辅助

这些文件不属于最简 Python 等价稿,但对理解 Rust tool loop 很重要:它们处理把函数工具暴露成代码、等待 delegate、结果适配和审计。

codex-rs/core/src/tools/code_mode/tool_definitions.rs
//! Prepares nested tool definitions for execution and search.
//! Cached definitions stay borrowed; rendered schemas are dropped after augmentation.
//! Description order follows the session-static feature setting.

use crate::tools::registry::CoreToolRuntime;
use codex_code_mode::ToolDefinition;
use codex_tools::ToolSpec;
use std::borrow::Cow;

pub(crate) fn prepare_code_mode_tool_definitions<'a>(
    runtime: Option<&'a dyn CoreToolRuntime>,
    spec: impl FnOnce() -> Cow<'a, ToolSpec>,
    code_mode_input_schema_max_bytes: Option<usize>,
    tool_description_first: bool,
) -> Cow<'a, [ToolDefinition]> {
    if let Some(definitions) = runtime.and_then(|runtime| {
        runtime
            .cached_code_mode_definitions(code_mode_input_schema_max_bytes, tool_description_first)
    }) {
        return Cow::Borrowed(definitions);
    }

    let spec = spec();
    let mut definitions = codex_tools::collect_code_mode_tool_definitions(
        std::iter::once(spec.as_ref()),
        code_mode_input_schema_max_bytes,
        tool_description_first,
    );
    for definition in &mut definitions {
        definition.input_schema = None;
        definition.output_schema = None;
    }
    Cow::Owned(definitions)
}
codex-rs/core/src/tools/code_mode/execute_spec.rs
use codex_tools::FreeformTool;
use codex_tools::FreeformToolFormat;
use codex_tools::ToolSpec;

pub(crate) fn create_code_mode_tool(description: String) -> ToolSpec {
    const CODE_MODE_FREEFORM_GRAMMAR: &str = r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE SOURCE
plain_source: SOURCE

PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
"#;

    ToolSpec::Freeform(FreeformTool {
        name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
        description,
        defer_loading: None,
        format: FreeformToolFormat {
            r#type: "grammar".to_string(),
            syntax: "lark".to_string(),
            definition: CODE_MODE_FREEFORM_GRAMMAR.to_string(),
        },
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn create_code_mode_tool_matches_expected_spec() {
        assert_eq!(
            create_code_mode_tool("Run JavaScript code.".to_string()),
            ToolSpec::Freeform(FreeformTool {
                name: codex_code_mode::PUBLIC_TOOL_NAME.to_string(),
                description: "Run JavaScript code.".to_string(),
                defer_loading: None,
                format: FreeformToolFormat {
                    r#type: "grammar".to_string(),
                    syntax: "lark".to_string(),
                    definition: r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE SOURCE
plain_source: SOURCE

PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
"#
                    .to_string(),
                },
            })
        );
    }
}
codex-rs/core/src/tools/code_mode/execute_handler.rs
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_features::Feature;
use codex_prompts::ResolvedModelMessages;
use codex_tools::IndirectNamespacePrefixes;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use std::borrow::Cow;
use std::sync::Arc;

use super::ExecContext;
use super::PUBLIC_TOOL_NAME;
use super::handle_runtime_response;
use super::is_exec_tool_name;
use super::output::CodeModeToolOutput;
use super::prepare_code_mode_tool_definitions;
use super::telemetry::CodeModeToolCallGuard;
use super::telemetry::trace_id;

type CodeModeNestedTool = (Arc<ToolSpec>, Option<Arc<dyn CoreToolRuntime>>);

pub struct CodeModeExecuteHandler {
    spec: ToolSpec,
    nested_tool_specs: Vec<CodeModeNestedTool>,
}

impl CodeModeExecuteHandler {
    pub(crate) fn new(spec: ToolSpec, nested_tool_specs: Vec<CodeModeNestedTool>) -> Self {
        Self {
            spec,
            nested_tool_specs,
        }
    }

    async fn execute(
        &self,
        session: std::sync::Arc<crate::session::session::Session>,
        step_context: Arc<crate::session::step_context::StepContext>,
        call_id: String,
        originating_call: Option<crate::tools::context::ToolCallOrigin>,
        code: String,
        telemetry: &mut CodeModeToolCallGuard,
    ) -> Result<CodeModeToolOutput, FunctionCallError> {
        let args =
            codex_code_mode::parse_exec_source(&code).map_err(FunctionCallError::RespondToModel)?;
        let exec = ExecContext {
            session,
            turn: Arc::clone(&step_context.turn),
        };
        let code_mode_input_schema_max_bytes = step_context
            .turn
            .config
            .code_mode
            .tool_input_schema_max_bytes;
        let tool_description_first = step_context
            .turn
            .config
            .features
            .enabled(Feature::CodeModeToolDescriptionFirst);
        let mut enabled_tools = Vec::with_capacity(self.nested_tool_specs.len());
        for (spec, cached_runtime) in &self.nested_tool_specs {
            let definitions = prepare_code_mode_tool_definitions(
                cached_runtime.as_deref(),
                || Cow::Borrowed(spec.as_ref()),
                code_mode_input_schema_max_bytes,
                tool_description_first,
            );
            match definitions {
                Cow::Borrowed(definitions) => enabled_tools.extend_from_slice(definitions),
                Cow::Owned(definitions) => enabled_tools.extend(definitions),
            }
        }
        enabled_tools.sort_by(|left, right| left.name.cmp(&right.name));
        enabled_tools.dedup_by(|left, right| left.name == right.name);
        let model_messages = ResolvedModelMessages::from_model(&step_context.settings.model_info);
        IndirectNamespacePrefixes::new(
            model_messages.indirect_description_prefixes(),
            step_context.tool_router.mcp_namespaces(),
        )
        .map_err(|error| FunctionCallError::Fatal(error.to_string()))?
        .apply_code_mode(&mut enabled_tools);
        let started_at = std::time::Instant::now();
        let started_cell = exec
            .session
            .services
            .code_mode_service
            .execute(
                codex_code_mode::ExecuteRequest {
                    tool_call_id: call_id.clone(),
                    enabled_tools,
                    source: args.code.clone(),
                    yield_time_ms: args.yield_time_ms,
                    max_output_tokens: args.max_output_tokens,
                },
                Arc::clone(&step_context),
            )
            .await
            .map_err(FunctionCallError::RespondToModel)?;
        let cell_id = started_cell.cell_id.clone();
        tracing::Span::current().record("cell.id", trace_id(cell_id.as_str()));
        telemetry.cell_id = Some(cell_id.to_string());
        exec.session
            .services
            .analytics_events_client
            .track_code_mode_tool_call(codex_analytics::CodeModeToolCallFact::CellStarted {
                thread_id: exec.session.thread_id.to_string(),
                turn_id: exec.turn.sub_id.clone(),
                call_id: call_id.clone(),
                cell_id: cell_id.to_string(),
            });
        exec.session
            .services
            .executed_tool_calls
            .start_cell(&cell_id, &call_id);
        let runtime_cell_id = cell_id.to_string();
        let code_cell_trace = exec
            .session
            .services
            .rollout_thread_trace
            .start_code_cell_trace(
                exec.turn.sub_id.as_str(),
                runtime_cell_id.as_str(),
                call_id.as_str(),
                args.code.as_str(),
            );
        exec.session
            .services
            .code_mode_service
            .mark_cell_ready_for_dispatch(&cell_id, originating_call);
        let response = started_cell
            .initial_response()
            .await
            .map_err(FunctionCallError::RespondToModel)?;
        if let Some(code_mode_host_duration) = response.code_mode_host_duration() {
            telemetry.record_code_mode_host_duration(code_mode_host_duration);
        }
        // Record the raw runtime boundary. The model-visible custom-tool output
        // is produced by `handle_runtime_response` and later linked through
        // `CodeCell.output_item_ids` in the reduced trace.
        code_cell_trace.record_initial_response(&response);
        // Yielded cells keep running, so terminal lifecycle is only emitted
        // here when the first response also ended the runtime.
        if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) {
            code_cell_trace.record_ended(&response);
            exec.session
                .services
                .code_mode_service
                .finish_cell_dispatch(&cell_id);
            exec.session
                .services
                .analytics_events_client
                .track_code_mode_tool_call(codex_analytics::CodeModeToolCallFact::CellClosed {
                    thread_id: exec.session.thread_id.to_string(),
                    turn_id: exec.turn.sub_id.clone(),
                    cell_id: cell_id.to_string(),
                });
        }
        exec.session.services.elicitations.wait_until_clear().await;
        let wall_time = response
            .code_mode_host_duration()
            .unwrap_or_else(|| started_at.elapsed());
        Ok(handle_runtime_response(
            &step_context.settings.model_info,
            response,
            args.max_output_tokens,
            wall_time,
            exec.turn.config.code_mode.experimental_show_cell_overhead,
        ))
    }
}

impl ToolExecutor<ToolInvocation> for CodeModeExecuteHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(PUBLIC_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl CodeModeExecuteHandler {
    // Default to interrupted if this future is dropped; telemetry::CodeModeToolCallGuard::finish
    // overwrites this handler's captured span on explicit success or failure.
    #[tracing::instrument(
        name = "code_mode.handler.execute",
        level = "info",
        skip_all,
        fields(
            conversation.id = %invocation.session.thread_id,
            turn_id = invocation.turn.sub_id.as_str(),
            call_id = trace_id(&invocation.call_id),
            cell.id = tracing::field::Empty,
            outcome = "interrupted",
        )
    )]
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let handler_span = tracing::Span::current();
        let originating_call = invocation.originating_call().await;
        let ToolInvocation {
            session,
            turn,
            step_context,
            call_id,
            tool_name,
            payload,
            ..
        } = invocation;

        let mut telemetry = CodeModeToolCallGuard::new(
            &session,
            turn.sub_id.clone(),
            turn.turn_metadata_state.clone(),
            call_id.clone(),
            PUBLIC_TOOL_NAME,
            handler_span,
        );
        let result = match payload {
            ToolPayload::Custom { input } if is_exec_tool_name(&tool_name) => self
                .execute(
                    session,
                    step_context,
                    call_id,
                    originating_call,
                    input,
                    &mut telemetry,
                )
                .await
                .map(boxed_tool_output),
            _ => Err(FunctionCallError::RespondToModel(format!(
                "{PUBLIC_TOOL_NAME} expects raw JavaScript source text"
            ))),
        };
        telemetry.finish(
            result
                .as_ref()
                .is_ok_and(|output| output.success_for_logging()),
        );
        result
    }
}

impl CoreToolRuntime for CodeModeExecuteHandler {
    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(payload, ToolPayload::Custom { .. })
    }
}
codex-rs/core/src/tools/code_mode/wait_spec.rs
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;

pub(crate) fn create_wait_tool(
    description_override: Option<&str>,
    parameters_override: Option<&str>,
) -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "cell_id".to_string(),
            JsonSchema::string(Some("Identifier of the running exec cell.".to_string())),
        ),
        (
            "yield_time_ms".to_string(),
            JsonSchema::number(Some(
                "Wait before yielding more output. Defaults to 10000 ms.".to_string(),
            )),
        ),
        (
            "max_tokens".to_string(),
            JsonSchema::number(Some(
                "Output token budget for this wait call. Defaults to 10000 tokens.".to_string(),
            )),
        ),
        (
            "terminate".to_string(),
            JsonSchema::boolean(Some(
                "True stops the running exec cell; false or omitted waits for output.".to_string(),
            )),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: codex_code_mode::WAIT_TOOL_NAME.to_string(),
        description: description_override.map(str::to_owned).unwrap_or_else(|| {
            format!(
                "Waits on a yielded `{}` cell and returns new output or completion.\n{}",
                codex_code_mode::PUBLIC_TOOL_NAME,
                codex_code_mode::build_wait_tool_description().trim()
            )
        }),
        strict: false,
        parameters: parameters_override
            .and_then(
                |parameters| match crate::tools::catalog_parameters::parse(parameters) {
                    Ok(parameters) => Some(parameters),
                    Err(reason) => {
                        tracing::warn!(
                            tool = codex_code_mode::WAIT_TOOL_NAME,
                            reason,
                            "Invalid catalog tool parameters; using bundled parameters"
                        );
                        None
                    }
                },
            )
            .unwrap_or_else(|| {
                JsonSchema::object(
                    properties,
                    Some(vec!["cell_id".to_string()]),
                    Some(false.into()),
                )
            }),
        output_schema: None,
        defer_loading: None,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn create_wait_tool_matches_expected_spec() {
        assert_eq!(
            create_wait_tool(/*description_override*/ None, /*parameters_override*/ None),
            ToolSpec::Function(ResponsesApiTool {
                name: codex_code_mode::WAIT_TOOL_NAME.to_string(),
                description: format!(
                    "Waits on a yielded `{}` cell and returns new output or completion.\n{}",
                    codex_code_mode::PUBLIC_TOOL_NAME,
                    codex_code_mode::build_wait_tool_description().trim()
                ),
                strict: false,
                defer_loading: None,
                parameters: JsonSchema::object(
                    BTreeMap::from([
                        (
                            "cell_id".to_string(),
                            JsonSchema::string(Some(
                                "Identifier of the running exec cell.".to_string()
                            )),
                        ),
                        (
                            "max_tokens".to_string(),
                            JsonSchema::number(Some(
                                "Output token budget for this wait call. Defaults to 10000 tokens."
                                    .to_string(),
                            )),
                        ),
                        (
                            "terminate".to_string(),
                            JsonSchema::boolean(Some(
                                "True stops the running exec cell; false or omitted waits for output."
                                    .to_string(),
                            )),
                        ),
                        (
                            "yield_time_ms".to_string(),
                            JsonSchema::number(Some(
                                "Wait before yielding more output. Defaults to 10000 ms."
                                    .to_string(),
                            )),
                        ),
                    ]),
                    Some(vec!["cell_id".to_string()]),
                    Some(false.into()),
                ),
                output_schema: None,
            })
        );
    }
}
codex-rs/core/src/tools/code_mode/wait_handler.rs
use serde::Deserialize;

use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::PostToolUsePayload;
use crate::tools::registry::PreToolUsePayload;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;

use super::DEFAULT_WAIT_YIELD_TIME_MS;
use super::ExecContext;
use super::WAIT_TOOL_NAME;
use super::handle_runtime_response;
use super::telemetry::CodeModeToolCallGuard;
use super::telemetry::trace_id;
use super::wait_spec::create_wait_tool;

pub struct CodeModeWaitHandler {
    spec: ToolSpec,
}

#[derive(Debug, Deserialize)]
struct ExecWaitArgs {
    cell_id: String,
    #[serde(default = "default_wait_yield_time_ms")]
    yield_time_ms: u64,
    #[serde(default)]
    max_tokens: Option<usize>,
    #[serde(default)]
    terminate: bool,
}

fn default_wait_yield_time_ms() -> u64 {
    DEFAULT_WAIT_YIELD_TIME_MS
}

fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
where
    T: for<'de> Deserialize<'de>,
{
    serde_json::from_str(arguments).map_err(|err| {
        FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}"))
    })
}

impl ToolExecutor<ToolInvocation> for CodeModeWaitHandler {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(WAIT_TOOL_NAME)
    }

    fn spec(&self) -> ToolSpec {
        self.spec.clone()
    }

    fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a>
    where
        ToolInvocation: 'a,
    {
        Box::pin(self.handle_call(invocation))
    }
}

impl CodeModeWaitHandler {
    pub(crate) fn new(
        description_override: Option<&str>,
        parameters_override: Option<&str>,
    ) -> Self {
        Self {
            spec: create_wait_tool(description_override, parameters_override),
        }
    }

    // Default to interrupted if this future is dropped; telemetry::CodeModeToolCallGuard::finish
    // overwrites this handler's captured span on explicit success or failure, including early errors.
    #[tracing::instrument(
        name = "code_mode.handler.wait",
        level = "info",
        skip_all,
        fields(
            conversation.id = %invocation.session.thread_id,
            turn_id = invocation.turn.sub_id.as_str(),
            call_id = trace_id(&invocation.call_id),
            cell.id = tracing::field::Empty,
            outcome = "interrupted",
        )
    )]
    async fn handle_call(
        &self,
        invocation: ToolInvocation,
    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {
        let handler_span = tracing::Span::current();
        let ToolInvocation {
            session,
            turn,
            step_context,
            call_id,
            tool_name,
            payload,
            ..
        } = invocation;

        let mut telemetry = CodeModeToolCallGuard::new(
            &session,
            turn.sub_id.clone(),
            turn.turn_metadata_state.clone(),
            call_id.clone(),
            WAIT_TOOL_NAME,
            handler_span,
        );
        let result = match payload {
            ToolPayload::Function { arguments }
                if tool_name.is_default_namespace()
                    && tool_name.name.as_str() == WAIT_TOOL_NAME =>
            {
                let args: ExecWaitArgs = parse_arguments(&arguments).inspect_err(|_error| {
                    telemetry.finish(/*success*/ false);
                })?;
                let exec = ExecContext { session, turn };
                let started_at = std::time::Instant::now();
                telemetry.cell_id = Some(args.cell_id.clone());
                let cell_id = codex_code_mode::CellId::new(args.cell_id);
                let wait_response = if args.terminate {
                    exec.session
                        .services
                        .code_mode_service
                        .terminate(cell_id)
                        .await
                } else {
                    exec.session
                        .services
                        .code_mode_service
                        .wait(
                            codex_code_mode::WaitRequest {
                                cell_id,
                                yield_time_ms: args.yield_time_ms,
                            },
                            step_context.preempt.clone(),
                        )
                        .await
                }
                .map_err(|error| {
                    telemetry.finish(/*success*/ false);
                    FunctionCallError::RespondToModel(error)
                })?;
                if let codex_code_mode::WaitOutcome::LiveCell(response) = &wait_response {
                    let runtime_cell_id = match response {
                        codex_code_mode::RuntimeResponse::Yielded { cell_id, .. }
                        | codex_code_mode::RuntimeResponse::Terminated { cell_id, .. }
                        | codex_code_mode::RuntimeResponse::Result { cell_id, .. } => cell_id,
                    };
                    tracing::Span::current().record("cell.id", trace_id(runtime_cell_id.as_str()));
                    telemetry.cell_id = Some(runtime_cell_id.to_string());
                    exec.session
                        .services
                        .executed_tool_calls
                        .register_cell(runtime_cell_id, &call_id);
                    if !matches!(response, codex_code_mode::RuntimeResponse::Yielded { .. }) {
                        exec.session
                            .services
                            .rollout_thread_trace
                            .code_cell_trace_context(
                                exec.turn.sub_id.as_str(),
                                runtime_cell_id.as_str(),
                            )
                            .record_ended(response);
                        exec.session
                            .services
                            .code_mode_service
                            .finish_cell_dispatch(runtime_cell_id);
                        exec.session
                            .services
                            .analytics_events_client
                            .track_code_mode_tool_call(
                                codex_analytics::CodeModeToolCallFact::CellClosed {
                                    thread_id: exec.session.thread_id.to_string(),
                                    turn_id: exec.turn.sub_id.clone(),
                                    cell_id: runtime_cell_id.to_string(),
                                },
                            );
                    }
                }
                if let Some(code_mode_host_duration) = wait_response.code_mode_host_duration() {
                    telemetry.record_code_mode_host_duration(code_mode_host_duration);
                }
                exec.session.services.elicitations.wait_until_clear().await;
                let wall_time = wait_response
                    .code_mode_host_duration()
                    .unwrap_or_else(|| started_at.elapsed());
                Ok(boxed_tool_output(handle_runtime_response(
                    &step_context.settings.model_info,
                    wait_response.into(),
                    args.max_tokens,
                    wall_time,
                    exec.turn.config.code_mode.experimental_show_cell_overhead,
                )))
            }
            _ => Err(FunctionCallError::RespondToModel(format!(
                "{WAIT_TOOL_NAME} expects JSON arguments"
            ))),
        };
        telemetry.finish(
            result
                .as_ref()
                .is_ok_and(codex_tools::ToolOutput::success_for_logging),
        );
        result
    }
}

impl CoreToolRuntime for CodeModeWaitHandler {
    fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        // Code-mode `wait` is runtime control for an existing code cell, not a
        // standalone user action. Tool calls made from code mode still flow
        // through normal dispatch, but hooks should not block or rewrite the
        // wait loop itself.
        None
    }

    fn post_tool_use_payload(
        &self,
        _invocation: &ToolInvocation,
        _result: &dyn ToolOutput,
    ) -> Option<PostToolUsePayload> {
        // The wait result feeds code-mode control flow, so do not let
        // PostToolUse replace it with model-facing hook feedback.
        None
    }
}
codex-rs/core/src/tools/code_mode/delegate.rs
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::Weak;

use crate::tools::context::ToolCallOrigin;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSessionDelegate;
use codex_code_mode::NotificationFuture;
use codex_code_mode::ToolInvocationFuture;
use codex_history::CodexHarnessMetadata;
use codex_history::ResponseItemEnvelope;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
use codex_utils_output_truncation::with_serialization_allowance;
use serde_json::Value as JsonValue;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use tracing::Instrument;

use super::PUBLIC_TOOL_NAME;
use super::submit_nested_tool;
use super::telemetry::DispatchInterruption;
use super::telemetry::NestedToolDispatchTrace;
use super::telemetry::trace_id;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::tools::ExecutedToolCalls;
use crate::tools::call_trace;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::parallel::ToolCallRuntime;

pub(super) struct CodeModeDispatchBroker {
    thread_id: ThreadId,
    dispatch_tx: async_channel::Sender<DispatchMessage>,
    dispatch_rx: async_channel::Receiver<DispatchMessage>,
    dispatch_gates: Arc<Mutex<HashMap<CellId, CellDispatchGate>>>,
    executed_tool_calls: ExecutedToolCalls,
}

/// Retains the step advertised to one execution, including callbacks after it yields.
pub(super) struct CodeModeCellDelegate {
    pub(super) broker: Arc<CodeModeDispatchBroker>,
    pub(super) step_context: Arc<StepContext>,
}

struct CellDispatchGate {
    ready: watch::Sender<bool>,
    // Callbacks may create the gate before exec attaches its origin. None means
    // no origin is attached; Some retains the original item and window across
    // waits, even if the best-effort item lookup found no ID.
    originating_call: Option<ToolCallOrigin>,
}

impl CodeModeDispatchBroker {
    pub(super) fn new(thread_id: ThreadId, executed_tool_calls: ExecutedToolCalls) -> Self {
        let (dispatch_tx, dispatch_rx) = async_channel::unbounded();
        Self {
            thread_id,
            dispatch_tx,
            dispatch_rx,
            dispatch_gates: Arc::new(Mutex::new(HashMap::new())),
            executed_tool_calls,
        }
    }

    pub(super) fn mark_cell_ready_for_dispatch(
        &self,
        cell_id: &CellId,
        originating_call: Option<ToolCallOrigin>,
    ) {
        let ready = {
            let mut dispatch_gates = self
                .dispatch_gates
                .lock()
                .unwrap_or_else(std::sync::PoisonError::into_inner);
            let gate = dispatch_gates
                .entry(cell_id.clone())
                .or_insert_with(|| CellDispatchGate {
                    ready: watch::channel(false).0,
                    originating_call: None,
                });
            gate.originating_call = originating_call;
            gate.ready.clone()
        };
        ready.send_replace(true);
    }

    pub(super) fn cell_originating_call(&self, cell_id: &CellId) -> Option<ToolCallOrigin> {
        self.dispatch_gates
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .get(cell_id)
            .and_then(|gate| gate.originating_call.clone())
    }

    pub(super) fn close_cell(&self, cell_id: &CellId) {
        let mut dispatch_gates = self
            .dispatch_gates
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner);
        dispatch_gates.remove(cell_id);
        self.executed_tool_calls.finish_cell_recording(cell_id);
    }

    pub(super) fn active_cell_ids(&self) -> Vec<CellId> {
        self.dispatch_gates
            .lock()
            .unwrap_or_else(std::sync::PoisonError::into_inner)
            .keys()
            .cloned()
            .collect()
    }

    pub(super) fn start_turn_worker(
        &self,
        session: Arc<Session>,
        step_context: Arc<StepContext>,
        tracker: SharedTurnDiffTracker,
    ) -> CodeModeDispatchWorker {
        let tool_runtime = ToolCallRuntime::new(Arc::clone(&session), step_context, tracker);
        let host = Arc::new(CoreTurnHost {
            session,
            tool_runtime,
        });
        let dispatch_rx = self.dispatch_rx.clone();
        let dispatch_gates = Arc::clone(&self.dispatch_gates);
        let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
        tokio::spawn(async move {
            loop {
                let message = tokio::select! {
                    _ = &mut shutdown_rx => break,
                    message = dispatch_rx.recv() => message.ok(),
                };
                let Some(message) = message else {
                    break;
                };
                match message {
                    DispatchMessage::Notify {
                        call_id,
                        cell_id,
                        text,
                        output_token_limit,
                        cancellation_token,
                        response_tx,
                    } => {
                        let response = if wait_until_cell_ready_for_dispatch(
                            &dispatch_gates,
                            &cell_id,
                            &cancellation_token,
                        )
                        .await
                        {
                            host.notify(call_id, cell_id, text, output_token_limit)
                                .await
                        } else {
                            remove_dispatch_gate(&dispatch_gates, &cell_id);
                            Err("code mode notification cancelled".to_string())
                        };
                        let _ = response_tx.send(response);
                    }
                    DispatchMessage::InvokeTool {
                        invocation,
                        step_context,
                        mut dispatch_trace,
                        cancellation_token,
                        response_tx,
                    } => {
                        let cell_id = invocation.cell_id.clone();
                        if !wait_until_cell_ready_for_dispatch(
                            &dispatch_gates,
                            &cell_id,
                            &cancellation_token,
                        )
                        .await
                        {
                            let outcome = if cancellation_token.is_cancelled() {
                                DispatchInterruption::Cancelled
                            } else {
                                DispatchInterruption::CellClosed
                            };
                            dispatch_trace.interruption = Some(outcome);
                            remove_dispatch_gate(&dispatch_gates, &cell_id);
                            continue;
                        }
                        let Some(step_context) = step_context
                            .upgrade()
                            .filter(|_| !cancellation_token.is_cancelled())
                        else {
                            let _ = response_tx
                                .send(Err("code mode nested tool call cancelled".to_string()));
                            continue;
                        };
                        let track_completeness =
                            ExecutedToolCalls::is_enabled(&step_context.turn.config.features);
                        let host = Arc::clone(&host);
                        let dispatch_gates = Arc::clone(&dispatch_gates);
                        let span = dispatch_trace.span.clone();
                        tokio::spawn(async move {
                            let invocation = {
                                let dispatch_gate = track_completeness.then(|| {
                                    dispatch_gates
                                        .lock()
                                        .unwrap_or_else(std::sync::PoisonError::into_inner)
                                });
                                if dispatch_gate.as_ref().is_some_and(|gates| {
                                    cancellation_token.is_cancelled()
                                        || !gates.contains_key(&cell_id)
                                }) {
                                    let outcome = if cancellation_token.is_cancelled() {
                                        DispatchInterruption::Cancelled
                                    } else {
                                        DispatchInterruption::CellClosed
                                    };
                                    dispatch_trace.interruption = Some(outcome);
                                    return;
                                }
                                // Submission and cell closure share this gate.
                                span.in_scope(|| {
                                    dispatch_trace.interruption = None;
                                    host.submit_tool(
                                        invocation,
                                        step_context,
                                        dispatch_trace.call_id.clone(),
                                        cancellation_token.clone(),
                                    )
                                })
                                .instrument(span)
                            };
                            tokio::pin!(invocation);
                            let response = tokio::select! {
                                biased;
                                _ = cancellation_token.cancelled() => invocation.await,
                                response = &mut invocation => response,
                            };
                            let _ = response_tx.send(response);
                        });
                    }
                }
            }
        });
        CodeModeDispatchWorker {
            shutdown_tx: Some(shutdown_tx),
        }
    }
}

fn dispatch_gate(
    dispatch_gates: &Mutex<HashMap<CellId, CellDispatchGate>>,
    cell_id: &CellId,
) -> watch::Sender<bool> {
    let mut dispatch_gates = match dispatch_gates.lock() {
        Ok(dispatch_gates) => dispatch_gates,
        Err(poisoned) => poisoned.into_inner(),
    };
    dispatch_gates
        .entry(cell_id.clone())
        .or_insert_with(|| CellDispatchGate {
            ready: watch::channel(false).0,
            originating_call: None,
        })
        .ready
        .clone()
}

fn remove_dispatch_gate(
    dispatch_gates: &Mutex<HashMap<CellId, CellDispatchGate>>,
    cell_id: &CellId,
) {
    let mut dispatch_gates = match dispatch_gates.lock() {
        Ok(dispatch_gates) => dispatch_gates,
        Err(poisoned) => poisoned.into_inner(),
    };
    dispatch_gates.remove(cell_id);
}

async fn wait_until_cell_ready_for_dispatch(
    dispatch_gates: &Mutex<HashMap<CellId, CellDispatchGate>>,
    cell_id: &CellId,
    cancellation_token: &CancellationToken,
) -> bool {
    if cancellation_token.is_cancelled() {
        return false;
    }
    let mut ready_rx = dispatch_gate(dispatch_gates, cell_id).subscribe();
    loop {
        if *ready_rx.borrow_and_update() {
            return true;
        }
        tokio::select! {
            changed = ready_rx.changed() => {
                if changed.is_err() {
                    return false;
                }
            }
            _ = cancellation_token.cancelled() => return false,
        }
    }
}

impl CodeModeSessionDelegate for CodeModeCellDelegate {
    #[tracing::instrument(
        name = "code_mode.broker.invoke_tool",
        level = "info",
        skip_all,
        fields(
            conversation.id = %self.broker.thread_id,
            cell.id = %invocation.cell_id,
            runtime_tool_call_id = invocation.runtime_tool_call_id.as_str(),
            tool_name = invocation.tool_name.name.as_str(),
            tool_namespace = invocation.tool_name.namespace.as_deref(),
        )
    )]
    fn invoke_tool<'a>(
        &'a self,
        invocation: CodeModeNestedToolCall,
        cancellation_token: CancellationToken,
    ) -> ToolInvocationFuture<'a> {
        Box::pin(async move {
            let call_id = format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4());
            call_trace::received(
                self.broker.thread_id,
                &invocation.tool_name,
                &call_id,
                call_trace::Receipt::CodeModeBroker {
                    cell_id: trace_id(invocation.cell_id.as_str()),
                    runtime_tool_call_id: trace_id(&invocation.runtime_tool_call_id),
                },
            );
            let mut dispatch_trace =
                Box::new(NestedToolDispatchTrace::new(self.broker.thread_id, call_id));
            if cancellation_token.is_cancelled() {
                dispatch_trace.interruption = Some(DispatchInterruption::Cancelled);
                return Err("code mode nested tool call cancelled".to_string());
            }
            let (response_tx, response_rx) = oneshot::channel();
            // Only the worker can tell whether dispatch beats cancellation once the call is queued.
            self.broker
                .dispatch_tx
                .send(DispatchMessage::InvokeTool {
                    invocation,
                    step_context: Arc::downgrade(&self.step_context),
                    dispatch_trace,
                    cancellation_token: cancellation_token.clone(),
                    response_tx,
                })
                .await
                .map_err(|_| "code mode nested tool dispatcher is unavailable".to_string())?;
            tokio::select! {
                response = response_rx => response
                    .map_err(|_| "code mode nested tool dispatcher stopped".to_string())?,
                _ = cancellation_token.cancelled() => {
                    Err("code mode nested tool call cancelled".to_string())
                }
            }
        })
    }

    fn notify<'a>(
        &'a self,
        call_id: String,
        cell_id: CellId,
        text: String,
        cancellation_token: CancellationToken,
    ) -> NotificationFuture<'a> {
        Box::pin(async move {
            if cancellation_token.is_cancelled() {
                return Err("code mode notification cancelled".to_string());
            }
            let (response_tx, response_rx) = oneshot::channel();
            self.broker
                .dispatch_tx
                .send(DispatchMessage::Notify {
                    call_id,
                    cell_id,
                    text,
                    output_token_limit: with_serialization_allowance(
                        self.step_context
                            .settings
                            .model_info
                            .truncation_policy
                            .into(),
                    )
                    .token_budget(),
                    cancellation_token: cancellation_token.clone(),
                    response_tx,
                })
                .await
                .map_err(|_| "code mode notification dispatcher is unavailable".to_string())?;
            tokio::select! {
                response = response_rx => response
                    .map_err(|_| "code mode notification dispatcher stopped".to_string())?,
                _ = cancellation_token.cancelled() => {
                    Err("code mode notification cancelled".to_string())
                }
            }
        })
    }

    fn cell_closed(&self, cell_id: &CellId) {
        self.broker.close_cell(cell_id);
    }
}

enum DispatchMessage {
    InvokeTool {
        invocation: CodeModeNestedToolCall,
        // The delegate owns the step while the callback is live; stale queued work must not.
        step_context: Weak<StepContext>,
        dispatch_trace: Box<NestedToolDispatchTrace>,
        cancellation_token: CancellationToken,
        response_tx: oneshot::Sender<Result<JsonValue, String>>,
    },
    Notify {
        call_id: String,
        cell_id: CellId,
        text: String,
        output_token_limit: usize,
        cancellation_token: CancellationToken,
        response_tx: oneshot::Sender<Result<(), String>>,
    },
}

pub(crate) struct CodeModeDispatchWorker {
    shutdown_tx: Option<oneshot::Sender<()>>,
}

impl Drop for CodeModeDispatchWorker {
    fn drop(&mut self) {
        if let Some(shutdown_tx) = self.shutdown_tx.take() {
            let _ = shutdown_tx.send(());
        }
    }
}

struct CoreTurnHost {
    session: Arc<Session>,
    tool_runtime: ToolCallRuntime,
}

impl CoreTurnHost {
    fn submit_tool(
        &self,
        invocation: CodeModeNestedToolCall,
        step_context: Arc<StepContext>,
        call_id: String,
        cancellation_token: CancellationToken,
    ) -> impl std::future::Future<Output = Result<JsonValue, String>> + Send + 'static {
        let invocation = submit_nested_tool(
            Arc::clone(&self.session),
            step_context,
            self.tool_runtime.clone(),
            invocation,
            call_id,
            cancellation_token,
        )
        .map_err(|error| error.to_string());
        async move { invocation?.await.map_err(|error| error.to_string()) }
    }

    async fn notify(
        &self,
        call_id: String,
        cell_id: CellId,
        text: String,
        output_token_limit: usize,
    ) -> Result<(), String> {
        if text.trim().is_empty() {
            return Ok(());
        }
        self.session
            .inject_if_running(vec![ResponseItemEnvelope {
                item: ResponseItem::CustomToolCallOutput {
                    id: None,
                    call_id,
                    name: Some(PUBLIC_TOOL_NAME.to_string()),
                    output: FunctionCallOutputPayload::from_text(text),
                    internal_chat_message_metadata_passthrough: None,
                },
                metadata: Some(CodexHarnessMetadata {
                    history_truncation_token_limit: Some(output_token_limit),
                    ..Default::default()
                }),
            }])
            .await
            .map_err(|_| {
                format!("failed to inject exec notify message for cell {cell_id}: no active turn")
            })
    }
}

#[cfg(test)]
#[path = "delegate_tests.rs"]
mod tests;
codex-rs/core/src/tools/code_mode/output.rs
//! Code-mode response headers use the host measurement and completed handler timing.
//! Content is already truncated; only the bounded header changes at completion.

use std::time::Duration;

use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ResponseInputItem;

use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;

pub(super) struct CodeModeToolOutput {
    output: FunctionToolOutput,
    status: String,
    host_duration: Option<Duration>,
}

impl CodeModeToolOutput {
    pub(super) fn new(
        mut output: FunctionToolOutput,
        status: String,
        wall_time: Duration,
        host_duration: Option<Duration>,
    ) -> Self {
        // Use the host-only header when overhead is hidden or host timing is unavailable.
        let wall_time_seconds = (wall_time.as_secs_f32() * 10.0).round() / 10.0;
        output.body.insert(
            /*index*/ 0,
            FunctionCallOutputContentItem::InputText {
                text: format!("{status}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n"),
            },
        );
        Self {
            output,
            status,
            host_duration,
        }
    }
}

impl ToolOutput for CodeModeToolOutput {
    fn log_output(&self) -> String {
        self.output.log_output()
    }

    fn success_for_logging(&self) -> bool {
        self.output.success_for_logging()
    }

    fn set_handler_duration_ms(&mut self, handler_duration_ms: u64) {
        let Some(host_duration) = self.host_duration else {
            return;
        };
        let total_seconds = handler_duration_ms as f64 / 1_000.0;
        let host_seconds = host_duration.as_secs_f64();
        // Match the logged measurements before rounding. Millisecond quantization of the
        // outer measurement can legitimately produce a small negative difference.
        let overhead_seconds = total_seconds - host_seconds;
        let status = &self.status;
        // The constructor owns this slot; script text and media stay untouched.
        self.output.body[0] = FunctionCallOutputContentItem::InputText {
            text: format!(
                "{status}\nWall time {total_seconds:.3} seconds (code-mode {host_seconds:.3} seconds; overhead {overhead_seconds:.3} seconds)\nOutput:\n"
            ),
        };
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        self.output.to_response_item(call_id, payload)
    }
}

#[cfg(test)]
#[path = "output_tests.rs"]
mod tests;
codex-rs/core/src/tools/code_mode/response_adapter.rs
use codex_code_mode::ImageDetail as CodeModeImageDetail;
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::ImageDetail;
use codex_protocol::models::ImageReference;

trait IntoProtocol<T> {
    fn into_protocol(self) -> T;
}

pub(super) fn into_function_call_output_content_items(
    items: Vec<codex_code_mode::FunctionCallOutputContentItem>,
) -> Vec<FunctionCallOutputContentItem> {
    items.into_iter().map(IntoProtocol::into_protocol).collect()
}

impl IntoProtocol<ImageDetail> for CodeModeImageDetail {
    fn into_protocol(self) -> ImageDetail {
        let value = self;
        match value {
            CodeModeImageDetail::Auto => ImageDetail::Auto,
            CodeModeImageDetail::Low => ImageDetail::Low,
            CodeModeImageDetail::High => ImageDetail::High,
            CodeModeImageDetail::Original => ImageDetail::Original,
        }
    }
}

impl IntoProtocol<FunctionCallOutputContentItem>
    for codex_code_mode::FunctionCallOutputContentItem
{
    fn into_protocol(self) -> FunctionCallOutputContentItem {
        let value = self;
        match value {
            codex_code_mode::FunctionCallOutputContentItem::InputText { text } => {
                FunctionCallOutputContentItem::InputText { text }
            }
            codex_code_mode::FunctionCallOutputContentItem::InputImage { image_url, detail } => {
                FunctionCallOutputContentItem::InputImage {
                    image: ImageReference::Inline { image_url },
                    detail: detail
                        .map(IntoProtocol::into_protocol)
                        .or(Some(DEFAULT_IMAGE_DETAIL)),
                }
            }
            codex_code_mode::FunctionCallOutputContentItem::InputAudio { audio_url } => {
                FunctionCallOutputContentItem::InputAudio { audio_url }
            }
        }
    }
}
codex-rs/core/src/tools/code_mode/telemetry.rs
//! Code-mode tool analytics and tracing for handlers and queued nested dispatch.

use codex_analytics::AnalyticsEventsClient;
use codex_analytics::CodeModeToolCallFact;
use codex_analytics::CodeModeToolCallStatus;
use codex_analytics::TurnAnalyticsMetadata;
use codex_protocol::ThreadId;
use std::sync::Arc;
use std::time::Duration;
use tracing::Span;

pub(super) struct CodeModeToolCallGuard {
    analytics: AnalyticsEventsClient,
    thread_id: String,
    turn_id: String,
    turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
    call_id: String,
    pub(super) cell_id: Option<String>,
    tool_name: &'static str,
    started_at_ms: u64,
    status: CodeModeToolCallStatus,
    handler_span: Span,
    extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
}

impl CodeModeToolCallGuard {
    pub(super) fn new(
        session: &crate::session::session::Session,
        turn_id: String,
        turn_metadata: Arc<dyn TurnAnalyticsMetadata>,
        call_id: String,
        tool_name: &'static str,
        handler_span: Span,
    ) -> Self {
        Self {
            analytics: session.services.analytics_events_client.clone(),
            thread_id: session.thread_id.to_string(),
            turn_id,
            turn_metadata,
            call_id,
            cell_id: None,
            tool_name,
            started_at_ms: codex_analytics::now_unix_millis(),
            status: CodeModeToolCallStatus::Interrupted,
            handler_span,
            extensions: Arc::clone(&session.services.extensions),
        }
    }

    pub(super) fn finish(&mut self, success: bool) {
        let mut outcome = "failed";
        self.status = if success {
            outcome = "completed";
            CodeModeToolCallStatus::Completed
        } else {
            CodeModeToolCallStatus::Failed
        };
        self.handler_span.record("outcome", outcome);
    }

    pub(super) fn record_code_mode_host_duration(&self, duration: Duration) {
        for observer in self.extensions.tool_lifecycle_contributors() {
            observer.on_tool_timing(codex_extension_api::ToolTimingInput {
                thread_id: &self.thread_id,
                turn_id: &self.turn_id,
                call_id: &self.call_id,
                boundary: codex_extension_api::ToolTimingBoundary::HostOperation,
                duration,
            });
        }
        let Ok(code_mode_host_duration_ns) = u64::try_from(duration.as_nanos()) else {
            return;
        };
        // Bridge joins this record to the outer tool-completion event. Emit it
        // before the handler returns so consumers never need another timeout.
        tracing::info!(
            target: "codex_code_mode::timing",
            {
                event.name = "codex.code_mode.host_timing",
                conversation_id = %self.thread_id,
                turn_id = %self.turn_id,
                call_id = %self.call_id,
                cell_id = self.cell_id.as_deref(),
                tool_name = self.tool_name,
                code_mode_host_duration_ns,
            },
            "code-mode host operation completed"
        );
    }
}

impl Drop for CodeModeToolCallGuard {
    fn drop(&mut self) {
        self.analytics
            .track_code_mode_tool_call(CodeModeToolCallFact::Completed {
                thread_id: self.thread_id.clone(),
                turn_id: self.turn_id.clone(),
                turn_metadata: self.turn_metadata.clone(),
                call_id: self.call_id.clone(),
                cell_id: self.cell_id.clone(),
                tool_name: self.tool_name.to_string(),
                started_at_ms: self.started_at_ms,
                completed_at_ms: codex_analytics::now_unix_millis(),
                status: self.status,
            });
    }
}

pub(super) struct NestedToolDispatchTrace {
    thread_id: ThreadId,
    pub(super) call_id: String,
    pub(super) interruption: Option<DispatchInterruption>,
    pub(super) span: Span,
}

impl NestedToolDispatchTrace {
    pub(super) fn new(thread_id: ThreadId, call_id: String) -> Self {
        Self {
            thread_id,
            call_id,
            interruption: Some(DispatchInterruption::Abandoned),
            span: Span::current(),
        }
    }
}

impl Drop for NestedToolDispatchTrace {
    fn drop(&mut self) {
        let Some(outcome) = self.interruption.take() else {
            return;
        };
        let outcome = match outcome {
            DispatchInterruption::Cancelled => "cancelled",
            DispatchInterruption::CellClosed => "cell_closed",
            DispatchInterruption::Abandoned => "abandoned",
        };
        tracing::event!(
            name: "codex.code_mode.nested_tool_dispatch_interrupted",
            target: "codex_otel.trace_safe",
            parent: &self.span,
            tracing::Level::INFO,
            event.name = "codex.code_mode.nested_tool_dispatch_interrupted",
            conversation.id = %self.thread_id,
            call_id = self.call_id.as_str(),
            outcome,
        );
    }
}

pub(super) enum DispatchInterruption {
    Cancelled,
    CellClosed,
    Abandoned,
}

pub(super) fn trace_id(id: &str) -> Option<&str> {
    (!id.is_empty() && id.len() <= 256).then_some(id)
}
codex-rs/core/src/tools/code_mode/mod.rs
mod delegate;
mod execute_handler;
pub(crate) mod execute_spec;
mod output;
mod response_adapter;
mod telemetry;
mod tool_definitions;
mod wait_handler;
pub(crate) mod wait_spec;

use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Duration;

use codex_code_mode::CellId;
use codex_code_mode::CodeModeNestedToolCall;
use codex_code_mode::CodeModeSession;
use codex_code_mode::CodeModeSessionProvider;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::RuntimeResponse;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputContentItem;
use futures::future::join_all;
use serde_json::Value as JsonValue;
use tokio::sync::OnceCell;
use tokio_util::sync::CancellationToken;

use crate::config::CodeModeConfig;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::original_image_detail::sanitize_original_image_detail as sanitize_image_detail_items;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext;
use crate::tools::ExecutedToolCalls;
use crate::tools::call_trace;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolPayload;
use crate::tools::parallel::ToolCallRuntime;
use crate::tools::router::ToolCall;
use crate::tools::router::ToolCallSource;
use crate::unified_exec::resolve_max_tokens;
use codex_protocol::openai_models::ToolMode;
use codex_tools::ToolName;
use codex_utils_audio::estimate_audio_token_count;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy;
use codex_utils_output_truncation::truncate_function_output_items_with_policy;

use delegate::CodeModeCellDelegate;
use delegate::CodeModeDispatchBroker;
use delegate::CodeModeDispatchWorker;
pub(crate) use execute_handler::CodeModeExecuteHandler;
use output::CodeModeToolOutput;
use response_adapter::into_function_call_output_content_items;
pub(crate) use tool_definitions::prepare_code_mode_tool_definitions;
pub(crate) use wait_handler::CodeModeWaitHandler;

pub(crate) const PUBLIC_TOOL_NAME: &str = codex_code_mode::PUBLIC_TOOL_NAME;
pub(crate) const WAIT_TOOL_NAME: &str = codex_code_mode::WAIT_TOOL_NAME;
pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = codex_code_mode::DEFAULT_WAIT_YIELD_TIME_MS;

/// Returns true for the code-mode `exec` tool in the default namespace.
pub(crate) fn is_exec_tool_name(tool_name: &ToolName) -> bool {
    tool_name.is_default_namespace() && tool_name.name == PUBLIC_TOOL_NAME
}

#[derive(Clone)]
pub(crate) struct ExecContext {
    pub(super) session: Arc<Session>,
    pub(super) turn: Arc<TurnContext>,
}

pub(crate) struct CodeModeService {
    session: OnceCell<Arc<dyn CodeModeSession>>,
    session_provider: Arc<dyn CodeModeSessionProvider>,
    availability: Result<(), String>,
    dispatch_broker: Arc<CodeModeDispatchBroker>,
    default_exec_yield_time_ms: u64,
    shutdown_token: CancellationToken,
    unavailable_warning_emitted: AtomicBool,
}

impl CodeModeService {
    pub(crate) fn new(
        thread_id: ThreadId,
        session_provider: Arc<dyn CodeModeSessionProvider>,
        config: &CodeModeConfig,
        executed_tool_calls: ExecutedToolCalls,
    ) -> Self {
        let dispatch_broker = Arc::new(CodeModeDispatchBroker::new(thread_id, executed_tool_calls));
        let availability = session_provider.availability();
        Self {
            session: OnceCell::new(),
            session_provider,
            availability,
            dispatch_broker,
            default_exec_yield_time_ms: config.default_exec_yield_time_ms,
            shutdown_token: CancellationToken::new(),
            unavailable_warning_emitted: AtomicBool::new(false),
        }
    }

    pub(crate) fn is_available(&self) -> bool {
        self.availability.is_ok()
    }

    pub(crate) fn take_unavailable_warning(&self, tool_mode: ToolMode) -> Option<String> {
        let error = self.availability.as_ref().err()?;
        let behavior = match tool_mode {
            ToolMode::Direct => "Falling back to direct tools",
            ToolMode::CodeMode | ToolMode::CodeModeOnly => "Code mode will fail closed",
        };
        (!self
            .unavailable_warning_emitted
            .swap(true, Ordering::Relaxed))
        .then(|| {
            format!(
                "Code Mode is unavailable because {error}. {behavior}; enable `features.code_mode_host` and install `codex-code-mode-host`."
            )
        })
    }

    pub(crate) fn session_provider(&self) -> Arc<dyn CodeModeSessionProvider> {
        Arc::clone(&self.session_provider)
    }

    pub(crate) async fn execute(
        &self,
        mut request: codex_code_mode::ExecuteRequest,
        step_context: Arc<StepContext>,
    ) -> Result<codex_code_mode::StartedCell, String> {
        request
            .yield_time_ms
            .get_or_insert(self.default_exec_yield_time_ms);
        let preempt = step_context.preempt.clone();
        let delegate = Arc::new(CodeModeCellDelegate {
            broker: Arc::clone(&self.dispatch_broker),
            step_context,
        });
        self.session()
            .await?
            .execute(request, delegate, preempt)
            .await
    }

    pub(crate) async fn wait(
        &self,
        request: codex_code_mode::WaitRequest,
        preempt: Option<CancellationToken>,
    ) -> Result<codex_code_mode::WaitOutcome, String> {
        self.session().await?.wait(request, preempt).await
    }

    pub(crate) async fn terminate(
        &self,
        cell_id: CellId,
    ) -> Result<codex_code_mode::WaitOutcome, String> {
        self.session().await?.terminate(cell_id).await
    }

    pub(crate) async fn interrupt_active_cells(&self) {
        let Some(session) = self.session.get() else {
            return;
        };
        join_all(
            self.dispatch_broker
                .active_cell_ids()
                .into_iter()
                .map(|cell_id| async move {
                    if let Err(error) = session.terminate(cell_id.clone()).await {
                        tracing::warn!(%cell_id, %error, "failed to terminate interrupted code-mode cell");
                    }
                }),
        )
        .await;
    }

    pub(crate) async fn shutdown(&self) -> Result<(), String> {
        self.shutdown_token.cancel();
        // Join any initialization already in progress without initializing an unused service.
        match self
            .session
            .get_or_try_init(|| async {
                Err::<Arc<dyn CodeModeSession>, String>(
                    "code mode session is shutting down".to_string(),
                )
            })
            .await
        {
            Ok(session) => session.shutdown().await,
            Err(_) => Ok(()),
        }
    }

    pub(crate) fn mark_cell_ready_for_dispatch(
        &self,
        cell_id: &codex_code_mode::CellId,
        originating_call: Option<crate::tools::context::ToolCallOrigin>,
    ) {
        self.dispatch_broker
            .mark_cell_ready_for_dispatch(cell_id, originating_call);
    }

    pub(crate) fn cell_originating_call(
        &self,
        cell_id: &codex_code_mode::CellId,
    ) -> Option<crate::tools::context::ToolCallOrigin> {
        self.dispatch_broker.cell_originating_call(cell_id)
    }

    pub(crate) fn finish_cell_dispatch(&self, cell_id: &CellId) {
        self.dispatch_broker.close_cell(cell_id);
    }

    pub(crate) fn start_turn_worker(
        &self,
        session: &Arc<Session>,
        step_context: Arc<StepContext>,
        tracker: SharedTurnDiffTracker,
    ) -> Option<CodeModeDispatchWorker> {
        if !step_context.tool_router.requires_code_mode_worker() {
            return None;
        }

        Some(
            self.dispatch_broker
                .start_turn_worker(Arc::clone(session), step_context, tracker),
        )
    }

    pub(crate) async fn session(&self) -> Result<Arc<dyn CodeModeSession>, String> {
        if self.shutdown_token.is_cancelled() {
            return Err("code mode session is shutting down".to_string());
        }
        self.session
            .get_or_try_init(|| async {
                if self.shutdown_token.is_cancelled() {
                    return Err("code mode session is shutting down".to_string());
                }
                let session = tokio::select! {
                    biased;
                    _ = self.shutdown_token.cancelled() => {
                        return Err("code mode session is shutting down".to_string());
                    }
                    session = self
                        .session_provider
                        .create_session() => session?,
                };
                if self.shutdown_token.is_cancelled() {
                    let _ = session.shutdown().await;
                    return Err("code mode session is shutting down".to_string());
                }
                Ok(session)
            })
            .await
            .map(Arc::clone)
    }
}

fn handle_runtime_response(
    model_info: &codex_protocol::openai_models::ModelInfo,
    response: RuntimeResponse,
    max_output_tokens: Option<usize>,
    wall_time: Duration,
    experimental_show_cell_overhead: bool,
) -> CodeModeToolOutput {
    let script_status = format_script_status(&response);
    let supports_original = can_request_original_image_detail(model_info);
    let host_duration = response
        .code_mode_host_duration()
        .filter(|_| experimental_show_cell_overhead);

    let (content_items, error_text) = match response {
        RuntimeResponse::Yielded { content_items, .. }
        | RuntimeResponse::Terminated { content_items, .. } => (content_items, None),
        RuntimeResponse::Result {
            content_items,
            error_text,
            ..
        } => (content_items, error_text),
    };
    let mut content_items = into_function_call_output_content_items(content_items);
    sanitize_image_detail_items(supports_original, &mut content_items);
    let success = error_text.is_none();
    if let Some(error_text) = error_text {
        content_items.push(FunctionCallOutputContentItem::InputText {
            text: format!("Script error:\n{error_text}"),
        });
    }
    content_items = truncate_code_mode_result(content_items, max_output_tokens);
    CodeModeToolOutput::new(
        FunctionToolOutput::from_content(content_items, Some(success)),
        script_status,
        wall_time,
        host_duration,
    )
}

fn format_script_status(response: &RuntimeResponse) -> String {
    match response {
        RuntimeResponse::Yielded { cell_id, .. } => {
            format!("Script running with cell ID {cell_id}")
        }
        RuntimeResponse::Terminated { .. } => "Script terminated".to_string(),
        RuntimeResponse::Result { error_text, .. } => {
            if error_text.is_none() {
                "Script completed".to_string()
            } else {
                "Script failed".to_string()
            }
        }
    }
}

fn truncate_code_mode_result(
    items: Vec<FunctionCallOutputContentItem>,
    max_output_tokens: Option<usize>,
) -> Vec<FunctionCallOutputContentItem> {
    let max_output_tokens = resolve_max_tokens(max_output_tokens);
    let policy = TruncationPolicy::Tokens(max_output_tokens);
    if items
        .iter()
        .all(|item| matches!(item, FunctionCallOutputContentItem::InputText { .. }))
    {
        let (truncated_items, _) =
            formatted_truncate_text_content_items_with_policy(&items, policy);
        return truncated_items;
    }

    truncate_function_output_items_with_policy(&items, policy, estimate_audio_token_count)
}

// Submit synchronously so the recorder sees the call before the cell's dispatch gate closes.
fn submit_nested_tool(
    session: Arc<Session>,
    step_context: Arc<StepContext>,
    tool_runtime: ToolCallRuntime,
    invocation: CodeModeNestedToolCall,
    call_id: String,
    cancellation_token: CancellationToken,
) -> Result<
    impl std::future::Future<Output = Result<JsonValue, FunctionCallError>> + Send + 'static,
    FunctionCallError,
> {
    let CodeModeNestedToolCall {
        cell_id,
        runtime_tool_call_id,
        tool_name,
        tool_kind,
        input,
    } = invocation;
    let thread_id = session.thread_id;
    let turn_id = step_context.turn.sub_id.clone();
    let tool_name = tool_name.with_default_namespace();
    // A cell can outlive a turn; the broker records arrival before a dispatching turn is known.
    tracing::event!(
        name: "codex.code_mode.nested_tool_dispatched",
        target: "codex_otel.trace_safe",
        tracing::Level::INFO,
        event.name = "codex.code_mode.nested_tool_dispatched",
        conversation.id = %thread_id,
        turn_id = turn_id.as_str(),
        cell.id = telemetry::trace_id(cell_id.as_str()),
        runtime_tool_call_id = telemetry::trace_id(&runtime_tool_call_id),
        call_id = call_id.as_str(),
    );
    let payload = if is_exec_tool_name(&tool_name) {
        Err(format!("{PUBLIC_TOOL_NAME} cannot invoke itself"))
    } else {
        build_nested_tool_payload(tool_kind, &tool_name, input)
    };
    let payload = match payload {
        Ok(payload) => payload,
        Err(error) => {
            call_trace::result_ready(
                thread_id,
                &turn_id,
                &tool_name,
                &call_id,
                call_trace::Source::CodeMode,
            );
            return Err(FunctionCallError::RespondToModel(error));
        }
    };

    let call = ToolCall {
        tool_name,
        call_id,
        payload,
        encrypted_function_args: None,
    };
    session
        .services
        .analytics_events_client
        .track_code_mode_tool_call(codex_analytics::CodeModeToolCallFact::ChildStarted {
            thread_id: session.thread_id.to_string(),
            turn_id: step_context.turn.sub_id.clone(),
            call_id: call.call_id.clone(),
            cell_id: cell_id.to_string(),
        });
    let result = tool_runtime.handle_tool_call_with_source(
        step_context,
        call,
        ToolCallSource::CodeMode {
            cell_id: cell_id.to_string(),
            runtime_tool_call_id,
        },
        cancellation_token,
        Arc::default(),
    );
    Ok(async move { Ok(result.await?.code_mode_result()) })
}

fn build_nested_tool_payload(
    tool_kind: CodeModeToolKind,
    tool_name: &ToolName,
    input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
    match tool_kind {
        CodeModeToolKind::Function => build_function_tool_payload(tool_name, input),
        CodeModeToolKind::Freeform => build_freeform_tool_payload(tool_name, input),
    }
}

fn build_function_tool_payload(
    tool_name: &ToolName,
    input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
    let arguments = serialize_function_tool_arguments(tool_name, input)?;
    Ok(ToolPayload::Function { arguments })
}

fn serialize_function_tool_arguments(
    tool_name: &ToolName,
    input: Option<JsonValue>,
) -> Result<String, String> {
    match input {
        None => Ok("{}".to_string()),
        Some(JsonValue::Object(map)) => serde_json::to_string(&JsonValue::Object(map))
            .map_err(|err| format!("failed to serialize tool `{tool_name}` arguments: {err}")),
        Some(_) => Err(format!(
            "tool `{tool_name}` expects a JSON object for arguments"
        )),
    }
}

fn build_freeform_tool_payload(
    tool_name: &ToolName,
    input: Option<JsonValue>,
) -> Result<ToolPayload, String> {
    match input {
        Some(JsonValue::String(input)) => Ok(ToolPayload::Custom { input }),
        _ => Err(format!("tool `{tool_name}` expects a string input")),
    }
}

#[cfg(test)]
mod tests {
    use std::collections::BTreeMap;
    use std::sync::Arc;

    use super::build_nested_tool_payload;
    use super::truncate_code_mode_result;
    use crate::session::step_context::StepContext;
    use crate::session::tests::make_session_and_context;
    use crate::tools::context::ToolPayload;
    use crate::tools::registry::ToolRegistry;
    use crate::tools::router::ToolRouter;
    use crate::turn_diff_tracker::TurnDiffTracker;
    use codex_code_mode::CodeModeToolKind;
    use codex_protocol::models::FunctionCallOutputContentItem;
    use codex_protocol::openai_models::ToolMode;
    use codex_tools::ToolName;
    use serde_json::json;

    #[tokio::test]
    async fn turn_worker_uses_step_router_mode_instead_of_admitted_turn() {
        let (session, turn) = make_session_and_context().await;
        assert_eq!(
            crate::tools::effective_tool_mode(&turn, turn.model_info()),
            ToolMode::Direct
        );
        let session = Arc::new(session);
        let step_context = StepContext::for_test(Arc::new(turn));
        let router = Arc::new(ToolRouter::from_parts(
            ToolRegistry::empty_for_test(),
            Vec::new(),
            ToolMode::CodeModeOnly,
            BTreeMap::new(),
            /*tool_namespaces_info*/ None,
            &[],
        ));
        let step_context = step_context.with_tool_router_for_test(router);
        let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));

        let worker =
            session
                .services
                .code_mode_service
                .start_turn_worker(&session, step_context, tracker);

        assert!(worker.is_some());
    }

    #[test]
    fn build_nested_tool_payload_uses_function_kind() {
        let payload = build_nested_tool_payload(
            CodeModeToolKind::Function,
            &ToolName::plain("example"),
            Some(json!({ "value": 1 })),
        )
        .expect("function payload should serialize");

        match payload {
            ToolPayload::Function { arguments } => {
                assert_eq!(arguments, r#"{"value":1}"#.to_string());
            }
            other => panic!("expected function payload, got {other:?}"),
        }
    }

    #[test]
    fn build_nested_tool_payload_uses_freeform_kind() {
        let payload = build_nested_tool_payload(
            CodeModeToolKind::Freeform,
            &ToolName::plain("example"),
            Some(json!("hello")),
        )
        .expect("freeform payload should preserve string input");

        match payload {
            ToolPayload::Custom { input } => {
                assert_eq!(input, "hello".to_string());
            }
            other => panic!("expected freeform payload, got {other:?}"),
        }
    }

    #[test]
    fn truncated_text_output_starts_with_warning() {
        let items = vec![FunctionCallOutputContentItem::InputText {
            text: "0123456789012345678901234567890123456789".to_string(),
        }];

        assert_eq!(
            truncate_code_mode_result(items, Some(5)),
            vec![FunctionCallOutputContentItem::InputText {
                text: concat!(
                    "Warning: truncated output (original token count: 10)\n",
                    "Total output lines: 1\n\n",
                    "0123456789…5 tokens truncated…0123456789"
                )
                .to_string(),
            }]
        );
    }

    #[test]
    fn over_budget_audio_output_is_omitted() {
        let items = vec![FunctionCallOutputContentItem::InputAudio {
            audio_url: format!("data:audio/wav;base64,{}", "A".repeat(100)),
        }];

        assert_eq!(
            truncate_code_mode_result(items, Some(5)),
            vec![FunctionCallOutputContentItem::InputText {
                text: "[omitted 1 audio items ...]".to_string(),
            }]
        );
    }
}
codex-rs/core/src/tools/approvals.rs
//! Central approval policy-stage execution and reviewer routing.

use crate::command_canonicalization::canonicalize_command_for_approval;
use crate::exec_policy::prompt_is_rejected_by_policy;
use crate::guardian::GuardianNetworkAccessTrigger;
use crate::guardian::GuardianReviewContext;
use crate::guardian::GuardianReviewOptions;
use crate::guardian::decide_approval;
use crate::guardian::new_guardian_review_id;
use crate::guardian::spawn_approval_decision;
use crate::hook_runtime::run_permission_request_hooks;
use crate::mcp_tool_call::request_mcp_tool_user_approval;
use crate::sandboxing::SandboxPermissions;
use crate::session::session::Session;
use crate::tools::hook_names::HookToolName;
use crate::tools::runtimes::apply_patch::ApplyPatchApprovalKey;
use crate::tools::runtimes::unified_exec::UnifiedExecApprovalKey;
use crate::tools::sandboxing::ApprovalRequestReasons;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::with_cached_approval;
use codex_analytics::GuardianApprovalRequestSource;
use codex_config::types::AppToolApproval;
use codex_hooks::PermissionRequestDecision;
use codex_otel::ToolDecisionSource;
use codex_prompts::ResolvedModelMessages;
use codex_protocol::approvals::ExecApprovalKind;
use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::approvals::NetworkApprovalContext;
use codex_protocol::approvals::NetworkApprovalProtocol;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::error::CodexErr;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::NetworkPolicyRuleAction;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::request_permissions::RequestPermissionProfile;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_path_uri::PathConvention;
use codex_utils_path_uri::PathUri;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use tracing::error;
use tracing::warn;

#[derive(Clone)]
pub(crate) struct ApprovalContext {
    pub(crate) review_context: GuardianReviewContext,
    pub(crate) cancellation_token: Option<CancellationToken>,
    pub(crate) call_id: String,
    pub(crate) tool_name: ToolName,
    pub(crate) strict_auto_review: bool,
    pub(crate) approval_reason: Option<String>,
    pub(crate) retry_reason: Option<String>,
    pub(crate) network_approval_context: Option<NetworkApprovalContext>,
}

#[derive(Clone, Debug, PartialEq, serde::Serialize)]
#[serde(tag = "tool", rename_all = "snake_case")]
pub(crate) enum ApprovalAction {
    ExecCommand {
        id: String,
        environment_id: String,
        command: Vec<String>,
        #[serde(skip_serializing)]
        hook_command: String,
        cwd: PathUri,
        sandbox_permissions: SandboxPermissions,
        additional_permissions: Option<AdditionalPermissionProfile>,
        justification: Option<String>,
        tty: bool,
        proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
    },
    WriteStdin {
        id: String,
        approval_id: String,
        environment_id: String,
        process_id: i32,
        input: String,
        cwd: PathUri,
        tty: bool,
        sandbox_permissions: SandboxPermissions,
        additional_permissions: Option<AdditionalPermissionProfile>,
    },
    ApplyPatch {
        id: String,
        environment_id: String,
        cwd: PathUri,
        files: Vec<PathUri>,
        patch: String,
        #[serde(skip_serializing)]
        changes: Arc<HashMap<PathBuf, FileChange>>,
        permissions_preapproved: bool,
    },
    McpToolCall {
        id: String,
        server: String,
        tool_name: String,
        arguments: Option<serde_json::Value>,
        connector_id: Option<String>,
        connector_name: Option<String>,
        connector_description: Option<String>,
        connected_account_email: Option<String>,
        tool_title: Option<String>,
        tool_description: Option<String>,
        annotations: Option<crate::guardian::GuardianMcpAnnotations>,
        #[serde(skip_serializing)]
        hook_tool_name: HookToolName,
        approval_policy: AskForApproval,
        reviewer: ApprovalsReviewer,
        approval_mode: AppToolApproval,
        allow_session_remember: bool,
        allow_persistent_approval: bool,
    },
    NetworkAccess {
        id: String,
        turn_id: String,
        environment_id: String,
        target: String,
        host: String,
        protocol: NetworkApprovalProtocol,
        port: u16,
        trigger: Option<GuardianNetworkAccessTrigger>,
        #[serde(skip_serializing)]
        hook_command: String,
        #[serde(skip_serializing)]
        hook_run_id: String,
        command: Vec<String>,
        cwd: AbsolutePathBuf,
    },
    RequestPermissions {
        id: String,
        environment_id: String,
        turn_id: String,
        reason: Option<String>,
        permissions: RequestPermissionProfile,
    },
}

#[derive(Clone, Debug, Eq, Hash, PartialEq, serde::Serialize)]
#[serde(untagged)]
pub(crate) enum ApprovalCacheKey {
    ExecCommand(UnifiedExecApprovalKey),
    ApplyPatch(ApplyPatchApprovalKey),
}

impl ApprovalAction {
    pub(crate) fn permission_request_payload(&self) -> PermissionRequestPayload {
        match self {
            Self::ExecCommand {
                hook_command,
                justification,
                ..
            } => PermissionRequestPayload::bash(hook_command.clone(), justification.clone()),
            Self::WriteStdin {
                id,
                approval_id,
                environment_id,
                process_id,
                input,
                cwd,
                tty,
                sandbox_permissions,
                additional_permissions,
            } => PermissionRequestPayload {
                tool_name: HookToolName::new("write_stdin"),
                tool_input: serde_json::json!({
                    "session_id": process_id,
                    "chars": input,
                    "parent_call_id": id,
                    "approval_id": approval_id,
                    "environment_id": environment_id,
                    "cwd": cwd,
                    "tty": tty,
                    "sandbox_permissions": sandbox_permissions,
                    "additional_permissions": additional_permissions,
                }),
            },
            Self::ApplyPatch { patch, .. } => PermissionRequestPayload {
                tool_name: HookToolName::apply_patch(),
                tool_input: serde_json::json!({ "command": patch }),
            },
            Self::McpToolCall {
                hook_tool_name,
                arguments,
                ..
            } => PermissionRequestPayload {
                tool_name: hook_tool_name.clone(),
                tool_input: arguments
                    .clone()
                    .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
            },
            Self::NetworkAccess {
                hook_command,
                target,
                ..
            } => PermissionRequestPayload::bash(
                hook_command.clone(),
                Some(format!("network-access {target}")),
            ),
            Self::RequestPermissions {
                reason,
                permissions,
                ..
            } => PermissionRequestPayload {
                tool_name: HookToolName::new("request_permissions"),
                tool_input: serde_json::json!({
                    "reason": reason,
                    "permissions": permissions,
                }),
            },
        }
    }

    pub(crate) fn cache_keys(&self) -> Vec<ApprovalCacheKey> {
        match self {
            Self::ExecCommand {
                environment_id,
                command,
                cwd,
                tty,
                sandbox_permissions,
                additional_permissions,
                ..
            } => vec![ApprovalCacheKey::ExecCommand(UnifiedExecApprovalKey {
                environment_id: environment_id.clone(),
                executable: command.first().cloned(),
                command: canonicalize_command_for_approval(command),
                cwd: cwd.clone(),
                tty: *tty,
                sandbox_permissions: *sandbox_permissions,
                additional_permissions: additional_permissions.clone(),
            })],
            Self::McpToolCall { .. }
            | Self::NetworkAccess { .. }
            | Self::RequestPermissions { .. }
            | Self::WriteStdin { .. } => Vec::new(),
            Self::ApplyPatch {
                environment_id,
                files,
                ..
            } => files
                .iter()
                .cloned()
                .map(|path| {
                    ApprovalCacheKey::ApplyPatch(ApplyPatchApprovalKey {
                        environment_id: environment_id.clone(),
                        path,
                    })
                })
                .collect(),
        }
    }

    pub(crate) fn into_guardian_request(
        self,
        exec_command_cwd_convention: Option<PathConvention>,
    ) -> std::io::Result<crate::guardian::GuardianApprovalRequest> {
        Ok(match self {
            Self::ExecCommand {
                id,
                environment_id,
                command,
                cwd,
                sandbox_permissions,
                additional_permissions,
                justification,
                tty,
                ..
            } => crate::guardian::GuardianApprovalRequest::ExecCommand {
                id,
                environment_id,
                command,
                guardian_cwd: codex_utils_path_uri::LegacyAppPathString::from_path_uri(
                    &cwd,
                    exec_command_cwd_convention.ok_or_else(|| {
                        std::io::Error::other("missing exec command cwd convention")
                    })?,
                )
                .map_err(std::io::Error::other)?,
                cwd,
                sandbox_permissions,
                additional_permissions,
                justification,
                tty,
            },
            Self::WriteStdin {
                id,
                approval_id,
                environment_id,
                process_id,
                input,
                cwd,
                tty,
                sandbox_permissions,
                additional_permissions,
            } => crate::guardian::GuardianApprovalRequest::WriteStdin {
                id,
                approval_id,
                environment_id,
                process_id,
                input,
                cwd,
                tty,
                sandbox_permissions,
                additional_permissions,
            },
            Self::ApplyPatch {
                id,
                environment_id,
                cwd,
                files,
                patch,
                ..
            } => crate::guardian::GuardianApprovalRequest::ApplyPatch {
                id,
                environment_id,
                cwd,
                files,
                patch,
            },
            Self::McpToolCall {
                id,
                server,
                tool_name,
                arguments,
                connector_id,
                connector_name,
                connector_description,
                connected_account_email,
                tool_title,
                tool_description,
                annotations,
                ..
            } => crate::guardian::GuardianApprovalRequest::McpToolCall {
                id,
                server,
                tool_name,
                arguments,
                connector_id,
                connector_name,
                connector_description,
                connected_account_email,
                tool_title,
                tool_description,
                annotations,
            },
            Self::NetworkAccess {
                id,
                turn_id,
                environment_id,
                target,
                host,
                protocol,
                port,
                trigger,
                ..
            } => crate::guardian::GuardianApprovalRequest::NetworkAccess {
                id,
                turn_id,
                environment_id,
                target,
                host,
                protocol,
                port,
                trigger,
            },
            Self::RequestPermissions {
                id,
                environment_id,
                turn_id,
                reason,
                permissions,
            } => crate::guardian::GuardianApprovalRequest::RequestPermissions {
                id,
                environment_id,
                turn_id,
                reason,
                permissions,
            },
        })
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ApprovalResolutionSource {
    Hook,
    Guardian,
    User,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct ApprovalResolution {
    decision: ReviewDecision,
    source: ApprovalResolutionSource,
}

impl ApprovalResolution {
    fn into_tool_result(self, model_info: &ModelInfo) -> Result<ReviewDecision, ToolError> {
        let source = self.source;
        match self.decision {
            ReviewDecision::ApprovedMcpPolicyAmendment => {
                error!("Tool approval received ApprovedMcpPolicyAmendment");
                Err(ToolError::Rejected(
                    "Error while requesting approval".to_string(),
                ))
            }
            ReviewDecision::NetworkPolicyAmendment {
                network_policy_amendment,
            } if network_policy_amendment.action == NetworkPolicyRuleAction::Deny => {
                let rejection = match source {
                    ApprovalResolutionSource::Hook => "rejected by configuration",
                    ApprovalResolutionSource::Guardian => {
                        "automatic approval review denied the action"
                    }
                    ApprovalResolutionSource::User => "rejected by user",
                };
                Err(ToolError::Rejected(rejection.to_string()))
            }
            ReviewDecision::Denied { rejection } => Err(ToolError::Rejected(rejection)),
            ReviewDecision::TimedOut => Err(ToolError::Rejected(
                ResolvedModelMessages::from_model(model_info)
                    .auto_review()
                    .timeout_instructions
                    .to_string(),
            )),
            ReviewDecision::Abort => Err(ToolError::Codex(CodexErr::TurnAborted)),
            decision => Ok(decision),
        }
    }
}

impl Session {
    pub(crate) async fn request_approval(
        self: &Arc<Self>,
        action: ApprovalAction,
        ctx: ApprovalContext,
    ) -> Result<ReviewDecision, ToolError> {
        // Stdin that exceeds current permissions needs a fresh sandbox approval.
        // Strict review of ordinary input follows the same routing as ordinary exec.
        let policy = ctx.review_context.turn().approval_policy();
        if matches!(&action, ApprovalAction::WriteStdin { sandbox_permissions, .. }
            if sandbox_permissions.requests_sandbox_override())
            && !(ctx.strict_auto_review && matches!(policy, AskForApproval::Never))
            && let Some(reason) =
                prompt_is_rejected_by_policy(policy, /*prompt_is_rule*/ false)
        {
            return Err(ToolError::Rejected(reason.to_string()));
        }
        let is_mcp_tool_call = matches!(&action, ApprovalAction::McpToolCall { .. });
        let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. });
        let permission_request_run_id = match &action {
            ApprovalAction::NetworkAccess { hook_run_id, .. } => hook_run_id.clone(),
            _ if ctx.retry_reason.is_some() => format!("{}:retry", ctx.call_id),
            _ => ctx.call_id.clone(),
        };

        // Approval precedence is:
        // 1. Hooks
        // 2. If StrictAutoReview || Guardian enabled, then Guardian. Else, user.
        let resolution = match run_permission_request_hooks(
            self,
            &ctx.review_context,
            &permission_request_run_id,
            action.permission_request_payload(),
        )
        .await
        {
            Some(PermissionRequestDecision::Allow) => ApprovalResolution {
                decision: ReviewDecision::Approved,
                source: ApprovalResolutionSource::Hook,
            },
            Some(PermissionRequestDecision::Deny { message }) => ApprovalResolution {
                decision: ReviewDecision::denied(message),
                source: ApprovalResolutionSource::Hook,
            },
            None => self.request_reviewer_approval(action, &ctx).await,
        };
        // Network approvals record their final telemetry after validation and persistence.
        if !is_network_approval {
            record_resolution(&ctx, &resolution);
        }
        if is_mcp_tool_call && resolution.decision == ReviewDecision::ApprovedMcpPolicyAmendment {
            return Ok(resolution.decision);
        }
        if is_network_approval {
            match (&resolution.decision, resolution.source) {
                (
                    ReviewDecision::NetworkPolicyAmendment {
                        network_policy_amendment,
                    },
                    _,
                ) if network_policy_amendment.action == NetworkPolicyRuleAction::Deny => {
                    return Ok(resolution.decision);
                }
                (ReviewDecision::Abort, ApprovalResolutionSource::Guardian) => {
                    return Err(ToolError::Rejected(
                        "automatic approval review was cancelled".to_string(),
                    ));
                }
                _ => {}
            }
        }
        resolution.into_tool_result(ctx.review_context.turn().model_info())
    }

    async fn request_reviewer_approval(
        self: &Arc<Self>,
        action: ApprovalAction,
        ctx: &ApprovalContext,
    ) -> ApprovalResolution {
        if let Some(decision) = self.request_guardian_approval(action.clone(), ctx).await {
            ApprovalResolution {
                decision,
                source: ApprovalResolutionSource::Guardian,
            }
        } else {
            ApprovalResolution {
                decision: self.request_user_approval(&action, ctx).await,
                source: ApprovalResolutionSource::User,
            }
        }
    }

    pub(crate) async fn request_guardian_approval(
        self: &Arc<Self>,
        action: ApprovalAction,
        ctx: &ApprovalContext,
    ) -> Option<ReviewDecision> {
        let mut context = ctx.review_context.clone();
        if let ApprovalAction::McpToolCall {
            approval_policy,
            reviewer,
            ..
        } = &action
        {
            context.approval_policy = *approval_policy;
            context.approvals_reviewer = *reviewer;
        }
        let is_network_approval = matches!(&action, ApprovalAction::NetworkAccess { .. });
        let review_id = new_guardian_review_id();
        let exec_command_cwd_convention = match &action {
            ApprovalAction::ExecCommand {
                environment_id,
                cwd,
                ..
            } => Some(
                match ctx
                    .review_context
                    .environments()
                    .turn_environments()
                    .find(|environment| environment.selection.environment_id == *environment_id)
                    .and_then(|environment| environment.executor_platform_os.as_deref())
                {
                    Some("windows") => PathConvention::Windows,
                    Some(_) => PathConvention::Posix,
                    // Legacy executors did not report their OS. Preserve the
                    // previous spelling-based behavior for those executors.
                    None => cwd
                        .infer_path_convention()
                        .unwrap_or_else(PathConvention::native),
                },
            ),
            _ => None,
        };
        let action = crate::guardian::ReviewAction::from_approval_action(
            action,
            exec_command_cwd_convention,
        );

        let reasons = ApprovalRequestReasons {
            approval: ctx.approval_reason.clone(),
            retry: ctx.retry_reason.clone(),
        };
        let options = GuardianReviewOptions {
            require_guardian: ctx.strict_auto_review,
            plugin_attribution_override: None,
            approval_request_source: GuardianApprovalRequestSource::MainTurn,
            external_cancel: ctx.cancellation_token.clone(),
            require_synchronous_review: false,
        };
        if ctx.cancellation_token.is_some() {
            spawn_approval_decision(
                Arc::clone(self),
                context,
                review_id,
                action,
                reasons,
                options,
            )
            .await
            .unwrap_or_else(|_| {
                Some(ReviewDecision::denied(
                    "automatic approval review could not complete",
                ))
            })
        } else if is_network_approval {
            let review_cancel = CancellationToken::new();
            let review_cancel_guard = review_cancel.clone().drop_guard();
            let review = tokio::spawn(decide_approval(
                Arc::clone(self),
                context,
                review_id,
                action,
                reasons,
                GuardianReviewOptions {
                    external_cancel: Some(review_cancel),
                    ..options
                },
            ));
            let decision = review.await.unwrap_or_else(|err| {
                warn!("network Guardian review task failed: {err}");
                Some(ReviewDecision::denied(
                    "automatic approval review could not complete",
                ))
            });
            drop(review_cancel_guard.disarm());
            decision
        } else {
            decide_approval(
                Arc::clone(self),
                context,
                review_id,
                action,
                reasons,
                options,
            )
            .await
        }
    }

    async fn request_user_approval(
        &self,
        action: &ApprovalAction,
        ctx: &ApprovalContext,
    ) -> ReviewDecision {
        match action {
            ApprovalAction::ExecCommand {
                environment_id,
                command,
                cwd,
                additional_permissions,
                justification,
                proposed_execpolicy_amendment,
                ..
            } => {
                let tool_name = "unified_exec";
                let reason = ctx
                    .retry_reason
                    .clone()
                    .or_else(|| ctx.approval_reason.clone())
                    .or_else(|| justification.clone());
                let policy_fingerprint = ctx
                    .review_context
                    .environments()
                    .turn_environments()
                    .find(|environment| environment.selection.environment_id == *environment_id)
                    .and_then(|environment| environment.config().exec_policy.as_ref())
                    .map(codex_execpolicy::RequirementsExecPolicy::fingerprint);
                let cache_keys = action
                    .cache_keys()
                    .into_iter()
                    .map(|key| (key, &policy_fingerprint))
                    .collect();
                with_cached_approval(&self.services, tool_name, cache_keys, || async {
                    self.request_command_approval(
                        ctx.review_context.turn(),
                        ExecApprovalKind::Command,
                        ctx.review_context.model_context(),
                        ctx.call_id.clone(),
                        /*approval_id*/ None,
                        Some(environment_id.clone()),
                        command.clone(),
                        cwd.clone(),
                        reason,
                        ctx.network_approval_context.clone(),
                        proposed_execpolicy_amendment.clone(),
                        additional_permissions.clone(),
                        /*available_decisions*/ None,
                        /*plugin_attribution_override*/ None,
                    )
                    .await
                })
                .await
            }
            ApprovalAction::WriteStdin {
                id,
                approval_id,
                environment_id,
                process_id,
                input,
                cwd,
                additional_permissions,
                ..
            } => {
                self.request_command_approval(
                    ctx.review_context.turn(),
                    ExecApprovalKind::WriteStdin,
                    ctx.review_context.model_context(),
                    id.clone(),
                    Some(approval_id.clone()),
                    Some(environment_id.clone()),
                    vec![
                        "write_stdin".to_string(),
                        "--session-id".to_string(),
                        process_id.to_string(),
                        input.clone(),
                    ],
                    cwd.clone(),
                    ctx.approval_reason.clone(),
                    /*network_approval_context*/ None,
                    /*proposed_execpolicy_amendment*/ None,
                    additional_permissions.clone(),
                    Some(vec![ReviewDecision::Approved, ReviewDecision::Abort]),
                    /*plugin_attribution_override*/ None,
                )
                .await
            }
            ApprovalAction::ApplyPatch {
                changes,
                permissions_preapproved,
                ..
            } => {
                let reason = ctx
                    .retry_reason
                    .clone()
                    .or_else(|| ctx.approval_reason.clone());
                if *permissions_preapproved && reason.is_none() {
                    return ReviewDecision::Approved;
                }
                if reason.is_some() {
                    return self
                        .request_patch_approval(
                            ctx.review_context.turn(),
                            ctx.call_id.clone(),
                            changes.as_ref().clone(),
                            reason,
                            /*grant_root*/ None,
                        )
                        .await;
                }
                with_cached_approval(
                    &self.services,
                    "apply_patch",
                    action.cache_keys(),
                    || async {
                        self.request_patch_approval(
                            ctx.review_context.turn(),
                            ctx.call_id.clone(),
                            changes.as_ref().clone(),
                            /*reason*/ None,
                            /*grant_root*/ None,
                        )
                        .await
                    },
                )
                .await
            }
            ApprovalAction::McpToolCall { .. } => {
                request_mcp_tool_user_approval(
                    self,
                    ctx.review_context.turn(),
                    &ctx.call_id,
                    action,
                )
                .await
            }
            ApprovalAction::NetworkAccess {
                environment_id,
                command,
                cwd,
                ..
            } => {
                self.request_command_approval(
                    ctx.review_context.turn(),
                    ExecApprovalKind::Command,
                    ctx.review_context.model_context(),
                    ctx.call_id.clone(),
                    /*approval_id*/ None,
                    Some(environment_id.clone()),
                    command.clone(),
                    cwd.clone().into(),
                    ctx.approval_reason.clone(),
                    ctx.network_approval_context.clone(),
                    /*proposed_execpolicy_amendment*/ None,
                    /*additional_permissions*/ None,
                    /*available_decisions*/ None,
                    /*plugin_attribution_override*/ None,
                )
                .await
            }
            ApprovalAction::RequestPermissions { .. } => {
                unreachable!("permission requests are routed directly to Guardian")
            }
        }
    }
}

fn record_resolution(ctx: &ApprovalContext, resolution: &ApprovalResolution) {
    let source = match resolution.source {
        ApprovalResolutionSource::Hook => ToolDecisionSource::Config,
        ApprovalResolutionSource::Guardian => ToolDecisionSource::AutomatedReviewer,
        ApprovalResolutionSource::User => ToolDecisionSource::User,
    };
    ctx.review_context.turn().session_telemetry.tool_decision(
        &ctx.tool_name,
        &ctx.call_id,
        &resolution.decision,
        Some(source),
    );
}

#[cfg(all(test, unix))]
#[path = "approvals_tests.rs"]
mod tests;
codex-rs/core/src/tools/call_trace.rs
//! Trace milestones for every direct or code-mode tool call handled by core.
//! These events contain call identifiers and names, never arguments or output.
//! Code-mode calls reach the broker before the dispatching turn is known.

use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::ThreadId;
use codex_tools::ToolName;

#[derive(Clone, Copy)]
pub(crate) enum Source {
    Direct,
    CodeMode,
}

impl Source {
    fn as_str(self) -> &'static str {
        match self {
            Self::Direct => "direct",
            Self::CodeMode => "code_mode",
        }
    }
}

pub(crate) enum Receipt<'a> {
    ModelTurn(&'a str),
    CodeModeBroker {
        cell_id: Option<&'a str>,
        runtime_tool_call_id: Option<&'a str>,
    },
}

pub(crate) fn received(
    thread_id: ThreadId,
    tool_name: &ToolName,
    call_id: &str,
    receipt: Receipt<'_>,
) {
    let (turn_id, source, cell_id, runtime_tool_call_id) = match receipt {
        Receipt::ModelTurn(turn_id) => (Some(turn_id), Source::Direct, None, None),
        Receipt::CodeModeBroker {
            cell_id,
            runtime_tool_call_id,
        } => (None, Source::CodeMode, cell_id, runtime_tool_call_id),
    };
    tracing::event!(
        name: "codex.tool_call_received",
        target: "codex_otel.trace_safe",
        tracing::Level::INFO,
        event.name = "codex.tool_call_received",
        conversation.id = %thread_id,
        turn_id,
        call_id,
        tool_name = tool_name.name.as_str(),
        tool_namespace = namespace(tool_name),
        tool_source = source.as_str(),
        cell.id = cell_id,
        runtime_tool_call_id,
    );
}

pub(crate) fn result_ready(
    thread_id: ThreadId,
    turn_id: &str,
    tool_name: &ToolName,
    call_id: &str,
    source: Source,
) {
    tracing::event!(
        name: "codex.tool_result_ready",
        target: "codex_otel.trace_safe",
        tracing::Level::INFO,
        event.name = "codex.tool_result_ready",
        conversation.id = %thread_id,
        turn_id,
        call_id,
        tool_name = tool_name.name.as_str(),
        tool_namespace = namespace(tool_name),
        tool_source = source.as_str(),
    );
}

fn namespace(tool_name: &ToolName) -> &str {
    tool_name
        .namespace
        .as_deref()
        .filter(|namespace| !namespace.is_empty())
        .unwrap_or(DEFAULT_FUNCTION_NAMESPACE)
}
codex-rs/core/src/tools/catalog_parameters.rs
//! Parses catalog parameter overrides using the harness's supported JSON Schema subset.
//! Rejected schemas leave fallback and transport-specific requirements to the tool owner.

use codex_tools::JsonSchema;
use serde_json::Value;

pub(super) fn parse(parameters: &str) -> Result<JsonSchema, &'static str> {
    let parameters: Value =
        serde_json::from_str(parameters).map_err(|_| "schema is not valid JSON")?;
    if !parameters.is_object() || parameters["type"] != "object" {
        return Err("schema must declare an object type");
    }
    serde_json::from_value(parameters).map_err(|_| "schema uses unsupported JSON Schema structures")
}

Goal / Web Search extension

Python 稿把 goal 当作内置 registry;Rust 固定树中它属于 extension。web_search 的独立 extension tool 同样只定义调用,搜索发生在 provider/服务侧。

codex-rs/ext/goal/src/spec.rs
//! Responses API tool definitions for persisted thread goals.

use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use serde_json::json;
use std::collections::BTreeMap;

pub const GET_GOAL_TOOL_NAME: &str = "get_goal";
pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal";
pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";

pub fn create_get_goal_tool() -> ToolSpec {
    ToolSpec::Function(ResponsesApiTool {
        name: GET_GOAL_TOOL_NAME.to_string(),
        description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget."
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())),
        output_schema: None,
    })
}

pub fn create_create_goal_tool() -> ToolSpec {
    let properties = BTreeMap::from([
        (
            "objective".to_string(),
            JsonSchema::string(Some(
                "Required. The concrete objective to start pursuing. This starts a new active goal when no goal exists or replaces the current goal when it is complete."
                    .to_string(),
            )),
        ),
        (
            "token_budget".to_string(),
            JsonSchema::integer(Some(
                "Positive token budget for the new goal. Omit unless explicitly requested."
                    .to_string(),
            )),
        ),
    ]);

    ToolSpec::Function(ResponsesApiTool {
        name: CREATE_GOAL_TOOL_NAME.to_string(),
        description: format!(
            r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.
Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."#
        ),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            /*required*/ Some(vec!["objective".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}

pub fn create_update_goal_tool() -> ToolSpec {
    let properties = BTreeMap::from([(
        "status".to_string(),
        JsonSchema::string_enum(
            vec![json!("complete"), json!("blocked"), json!("paused")],
            Some(
                "Required. `paused` requires an explicit user request. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit."
                    .to_string(),
            ),
        ),
    )]);

    ToolSpec::Function(ResponsesApiTool {
        name: UPDATE_GOAL_TOOL_NAME.to_string(),
        description: r#"Update the existing goal.
Set status to `paused` only at the user's explicit request to pause this goal, never on your own initiative. Ask if unclear; a later resume revokes that request. Report the returned status and stop goal work. Budget limits take precedence over pausing.
Set status to `complete` only when the objective has actually been achieved and no required work remains.
Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change.
If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again.
Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`.
Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification.
Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work.
You cannot use this tool to resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system.
When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."#
            .to_string(),
        strict: false,
        defer_loading: None,
        parameters: JsonSchema::object(
            properties,
            /*required*/ Some(vec!["status".to_string()]),
            Some(false.into()),
        ),
        output_schema: None,
    })
}
codex-rs/ext/goal/src/tool.rs
use std::sync::Arc;

use codex_extension_api::FunctionCallError;
use codex_extension_api::JsonToolOutput;
use codex_extension_api::ToolCall;
use codex_extension_api::ToolExecutor;
use codex_extension_api::ToolName;
use codex_extension_api::ToolOutput;
use codex_extension_api::ToolSpec;
use codex_protocol::ThreadId;
use codex_protocol::protocol::ThreadGoal;
use codex_protocol::protocol::ThreadGoalStatus;
use codex_protocol::protocol::validate_thread_goal_objective;
use serde::Deserialize;
use serde::Serialize;

use crate::accounting::BudgetLimitedGoalDisposition;
use crate::accounting::GoalAccountingState;
use crate::analytics::GoalAnalytics;
use crate::analytics::GoalEventAttribution;
use crate::events::GoalEventEmitter;
use crate::metrics::GoalMetrics;
use crate::spec::CREATE_GOAL_TOOL_NAME;
use crate::spec::GET_GOAL_TOOL_NAME;
use crate::spec::UPDATE_GOAL_TOOL_NAME;
use crate::spec::create_create_goal_tool;
use crate::spec::create_get_goal_tool;
use crate::spec::create_update_goal_tool;

#[derive(Clone)]
pub(crate) struct GoalToolExecutor {
    pub(crate) execution_allowed: bool,
    kind: GoalToolKind,
    thread_id: ThreadId,
    state_db: Arc<codex_state::StateRuntime>,
    accounting_state: Arc<GoalAccountingState>,
    analytics: GoalAnalytics,
    event_emitter: GoalEventEmitter,
    metrics: GoalMetrics,
    max_goal_token_budget: Option<i64>,
}

#[derive(Clone, Copy)]
enum GoalToolKind {
    Get,
    Create,
    Update,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct CreateGoalRequest {
    pub objective: String,
    pub token_budget: Option<i64>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
struct UpdateGoalArgs {
    status: ThreadGoalStatus,
}

#[derive(Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GoalToolResponse {
    goal: Option<ThreadGoal>,
    remaining_tokens: Option<i64>,
    completion_budget_report: Option<String>,
}

#[derive(Clone, Copy)]
enum CompletionBudgetReport {
    Include,
    Omit,
}

impl GoalToolExecutor {
    pub(crate) fn get(
        thread_id: ThreadId,
        state_db: Arc<codex_state::StateRuntime>,
        accounting_state: Arc<GoalAccountingState>,
        analytics: GoalAnalytics,
        event_emitter: GoalEventEmitter,
        metrics: GoalMetrics,
    ) -> Self {
        Self {
            kind: GoalToolKind::Get,
            execution_allowed: true,
            thread_id,
            state_db,
            accounting_state,
            analytics,
            event_emitter,
            metrics,
            max_goal_token_budget: None,
        }
    }

    pub(crate) fn create(
        thread_id: ThreadId,
        state_db: Arc<codex_state::StateRuntime>,
        accounting_state: Arc<GoalAccountingState>,
        analytics: GoalAnalytics,
        event_emitter: GoalEventEmitter,
        metrics: GoalMetrics,
        max_goal_token_budget: Option<i64>,
    ) -> Self {
        Self {
            kind: GoalToolKind::Create,
            execution_allowed: true,
            thread_id,
            state_db,
            accounting_state,
            analytics,
            event_emitter,
            metrics,
            max_goal_token_budget,
        }
    }

    pub(crate) fn update(
        thread_id: ThreadId,
        state_db: Arc<codex_state::StateRuntime>,
        accounting_state: Arc<GoalAccountingState>,
        analytics: GoalAnalytics,
        event_emitter: GoalEventEmitter,
        metrics: GoalMetrics,
    ) -> Self {
        Self {
            kind: GoalToolKind::Update,
            execution_allowed: true,
            thread_id,
            state_db,
            accounting_state,
            analytics,
            event_emitter,
            metrics,
            max_goal_token_budget: None,
        }
    }
}

impl<'call> ToolExecutor<ToolCall<'call>> for GoalToolExecutor {
    fn tool_name(&self) -> ToolName {
        ToolName::plain(match self.kind {
            GoalToolKind::Get => GET_GOAL_TOOL_NAME,
            GoalToolKind::Create => CREATE_GOAL_TOOL_NAME,
            GoalToolKind::Update => UPDATE_GOAL_TOOL_NAME,
        })
    }

    fn supports_parallel_tool_calls(&self) -> bool {
        matches!(self.kind, GoalToolKind::Get)
    }

    fn spec(&self) -> ToolSpec {
        match self.kind {
            GoalToolKind::Get => create_get_goal_tool(),
            GoalToolKind::Create => create_create_goal_tool(),
            GoalToolKind::Update => create_update_goal_tool(),
        }
    }

    fn handle<'a>(
        &'a self,
        invocation: ToolCall<'call>,
    ) -> codex_extension_api::ToolExecutorFuture<'a>
    where
        'call: 'a,
    {
        Box::pin(async move {
            if !self.execution_allowed {
                return Err(FunctionCallError::RespondToModel(
                    "Goal tools require a persistent thread.".to_string(),
                ));
            }
            match self.kind {
                GoalToolKind::Get => self.handle_get(invocation).await,
                GoalToolKind::Create => self.handle_create(invocation).await,
                GoalToolKind::Update => self.handle_update(invocation).await,
            }
        })
    }
}

impl GoalToolExecutor {
    async fn handle_get(
        &self,
        invocation: ToolCall<'_>,
    ) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
        let _ = invocation.function_arguments()?;
        let goal = self
            .state_db
            .thread_goals()
            .get_thread_goal(self.thread_id)
            .await
            .map(|goal| goal.map(protocol_goal_from_state))
            .map_err(|err| {
                FunctionCallError::RespondToModel(format!("failed to read goal: {err}"))
            })?;
        goal_response(goal, CompletionBudgetReport::Omit)
    }

    async fn handle_create(
        &self,
        invocation: ToolCall<'_>,
    ) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
        let mut request: CreateGoalRequest = parse_arguments(invocation.function_arguments()?)?;
        request.objective = request.objective.trim().to_string();
        validate_thread_goal_objective(&request.objective)
            .map_err(FunctionCallError::RespondToModel)?;
        request.token_budget = request.token_budget.or(self.max_goal_token_budget);
        validate_goal_budget(request.token_budget, self.max_goal_token_budget)
            .map_err(FunctionCallError::RespondToModel)?;

        let goal = self
            .state_db
            .thread_goals()
            .insert_thread_goal(
                self.thread_id,
                request.objective.as_str(),
                codex_state::ThreadGoalStatus::Active,
                request.token_budget,
            )
            .await
            .map_err(|err| FunctionCallError::RespondToModel(format!("failed to create goal: {err}")))?
            .ok_or_else(|| {
                FunctionCallError::RespondToModel(
                    "cannot create a new goal because this thread has an unfinished goal; complete the existing goal first"
                        .to_string(),
                )
            })?;
        fill_empty_thread_preview_if_possible(self.state_db.as_ref(), self.thread_id, &goal).await;
        let turn_id = self
            .accounting_state
            .mark_current_turn_goal_active(goal.goal_id.clone());
        self.metrics.record_created();
        self.analytics.created(
            &goal,
            GoalEventAttribution::Turn(invocation.turn_id.as_str()),
        );
        let goal = protocol_goal_from_state(goal);
        self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone());
        goal_response(Some(goal), CompletionBudgetReport::Omit)
    }

    async fn handle_update(
        &self,
        invocation: ToolCall<'_>,
    ) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
        let args: UpdateGoalArgs = parse_arguments(invocation.function_arguments()?)?;
        if !matches!(
            args.status,
            ThreadGoalStatus::Complete | ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused
        ) {
            return Err(FunctionCallError::RespondToModel(
                "update_goal can only mark the existing goal complete, blocked, or paused at the user's explicit request; resume, budget-limited, and usage-limited status changes are controlled by the user or system"
                    .to_string(),
            ));
        }

        self.account_active_goal_progress(
            match args.status {
                ThreadGoalStatus::Complete => codex_state::GoalAccountingMode::ActiveOrComplete,
                ThreadGoalStatus::Blocked | ThreadGoalStatus::Paused => {
                    codex_state::GoalAccountingMode::ActiveOrStopped
                }
                ThreadGoalStatus::Active
                | ThreadGoalStatus::UsageLimited
                | ThreadGoalStatus::BudgetLimited => unreachable!("status validated above"),
            },
            invocation.call_id.as_str(),
            BudgetLimitedGoalDisposition::ClearActive,
        )
        .await?;
        let previous_status = self
            .current_goal_status_for_metrics(/*expected_goal_id*/ None)
            .await?;
        let goal = self
            .state_db
            .thread_goals()
            .update_thread_goal(
                self.thread_id,
                codex_state::GoalUpdate {
                    objective: None,
                    status: Some(state_status_from_protocol(args.status)),
                    token_budget: None,
                    expected_goal_id: None,
                },
            )
            .await
            .map_err(|err| {
                FunctionCallError::RespondToModel(format!("failed to update goal: {err}"))
            })?
            .ok_or_else(|| {
                FunctionCallError::RespondToModel(
                    "cannot update goal because this thread has no goal".to_string(),
                )
            })?;
        self.metrics
            .record_terminal_if_status_changed(previous_status, &goal);
        self.analytics.status_changed(
            &goal,
            previous_status,
            GoalEventAttribution::Turn(invocation.turn_id.as_str()),
        );
        let goal = protocol_goal_from_state(goal);
        let turn_id = self.accounting_state.clear_current_turn_goal();
        self.emit_goal_updated_from_tool_call(&invocation, turn_id, goal.clone());
        goal_response(
            Some(goal),
            if args.status == ThreadGoalStatus::Complete {
                CompletionBudgetReport::Include
            } else {
                CompletionBudgetReport::Omit
            },
        )
    }

    fn emit_goal_updated_from_tool_call(
        &self,
        invocation: &ToolCall<'_>,
        turn_id: Option<String>,
        goal: ThreadGoal,
    ) {
        self.event_emitter
            .thread_goal_updated(invocation.call_id.clone(), turn_id, goal);
    }

    async fn account_active_goal_progress(
        &self,
        mode: codex_state::GoalAccountingMode,
        event_id: &str,
        budget_limited_goal_disposition: BudgetLimitedGoalDisposition,
    ) -> Result<Option<ThreadGoal>, FunctionCallError> {
        let Some(turn_id) = self.accounting_state.current_turn_id() else {
            return Ok(None);
        };
        let _accounting_permit = self
            .accounting_state
            .progress_accounting_permit()
            .await
            .map_err(|err| {
                FunctionCallError::Fatal(format!(
                    "goal progress accounting semaphore closed: {err}"
                ))
            })?;
        let Some(snapshot) = self.accounting_state.progress_snapshot(turn_id.as_str()) else {
            return Ok(None);
        };
        let previous_status = self
            .current_goal_status_for_metrics(Some(snapshot.expected_goal_id.as_str()))
            .await?;
        let outcome = self
            .state_db
            .thread_goals()
            .account_thread_goal_usage(
                self.thread_id,
                snapshot.time_delta_seconds,
                snapshot.token_delta,
                mode,
                Some(snapshot.expected_goal_id.as_str()),
            )
            .await
            .map_err(|err| {
                FunctionCallError::RespondToModel(format!("failed to account goal progress: {err}"))
            })?;
        Ok(match outcome {
            codex_state::GoalAccountingOutcome::Updated(goal) => {
                self.metrics
                    .record_terminal_if_status_changed(previous_status, &goal);
                self.analytics
                    .usage_accounted(&goal, GoalEventAttribution::Turn(turn_id.as_str()));
                self.analytics.status_changed(
                    &goal,
                    previous_status,
                    GoalEventAttribution::Turn(turn_id.as_str()),
                );
                self.accounting_state.mark_progress_accounted_for_status(
                    turn_id.as_str(),
                    &snapshot,
                    goal.status,
                    budget_limited_goal_disposition,
                );
                let goal = protocol_goal_from_state(goal);
                self.event_emitter.thread_goal_updated(
                    event_id.to_string(),
                    Some(turn_id),
                    goal.clone(),
                );
                Some(goal)
            }
            codex_state::GoalAccountingOutcome::Unchanged(_) => None,
        })
    }

    async fn current_goal_status_for_metrics(
        &self,
        expected_goal_id: Option<&str>,
    ) -> Result<Option<codex_state::ThreadGoalStatus>, FunctionCallError> {
        let goal = self
            .state_db
            .thread_goals()
            .get_thread_goal(self.thread_id)
            .await
            .map_err(|err| {
                FunctionCallError::RespondToModel(format!(
                    "failed to read goal metrics status: {err}"
                ))
            })?;
        Ok(goal.and_then(|goal| {
            expected_goal_id
                .is_none_or(|expected_goal_id| goal.goal_id == expected_goal_id)
                .then_some(goal.status)
        }))
    }
}

fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
where
    T: for<'de> Deserialize<'de>,
{
    serde_json::from_str(arguments)
        .map_err(|err| FunctionCallError::RespondToModel(err.to_string()))
}

pub(crate) fn validate_goal_budget(
    value: Option<i64>,
    max_goal_token_budget: Option<i64>,
) -> Result<(), String> {
    if let Some(value) = value
        && value <= 0
    {
        return Err("goal budgets must be positive when provided".to_string());
    }
    if let Some(value) = value
        && let Some(max_goal_token_budget) = max_goal_token_budget
        && value > max_goal_token_budget
    {
        return Err(format!(
            "goal token budget {value} exceeds the maximum allowed goal token budget of {max_goal_token_budget}"
        ));
    }
    Ok(())
}

fn goal_response(
    goal: Option<ThreadGoal>,
    completion_budget_report: CompletionBudgetReport,
) -> Result<Box<dyn ToolOutput>, FunctionCallError> {
    let value = serde_json::to_value(GoalToolResponse::new(goal, completion_budget_report))
        .map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
    Ok(Box::new(JsonToolOutput::new(value)))
}

impl GoalToolResponse {
    fn new(goal: Option<ThreadGoal>, report_mode: CompletionBudgetReport) -> Self {
        let remaining_tokens = goal.as_ref().and_then(|goal| {
            goal.token_budget
                .map(|budget| (budget - goal.tokens_used).max(0))
        });
        let completion_budget_report = match report_mode {
            CompletionBudgetReport::Include => goal
                .as_ref()
                .filter(|goal| goal.status == ThreadGoalStatus::Complete)
                .and_then(completion_budget_report),
            CompletionBudgetReport::Omit => None,
        };
        Self {
            goal,
            remaining_tokens,
            completion_budget_report,
        }
    }
}

pub(crate) async fn fill_empty_thread_preview_if_possible(
    state_db: &codex_state::StateRuntime,
    thread_id: ThreadId,
    goal: &codex_state::ThreadGoal,
) {
    if let Err(err) = state_db
        .set_thread_preview_if_empty(thread_id, goal.objective.as_str())
        .await
    {
        tracing::warn!(
            "failed to set empty thread preview from goal objective for {thread_id}: {err}"
        );
    }
}

pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
    ThreadGoal {
        thread_id: goal.thread_id,
        objective: goal.objective,
        status: protocol_status_from_state(goal.status),
        token_budget: goal.token_budget,
        tokens_used: goal.tokens_used,
        time_used_seconds: goal.time_used_seconds,
        created_at: goal.created_at.timestamp(),
        updated_at: goal.updated_at.timestamp(),
    }
}

fn protocol_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus {
    match status {
        codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
        codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
        codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked,
        codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited,
        codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
        codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
    }
}

pub(crate) fn state_status_from_protocol(
    status: ThreadGoalStatus,
) -> codex_state::ThreadGoalStatus {
    match status {
        ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
        ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
        ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked,
        ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited,
        ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
        ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
    }
}

fn completion_budget_report(goal: &ThreadGoal) -> Option<String> {
    if goal.token_budget.is_none() && goal.time_used_seconds <= 0 {
        None
    } else {
        Some(
            "Goal achieved. Report final usage from this tool result's structured goal fields. If `goal.tokenBudget` is present, include token usage from `goal.tokensUsed` and `goal.tokenBudget`. If `goal.timeUsedSeconds` is greater than 0, summarize elapsed time in a concise, human-friendly form appropriate to the response language."
                .to_string(),
        )
    }
}
codex-rs/ext/items/src/web_search.rs
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;

// Standalone web-search item owned by the web extension. This is also the
// field-level representation exposed by app-server; core and rollout
// persistence only carry it inside an ExtensionItem envelope.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
pub struct WebSearchItem {
    pub id: String,
    pub query: String,
    pub action: Option<WebSearchAction>,
    /// Structured search results returned out-of-band by standalone web search.
    ///
    /// These stay as opaque JSON at the extension/app-server boundary so new
    /// result fields and result types can pass through without a Codex release.
    #[serde(default)]
    pub results: Option<Vec<JsonValue>>,
}

// App-server-facing description of the action performed by standalone web search.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
#[serde(tag = "type", rename_all = "camelCase")]
#[ts(tag = "type", rename_all = "camelCase")]
// Keep app-server's existing v2 TS path. The root WebSearchAction name is
// already used by the snake_case Responses API action type.
#[ts(export_to = "v2/")]
pub enum WebSearchAction {
    Search {
        query: Option<String>,
        queries: Option<Vec<String>>,
    },
    OpenPage {
        url: Option<String>,
    },
    FindInPage {
        url: Option<String>,
        pattern: Option<String>,
    },
    #[serde(other)]
    Other,
}

Registry 与计划入口

这段是理解“谁真正注册”的入口。Python 的 tool_types.py 对应 context.rs 里的 typed ToolOutput;tool_registry.py 对应 registry.rs/spec_plan.rs;orchestrator.rs 是完整 tool loop 的 Rust 对应实现。

codex-rs/core/src/tools/context.rs
use crate::original_image_detail::sanitize_original_image_detail;
use crate::session::session::Session;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnContext;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::format_output_omission_marker;
use crate::unified_exec::resolve_max_tokens;
use codex_protocol::ResponseItemId;
use codex_protocol::mcp::CallToolResult;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::function_call_output_content_items_to_text;
use codex_tools::LoadableToolSpec;
use codex_tools::ToolName;
use codex_utils_audio::estimate_audio_token_count;
use codex_utils_output_truncation::TruncationPolicy;
use codex_utils_output_truncation::approx_token_count;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_output_truncation::truncate_function_output_payload;
use codex_utils_output_truncation::truncate_text;
use codex_utils_output_truncation::with_serialization_allowance;
use serde::Serialize;
use serde_json::Value as JsonValue;
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;

pub use codex_tools::ToolOutput;
pub use codex_tools::ToolPayload;

pub(crate) fn boxed_tool_output<T>(output: T) -> Box<dyn ToolOutput>
where
    T: ToolOutput + 'static,
{
    Box::new(output)
}

pub type SharedTurnDiffTracker = Arc<Mutex<TurnDiffTracker>>;

/// Host-observed state for one call, owned outside its abortable dispatch task.
/// Delivery does not finish the call: post-tool hooks can still be cancelled.
#[derive(Default)]
pub(crate) struct ToolCallState {
    pub(crate) terminal_outcome_reached: AtomicBool,
    pub(crate) delivered_assistant_message: OnceLock<String>,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ToolCallSource {
    Direct,
    DirectPlaintextMessage,
    CodeMode {
        /// Runtime cell that issued the nested tool request.
        cell_id: String,
        /// Code-mode's per-cell tool invocation id. This is useful for
        /// debugging the JS/runtime bridge, but it is not the Codex tool call id
        /// because the runtime id only needs to be unique within one cell.
        runtime_tool_call_id: String,
    },
}

#[derive(Clone)]
pub struct ToolInvocation {
    pub session: Arc<Session>,
    // TODO(sayan): Remove this compatibility field once handlers use `step_context.turn`.
    pub turn: Arc<TurnContext>,
    pub(crate) step_context: Arc<StepContext>,
    pub cancellation_token: CancellationToken,
    pub tracker: SharedTurnDiffTracker,
    pub call_id: String,
    pub tool_name: ToolName,
    pub source: ToolCallSource,
    pub payload: ToolPayload,
}

/// Identity of the model call that started a tool invocation, retained across code-mode waits.
#[derive(Clone)]
pub(crate) struct ToolCallOrigin {
    /// Best-effort lookup: invocations without a matching history item still
    /// retain their known window ID.
    pub(crate) item_id: Option<ResponseItemId>,
    pub(crate) window_id: String,
}

impl ToolInvocation {
    /// Returns the item and window that requested this call or started its code-mode cell.
    pub(crate) async fn originating_call(&self) -> Option<ToolCallOrigin> {
        if let ToolCallSource::CodeMode { cell_id, .. } = &self.source {
            return self
                .session
                .services
                .code_mode_service
                .cell_originating_call(&codex_code_mode::CellId::new(cell_id.clone()));
        }

        let item_id = self
            .session
            .clone_history()
            .await
            .raw_items()
            .rev()
            .find_map(|item| match item {
                ResponseItem::FunctionCall { id, call_id, .. }
                | ResponseItem::CustomToolCall { id, call_id, .. }
                    if call_id == &self.call_id =>
                {
                    id.clone()
                }
                _ => None,
            });
        Some(ToolCallOrigin {
            item_id,
            window_id: self.session.current_window_id().await,
        })
    }
}

#[derive(Clone, Debug)]
pub struct McpToolOutput {
    pub result: CallToolResult,
    pub tool_input: JsonValue,
    // Keep the original metadata for hooks; this flag only controls analytics capture.
    pub(crate) result_metadata_capture_allowed: bool,
    pub wall_time: Duration,
    pub original_image_detail_supported: bool,
    pub truncation_policy: TruncationPolicy,
}

impl ToolOutput for McpToolOutput {
    fn log_output(&self) -> String {
        // Logging has its own budget; do not first apply the model-context budget.
        let output = self.result.log_output();
        let wall_time_seconds = self.wall_time.as_secs_f64();
        let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
        if output.is_empty() {
            header
        } else {
            format!("{header}\n{output}")
        }
    }

    fn success_for_logging(&self) -> bool {
        self.result.success()
    }

    fn fallback_token_limit_override(&self) -> Option<usize> {
        Some(with_serialization_allowance(self.truncation_policy).token_budget())
    }

    fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
        ResponseInputItem::FunctionCallOutput {
            call_id: call_id.to_string(),
            output: self.response_payload(),
        }
    }

    fn code_mode_result(&self, payload: &ToolPayload) -> JsonValue {
        self.result.code_mode_result(payload)
    }

    fn tool_result_metadata(&self) -> Option<&JsonValue> {
        if !self.result_metadata_capture_allowed {
            return None;
        }
        self.result.meta.as_ref()
    }

    fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
        Some(self.tool_input.clone())
    }

    fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
        serde_json::to_value(&self.result).ok()
    }
}

impl McpToolOutput {
    fn response_payload(&self) -> FunctionCallOutputPayload {
        let mut payload = self.result.as_function_call_output_payload();
        if let Some(items) = payload.content_items_mut() {
            sanitize_original_image_detail(self.original_image_detail_supported, items);
        }

        let wall_time_seconds = self.wall_time.as_secs_f64();
        let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");

        match &mut payload.body {
            FunctionCallOutputBody::Text(text) => {
                if text.is_empty() {
                    *text = header;
                } else {
                    *text = format!("{header}\n{text}");
                }
            }
            FunctionCallOutputBody::ContentItems(items) => {
                items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
            }
        }

        // History receives this budget in tokens. Code Mode keeps the raw result.
        truncate_function_output_payload(
            &mut payload,
            with_serialization_allowance(self.truncation_policy),
            estimate_audio_token_count,
        );
        payload
    }
}

#[derive(Clone)]
pub struct ToolSearchOutput {
    pub tools: Vec<LoadableToolSpec>,
}

impl ToolOutput for ToolSearchOutput {
    fn log_output(&self) -> String {
        let tools = self
            .tools
            .iter()
            .map(|tool| {
                serde_json::to_value(tool).unwrap_or_else(|err| {
                    JsonValue::String(format!("failed to serialize tool_search output: {err}"))
                })
            })
            .collect();
        JsonValue::Array(tools).to_string()
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
        ResponseInputItem::ToolSearchOutput {
            call_id: call_id.to_string(),
            status: "completed".to_string(),
            execution: "client".to_string(),
            tools: self
                .tools
                .iter()
                .map(|tool| {
                    serde_json::to_value(tool).unwrap_or_else(|err| {
                        JsonValue::String(format!("failed to serialize tool_search output: {err}"))
                    })
                })
                .collect(),
        }
    }
}

pub struct FunctionToolOutput {
    pub body: Vec<FunctionCallOutputContentItem>,
    pub success: Option<bool>,
    pub post_tool_use_response: Option<JsonValue>,
}

impl FunctionToolOutput {
    pub fn from_text(text: String, success: Option<bool>) -> Self {
        Self {
            body: vec![FunctionCallOutputContentItem::InputText { text }],
            success,
            post_tool_use_response: None,
        }
    }

    pub fn from_content(
        content: Vec<FunctionCallOutputContentItem>,
        success: Option<bool>,
    ) -> Self {
        Self {
            body: content,
            success,
            post_tool_use_response: None,
        }
    }

    pub fn into_text(self) -> String {
        function_call_output_content_items_to_text(&self.body).unwrap_or_default()
    }
}

impl ToolOutput for FunctionToolOutput {
    fn log_output(&self) -> String {
        function_call_output_content_items_to_text(&self.body).unwrap_or_default()
    }

    fn success_for_logging(&self) -> bool {
        self.success.unwrap_or(true)
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        function_tool_response(call_id, payload, self.body.clone(), self.success)
    }

    fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
        self.post_tool_use_response.clone()
    }
}

pub struct ApplyPatchToolOutput {
    pub text: String,
}

impl ApplyPatchToolOutput {
    pub fn from_text(text: String) -> Self {
        Self { text }
    }
}

impl ToolOutput for ApplyPatchToolOutput {
    fn log_output(&self) -> String {
        self.text.clone()
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        function_tool_response(
            call_id,
            payload,
            vec![FunctionCallOutputContentItem::InputText {
                text: self.text.clone(),
            }],
            Some(true),
        )
    }

    fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
        Some(JsonValue::String(self.text.clone()))
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        JsonValue::Object(serde_json::Map::new())
    }
}

pub struct AbortedToolOutput {
    pub message: String,
}

impl ToolOutput for AbortedToolOutput {
    fn log_output(&self) -> String {
        self.message.clone()
    }

    fn success_for_logging(&self) -> bool {
        false
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        match payload {
            ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
                call_id: call_id.to_string(),
                status: "completed".to_string(),
                execution: "client".to_string(),
                tools: Vec::new(),
            },
            _ => function_tool_response(
                call_id,
                payload,
                vec![FunctionCallOutputContentItem::InputText {
                    text: self.message.clone(),
                }],
                /*success*/ None,
            ),
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub struct ExecCommandToolOutput {
    pub event_call_id: String,
    pub chunk_id: String,
    pub wall_time: Duration,
    /// Raw bytes returned for this unified exec call before any truncation.
    pub raw_output: Vec<u8>,
    pub truncation_policy: TruncationPolicy,
    pub max_output_tokens: Option<usize>,
    pub process_id: Option<i32>,
    pub exit_code: Option<i32>,
    pub original_token_count: Option<usize>,
    /// Bytes omitted by the output collection cap before model-facing truncation.
    pub output_omitted_bytes: Option<NonZeroUsize>,
    pub hook_command: Option<String>,
}

impl ToolOutput for ExecCommandToolOutput {
    fn log_output(&self) -> String {
        // The telemetry budget must not inherit the model's output-token limit.
        let mut output = String::from_utf8_lossy(&self.raw_output).into_owned();
        if let Some(omitted_bytes) = self.output_omitted_bytes {
            let marker = format_output_omission_marker(omitted_bytes.get());
            if !output.contains(&marker) {
                output = format!("{marker}\n{output}");
            }
        }
        format!("{}\n{output}", self.response_header())
    }

    fn success_for_logging(&self) -> bool {
        true
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        function_tool_response(
            call_id,
            payload,
            vec![FunctionCallOutputContentItem::InputText {
                text: self.response_text(),
            }],
            Some(true),
        )
    }

    fn post_tool_use_id(&self, call_id: &str) -> String {
        if self.event_call_id.is_empty() {
            call_id.to_string()
        } else {
            self.event_call_id.clone()
        }
    }

    fn post_tool_use_input(&self, _payload: &ToolPayload) -> Option<JsonValue> {
        self.hook_command
            .as_ref()
            .map(|command| serde_json::json!({ "command": command }))
    }

    fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option<JsonValue> {
        if self.process_id.is_some() || self.hook_command.is_none() {
            return None;
        }

        Some(JsonValue::String(
            self.truncated_output_with_policy(self.model_output_policy()),
        ))
    }

    fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue {
        #[derive(Serialize)]
        struct UnifiedExecCodeModeResult {
            #[serde(skip_serializing_if = "Option::is_none")]
            chunk_id: Option<String>,
            wall_time_seconds: f64,
            #[serde(skip_serializing_if = "Option::is_none")]
            exit_code: Option<i32>,
            #[serde(skip_serializing_if = "Option::is_none")]
            session_id: Option<i32>,
            #[serde(skip_serializing_if = "Option::is_none")]
            original_token_count: Option<usize>,
            output: String,
        }

        let result = UnifiedExecCodeModeResult {
            chunk_id: (!self.chunk_id.is_empty()).then(|| self.chunk_id.clone()),
            wall_time_seconds: self.wall_time.as_secs_f64(),
            exit_code: self.exit_code,
            session_id: self.process_id,
            original_token_count: self.original_token_count,
            output: match self.max_output_tokens {
                Some(max_tokens) => self.truncated_output(max_tokens),
                None => String::from_utf8_lossy(&self.raw_output).to_string(),
            },
        };

        serde_json::to_value(result).unwrap_or_else(|err| {
            JsonValue::String(format!("failed to serialize exec result: {err}"))
        })
    }
}

impl ExecCommandToolOutput {
    fn model_output_policy(&self) -> TruncationPolicy {
        let requested_policy = TruncationPolicy::Tokens(resolve_max_tokens(self.max_output_tokens));
        if requested_policy.byte_budget() < self.truncation_policy.byte_budget() {
            requested_policy
        } else {
            self.truncation_policy
        }
    }

    pub(crate) fn truncated_output(&self, max_tokens: usize) -> String {
        self.truncated_output_with_policy(TruncationPolicy::Tokens(max_tokens))
    }

    fn truncated_output_with_policy(&self, policy: TruncationPolicy) -> String {
        let text = String::from_utf8_lossy(&self.raw_output).to_string();
        let Some(omitted_bytes) = self.output_omitted_bytes else {
            return formatted_truncate_text(&text, policy);
        };

        let marker = format_output_omission_marker(omitted_bytes.get());
        if text.len() <= policy.byte_budget() {
            return if text.contains(&marker) {
                text
            } else {
                format!("{marker}\n{text}")
            };
        }

        let original_token_count = self
            .original_token_count
            .unwrap_or_else(|| approx_token_count(&text));
        let truncated = truncate_text(&text, policy);
        let omission_notice = if truncated.contains(&marker) {
            String::new()
        } else {
            format!("{marker}\n")
        };
        format!(
            "Warning: truncated output (original token count: {original_token_count})\n{omission_notice}\n{truncated}"
        )
    }

    fn response_header(&self) -> String {
        let mut sections = Vec::new();

        if !self.chunk_id.is_empty() {
            sections.push(format!("Chunk ID: {}", self.chunk_id));
        }

        let wall_time_seconds = self.wall_time.as_secs_f64();
        sections.push(format!("Wall time: {wall_time_seconds:.4} seconds"));

        if let Some(exit_code) = self.exit_code {
            sections.push(format!("Process exited with code {exit_code}"));
        }

        if let Some(process_id) = &self.process_id {
            sections.push(format!("Process running with session ID {process_id}"));
        }

        if let Some(original_token_count) = self.original_token_count {
            sections.push(format!("Original token count: {original_token_count}"));
        }

        sections.push("Output:".to_string());
        sections.join("\n")
    }

    fn response_text(&self) -> String {
        let header = self.response_header();
        let output_budget = with_serialization_allowance(self.truncation_policy)
            .byte_budget()
            .saturating_sub(header.len().saturating_add(/*rhs*/ 1));
        let mut policy = self.model_output_policy();
        let mut output = self.truncated_output_with_policy(policy);

        // History applies this same serialization budget to the complete response.
        // Reserve room for metadata, warning headers, and the truncation marker so
        // it does not truncate an already-truncated output a second time.
        while output.len() > output_budget && policy.byte_budget() > 0 {
            let excess_bytes = output.len() - output_budget;
            policy = match policy {
                TruncationPolicy::Bytes(bytes) => {
                    TruncationPolicy::Bytes(bytes.saturating_sub(excess_bytes))
                }
                TruncationPolicy::Tokens(tokens) => TruncationPolicy::Tokens(
                    tokens.saturating_sub(TruncationPolicy::Bytes(excess_bytes).token_budget()),
                ),
            };
            output = self.truncated_output_with_policy(policy);
        }

        format!("{header}\n{output}")
    }
}

fn function_tool_response(
    call_id: &str,
    payload: &ToolPayload,
    body: Vec<FunctionCallOutputContentItem>,
    success: Option<bool>,
) -> ResponseInputItem {
    let body = match body.as_slice() {
        [FunctionCallOutputContentItem::InputText { text }] => {
            FunctionCallOutputBody::Text(text.clone())
        }
        _ => FunctionCallOutputBody::ContentItems(body),
    };

    if matches!(payload, ToolPayload::Custom { .. }) {
        return ResponseInputItem::CustomToolCallOutput {
            call_id: call_id.to_string(),
            name: None,
            output: FunctionCallOutputPayload { body, success },
        };
    }

    ResponseInputItem::FunctionCallOutput {
        call_id: call_id.to_string(),
        output: FunctionCallOutputPayload { body, success },
    }
}

#[cfg(test)]
#[path = "context_tests.rs"]
mod tests;
codex-rs/core/src/tools/handlers/mod.rs
pub(crate) mod apply_patch;
pub(crate) mod apply_patch_spec;
mod current_time;
mod dynamic;
pub(crate) mod extension_tools;
mod get_context_remaining;
pub(crate) mod get_context_remaining_spec;
mod list_available_plugins_to_install;
pub(crate) mod list_available_plugins_to_install_spec;
mod mcp;
mod mcp_resource;
pub(crate) mod mcp_resource_spec;
pub(crate) mod multi_agents;
pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_spec;
pub(crate) mod multi_agents_v2;
mod new_context_window;
pub(crate) mod new_context_window_spec;
mod plan;
pub(crate) mod plan_spec;
mod request_permissions;
mod request_plugin_install;
pub(crate) mod request_plugin_install_spec;
mod request_user_input;
mod request_user_input_async;
pub(crate) mod request_user_input_spec;
mod send_message_to_user_async;
pub(crate) mod shell_spec;
mod sleep;
mod test_sync;
pub(crate) mod test_sync_spec;
mod tool_search;
pub(crate) mod tool_search_spec;
pub(crate) mod unified_exec;
mod view_image;
pub(crate) mod view_image_spec;
mod wait_for_environment;

use codex_features::Feature;
use codex_file_system::FileSystemSandboxContext;
use codex_sandboxing::policy_transforms::materialize_additional_permissions_with_context;
use codex_sandboxing::policy_transforms::merge_permission_profiles;
use codex_sandboxing::policy_transforms::normalize_additional_permissions_with_context;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::AbsolutePathBufGuard;
use codex_utils_path_uri::PathUri;
use serde::Deserialize;
use serde_json::Map;
use serde_json::Value;

use crate::function_tool::FunctionCallError;
use crate::sandboxing::SandboxPermissions;
use crate::session::step_context::StepContext;
use crate::session::turn_context::TurnEnvironment;
pub(crate) use crate::tools::code_mode::CodeModeExecuteHandler;
pub(crate) use crate::tools::code_mode::CodeModeWaitHandler;
pub use apply_patch::ApplyPatchHandler;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::protocol::AskForApproval;
pub use current_time::CurrentTimeHandler;
pub use dynamic::DynamicToolHandler;
pub use get_context_remaining::GetContextRemainingHandler;
pub use list_available_plugins_to_install::ListAvailablePluginsToInstallHandler;
pub use mcp::McpHandler;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;
pub use new_context_window::NewContextWindowHandler;
pub use plan::PlanHandler;
pub(crate) use request_permissions::RequestPermissionsEnvironmentArgs;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
pub use request_user_input::RequestUserInputHandler;
pub use request_user_input_async::RequestUserInputAsyncHandler;
pub use send_message_to_user_async::SendMessageToUserAsyncHandler;
pub use sleep::SleepHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::ToolSearchHandlerCache;
pub use unified_exec::ExecCommandHandler;
pub(crate) use unified_exec::ExecCommandHandlerOptions;
pub use unified_exec::WriteStdinHandler;
pub use view_image::ViewImageHandler;
pub(crate) use wait_for_environment::WaitForEnvironmentHandler;
pub use wait_for_environment::WaitForEnvironmentToolConfig;

pub(crate) fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>
where
    T: for<'de> Deserialize<'de>,
{
    serde_json::from_str(arguments).map_err(|err| {
        FunctionCallError::RespondToModel(format!("failed to parse function arguments: {err}"))
    })
}

fn resolve_sandbox_permissions(
    sandbox_permissions: Option<SandboxPermissions>,
    justification: Option<&str>,
) -> Result<SandboxPermissions, FunctionCallError> {
    if justification.is_some() && sandbox_permissions.is_none() {
        return Err(FunctionCallError::RespondToModel(
            "`justification` requires an explicit `sandbox_permissions`; use `sandbox_permissions: \"require_escalated\"` for unsandboxed execution, or omit `justification`.".to_string(),
        ));
    }

    Ok(sandbox_permissions.unwrap_or_default())
}

fn updated_hook_command(updated_input: &Value) -> Result<&str, FunctionCallError> {
    updated_input
        .get("command")
        .and_then(Value::as_str)
        .ok_or_else(|| {
            FunctionCallError::RespondToModel(
                "hook returned updatedInput without string field `command`".to_string(),
            )
        })
}

fn rewrite_function_arguments(
    arguments: &str,
    tool_name: &str,
    rewrite: impl FnOnce(&mut Map<String, Value>),
) -> Result<String, FunctionCallError> {
    let mut arguments: Value = parse_arguments(arguments)?;
    let Value::Object(arguments) = &mut arguments else {
        return Err(FunctionCallError::RespondToModel(format!(
            "{tool_name} arguments must be an object"
        )));
    };
    rewrite(arguments);
    serde_json::to_string(&arguments).map_err(|err| {
        FunctionCallError::RespondToModel(format!(
            "failed to serialize rewritten {tool_name} arguments: {err}"
        ))
    })
}

fn rewrite_function_string_argument(
    arguments: &str,
    tool_name: &str,
    field_name: &str,
    value: &str,
) -> Result<String, FunctionCallError> {
    rewrite_function_arguments(arguments, tool_name, |arguments| {
        arguments.insert(field_name.to_string(), Value::String(value.to_string()));
    })
}

fn parse_arguments_with_base_path<T>(
    arguments: &str,
    base_path: &AbsolutePathBuf,
) -> Result<T, FunctionCallError>
where
    T: for<'de> Deserialize<'de>,
{
    let _guard = AbsolutePathBufGuard::new(base_path);
    parse_arguments(arguments)
}

fn resolve_tool_environment<'a>(
    step_context: &'a StepContext,
    environment_id: Option<&str>,
    legacy_unavailable_message: &'static str,
) -> Result<&'a TurnEnvironment, FunctionCallError> {
    let environments = &step_context.environments;
    let stable_environment_tools = step_context
        .turn
        .config
        .features
        .get()
        .enabled(Feature::StableEnvironmentTools);
    environment_id.map_or_else(
        || environments.primary(),
        |environment_id| {
            environments
                .turn_environments()
                .find(|environment| environment.selection.environment_id == environment_id)
        },
    ).ok_or_else(|| {
        if let Some(environment_id) = environment_id
            && (!stable_environment_tools
                || !environments
                    .all_selections()
                    .iter()
                    .any(|selection| selection.environment_id == environment_id))
        {
            return FunctionCallError::RespondToModel(format!(
                "unknown turn environment id `{environment_id}`"
            ));
        }
        FunctionCallError::RespondToModel(if stable_environment_tools {
            "No usable execution environment is available. Wait for an environment to become available before using this tool.".to_string()
        } else {
            legacy_unavailable_message.to_string()
        })
    })
}

/// Validates feature/policy constraints for `with_additional_permissions` and
/// normalizes any path-based permissions. Errors if the request is invalid.
pub(crate) fn normalize_and_validate_additional_permissions(
    additional_permissions_allowed: bool,
    approval_policy: AskForApproval,
    sandbox_permissions: SandboxPermissions,
    additional_permissions: Option<AdditionalPermissionProfile>,
    permissions_preapproved: bool,
    context: &codex_protocol::permissions::FileSystemSandboxPolicyContext<'_>,
) -> Result<Option<AdditionalPermissionProfile>, String> {
    let uses_additional_permissions = matches!(
        sandbox_permissions,
        SandboxPermissions::WithAdditionalPermissions
    );

    if !permissions_preapproved
        && !additional_permissions_allowed
        && (uses_additional_permissions || additional_permissions.is_some())
    {
        return Err(
            "additional permissions are disabled; enable `features.exec_permission_approvals` before using `with_additional_permissions`"
                .to_string(),
        );
    }

    if uses_additional_permissions {
        if !permissions_preapproved && !matches!(approval_policy, AskForApproval::OnRequest) {
            return Err(format!(
                "approval policy is {approval_policy:?}; reject command — you cannot request additional permissions unless the approval policy is OnRequest"
            ));
        }
        let Some(additional_permissions) = additional_permissions else {
            return Err(
                "missing `additional_permissions`; provide at least one of `network` or `file_system` when using `with_additional_permissions`"
                    .to_string(),
            );
        };
        let normalized =
            normalize_additional_permissions_with_context(additional_permissions, context)?;
        if normalized.is_empty() {
            return Err(
                "`additional_permissions` must include at least one requested permission in `network` or `file_system`"
                    .to_string(),
            );
        }
        return Ok(Some(normalized));
    }

    if additional_permissions.is_some() {
        Err(
            "`additional_permissions` requires `sandbox_permissions` set to `with_additional_permissions`"
                .to_string(),
        )
    } else {
        Ok(None)
    }
}

pub(super) struct EffectiveAdditionalPermissions {
    pub sandbox_permissions: SandboxPermissions,
    pub additional_permissions: Option<AdditionalPermissionProfile>,
    pub permissions_preapproved: bool,
}

pub(super) fn file_system_sandbox_policy_context_for_cwd<'a>(
    sandbox_context: &'a FileSystemSandboxContext,
    cwd: &'a PathUri,
) -> codex_protocol::permissions::FileSystemSandboxPolicyContext<'a> {
    let mut context = sandbox_context.policy_context();
    context.cwd = cwd;
    context
}

pub(super) fn implicit_granted_permissions(
    sandbox_permissions: SandboxPermissions,
    additional_permissions: Option<&AdditionalPermissionProfile>,
    effective_additional_permissions: &EffectiveAdditionalPermissions,
) -> Option<AdditionalPermissionProfile> {
    if !sandbox_permissions.uses_additional_permissions()
        && !matches!(sandbox_permissions, SandboxPermissions::RequireEscalated)
        && additional_permissions.is_none()
    {
        effective_additional_permissions
            .additional_permissions
            .clone()
    } else {
        None
    }
}

pub(super) fn apply_granted_turn_permissions(
    step_context: &StepContext,
    environment: &TurnEnvironment,
    cwd: &PathUri,
    sandbox_permissions: SandboxPermissions,
    additional_permissions: Option<AdditionalPermissionProfile>,
) -> EffectiveAdditionalPermissions {
    if matches!(sandbox_permissions, SandboxPermissions::RequireEscalated) {
        return EffectiveAdditionalPermissions {
            sandbox_permissions,
            additional_permissions,
            permissions_preapproved: false,
        };
    }

    let environment_id = &environment.selection.environment_id;
    let granted_permissions = step_context.turn.granted_permissions(environment_id);
    let effective_permissions = merge_permission_profiles(
        additional_permissions.as_ref(),
        granted_permissions.as_ref(),
    );
    let sandbox_context = environment.sandbox_context(/*additional_permissions*/ None);
    let context = file_system_sandbox_policy_context_for_cwd(&sandbox_context, cwd);
    let preapproved_permissions = granted_permissions.as_ref().and_then(|granted| {
        if additional_permissions.is_none() {
            Some(granted.clone())
        } else {
            effective_permissions
                .as_ref()
                .and_then(|effective| preapproved_permission_profile(effective, granted, &context))
        }
    });
    let permissions_preapproved = preapproved_permissions.is_some();
    // A preapproved command must execute with the stored authority, never an
    // unchecked merge that could reopen one of the grant's denied paths.
    let effective_permissions = preapproved_permissions.or(effective_permissions);

    let sandbox_permissions =
        if effective_permissions.is_some() && !sandbox_permissions.uses_additional_permissions() {
            SandboxPermissions::WithAdditionalPermissions
        } else {
            sandbox_permissions
        };

    EffectiveAdditionalPermissions {
        sandbox_permissions,
        additional_permissions: effective_permissions,
        permissions_preapproved,
    }
}

fn preapproved_permission_profile(
    effective_permissions: &AdditionalPermissionProfile,
    granted_permissions: &AdditionalPermissionProfile,
    context: &codex_protocol::permissions::FileSystemSandboxPolicyContext<'_>,
) -> Option<AdditionalPermissionProfile> {
    if effective_permissions
        .file_system
        .as_ref()
        .is_some_and(|permissions| {
            permissions.entries.iter().any(|entry| {
                (matches!(
                    &entry.path,
                    codex_protocol::permissions::FileSystemPath::Special {
                        value: codex_protocol::permissions::FileSystemSpecialPath::Tmpdir,
                    }
                ) && context
                    .temporary_directories
                    .is_none_or(<[PathUri]>::is_empty))
                    || matches!(
                    &entry.path,
                    codex_protocol::permissions::FileSystemPath::Special {
                        value: codex_protocol::permissions::FileSystemSpecialPath::ProjectRoots { .. },
                    } if context.workspace_roots.is_empty()
                )
            })
        })
    {
        return None;
    }
    let (Ok(effective), Ok(granted)) = (
        materialize_additional_permissions_with_context(effective_permissions.clone(), context),
        materialize_additional_permissions_with_context(granted_permissions.clone(), context),
    ) else {
        return None;
    };
    if effective.network != granted.network {
        return None;
    }
    let unchanged = match (effective.file_system, granted.file_system) {
        (Some(effective), Some(granted)) => {
            effective.glob_scan_max_depth == granted.glob_scan_max_depth
                && effective.entries.len() == granted.entries.len()
                && effective
                    .entries
                    .iter()
                    .all(|entry| granted.entries.contains(entry))
        }
        (None, None) => true,
        (Some(_), None) | (None, Some(_)) => false,
    };
    unchanged.then(|| granted_permissions.clone())
}

#[cfg(test)]
#[path = "permission_preapproval_tests.rs"]
mod permission_preapproval_tests;

#[cfg(test)]
mod tests {
    use super::EffectiveAdditionalPermissions;
    use super::implicit_granted_permissions;
    use super::normalize_and_validate_additional_permissions;
    use super::preapproved_permission_profile;
    use crate::sandboxing::SandboxPermissions;
    use codex_protocol::models::AdditionalPermissionProfile;
    use codex_protocol::models::FileSystemPermissions;
    use codex_protocol::models::NetworkPermissions;
    use codex_protocol::permissions::FileSystemAccessMode;
    use codex_protocol::permissions::FileSystemPath;
    use codex_protocol::permissions::FileSystemSandboxEntry;
    use codex_protocol::permissions::FileSystemSandboxPolicyContext;
    use codex_protocol::permissions::FileSystemSpecialPath;
    use codex_protocol::protocol::AskForApproval;
    use codex_protocol::protocol::GranularApprovalConfig;
    use codex_sandboxing::policy_transforms::intersect_permission_profiles_with_context;
    use codex_sandboxing::policy_transforms::merge_permission_profiles;
    use codex_utils_absolute_path::AbsolutePathBuf;
    use codex_utils_path_uri::PathUri;
    use pretty_assertions::assert_eq;
    use tempfile::tempdir;

    fn network_permissions() -> AdditionalPermissionProfile {
        AdditionalPermissionProfile {
            network: Some(NetworkPermissions {
                enabled: Some(true),
            }),
            ..Default::default()
        }
    }

    fn file_system_permissions(path: &std::path::Path) -> AdditionalPermissionProfile {
        AdditionalPermissionProfile {
            file_system: Some(FileSystemPermissions::from_read_write_roots(
                /*read*/ None,
                Some(vec![
                    AbsolutePathBuf::from_absolute_path(path).expect("absolute path"),
                ]),
            )),
            ..Default::default()
        }
    }

    fn local_context(cwd: &PathUri) -> FileSystemSandboxPolicyContext<'_> {
        FileSystemSandboxPolicyContext {
            cwd,
            workspace_roots: std::slice::from_ref(cwd),
            temporary_directories: None,
            user_home_dir: None,
        }
    }

    #[test]
    fn preapproved_permissions_work_when_request_permissions_tool_is_enabled_without_exec_permission_approvals_feature()
     {
        let cwd = tempdir().expect("tempdir");
        let cwd = PathUri::from_host_native_path(cwd.path()).expect("cwd URI");

        let normalized = normalize_and_validate_additional_permissions(
            /*additional_permissions_allowed*/ false,
            AskForApproval::Granular(GranularApprovalConfig {
                sandbox_approval: true,
                rules: true,
                skill_approval: true,
                request_permissions: false,
                mcp_elicitations: true,
            }),
            SandboxPermissions::WithAdditionalPermissions,
            Some(network_permissions()),
            /*permissions_preapproved*/ true,
            &local_context(&cwd),
        )
        .expect("preapproved permissions should be allowed");

        assert_eq!(normalized, Some(network_permissions()));
    }

    #[test]
    fn fresh_additional_permissions_still_require_exec_permission_approvals_feature() {
        let cwd = tempdir().expect("tempdir");
        let cwd = PathUri::from_host_native_path(cwd.path()).expect("cwd URI");

        let err = normalize_and_validate_additional_permissions(
            /*additional_permissions_allowed*/ false,
            AskForApproval::OnRequest,
            SandboxPermissions::WithAdditionalPermissions,
            Some(network_permissions()),
            /*permissions_preapproved*/ false,
            &local_context(&cwd),
        )
        .expect_err("fresh inline permission requests should remain disabled");

        assert_eq!(
            err,
            "additional permissions are disabled; enable `features.exec_permission_approvals` before using `with_additional_permissions`"
        );
    }

    #[test]
    fn implicit_sticky_grants_bypass_inline_permission_validation() {
        let cwd = tempdir().expect("tempdir");
        let granted_permissions = file_system_permissions(cwd.path());
        let implicit_permissions = implicit_granted_permissions(
            SandboxPermissions::UseDefault,
            /*additional_permissions*/ None,
            &EffectiveAdditionalPermissions {
                sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
                additional_permissions: Some(granted_permissions.clone()),
                permissions_preapproved: false,
            },
        );

        assert_eq!(implicit_permissions, Some(granted_permissions));
    }

    #[test]
    fn explicit_inline_permissions_do_not_use_implicit_sticky_grant_path() {
        let cwd = tempdir().expect("tempdir");
        let requested_permissions = file_system_permissions(cwd.path());
        let implicit_permissions = implicit_granted_permissions(
            SandboxPermissions::WithAdditionalPermissions,
            Some(&requested_permissions),
            &EffectiveAdditionalPermissions {
                sandbox_permissions: SandboxPermissions::WithAdditionalPermissions,
                additional_permissions: Some(requested_permissions.clone()),
                permissions_preapproved: false,
            },
        );

        assert_eq!(implicit_permissions, None);
    }

    #[test]
    fn relative_deny_glob_grants_remain_preapproved_after_materialization() {
        let cwd = tempdir().expect("tempdir");
        let requested_permissions = AdditionalPermissionProfile {
            file_system: Some(FileSystemPermissions {
                entries: vec![
                    FileSystemSandboxEntry {
                        path: FileSystemPath::Special {
                            value: FileSystemSpecialPath::project_roots(/*subpath*/ None),
                        },
                        access: FileSystemAccessMode::Write,
                        missing_path_behavior: None,
                    },
                    FileSystemSandboxEntry {
                        path: FileSystemPath::GlobPattern {
                            pattern: "**/*.env".to_string(),
                        },
                        access: FileSystemAccessMode::Deny,
                        missing_path_behavior: None,
                    },
                ],
                glob_scan_max_depth: None,
            }),
            ..Default::default()
        };
        let cwd_uri = PathUri::from_host_native_path(cwd.path()).expect("cwd URI");
        let stored_grant = intersect_permission_profiles_with_context(
            requested_permissions.clone(),
            requested_permissions.clone(),
            &local_context(&cwd_uri),
        );
        let effective_permissions =
            merge_permission_profiles(Some(&requested_permissions), Some(&stored_grant))
                .expect("merged permissions");

        assert_eq!(
            preapproved_permission_profile(
                &effective_permissions,
                &stored_grant,
                &local_context(&cwd_uri),
            ),
            Some(stored_grant)
        );
    }

    #[test]
    fn symbolic_tmpdir_preapproval_requires_executor_metadata() {
        let cwd = PathUri::parse("file:///C:/workspace").expect("Windows cwd");
        let temporary_directory = PathUri::parse("file:///C:/Temp").expect("Windows temp dir");
        let concrete = FileSystemSandboxEntry::new(
            temporary_directory.clone().into(),
            FileSystemAccessMode::Write,
        );
        let symbolic = FileSystemSandboxEntry {
            path: FileSystemPath::Special {
                value: FileSystemSpecialPath::Tmpdir,
            },
            access: FileSystemAccessMode::Write,
            missing_path_behavior: None,
        };
        let effective = AdditionalPermissionProfile {
            file_system: Some(FileSystemPermissions {
                entries: vec![concrete.clone(), symbolic],
                glob_scan_max_depth: None,
            }),
            ..Default::default()
        };
        let granted = AdditionalPermissionProfile {
            file_system: Some(FileSystemPermissions {
                entries: vec![concrete],
                glob_scan_max_depth: None,
            }),
            ..Default::default()
        };

        assert_eq!(
            preapproved_permission_profile(&effective, &granted, &local_context(&cwd)),
            None
        );

        let empty_temporary_directories = [];
        let empty_tmpdir_context = FileSystemSandboxPolicyContext {
            temporary_directories: Some(&empty_temporary_directories),
            ..local_context(&cwd)
        };
        assert_eq!(
            preapproved_permission_profile(&effective, &granted, &empty_tmpdir_context),
            None
        );

        let temporary_directories = [temporary_directory];
        let tmpdir_context = FileSystemSandboxPolicyContext {
            temporary_directories: Some(&temporary_directories),
            ..local_context(&cwd)
        };
        assert_eq!(
            preapproved_permission_profile(&effective, &granted, &tmpdir_context),
            Some(granted)
        );
    }
}
codex-rs/core/src/tools/registry.rs
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::Duration;

use crate::function_tool::FunctionCallError;
use crate::function_tool::OrCancelToolExt;
use crate::hook_runtime::PreToolUseHookResult;
use crate::hook_runtime::record_additional_contexts;
use crate::hook_runtime::run_post_tool_use_hooks;
use crate::hook_runtime::run_pre_tool_use_hooks;
use crate::memory_usage::emit_metric_for_tool_read;
use crate::memory_usage::shell_script_for_invocation;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolCallState;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::control_tool_analytics::ControlToolCallGuard;
use crate::tools::flat_tool_name;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::hook_names::HookToolName;
use crate::tools::lifecycle::notify_tool_finish;
use crate::tools::lifecycle::notify_tool_start;
use crate::tools::router::tool_log_payload;
use crate::tools::tool_dispatch_trace::ToolDispatchTrace;
use crate::util::error_or_panic;
use codex_analytics::ControlToolCallStatus;
use codex_extension_api::ToolCallOutcome;
use codex_extension_api::ToolPolicy;
use codex_history::CodexHarnessMetadata;
use codex_history::ResponseItemEnvelope;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::EventMsg;
use codex_rollout::state_db;
use codex_shell_command::parse_command::parse_shell_script;
use codex_tools::FunctionsNamespaceFunctionPrefixes;
use codex_tools::ToolName;
use codex_tools::ToolSearchInfo;
use codex_tools::ToolSpec;
use futures::future::BoxFuture;
use indexmap::IndexMap;
use indexmap::map::Entry;
use serde_json::Value;

pub(crate) type ToolTelemetryTags = Vec<(&'static str, String)>;

pub use codex_tools::ToolExecutor;
pub use codex_tools::ToolExposure;

/// Typed runtime contract for locally executed tools.
///
/// Implementers provide the shared `ToolExecutor` behavior plus optional
/// core-owned metadata for hooks, telemetry, tool search, and argument diffs.
pub(crate) trait CoreToolRuntime: ToolExecutor<ToolInvocation> {
    /// The handler observes its cancellation token and must finish before dispatch is aborted.
    fn finishes_on_cancellation(&self) -> bool {
        false
    }

    /// Whether this built-in control tool needs a structured tool-call event.
    fn is_builtin_control_tool(&self) -> bool {
        false
    }

    /// Returns a shared spec when both the spec and search metadata are immutable.
    fn immutable_spec(&self) -> Option<&Arc<ToolSpec>> {
        None
    }

    /// Returns lazily cached, augmented Code Mode definitions owned by this runtime.
    /// Input and output schemas must be cleared after rendering their declarations.
    /// Return None when the requested rendering inputs differ from the cached inputs.
    fn cached_code_mode_definitions(
        &self,
        _code_mode_input_schema_max_bytes: Option<usize>,
        _tool_description_first: bool,
    ) -> Option<&[codex_code_mode::ToolDefinition]> {
        None
    }

    /// Returns a readiness wait for this exact tool before taking the execution gate.
    fn wait_until_ready<'a>(&'a self, _session: &'a Arc<Session>) -> Option<BoxFuture<'a, ()>> {
        None
    }

    /// True for MCP/app and client-supplied dynamic tools.
    /// A built-in stays false even if a client's tool has the same name.
    fn is_third_party_tool(&self) -> bool {
        false
    }

    /// Returns the owning server only for MCP-backed tool runtimes.
    fn mcp_server_name(&self) -> Option<&str> {
        None
    }

    fn matches_kind(&self, payload: &ToolPayload) -> bool {
        matches!(
            payload,
            ToolPayload::Function { .. } | ToolPayload::ToolSearch { .. }
        )
    }

    fn telemetry_tags(&self, _invocation: &ToolInvocation) -> ToolTelemetryTags {
        Vec::new()
    }

    /// Observes a tool result only after all PostToolUse hooks accept it.
    fn on_tool_result_accepted(&self, _invocation: &ToolInvocation, _result: &dyn ToolOutput) {}

    fn post_tool_use_payload(
        &self,
        invocation: &ToolInvocation,
        result: &dyn ToolOutput,
    ) -> Option<PostToolUsePayload> {
        let ToolPayload::Function { arguments } = &invocation.payload else {
            return None;
        };

        Some(PostToolUsePayload {
            tool_name: function_hook_tool_name(invocation),
            tool_use_id: result.post_tool_use_id(&invocation.call_id),
            tool_input: result
                .post_tool_use_input(&invocation.payload)
                .unwrap_or_else(|| function_hook_tool_input(arguments)),
            tool_response: result
                .post_tool_use_response(&invocation.call_id, &invocation.payload)
                .or_else(|| {
                    // Most function tools can expose their model-facing output
                    // as the hook response. Outputs with a more stable hook
                    // contract should override post_tool_use_response above.
                    let ResponseInputItem::FunctionCallOutput {
                        output: FunctionCallOutputPayload { body, .. },
                        ..
                    } = result.to_response_item(&invocation.call_id, &invocation.payload)
                    else {
                        return None;
                    };

                    serde_json::to_value(body).ok()
                })?,
        })
    }

    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {
        let ToolPayload::Function { arguments } = &invocation.payload else {
            return None;
        };

        Some(PreToolUsePayload {
            tool_name: function_hook_tool_name(invocation),
            tool_input: function_hook_tool_input(arguments),
        })
    }

    /// Rebuilds a tool invocation from hook-facing `tool_input`.
    ///
    /// Tools that opt into input-rewriting hooks should invert the same stable
    /// hook contract they expose from `pre_tool_use_payload`.
    fn with_updated_hook_input(
        &self,
        invocation: ToolInvocation,
        updated_input: Value,
    ) -> Result<ToolInvocation, FunctionCallError> {
        let ToolPayload::Function { .. } = &invocation.payload else {
            return Err(FunctionCallError::RespondToModel(
                "hook input rewrite received unsupported function tool payload".to_string(),
            ));
        };

        let arguments = serde_json::to_string(&updated_input).map_err(|err| {
            FunctionCallError::RespondToModel(format!(
                "failed to serialize rewritten {} arguments: {err}",
                flat_tool_name(&invocation.tool_name)
            ))
        })?;
        Ok(ToolInvocation {
            payload: ToolPayload::Function { arguments },
            ..invocation
        })
    }

    /// Creates an optional consumer for streamed tool argument diffs.
    fn create_diff_consumer(&self) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
        None
    }
}

/// Consumes streamed argument diffs for a tool call and emits protocol events
/// derived from partial tool input.
pub(crate) trait ToolArgumentDiffConsumer: Send {
    /// Consume the next argument diff for a tool call.
    fn consume_diff(&mut self, turn: &TurnContext, call_id: String, diff: &str)
    -> Option<EventMsg>;

    /// Finish consuming argument diffs before the tool call completes.
    fn finish(&mut self) -> Result<Option<EventMsg>, FunctionCallError> {
        Ok(None)
    }
}

pub(crate) struct AnyToolResult {
    pub(crate) call_id: String,
    pub(crate) payload: ToolPayload,
    pub(crate) result: Box<dyn ToolOutput>,
    pub(crate) post_tool_use_payload: Option<PostToolUsePayload>,
}

impl AnyToolResult {
    pub(crate) fn into_response(self) -> ResponseItemEnvelope {
        let Self {
            call_id,
            payload,
            result,
            ..
        } = self;
        let history_truncation_token_limit = result.fallback_token_limit_override();
        ResponseItemEnvelope {
            item: result.to_response_item(&call_id, &payload).into(),
            metadata: history_truncation_token_limit.map(|limit| CodexHarnessMetadata {
                history_truncation_token_limit: Some(limit),
                ..Default::default()
            }),
        }
    }

    pub(crate) fn code_mode_result(self) -> serde_json::Value {
        let Self {
            payload, result, ..
        } = self;
        result.code_mode_result(&payload)
    }
}

struct PostToolUseFeedbackOutput {
    original: Box<dyn ToolOutput>,
    model_visible: FunctionToolOutput,
}

impl ToolOutput for PostToolUseFeedbackOutput {
    fn log_output(&self) -> String {
        self.original.log_output()
    }

    fn success_for_logging(&self) -> bool {
        self.original.success_for_logging()
    }

    fn set_handler_duration_ms(&mut self, handler_duration_ms: u64) {
        self.original.set_handler_duration_ms(handler_duration_ms);
    }

    fn fallback_token_limit_override(&self) -> Option<usize> {
        self.original.fallback_token_limit_override()
    }

    fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem {
        self.model_visible.to_response_item(call_id, payload)
    }

    fn code_mode_result(&self, payload: &ToolPayload) -> Value {
        self.original.code_mode_result(payload)
    }

    fn tool_result_metadata(&self) -> Option<&Value> {
        self.original.tool_result_metadata()
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreToolUsePayload {
    /// Hook-facing tool name model.
    ///
    /// The canonical name is serialized to hook stdin, while aliases are used
    /// only for matcher compatibility.
    pub(crate) tool_name: HookToolName,
    /// Tool-specific input exposed at `tool_input`.
    ///
    /// Shell-like tools use `{ "command": ... }`; MCP tools use their resolved
    /// JSON arguments.
    pub(crate) tool_input: Value,
}

#[derive(Debug, Clone, PartialEq)]
pub(crate) struct PostToolUsePayload {
    /// Hook-facing tool name model.
    ///
    /// The canonical name is serialized to hook stdin, while aliases are used
    /// only for matcher compatibility.
    pub(crate) tool_name: HookToolName,
    /// The originating tool-use id exposed at `tool_use_id`.
    pub(crate) tool_use_id: String,
    /// Tool-specific input exposed at `tool_input`.
    pub(crate) tool_input: Value,
    /// Tool result exposed at `tool_response`.
    pub(crate) tool_response: Value,
}

/// A tool runtime together with its effective exposure for the current step.
pub(crate) struct RegisteredTool {
    pub(crate) runtime: Arc<dyn CoreToolRuntime>,
    pub(crate) exposure: ToolExposure,
    /// Per-step description override; never written back to a shared runtime.
    pub(crate) model_spec: Option<Arc<ToolSpec>>,
}

impl RegisteredTool {
    /// Direct declarations and Code Mode read this spec; execution uses the runtime.
    pub(crate) fn spec(&self) -> Arc<ToolSpec> {
        self.model_spec
            .as_ref()
            .or_else(|| self.runtime.immutable_spec())
            .cloned()
            .unwrap_or_else(|| Arc::new(self.runtime.spec()))
    }

    /// Runtime caches describe the original spec, so cannot serve an overridden one.
    pub(crate) fn cached_runtime(&self) -> Option<&Arc<dyn CoreToolRuntime>> {
        (self.model_spec.is_none() && self.runtime.immutable_spec().is_some())
            .then_some(&self.runtime)
    }
}

#[derive(Default)]
pub struct ToolRegistry {
    tools: IndexMap<ToolName, RegisteredTool>,
    functions_namespace_function_prefixes: FunctionsNamespaceFunctionPrefixes,
    first_collision: Option<ToolName>,
    pub(crate) tool_policy: Arc<ToolPolicy>,
}

impl ToolRegistry {
    pub(crate) fn search_info(&self, tool: &RegisteredTool) -> Option<ToolSearchInfo> {
        let info = tool.runtime.search_info()?;
        // A runtime may advertise a different search schema or description. Preserve it,
        // along with its ranking text and source, rather than substituting the normal spec.
        match self
            .functions_namespace_function_prefixes
            .prepare(&tool.runtime.tool_name(), || {
                info.entry.to_loadable_spec().into()
            }) {
            Some(spec) => ToolSearchInfo::from_spec(info.entry.search_text, spec, info.source_info),
            None => Some(info),
        }
    }

    /// Apply catalog prefixes to this step's specs, budgeting only reachable tools.
    /// The planner supplies the names; this does not change execution or exposure.
    pub(crate) fn apply_functions_namespace_function_prefixes(
        &mut self,
        prefixes: Option<&BTreeMap<String, String>>,
        available_tools: impl IntoIterator<Item = ToolName>,
    ) -> Result<(), &'static str> {
        let prefixes = FunctionsNamespaceFunctionPrefixes::new(prefixes, available_tools)?;
        for tool in self.tools.values_mut() {
            tool.model_spec = prefixes
                .prepare(&tool.runtime.tool_name(), || tool.runtime.spec())
                .map(Arc::new);
        }
        self.functions_namespace_function_prefixes = prefixes;
        Ok(())
    }

    fn prepare_tool(
        &self,
        runtime: Arc<dyn CoreToolRuntime>,
        exposure: ToolExposure,
    ) -> RegisteredTool {
        let model_spec = self
            .functions_namespace_function_prefixes
            .prepare(&runtime.tool_name(), || runtime.spec())
            .map(Arc::new);
        RegisteredTool {
            runtime,
            exposure,
            model_spec,
        }
    }

    pub(crate) fn with_tool_policy(tool_policy: Arc<ToolPolicy>) -> Self {
        Self {
            tool_policy,
            ..Self::default()
        }
    }

    #[cfg(test)]
    pub(crate) fn from_tools(tools: impl IntoIterator<Item = Arc<dyn CoreToolRuntime>>) -> Self {
        let mut registry = Self::default();

        for runtime in tools {
            registry.register_trusted(runtime);
        }

        registry
    }

    pub(crate) fn add<T>(&mut self, handler: T)
    where
        T: CoreToolRuntime + 'static,
    {
        self.register_trusted(Arc::new(handler));
    }

    pub(crate) fn add_with_exposure<T>(&mut self, handler: T, exposure: ToolExposure)
    where
        T: CoreToolRuntime + 'static,
    {
        self.register_trusted_with_exposure(Arc::new(handler), exposure);
    }

    pub(crate) fn register_trusted(&mut self, runtime: Arc<dyn CoreToolRuntime>) {
        let exposure = runtime.exposure();
        self.register_trusted_with_exposure(runtime, exposure);
    }

    pub(crate) fn register_trusted_with_exposure(
        &mut self,
        runtime: Arc<dyn CoreToolRuntime>,
        exposure: ToolExposure,
    ) {
        let tool_name = runtime.tool_name().with_default_namespace();
        if !self.tool_policy.allows(&tool_name) {
            return;
        }
        let tool = self.prepare_tool(runtime, exposure);
        match self.tools.entry(tool_name) {
            Entry::Vacant(entry) => {
                entry.insert(tool);
            }
            Entry::Occupied(entry) => {
                let tool_name = entry.key();
                error_or_panic(format!("tool {tool_name} already registered"));
            }
        }
    }

    pub(crate) fn prepend_trusted(&mut self, runtime: Arc<dyn CoreToolRuntime>) {
        let tool_name = runtime.tool_name().with_default_namespace();
        if !self.tool_policy.allows(&tool_name) {
            return;
        }
        if self.tools.contains_key(&tool_name) {
            error_or_panic(format!("tool {tool_name} already registered"));
            return;
        }

        let exposure = runtime.exposure();
        let tool = self.prepare_tool(runtime, exposure);
        self.tools.shift_insert(0, tool_name, tool);
    }

    pub(crate) fn register_external(&mut self, runtime: Arc<dyn CoreToolRuntime>) -> bool {
        let exposure = runtime.exposure();
        self.register_external_with_exposure(runtime, exposure)
    }

    pub(crate) fn register_external_with_exposure(
        &mut self,
        runtime: Arc<dyn CoreToolRuntime>,
        exposure: ToolExposure,
    ) -> bool {
        let tool_name = runtime.tool_name().with_default_namespace();
        if !self.tool_policy.allows(&tool_name) {
            return false;
        }
        if tool_name.is_default_namespace()
            && matches!(tool_name.name.as_str(), "exec_command" | "shell_command")
        {
            tracing::warn!(tool_name = %tool_name, "skipping external tool with reserved name");
            if self.tools.contains_key(&tool_name) {
                self.record_collision(tool_name);
            }
            return false;
        }

        let tool = self.prepare_tool(runtime, exposure);
        match self.tools.entry(tool_name) {
            Entry::Vacant(entry) => {
                entry.insert(tool);
                true
            }
            Entry::Occupied(entry) => {
                tracing::warn!(
                    tool_name = %entry.key(),
                    "skipping duplicate external tool that is already registered"
                );
                self.first_collision
                    .get_or_insert_with(|| entry.key().clone());
                false
            }
        }
    }

    pub(crate) fn record_collision(&mut self, tool_name: ToolName) {
        self.first_collision.get_or_insert(tool_name);
    }

    pub(crate) fn first_collision(&self) -> Option<&ToolName> {
        self.first_collision.as_ref()
    }

    pub(crate) fn remove(&mut self, tool_name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
        self.tools
            .shift_remove(&tool_name.clone().with_default_namespace())
            .map(|tool| tool.runtime)
    }

    pub(crate) fn entries(&self) -> impl Iterator<Item = &RegisteredTool> {
        self.tools.values()
    }

    pub(crate) fn entries_mut(&mut self) -> impl Iterator<Item = &mut RegisteredTool> {
        self.tools.values_mut()
    }

    /// Returns configured MCP server names and their registered callable namespaces.
    pub(crate) fn mcp_namespaces(&self) -> impl Iterator<Item = (&str, &str)> {
        self.tools.iter().filter_map(|(name, tool)| {
            Some((tool.runtime.mcp_server_name()?, name.namespace.as_deref()?))
        })
    }

    pub(crate) fn deferred_tool_namespaces(&self) -> BTreeMap<String, String> {
        let mut namespaces = BTreeMap::<String, String>::new();
        for (name, tool) in &self.tools {
            if !tool.exposure.is_deferred() || name.is_default_namespace() {
                continue;
            }
            let Some(namespace) = &name.namespace else {
                continue;
            };
            let existing_description = namespaces.entry(namespace.clone()).or_default();
            if !existing_description.trim().is_empty() {
                continue;
            }
            let owned_spec;
            let spec = if let Some(spec) = tool.runtime.immutable_spec() {
                spec.as_ref()
            } else {
                owned_spec = tool.runtime.spec();
                &owned_spec
            };
            let description = match spec {
                ToolSpec::Namespace(namespace) => namespace.description.as_str(),
                ToolSpec::Function(_)
                | ToolSpec::Freeform(_)
                | ToolSpec::ToolSearch { .. }
                | ToolSpec::WebSearch { .. } => "",
            };
            if !description.trim().is_empty() {
                *existing_description = description.to_string();
            }
        }
        namespaces
    }

    #[cfg(test)]
    pub(crate) fn empty_for_test() -> Self {
        Self::from_tools(std::iter::empty())
    }

    #[cfg(test)]
    pub(crate) fn with_handler_for_test<T>(handler: Arc<T>) -> Self
    where
        T: CoreToolRuntime + 'static,
    {
        Self::from_tools([handler as Arc<dyn CoreToolRuntime>])
    }

    pub(crate) fn tool(&self, name: &ToolName) -> Option<Arc<dyn CoreToolRuntime>> {
        self.registered_tool(name)
            .map(|tool| Arc::clone(&tool.runtime))
    }

    pub(crate) fn registered_tool(&self, name: &ToolName) -> Option<&RegisteredTool> {
        self.tools.get(&name.clone().with_default_namespace())
    }

    #[cfg(test)]
    pub(crate) fn tool_names_for_test(&self) -> Vec<ToolName> {
        let mut names = self.tools.keys().cloned().collect::<Vec<_>>();
        names.sort();
        names
    }

    #[cfg(test)]
    pub(crate) fn tool_exposure(&self, name: &ToolName) -> Option<ToolExposure> {
        self.tools
            .get(&name.clone().with_default_namespace())
            .map(|tool| tool.exposure)
    }

    pub(crate) fn create_diff_consumer(
        &self,
        name: &ToolName,
    ) -> Option<Box<dyn ToolArgumentDiffConsumer>> {
        self.tool(name)?.create_diff_consumer()
    }

    pub(crate) fn supports_parallel_tool_calls(&self, name: &ToolName) -> Option<bool> {
        let tool = self.tools.get(&name.clone().with_default_namespace())?;
        Some(tool.exposure != ToolExposure::Hidden && tool.runtime.supports_parallel_tool_calls())
    }

    #[expect(
        clippy::await_holding_invalid_type,
        reason = "tool dispatch must keep active-turn accounting atomic"
    )]
    pub(crate) async fn dispatch_any_with_state(
        &self,
        mut invocation: ToolInvocation,
        call_state: Option<Arc<ToolCallState>>,
    ) -> Result<AnyToolResult, FunctionCallError> {
        let cancellation_token = invocation.cancellation_token.clone();
        let tool_name = invocation.tool_name.clone();
        let call_id_owned = invocation.call_id.clone();
        let otel = invocation
            .step_context
            .session_telemetry
            .clone()
            .with_product_sku(invocation.turn.config.apps_mcp_product_sku.as_deref());
        // TODO(anp): Reconcile these tags with TurnEnvironment::sandbox_context
        // instead of reporting the thread-wide backend for environment-scoped tools.
        let sandbox_tags = invocation.turn.turn_metadata_state.sandbox_tags;

        {
            let mut active = invocation
                .session
                .active_turn
                .lock()
                .or_cancel_tool(&cancellation_token)
                .await?;
            if let Some(active_turn) = active.as_mut() {
                let mut turn_state = active_turn
                    .turn_state
                    .lock()
                    .or_cancel_tool(&cancellation_token)
                    .await?;
                turn_state.tool_calls = turn_state.tool_calls.saturating_add(1);
            }
        }

        let dispatch_trace = ToolDispatchTrace::start(&invocation);
        let tool = match self.tool(&tool_name) {
            Some(tool) => tool,
            None => {
                let message = unsupported_tool_call_message(&invocation.payload, &tool_name);
                let log_payload = tool_log_payload(&invocation.payload, &invocation.source);
                let mut tool_result_tags = Vec::with_capacity(2);
                sandbox_tags.append_metric_tags(&mut tool_result_tags);
                otel.tool_result_with_tags(
                    &tool_name,
                    &call_id_owned,
                    log_payload.as_ref(),
                    Duration::ZERO,
                    /*success*/ false,
                    &message,
                    &tool_result_tags,
                    /*extra_trace_fields*/ &[],
                );
                let err = FunctionCallError::RespondToModel(message);
                dispatch_trace.record_failed(&err);
                return Err(err);
            }
        };
        let telemetry_tags = tool.telemetry_tags(&invocation);
        let mut tool_result_tags = Vec::with_capacity(2 + telemetry_tags.len() + 1);
        let mut extra_trace_fields = Vec::new();
        sandbox_tags.append_metric_tags(&mut tool_result_tags);
        for (key, value) in &telemetry_tags {
            if matches!(*key, "mcp_server" | "mcp_server_origin") {
                extra_trace_fields.push((*key, value.as_str()));
            } else {
                tool_result_tags.push((*key, value.as_str()));
            }
        }
        if !tool.matches_kind(&invocation.payload) {
            let message = format!("tool {tool_name} invoked with incompatible payload");
            let log_payload = tool_log_payload(&invocation.payload, &invocation.source);
            otel.tool_result_with_tags(
                &tool_name,
                &call_id_owned,
                log_payload.as_ref(),
                Duration::ZERO,
                /*success*/ false,
                &message,
                &tool_result_tags,
                &extra_trace_fields,
            );
            let err = FunctionCallError::Fatal(message);
            dispatch_trace.record_failed(&err);
            return Err(err);
        }

        if let Some(pre_tool_use_payload) = tool.pre_tool_use_payload(&invocation) {
            match run_pre_tool_use_hooks(
                &invocation.session,
                invocation.step_context.as_ref(),
                invocation.call_id.clone(),
                &pre_tool_use_payload.tool_name,
                &pre_tool_use_payload.tool_input,
            )
            .or_cancel_tool(&cancellation_token)
            .await?
            {
                PreToolUseHookResult::Blocked(message) => {
                    if tool.is_builtin_control_tool() {
                        let mut analytics = ControlToolCallGuard::new(&invocation);
                        analytics.finish(ControlToolCallStatus::Rejected);
                    }
                    let err = FunctionCallError::RespondToModel(message);
                    dispatch_trace.record_failed(&err);
                    notify_tool_finish_if_unclaimed(
                        &invocation,
                        call_state.as_deref(),
                        ToolCallOutcome::Blocked,
                    )
                    .await;
                    return Err(err);
                }
                PreToolUseHookResult::Continue {
                    updated_input: Some(updated_input),
                } => match tool.with_updated_hook_input(invocation.clone(), updated_input) {
                    Ok(updated_invocation) => {
                        invocation = updated_invocation;
                    }
                    Err(err) => {
                        if tool.is_builtin_control_tool() {
                            let mut analytics = ControlToolCallGuard::new(&invocation);
                            analytics.finish(ControlToolCallStatus::Failed);
                        }
                        dispatch_trace.record_failed(&err);
                        notify_tool_finish_if_unclaimed(
                            &invocation,
                            call_state.as_deref(),
                            ToolCallOutcome::Failed {
                                handler_executed: false,
                            },
                        )
                        .await;
                        return Err(err);
                    }
                },
                PreToolUseHookResult::Continue {
                    updated_input: None,
                } => {}
            }
        }

        if tool.mcp_server_name().is_none() {
            notify_tool_start(&invocation, /*mcp_tool*/ None)
                .or_cancel_tool(&cancellation_token)
                .await?;
        }
        let mut control_tool_analytics = tool
            .is_builtin_control_tool()
            .then(|| ControlToolCallGuard::new(&invocation));

        if let Some(command) = shell_script_for_invocation(&invocation) {
            let parsed = parse_shell_script(&command);
            let mut categories = parsed.iter().map(|command| match command {
                ParsedCommand::Read { .. } => "read",
                ParsedCommand::ListFiles { .. } => "list_files",
                ParsedCommand::Search { .. } => "search",
                ParsedCommand::Unknown { .. } => "unknown",
            });
            let category = match categories.next() {
                Some(first) if categories.all(|category| category == first) => first,
                Some(_) => "mixed",
                None => "unknown",
            };
            tool_result_tags.push(("command_category", category));
        }

        let log_payload = tool_log_payload(&invocation.payload, &invocation.source);

        let result = otel
            .log_tool_result_with_tags(
                &tool_name,
                &call_id_owned,
                log_payload.as_ref(),
                &tool_result_tags,
                &extra_trace_fields,
                || handle_any_tool(tool.as_ref(), invocation.clone(), call_state.as_deref()),
                |result| {
                    (
                        result.result.log_output(),
                        result.result.success_for_logging(),
                    )
                },
            )
            .await;
        let success = match &result {
            Ok(result) => result.result.success_for_logging(),
            Err(_) => false,
        };
        if let Some(analytics) = control_tool_analytics.as_mut() {
            analytics.finish(if success {
                ControlToolCallStatus::Completed
            } else {
                ControlToolCallStatus::Failed
            });
        }
        emit_metric_for_tool_read(&invocation, success);
        let post_tool_use_payload = if success {
            result
                .as_ref()
                .ok()
                .and_then(|result| result.post_tool_use_payload.clone())
        } else {
            None
        };
        let post_tool_use_outcome = if let Some(post_tool_use_payload) = post_tool_use_payload {
            Some(
                run_post_tool_use_hooks(
                    &invocation.session,
                    invocation.step_context.as_ref(),
                    post_tool_use_payload.tool_use_id,
                    post_tool_use_payload.tool_name.name().to_string(),
                    post_tool_use_payload.tool_name.matcher_aliases().to_vec(),
                    post_tool_use_payload.tool_input,
                    post_tool_use_payload.tool_response,
                )
                .or_cancel_tool(&cancellation_token)
                .await?,
            )
        } else {
            None
        };
        if let Some(outcome) = &post_tool_use_outcome {
            record_additional_contexts(
                &invocation.session,
                &invocation.turn,
                outcome.additional_contexts.clone(),
            )
            .or_cancel_tool(&cancellation_token)
            .await?;
        }

        // A PostToolUse block rejects the result, not the already-completed tool execution.
        let lifecycle_outcome = match &result {
            _ if tool.finishes_on_cancellation()
                && invocation.cancellation_token.is_cancelled() =>
            {
                ToolCallOutcome::Aborted
            }
            Ok(_) => ToolCallOutcome::Completed { success },
            Err(_) => ToolCallOutcome::Failed {
                handler_executed: true,
            },
        };
        notify_tool_finish_if_unclaimed(&invocation, call_state.as_deref(), lifecycle_outcome)
            .await;

        match result {
            Ok(mut result) => {
                if let Some(outcome) = post_tool_use_outcome {
                    if outcome.should_block {
                        let message = outcome.feedback_message.unwrap_or_else(|| {
                            "PostToolUse hook blocked the tool result".to_string()
                        });
                        let err = FunctionCallError::RespondToModel(message);
                        dispatch_trace.record_failed(&err);
                        return Err(err);
                    }
                    if let Some(feedback_message) = outcome.feedback_message {
                        result.result = Box::new(PostToolUseFeedbackOutput {
                            original: result.result,
                            model_visible: FunctionToolOutput::from_text(
                                feedback_message,
                                /*success*/ None,
                            ),
                        });
                    }
                }
                tool.on_tool_result_accepted(&invocation, result.result.as_ref());
                dispatch_trace.record_completed(
                    &invocation,
                    &result.call_id,
                    &result.payload,
                    result.result.as_ref(),
                );
                Ok(result)
            }
            Err(err) => {
                dispatch_trace.record_failed(&err);
                Err(err)
            }
        }
    }
}

async fn notify_tool_finish_if_unclaimed(
    invocation: &ToolInvocation,
    call_state: Option<&ToolCallState>,
    outcome: ToolCallOutcome,
) -> bool {
    if call_state.is_some_and(|state| state.terminal_outcome_reached.swap(true, Ordering::AcqRel)) {
        return false;
    }

    notify_tool_finish(invocation, outcome).await;
    true
}

async fn handle_any_tool(
    tool: &dyn CoreToolRuntime,
    invocation: ToolInvocation,
    call_state: Option<&ToolCallState>,
) -> Result<AnyToolResult, FunctionCallError> {
    let call_id = invocation.call_id.clone();
    let payload = invocation.payload.clone();
    let output = tool.handle(invocation.clone()).await?;
    let post_tool_use_payload =
        CoreToolRuntime::post_tool_use_payload(tool, &invocation, output.as_ref());
    let result = AnyToolResult {
        call_id,
        payload,
        result: output,
        post_tool_use_payload,
    };
    super::user_messaging::capture_delivery(&result, call_state);
    if result.result.contains_external_context()
        && invocation.turn.config.memories.disable_on_external_context
    {
        state_db::mark_thread_memory_mode_polluted(
            invocation.session.services.state_db.as_deref(),
            invocation.session.thread_id,
            "tool_output",
        )
        .or_cancel_tool(&invocation.cancellation_token)
        .await?;
    }
    Ok(result)
}

fn function_hook_tool_name(invocation: &ToolInvocation) -> HookToolName {
    if invocation.tool_name.name == "spawn_agent"
        && (invocation.tool_name.is_default_namespace()
            || invocation.tool_name.namespace.as_deref() == Some(MULTI_AGENT_V1_NAMESPACE))
    {
        return HookToolName::spawn_agent();
    }

    HookToolName::new(flat_tool_name(&invocation.tool_name).into_owned())
}

fn function_hook_tool_input(arguments: &str) -> Value {
    if arguments.trim().is_empty() {
        return Value::Object(serde_json::Map::new());
    }

    serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))
}

fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String {
    match payload {
        ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
        _ => format!("unsupported call: {tool_name}"),
    }
}
#[cfg(test)]
#[path = "registry_tests.rs"]
mod tests;
codex-rs/core/src/tools/spec_plan.rs
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::agent::next_thread_spawn_depth;
use crate::environment_selection::TurnEnvironmentSnapshot;
use crate::image_preparation::unified_image_budget_enabled;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::tools::code_mode::execute_spec::create_code_mode_tool;
use crate::tools::effective_tool_mode;
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CodeModeExecuteHandler;
use crate::tools::handlers::CodeModeWaitHandler;
use crate::tools::handlers::CurrentTimeHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::ExecCommandHandler;
use crate::tools::handlers::ExecCommandHandlerOptions;
use crate::tools::handlers::GetContextRemainingHandler;
use crate::tools::handlers::ListAvailablePluginsToInstallHandler;
use crate::tools::handlers::ListMcpResourceTemplatesHandler;
use crate::tools::handlers::ListMcpResourcesHandler;
use crate::tools::handlers::NewContextWindowHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::RequestPermissionsHandler;
use crate::tools::handlers::RequestPluginInstallHandler;
use crate::tools::handlers::RequestUserInputAsyncHandler;
use crate::tools::handlers::RequestUserInputHandler;
use crate::tools::handlers::SendMessageToUserAsyncHandler;
use crate::tools::handlers::SleepHandler;
use crate::tools::handlers::TestSyncHandler;
use crate::tools::handlers::ToolSearchHandlerCache;
use crate::tools::handlers::ViewImageHandler;
use crate::tools::handlers::WaitForEnvironmentHandler;
use crate::tools::handlers::WriteStdinHandler;
use crate::tools::handlers::extension_tools::ExtensionToolAdapter;
use crate::tools::handlers::multi_agents::CloseAgentHandler;
use crate::tools::handlers::multi_agents::ResumeAgentHandler;
use crate::tools::handlers::multi_agents::SendInputHandler;
use crate::tools::handlers::multi_agents::SpawnAgentHandler;
use crate::tools::handlers::multi_agents::WaitAgentHandler;
use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
use crate::tools::handlers::multi_agents_spec::MULTI_AGENT_V1_NAMESPACE;
use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions;
use crate::tools::handlers::multi_agents_spec::WaitAgentTimeoutOptions;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
use crate::tools::handlers::multi_agents_v2::InterruptAgentHandler;
use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2;
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::tools::handlers::tool_search_spec::ToolSearchSourceListing;
use crate::tools::handlers::view_image_spec::ViewImageToolOptions;
use crate::tools::hosted_spec::WebSearchToolOptions;
use crate::tools::hosted_spec::create_web_search_tool;
use crate::tools::multi_agent_tool::multi_agent_v2_handler;
use crate::tools::registry::RegisteredTool;
use crate::tools::registry::ToolExposure;
use crate::tools::registry::ToolRegistry;
use crate::tools::router::ToolRouter;
use crate::tools::tool_namespaces_info::collect_tool_namespaces_info;
use codex_connectors::apps_config_from_layer_stack;
use codex_extension_api::ExtensionData;
use codex_features::Feature;
use codex_features::SleepToolMode;
use codex_login::AuthManager;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_prompts::ResolvedModelMessages;
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::error::Result as CodexResult;
use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ConfigShellToolType;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::MultiAgentVersion;
use codex_tools::IndirectNamespacePrefixes;
use codex_tools::ResponsesApiNamespaceTool;
use codex_tools::TOOL_SEARCH_TOOL_NAME;
use codex_tools::ToolCall as ExtensionToolCall;
use codex_tools::ToolEnvironmentMode;
use codex_tools::ToolExecutor;
use codex_tools::ToolExposures;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
use codex_tools::can_request_original_image_detail;
use codex_tools::collect_code_mode_exec_prompt_tool_definitions;
use codex_tools::collect_request_plugin_install_entries;
use codex_tools::default_namespace_description;
use codex_tools::request_user_input_available_modes;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::btree_map::Entry;
use std::sync::Arc;
use tracing::instrument;

const IMAGE_GEN_NAMESPACE: &str = "image_gen";
const IMAGEGEN_TOOL_NAME: &str = "imagegen";

#[derive(Clone, Copy)]
struct CoreToolPlanContext<'a> {
    tool_policy: &'a codex_extension_api::ToolPolicy,
    turn_context: &'a TurnContext,
    model_info: &'a ModelInfo,
    environments: &'a TurnEnvironmentSnapshot,
    mcp: &'a codex_mcp::McpBinding,
    tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>,
    wait_for_environment_tool_config: Option<&'a Arc<crate::WaitForEnvironmentToolConfig>>,
    default_agent_type_description: &'a str,
    wait_agent_timeouts: WaitAgentTimeoutOptions,
}

#[instrument(level = "trace", skip_all)]
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_tool_router(
    session: &Session,
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    environments: &TurnEnvironmentSnapshot,
    mcp: &Arc<codex_mcp::McpBinding>,
    apps_enabled: bool,
    step_store: &ExtensionData,
    tool_suggest_candidates: Option<&crate::tools::router::ToolSuggestCandidates>,
) -> CodexResult<ToolRouter> {
    let default_agent_type_description =
        crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new());
    let wait_for_environment_tool_config = session
        .services
        .thread_extension_data
        .get::<crate::WaitForEnvironmentToolConfig>();
    let context = CoreToolPlanContext {
        tool_policy: &session.tool_policy,
        turn_context,
        model_info,
        environments,
        mcp,
        tool_suggest_candidates,
        wait_for_environment_tool_config: wait_for_environment_tool_config.as_ref(),
        default_agent_type_description: &default_agent_type_description,
        wait_agent_timeouts: wait_agent_timeout_options(turn_context),
    };
    let mut registry = ToolRegistry::with_tool_policy(Arc::clone(&session.tool_policy));
    add_core_tool_sources(&context, &mut registry);

    let registered_mcp_tools = session.services.mcp_handler_cache.append_mcp_tools(
        mcp,
        &turn_context.config,
        apps_enabled,
        &mcp.config().mcp_server_catalog,
        model_info.supports_search_tool,
        &mut registry,
    );
    apply_mcp_tool_exposure_policy(
        turn_context,
        model_info,
        mcp,
        &registered_mcp_tools,
        &mut registry,
    );
    let standalone_web_search_tool = append_extension_tool_executors(
        turn_context,
        model_info,
        extension_tool_executors(session, step_store),
        &mut registry,
    );
    append_dynamic_tool_runtimes(&turn_context.dynamic_tools, &mut registry);
    let hosted_specs = hosted_model_tool_specs(
        turn_context,
        model_info,
        standalone_web_search_tool.as_slice(),
    );

    finalize_tool_router(
        turn_context,
        model_info,
        registry,
        hosted_specs,
        &session.services.tool_search_handler_cache,
    )
}

/// Use the effective mode because the model can override the thread's configured mode.
fn code_mode_only_strict_3p_tools(turn_context: &TurnContext, model_info: &ModelInfo) -> bool {
    effective_tool_mode(turn_context, model_info) == ToolMode::CodeModeOnly
        && turn_context
            .config
            .features
            .enabled(Feature::CodeModeOnlyStrictThirdPartyTools)
}

fn apply_mcp_tool_exposure_policy(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    mcp: &codex_mcp::McpBinding,
    registered_mcp_tools: &HashSet<ToolName>,
    registry: &mut ToolRegistry,
) {
    let mut omitted_exposures_by_tool = HashMap::new();
    let apps_config = apps_config_from_layer_stack(&turn_context.config.config_layer_stack);
    for tool in mcp.tools() {
        let tool_name = tool.canonical_tool_name();
        // Disabled, denied, app-only and over-budget MCP tools are absent from this set.
        if !registered_mcp_tools.contains(&tool_name) {
            continue;
        }
        let Some(server) = mcp.config().mcp_server_catalog.server(&tool.server_name) else {
            continue;
        };
        omitted_exposures_by_tool
            .entry(tool_name)
            .or_insert_with(|| {
                let connector_omissions = (tool.server_name == CODEX_APPS_MCP_SERVER_NAME)
                    .then_some(tool.connector_id.as_deref())
                    .flatten()
                    .and_then(|id| apps_config.as_ref()?.apps.get(id))
                    .and_then(|app| app.omit_tools_from.as_deref())
                    .unwrap_or_default();
                server
                    .config()
                    .omit_tools_from
                    .as_deref()
                    .unwrap_or_default()
                    .iter()
                    .chain(connector_omissions)
                    .copied()
                    .collect::<ToolExposures>()
            });
    }

    for tool in registry.entries_mut() {
        let tool_name = tool.runtime.tool_name();
        let Some(omitted_exposures) = omitted_exposures_by_tool.get(&tool_name) else {
            continue;
        };
        if code_mode_only_strict_3p_tools(turn_context, model_info) {
            // Ignore MCP/app omit_tools_from; eligible tools must stay deferred in exec.
            // Log conflicts without adding a warning to the model's prompt.
            if omitted_exposures.intersects(ToolExposures::DEFERRED | ToolExposures::CODE_MODE) {
                tracing::warn!(
                    tool_name = %tool_name,
                    "Ignoring MCP/app omit_tools_from because code_mode_only_strict_3p_tools is enabled"
                );
            }
            continue;
        }
        let tool_name = tool_name.with_default_namespace();

        let mut exposures = ToolExposures::ALL.difference(*omitted_exposures);
        if tool_name.namespace.as_ref().is_some_and(|namespace| {
            turn_context
                .config
                .code_mode
                .direct_only_tool_namespaces
                .contains(namespace)
        }) {
            exposures = exposures.difference(ToolExposures::DEFERRED | ToolExposures::CODE_MODE);
        }

        exposures = if model_info.supports_search_tool
            && exposures.contains(ToolExposures::DEFERRED)
            && (effective_tool_mode(turn_context, model_info) != ToolMode::CodeModeOnly
                || exposures.contains(ToolExposures::CODE_MODE))
        {
            exposures.difference(ToolExposures::DIRECT)
        } else {
            exposures.difference(ToolExposures::DEFERRED)
        };

        tool.exposure = match (
            exposures.contains(ToolExposures::DIRECT),
            exposures.contains(ToolExposures::DEFERRED),
            exposures.contains(ToolExposures::CODE_MODE),
        ) {
            (false, false, false) => ToolExposure::Hidden,
            (false, false, true) => ToolExposure::CodeModeOnly,
            (true, false, false) => ToolExposure::DirectModelOnly,
            (true, false, true) => ToolExposure::Direct,
            (false, true, false) => ToolExposure::DeferredModelOnly,
            (false, true, true) => ToolExposure::Deferred,
            (true, true, _) => unreachable!("direct and deferred exposure are mutually exclusive"),
        };
    }
}

#[cfg(test)]
#[instrument(level = "trace", skip_all)]
pub(crate) fn build_core_tool_registry(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    environments: &TurnEnvironmentSnapshot,
    mcp: &codex_mcp::McpBinding,
    tool_suggest_candidates: Option<&crate::tools::router::ToolSuggestCandidates>,
    wait_for_environment_tool_config: Option<&Arc<crate::WaitForEnvironmentToolConfig>>,
) -> ToolRegistry {
    let default_agent_type_description =
        crate::agent::role::spawn_tool_spec::build(&std::collections::BTreeMap::new());
    let context = CoreToolPlanContext {
        tool_policy: &Default::default(),
        turn_context,
        model_info,
        environments,
        mcp,
        tool_suggest_candidates,
        wait_for_environment_tool_config,
        default_agent_type_description: &default_agent_type_description,
        wait_agent_timeouts: wait_agent_timeout_options(turn_context),
    };
    let mut registry = ToolRegistry::default();
    add_core_tool_sources(&context, &mut registry);
    registry
}

#[cfg(test)]
#[instrument(level = "trace", skip_all)]
pub(crate) fn append_source_tools(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registry: &mut ToolRegistry,
    mcp_tools: Vec<RegisteredTool>,
    extension_tool_executors: impl IntoIterator<
        Item = Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>>,
    >,
    dynamic_tools: &[DynamicToolSpec],
) -> Vec<ToolSpec> {
    for tool in mcp_tools {
        registry.register_external_with_exposure(tool.runtime, tool.exposure);
    }
    let standalone_web_search_tool = append_extension_tool_executors(
        turn_context,
        model_info,
        extension_tool_executors,
        registry,
    );
    append_dynamic_tool_runtimes(dynamic_tools, registry);
    hosted_model_tool_specs(
        turn_context,
        model_info,
        standalone_web_search_tool.as_slice(),
    )
}

#[instrument(level = "trace", skip_all)]
pub(crate) fn extension_tool_executors<'a>(
    session: &'a Session,
    step_store: &'a ExtensionData,
) -> impl Iterator<Item = Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>>> + 'a {
    session
        .services
        .extensions
        .tool_contributors()
        .iter()
        .flat_map(move |contributor| {
            contributor.tools_for_step(
                &session.services.session_extension_data,
                &session.services.thread_extension_data,
                step_store,
            )
        })
}

#[instrument(level = "trace", skip_all)]
pub(crate) fn finalize_tool_router(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    mut registry: ToolRegistry,
    mut hosted_specs: Vec<ToolSpec>,
    tool_search_handler_cache: &ToolSearchHandlerCache,
) -> CodexResult<ToolRouter> {
    let model_messages = ResolvedModelMessages::from_model(model_info);
    hosted_specs.retain(|spec| registry.tool_policy.allows(&ToolName::plain(spec.name())));
    apply_direct_model_only_namespace_overrides(turn_context, &mut registry);
    enforce_strict_3p_tools(turn_context, model_info, &mut registry);
    let tool_mode = effective_tool_mode(turn_context, model_info);
    let code_mode_enabled = matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly);
    if code_mode_enabled {
        for tool_name in [
            ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME),
            ToolName::plain(codex_code_mode::WAIT_TOOL_NAME),
        ] {
            if registry.remove(&tool_name).is_some() {
                registry.record_collision(tool_name);
            }
        }
    }
    let tool_search_name = ToolName::plain(TOOL_SEARCH_TOOL_NAME);
    let search_exposure = if model_info.supports_search_tool
        && registry.tool_policy.allows(&tool_search_name)
        && registry.entries().any(|tool| {
            tool.runtime.tool_name() != tool_search_name
                && tool.exposure.is_deferred()
                && tool.runtime.search_info().is_some()
        }) {
        Some(
            if turn_context
                .config
                .features
                .enabled(Feature::CodeModeToolSearch)
            {
                tool_exposure_with_namespace_override(
                    turn_context,
                    tool_search_name.clone(),
                    ToolExposure::Direct,
                )
            } else {
                ToolExposure::Direct
            },
        )
    } else {
        None
    };
    if search_exposure.is_some() {
        if registry.remove(&tool_search_name).is_some() {
            registry.record_collision(tool_search_name.clone());
        }
        // Special model tools own the namespace matching their wire identity, so
        // regular namespace tools cannot advertise that same model-visible surface.
        let conflicting_tool_names = registry
            .entries()
            .filter_map(|tool| {
                let owned_spec;
                let spec = if let Some(spec) = tool.runtime.immutable_spec() {
                    spec.as_ref()
                } else {
                    owned_spec = tool.runtime.spec();
                    &owned_spec
                };
                let ToolSpec::Namespace(namespace) = spec else {
                    return None;
                };
                (namespace.name == tool_search_name.name).then(|| tool.runtime.tool_name())
            })
            .collect::<Vec<_>>();
        for tool_name in conflicting_tool_names {
            if registry.remove(&tool_name).is_some() {
                registry.record_collision(tool_name);
            }
        }
    }

    prepare_function_description_prefixes(
        turn_context,
        model_info,
        &mut registry,
        search_exposure,
    )?;

    if let Some(exposure) = search_exposure {
        append_tool_search_executor(
            turn_context,
            &mut registry,
            tool_search_handler_cache,
            exposure,
        );
    }

    let indirect_prefixes = IndirectNamespacePrefixes::new(
        model_messages.indirect_description_prefixes(),
        registry.mcp_namespaces(),
    )
    .map_err(|error| CodexErrorDetails::InvalidRequest(error.to_string()))?;
    let code_mode_tool_names =
        register_code_mode_executors(turn_context, model_info, &mut registry, &indirect_prefixes);
    let include_tool_namespaces_info = turn_context
        .config
        .tool_registry
        .turn_metadata_includes_tool_info
        && model_info.use_responses_lite;

    if turn_context.config.tool_registry.error_on_tool_collisions {
        if let Some(tool_name) = registry.first_collision() {
            let namespace = tool_name.namespace.as_deref().unwrap_or("functions");
            let name = format!("{namespace}.{}", tool_name.name);
            return Err(CodexErrorDetails::ToolCollision(name).into());
        }

        let mut namespace_descriptions = BTreeMap::new();
        let mut namespace_owners = BTreeMap::new();
        for tool in registry.entries() {
            let owned_spec;
            let spec = if let Some(spec) = tool.runtime.immutable_spec() {
                spec.as_ref()
            } else {
                owned_spec = tool.runtime.spec();
                &owned_spec
            };
            if include_tool_namespaces_info && tool.exposure != ToolExposure::Hidden {
                let namespace_name = match spec {
                    ToolSpec::Namespace(namespace) => namespace.name.as_str(),
                    ToolSpec::Function(_) | ToolSpec::Freeform(_) => DEFAULT_FUNCTION_NAMESPACE,
                    ToolSpec::ToolSearch { .. } => TOOL_SEARCH_TOOL_NAME,
                    ToolSpec::WebSearch { .. } => continue,
                };
                let owner = tool.runtime.mcp_server_name();
                match namespace_owners.get(namespace_name) {
                    Some(existing_owner) if existing_owner != &owner => {
                        return Err(
                            CodexErrorDetails::ToolCollision(namespace_name.to_string()).into()
                        );
                    }
                    Some(_) => {}
                    None => {
                        namespace_owners.insert(namespace_name.to_string(), owner);
                    }
                }
            }

            let ToolSpec::Namespace(namespace) = spec else {
                continue;
            };
            if namespace.description.trim().is_empty() {
                continue;
            }
            match namespace_descriptions.entry(namespace.name.clone()) {
                Entry::Vacant(entry) => {
                    entry.insert(namespace.description.clone());
                }
                Entry::Occupied(entry) if entry.get() != &namespace.description => {
                    return Err(CodexErrorDetails::ToolCollision(entry.key().clone()).into());
                }
                Entry::Occupied(_) => {}
            }
        }
    }

    let model_visible_specs = build_model_visible_specs(
        turn_context,
        model_info,
        &registry,
        &code_mode_tool_names,
        hosted_specs,
    );
    let tool_namespaces_info = include_tool_namespaces_info
        .then(|| {
            collect_tool_namespaces_info(&registry, &code_mode_tool_names, &model_visible_specs)
        })
        .filter(|info| !info.is_empty());
    let child_management_tools = required_child_management_tool_names(turn_context, model_info);

    let router = ToolRouter::from_parts(
        registry,
        model_visible_specs,
        tool_mode,
        code_mode_tool_names,
        tool_namespaces_info,
        &child_management_tools,
    );
    // Internal workers can inherit MAv2 configuration without using the board.
    if multi_agent_v2_enabled(turn_context)
        && collab_tools_enabled(turn_context, model_info)
        && turn_context.config.multi_agent_v2.disable_direct_message
        && !turn_context.session_source.is_internal()
    {
        let post_tool = ToolName::new(
            turn_context.config.multi_agent_v2.tool_namespace.clone(),
            "post",
        );
        if !router.exposes_tool(&post_tool) {
            return Err(CodexErrorDetails::InvalidRequest(
                "disable_direct_message requires an available agent message board in this session"
                    .to_owned(),
            )
            .into());
        }
    }
    Ok(router)
}

fn apply_direct_model_only_namespace_overrides(
    turn_context: &TurnContext,
    registry: &mut ToolRegistry,
) {
    if turn_context
        .config
        .code_mode
        .direct_only_tool_namespaces
        .is_empty()
    {
        return;
    }

    for tool in registry.entries_mut() {
        tool.exposure = tool_exposure_with_namespace_override(
            turn_context,
            tool.runtime.tool_name(),
            tool.exposure,
        );
    }
}

fn tool_exposure_with_namespace_override(
    turn_context: &TurnContext,
    tool_name: ToolName,
    exposure: ToolExposure,
) -> ToolExposure {
    if exposure.is_available_in_code_mode()
        && tool_name
            .with_default_namespace()
            .namespace
            .as_ref()
            .is_some_and(|namespace| {
                turn_context
                    .config
                    .code_mode
                    .direct_only_tool_namespaces
                    .contains(namespace)
            })
    {
        ToolExposure::DirectModelOnly
    } else {
        exposure
    }
}

/// Run after namespace overrides so eligible third-party tools always remain deferred.
fn enforce_strict_3p_tools(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registry: &mut ToolRegistry,
) {
    if !code_mode_only_strict_3p_tools(turn_context, model_info) {
        return;
    }

    for tool in registry.entries_mut() {
        // Remaining Hidden entries failed eligibility or budget checks; keep them hidden.
        if !tool.runtime.is_third_party_tool() || tool.exposure == ToolExposure::Hidden {
            continue;
        }
        if tool.exposure != ToolExposure::Deferred {
            tracing::warn!(
                tool_name = %tool.runtime.tool_name().with_default_namespace(),
                "Deferring third-party tool because code_mode_only_strict_3p_tools is enabled"
            );
            tool.exposure = ToolExposure::Deferred;
        }
    }
}

#[instrument(level = "trace", skip_all)]
fn build_model_visible_specs(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registry: &ToolRegistry,
    code_mode_tool_names: &BTreeMap<String, ToolName>,
    hosted_specs: Vec<ToolSpec>,
) -> Vec<ToolSpec> {
    let mut specs = Vec::new();
    for tool in registry.entries() {
        let exposure = tool.exposure;
        if !is_directly_visible(turn_context, model_info, tool) {
            continue;
        }

        let tool_name = tool.runtime.tool_name();
        let spec = Arc::unwrap_or_clone(tool.spec());
        specs.push(spec_for_model_request(
            turn_context,
            model_info,
            exposure,
            &tool_name,
            code_mode_tool_names,
            spec,
        ));
    }
    specs.extend(hosted_specs);

    merge_into_namespaces(specs)
}

fn spec_for_model_request(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    exposure: ToolExposure,
    tool_name: &ToolName,
    code_mode_tool_names: &BTreeMap<String, ToolName>,
    spec: ToolSpec,
) -> ToolSpec {
    let tool_mode = effective_tool_mode(turn_context, model_info);
    if matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
        && exposure.is_available_in_code_mode()
        && !is_excluded_from_code_mode(turn_context, tool_name)
        && codex_code_mode::is_code_mode_nested_tool(spec.name())
        && code_mode_tool_names
            .get(&codex_code_mode::normalize_code_mode_identifier(
                &codex_tools::code_mode_name_for_tool_name(tool_name),
            ))
            .is_some_and(|winner| winner == tool_name)
    {
        codex_tools::augment_tool_spec_for_code_mode(
            spec,
            turn_context.config.code_mode.tool_input_schema_max_bytes,
        )
    } else {
        spec
    }
}

#[instrument(level = "trace", skip_all)]
fn hosted_model_tool_specs(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registered_extension_tool_names: &[ToolName],
) -> Vec<ToolSpec> {
    // Responses Lite accepts schemas for client-executed tools, not hosted Responses tools.
    if model_info.use_responses_lite {
        return Vec::new();
    }

    let mut specs = Vec::new();
    let standalone_web_search_available = standalone_web_search_enabled(turn_context, model_info)
        && registered_extension_tool_names.contains(&ToolName::namespaced("web", "run"));
    // `Some(Cached/Live/Disabled)` are the options for mode when standalone search is unavailable
    // and the provider supports hosted search. `None` prevents emitting a hosted search tool.
    let web_search_mode = (!standalone_web_search_available
        && turn_context.provider.capabilities().web_search)
        .then_some(turn_context.config.web_search_mode.value());
    let web_search_config = web_search_mode
        .as_ref()
        .and(turn_context.config.web_search_config.as_ref());
    if let Some(hosted_web_search_tool) = create_web_search_tool(WebSearchToolOptions {
        web_search_mode,
        web_search_config,
        web_search_tool_type: model_info.web_search_tool_type,
    }) {
        specs.push(hosted_web_search_tool);
    }
    specs
}

pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool {
    let features = turn_context.config.features.get();
    features.enabled(Feature::ToolSuggest)
        && features.enabled(Feature::Apps)
        && features.enabled(Feature::Plugins)
}

fn multi_agent_v2_enabled(turn_context: &TurnContext) -> bool {
    turn_context.multi_agent_version == MultiAgentVersion::V2
}

fn collab_tools_enabled(turn_context: &TurnContext, model_info: &ModelInfo) -> bool {
    match turn_context.multi_agent_version {
        MultiAgentVersion::Disabled => false,
        MultiAgentVersion::V1 => !exceeds_thread_spawn_depth_limit(
            next_thread_spawn_depth(&turn_context.session_source),
            turn_context.config.agent_max_depth,
        ),
        MultiAgentVersion::V2 => {
            turn_context.session_source.get_agent_path().is_none()
                || model_info.multi_agent_version == Some(MultiAgentVersion::V2)
        }
    }
}

fn required_child_management_tool_names(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
) -> Vec<ToolName> {
    if !collab_tools_enabled(turn_context, model_info) {
        return Vec::new();
    }

    let (namespace, names): (_, &[&str]) = match turn_context.multi_agent_version {
        MultiAgentVersion::Disabled => return Vec::new(),
        MultiAgentVersion::V1 => (
            Some(MULTI_AGENT_V1_NAMESPACE),
            &["send_input", "wait_agent", "resume_agent", "close_agent"],
        ),
        MultiAgentVersion::V2 => (
            turn_context.config.multi_agent_v2.tool_namespace.as_deref(),
            if turn_context.config.multi_agent_v2.disable_direct_message {
                &["interrupt_agent", "list_agents"]
            } else {
                &[
                    "send_message",
                    "followup_task",
                    "interrupt_agent",
                    "list_agents",
                ]
            },
        ),
    };
    let mut tools = names
        .iter()
        .map(|name| ToolName::new(namespace.map(str::to_owned), (*name).to_owned()))
        .collect::<Vec<_>>();
    if multi_agent_v2_enabled(turn_context) && turn_context.config.multi_agent_v2.wait_agent_enabled
    {
        tools.push(ToolName::new(namespace.map(str::to_owned), "wait_agent"));
    }
    tools
}

fn image_generation_available(turn_context: &TurnContext, model_info: &ModelInfo) -> bool {
    if !turn_context
        .config
        .features
        .get()
        .enabled(Feature::ImageGeneration)
    {
        return false;
    }

    if turn_context
        .auth_manager
        .as_deref()
        .and_then(AuthManager::auth_cached)
        .and_then(|auth| auth.account_plan_type())
        == Some(PlanType::Free)
    {
        return false;
    }

    if !turn_context.provider.capabilities().image_generation {
        return false;
    }

    if !model_info.input_modalities.contains(&InputModality::Image) {
        return false;
    }

    let provider = turn_context.provider.info();
    provider.uses_openai_actor_authorization()
        || (provider.requires_openai_auth
            && turn_context
                .auth_manager
                .as_deref()
                .is_some_and(AuthManager::current_auth_uses_codex_backend))
}

fn wait_agent_timeout_options(turn_context: &TurnContext) -> WaitAgentTimeoutOptions {
    if multi_agent_v2_enabled(turn_context) {
        return WaitAgentTimeoutOptions {
            default_timeout_ms: turn_context.config.multi_agent_v2.default_wait_timeout_ms,
            min_timeout_ms: turn_context.config.multi_agent_v2.min_wait_timeout_ms,
            max_timeout_ms: turn_context.config.multi_agent_v2.max_wait_timeout_ms,
        };
    }

    WaitAgentTimeoutOptions {
        default_timeout_ms: DEFAULT_WAIT_TIMEOUT_MS,
        min_timeout_ms: MIN_WAIT_TIMEOUT_MS,
        max_timeout_ms: MAX_WAIT_TIMEOUT_MS,
    }
}

fn agent_type_description(
    turn_context: &TurnContext,
    default_agent_type_description: &str,
) -> String {
    let agent_type_description =
        crate::agent::role::spawn_tool_spec::build(&turn_context.config.agent_roles);
    if agent_type_description.is_empty() {
        default_agent_type_description.to_string()
    } else {
        agent_type_description
    }
}

fn is_hidden_by_code_mode_only(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    tool_name: &ToolName,
    exposure: ToolExposure,
) -> bool {
    let tool_mode = effective_tool_mode(turn_context, model_info);
    tool_mode == ToolMode::CodeModeOnly
        && exposure.is_available_in_code_mode()
        && codex_code_mode::is_code_mode_nested_tool(&codex_tools::code_mode_name_for_tool_name(
            tool_name,
        ))
}

fn is_directly_visible(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    tool: &RegisteredTool,
) -> bool {
    tool.exposure.is_direct()
        && !is_hidden_by_code_mode_only(
            turn_context,
            model_info,
            &tool.runtime.tool_name(),
            tool.exposure,
        )
}

fn is_excluded_from_code_mode(turn_context: &TurnContext, tool_name: &ToolName) -> bool {
    let tool_name = tool_name.clone().with_default_namespace();
    tool_name.namespace.as_ref().is_some_and(|namespace| {
        turn_context
            .config
            .code_mode
            .excluded_tool_namespaces
            .contains(namespace)
    })
}

fn prepare_function_description_prefixes(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registry: &mut ToolRegistry,
    search_exposure: Option<ToolExposure>,
) -> CodexResult<()> {
    let model_messages = ResolvedModelMessages::from_model(model_info);
    let prefixes = model_messages.functions_namespace_functions_description_prefixes();
    let mut available_tools = Vec::new();
    if prefixes.is_some_and(|prefixes| !prefixes.is_empty()) {
        let code_mode_enabled = matches!(
            effective_tool_mode(turn_context, model_info),
            ToolMode::CodeMode | ToolMode::CodeModeOnly
        );
        let search_name = ToolName::plain(TOOL_SEARCH_TOOL_NAME);
        let search_visible = search_exposure.is_some_and(|exposure| {
            !is_hidden_by_code_mode_only(turn_context, model_info, &search_name, exposure)
                || (code_mode_enabled
                    && registry
                        .tool_policy
                        .allows(&ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME))
                    && turn_context
                        .config
                        .features
                        .enabled(Feature::CodeModeToolSearch)
                    && exposure.is_available_in_code_mode()
                    && !is_excluded_from_code_mode(turn_context, &search_name))
        });
        available_tools.extend(
            registry
                .entries()
                .filter(|tool| {
                    is_directly_visible(turn_context, model_info, tool)
                        || (search_visible
                            && tool.exposure.is_deferred()
                            && tool.runtime.search_info().is_some())
                })
                .map(|tool| tool.runtime.tool_name()),
        );
        available_tools.extend(
            code_mode_tools(turn_context, model_info, registry)
                .map(|(_, _, tool)| tool.runtime.tool_name()),
        );
        // These wrappers are generated after their inner descriptions are ready.
        if code_mode_enabled {
            available_tools.extend(
                [
                    codex_code_mode::PUBLIC_TOOL_NAME,
                    codex_code_mode::WAIT_TOOL_NAME,
                ]
                .into_iter()
                .map(ToolName::plain)
                .filter(|name| registry.tool_policy.allows(name)),
            );
        }
    }
    registry
        .apply_functions_namespace_function_prefixes(prefixes, available_tools)
        .map_err(|error| CodexErrorDetails::InvalidRequest(error.to_owned()).into())
}

/// Select the same nested tools for prefix budgeting and Code Mode construction.
/// A registered tool alone is not reachable when policy disallows the exec wrapper.
fn code_mode_tools<'a>(
    turn_context: &'a TurnContext,
    model_info: &'a ModelInfo,
    registry: &'a ToolRegistry,
) -> impl Iterator<Item = (String, Arc<ToolSpec>, &'a RegisteredTool)> {
    let tool_mode = effective_tool_mode(turn_context, model_info);
    let enabled = matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly)
        && registry
            .tool_policy
            .allows(&ToolName::plain(codex_code_mode::PUBLIC_TOOL_NAME));
    let mut seen = HashSet::new();
    registry.entries().filter_map(move |tool| {
        if !enabled || !tool.exposure.is_available_in_code_mode() {
            return None;
        }

        let tool_name = tool.runtime.tool_name();
        // Deferred third-party tools still need an exec route to be discoverable.
        if is_excluded_from_code_mode(turn_context, &tool_name)
            && !(code_mode_only_strict_3p_tools(turn_context, model_info)
                && tool.runtime.is_third_party_tool())
        {
            return None;
        }

        let spec = tool.spec();
        // Derive the name without serializing and augmenting every tool schema.
        let code_mode_name = match spec.as_ref() {
            ToolSpec::Function(_) | ToolSpec::Freeform(_) => {
                codex_tools::code_mode_name_for_tool_name(&tool_name)
            }
            ToolSpec::Namespace(namespace) if !namespace.tools.is_empty() => {
                codex_tools::code_mode_name_for_tool_name(&tool_name)
            }
            ToolSpec::ToolSearch { .. }
                if turn_context
                    .config
                    .features
                    .enabled(Feature::CodeModeToolSearch) =>
            {
                codex_tools::code_mode_name_for_tool_name(&tool_name)
            }
            ToolSpec::Namespace(_) | ToolSpec::ToolSearch { .. } | ToolSpec::WebSearch { .. } => {
                return None;
            }
        };
        if !codex_code_mode::is_code_mode_nested_tool(&code_mode_name) {
            return None;
        }
        let code_mode_name = codex_code_mode::normalize_code_mode_identifier(&code_mode_name);
        if !seen.insert(code_mode_name.clone()) {
            tracing::warn!(
                tool_name = %tool_name,
                code_mode_name = %code_mode_name,
                "skipping tool with a duplicate normalized code-mode name"
            );
            return None;
        }
        Some((code_mode_name, spec, tool))
    })
}

fn register_code_mode_executors(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    registry: &mut ToolRegistry,
    indirect_prefixes: &IndirectNamespacePrefixes<'_>,
) -> BTreeMap<String, ToolName> {
    let tool_mode = effective_tool_mode(turn_context, model_info);
    if !matches!(tool_mode, ToolMode::CodeMode | ToolMode::CodeModeOnly) {
        return BTreeMap::new();
    }

    let mut code_mode_tool_names = BTreeMap::new();
    let mut code_mode_nested_tool_specs = Vec::new();
    let mut exec_prompt_tool_specs = Vec::new();
    let mut deferred_exec_prompt_tool_specs = Vec::new();
    let mut included_deferred_mcp_output_schema = false;
    let deferred_tools_guidance_enabled = model_info.supports_search_tool;
    for (name, spec, tool) in code_mode_tools(turn_context, model_info, registry) {
        code_mode_tool_names.insert(name, tool.runtime.tool_name());
        let exposure = tool.exposure;
        let cached_runtime = tool.cached_runtime().cloned();

        if exposure == ToolExposure::Deferred {
            if deferred_tools_guidance_enabled
                && (cached_runtime.is_none() || !included_deferred_mcp_output_schema)
            {
                if cached_runtime.is_some() {
                    included_deferred_mcp_output_schema = true;
                }
                deferred_exec_prompt_tool_specs.push(Arc::clone(&spec));
            }
        } else {
            exec_prompt_tool_specs.push(spec.as_ref().clone());
        }
        code_mode_nested_tool_specs.push((spec, cached_runtime));
    }

    let deferred_tool_discovery = if exec_prompt_tool_specs
        .iter()
        .any(|spec| matches!(spec, ToolSpec::ToolSearch { .. }))
    {
        codex_code_mode::DeferredToolDiscovery::RankedSearch
    } else {
        codex_code_mode::DeferredToolDiscovery::Catalog
    };
    let mut namespace_descriptions = code_mode_namespace_descriptions(&exec_prompt_tool_specs);
    let code_mode_input_schema_max_bytes =
        turn_context.config.code_mode.tool_input_schema_max_bytes;
    let mut enabled_tools = collect_code_mode_exec_prompt_tool_definitions(
        exec_prompt_tool_specs.iter(),
        code_mode_input_schema_max_bytes,
    );
    let deferred_tools = collect_code_mode_exec_prompt_tool_definitions(
        deferred_exec_prompt_tool_specs.iter().map(Arc::as_ref),
        code_mode_input_schema_max_bytes,
    );
    let model_messages = ResolvedModelMessages::from_model(model_info);
    if tool_mode == ToolMode::CodeModeOnly {
        indirect_prefixes.apply_exec_prompt(&mut enabled_tools, &mut namespace_descriptions);
    }
    enabled_tools
        .sort_by(|left, right| compare_code_mode_tools(left, right, &namespace_descriptions));
    let exec_description = codex_code_mode::build_exec_tool_description(
        &enabled_tools,
        &deferred_tools,
        &namespace_descriptions,
        turn_context.config.code_mode.default_exec_yield_time_ms,
        tool_mode == ToolMode::CodeModeOnly,
        if unified_image_budget_enabled(&turn_context.config.features, model_info) {
            codex_code_mode::ImageDetailVisibility::Hidden
        } else {
            codex_code_mode::ImageDetailVisibility::Visible
        },
        deferred_tool_discovery,
        model_messages.code_mode(),
    );
    let execute_handler = CodeModeExecuteHandler::new(
        create_code_mode_tool(exec_description),
        code_mode_nested_tool_specs,
    );

    registry.prepend_trusted(Arc::new(CodeModeWaitHandler::new(
        model_messages.code_mode_wait_description_override(),
        model_messages.code_mode_wait_parameters_override(),
    )));
    registry.prepend_trusted(Arc::new(execute_handler));

    code_mode_tool_names
}

#[instrument(level = "trace", skip_all, fields(tool_spec_count = specs.len()))]
fn merge_into_namespaces(specs: Vec<ToolSpec>) -> Vec<ToolSpec> {
    let mut merged_specs = Vec::with_capacity(specs.len());
    let mut namespace_indices = BTreeMap::<String, usize>::new();
    for spec in specs {
        match spec {
            ToolSpec::Namespace(mut namespace) => {
                if let Some(index) = namespace_indices.get(&namespace.name).copied() {
                    let ToolSpec::Namespace(existing_namespace) = &mut merged_specs[index] else {
                        unreachable!("namespace index must point to a namespace spec");
                    };
                    if existing_namespace.description.trim().is_empty()
                        && !namespace.description.trim().is_empty()
                    {
                        existing_namespace.description = namespace.description;
                    }
                    existing_namespace.tools.append(&mut namespace.tools);
                    continue;
                }

                namespace_indices.insert(namespace.name.clone(), merged_specs.len());
                merged_specs.push(ToolSpec::Namespace(namespace));
            }
            spec => merged_specs.push(spec),
        }
    }

    for spec in &mut merged_specs {
        let ToolSpec::Namespace(namespace) = spec else {
            continue;
        };

        namespace.tools.sort_by(|left, right| {
            let left_name = match left {
                ResponsesApiNamespaceTool::Function(tool) => &tool.name,
                ResponsesApiNamespaceTool::Custom(tool) => &tool.name,
            };
            let right_name = match right {
                ResponsesApiNamespaceTool::Function(tool) => &tool.name,
                ResponsesApiNamespaceTool::Custom(tool) => &tool.name,
            };
            left_name.cmp(right_name)
        });

        if namespace.description.trim().is_empty() {
            namespace.description = default_namespace_description(&namespace.name);
        }
    }

    merged_specs
}

fn code_mode_namespace_descriptions(
    specs: &[ToolSpec],
) -> BTreeMap<String, codex_code_mode::ToolNamespaceDescription> {
    let mut namespace_descriptions = BTreeMap::new();
    for spec in specs {
        let ToolSpec::Namespace(namespace) = spec else {
            continue;
        };

        let entry = namespace_descriptions
            .entry(namespace.name.clone())
            .or_insert_with(|| codex_code_mode::ToolNamespaceDescription {
                name: namespace.name.clone(),
                description: namespace.description.clone(),
            });
        if entry.description.trim().is_empty() && !namespace.description.trim().is_empty() {
            entry.description = namespace.description.clone();
        }
    }
    namespace_descriptions
}

#[instrument(level = "trace", skip_all)]
fn add_core_tool_sources(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    // The startup ceiling applies to the thread and every selected environment.
    if context.tool_policy.require_managed_sandbox
        && (!matches!(
            context.turn_context.permission_profile(),
            PermissionProfile::Managed { .. }
        ) || context.environments.turn_environments().any(|environment| {
            !matches!(
                environment.permission_profile(),
                PermissionProfile::Managed { .. }
            )
        }))
    {
        return;
    }

    add_shell_tools(context, registry);
    add_mcp_resource_tools(context, registry);
    add_core_utility_tools(context, registry);
    add_collaboration_tools(context, registry);
}

fn standalone_web_search_enabled(turn_context: &TurnContext, model_info: &ModelInfo) -> bool {
    turn_context.provider.capabilities().web_search
        && (model_info.use_responses_lite
            || turn_context
                .config
                .features
                .get()
                .enabled(Feature::StandaloneWebSearch))
}

fn tool_environment_mode(context: &CoreToolPlanContext<'_>) -> ToolEnvironmentMode {
    let count = if context
        .turn_context
        .config
        .features
        .get()
        .enabled(Feature::StableEnvironmentTools)
    {
        // Keep selectors stable as selected attachments change readiness.
        context.environments.environments.len()
    } else {
        context.environments.turn_environments().count()
    };
    ToolEnvironmentMode::from_count(count)
}

fn should_include_windows_shell_guidance(environments: &TurnEnvironmentSnapshot) -> bool {
    let mut environments = environments.turn_environments();
    let Some(environment) = environments.next() else {
        return false;
    };
    let executor_platform_os = if environments.next().is_none() {
        environment.executor_platform_os.as_deref()
    } else {
        None
    };

    // One tool schema can target any ready environment. Multi-environment turns and legacy
    // executors without platform OS information preserve the host-derived guidance.
    match executor_platform_os {
        Some(platform_os) => platform_os == "windows",
        None => cfg!(windows),
    }
}

#[instrument(level = "trace", skip_all)]
fn add_shell_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    let turn_context = context.turn_context;
    let features = turn_context.config.features.get();
    let environment_mode = tool_environment_mode(context);
    let stable_environment_tools = features.enabled(Feature::StableEnvironmentTools);
    let advertise_environment_tools =
        stable_environment_tools || environment_mode.has_environment();
    if !advertise_environment_tools
        || !features.enabled(Feature::ShellTool)
        || matches!(context.model_info.shell_type, ConfigShellToolType::Disabled)
    {
        return;
    }

    if context.tool_policy.require_unified_exec && !features.enabled(Feature::UnifiedExec) {
        return;
    }
    let exec_permission_approvals_enabled = features.enabled(Feature::ExecPermissionApprovals)
        && context.tool_policy.expose_additional_permissions;
    let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
    let options = ExecCommandHandlerOptions {
        include_login_parameter: stable_environment_tools
            || context
                .environments
                .turn_environments()
                .any(|environment| environment.config().allow_login_shell),
        include_shell_parameter: true,
        allow_tty: features.enabled(Feature::UnifiedExecTty),
        exec_permission_approvals_enabled,
        include_environment_id,
        include_windows_shell_guidance: should_include_windows_shell_guidance(context.environments),
    };
    if features.enabled(Feature::UnifiedExec) {
        registry.add(ExecCommandHandler::new(options));
        registry.add(WriteStdinHandler);
    } else {
        // Managed requirements are the only configuration path that can keep
        // unified exec disabled. Preserve command execution without exposing a
        // resumable process or write_stdin authority prohibited by policy.
        registry.add(ExecCommandHandler::one_shot(options));
    }
}

#[instrument(level = "trace", skip_all)]
fn add_mcp_resource_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    if context.mcp.has_servers()
        || effective_tool_mode(context.turn_context, context.model_info) == ToolMode::CodeModeOnly
    {
        let messages = ResolvedModelMessages::from_model(context.model_info).mcp_resources();
        registry.add(ListMcpResourcesHandler::new(
            messages.and_then(|messages| messages.list_mcp_resources.as_ref()),
        ));
        registry.add(ListMcpResourceTemplatesHandler::new(
            messages.and_then(|messages| messages.list_mcp_resource_templates.as_ref()),
        ));
        registry.add(ReadMcpResourceHandler::new(
            messages.and_then(|messages| messages.read_mcp_resource.as_ref()),
        ));
    }
}

#[instrument(level = "trace", skip_all)]
fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    let turn_context = context.turn_context;
    let features = turn_context.config.features.get();
    let environment_mode = tool_environment_mode(context);
    let stable_environment_tools = features.enabled(Feature::StableEnvironmentTools);
    let advertise_environment_tools =
        stable_environment_tools || environment_mode.has_environment();

    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,
                ),
        );
    }

    if turn_context.config.experimental_request_user_input_enabled {
        registry.add_with_exposure(
            RequestUserInputHandler {
                available_modes: request_user_input_available_modes(features),
            },
            ToolExposure::DirectModelOnly,
        );
    }

    if turn_context.config.experimental_request_user_input_enabled
        && !turn_context.session_source.is_non_root_agent()
        && context
            .model_info
            .experimental_supported_tools
            .iter()
            // Existing model catalogs still advertise the previous name.
            .any(|tool| {
                matches!(
                    tool.as_str(),
                    "request_user_input_async" | "send_user_message_async"
                )
            })
    {
        let model_messages = ResolvedModelMessages::from_model(context.model_info);
        registry.add_with_exposure(
            RequestUserInputAsyncHandler {
                description: model_messages
                    .request_user_input_async_description()
                    .to_string(),
                parameters: model_messages
                    .request_user_input_async_parameters_override()
                    .map(str::to_owned),
            },
            ToolExposure::DirectModelOnly,
        );
    }

    if !turn_context.session_source.is_non_root_agent()
        && (features.enabled(Feature::SendMessageToUserAsync)
            || context
                .model_info
                .experimental_supported_tools
                .iter()
                .any(|tool| tool == "send_message_to_user_async"))
    {
        registry.add_with_exposure(SendMessageToUserAsyncHandler, ToolExposure::DirectModelOnly);
    }

    if advertise_environment_tools && features.enabled(Feature::RequestPermissionsTool) {
        registry.add(RequestPermissionsHandler);
    }

    if features.enabled(Feature::TokenBudget) {
        registry.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly);
        registry.add(GetContextRemainingHandler);
    }

    let current_time_reminder_enabled = features.enabled(Feature::CurrentTimeReminder);
    let model_has_clock = context
        .model_info
        .experimental_supported_tools
        .iter()
        .any(|tool| tool == "clock");
    if current_time_reminder_enabled || model_has_clock {
        registry.add(CurrentTimeHandler);
    }
    if features.enabled(Feature::SleepTool)
        && match turn_context.config.sleep_tool_mode {
            SleepToolMode::AlwaysOn => true,
            SleepToolMode::ModelDriven => {
                if current_time_reminder_enabled {
                    turn_context
                        .config
                        .current_time_reminder
                        .as_ref()
                        .is_some_and(|config| config.sleep_tool)
                } else {
                    model_has_clock
                }
            }
        }
    {
        registry.add(SleepHandler);
    }

    if tool_suggest_enabled(turn_context)
        && let Some(candidates) = context
            .tool_suggest_candidates
            .filter(|candidates| !candidates.tools.is_empty())
    {
        if candidates.presentation == crate::tools::router::ToolSuggestPresentation::ListTool {
            registry.add(ListAvailablePluginsToInstallHandler::new(
                collect_request_plugin_install_entries(&candidates.tools),
            ));
        }
        registry.add(RequestPluginInstallHandler::new(
            candidates.tools.clone(),
            candidates.presentation,
        ));
    }

    if advertise_environment_tools && context.model_info.apply_patch_tool_type.is_some() {
        let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
        registry.add(ApplyPatchHandler::new(include_environment_id));
    }

    if context
        .model_info
        .experimental_supported_tools
        .iter()
        .any(|tool| tool == "test_sync_tool")
    {
        registry.add(TestSyncHandler);
    }

    if advertise_environment_tools && features.enabled(Feature::ViewImage) {
        let include_environment_id = matches!(environment_mode, ToolEnvironmentMode::Multiple);
        registry.add(ViewImageHandler::new(ViewImageToolOptions {
            can_request_original_image_detail: can_request_original_image_detail(
                context.model_info,
            ),
            unified_image_budget: unified_image_budget_enabled(
                &turn_context.config.features,
                context.model_info,
            ),
            include_environment_id,
        }));
    }
}

#[instrument(level = "trace", skip_all)]
fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut ToolRegistry) {
    let turn_context = context.turn_context;
    if collab_tools_enabled(turn_context, context.model_info) {
        if multi_agent_v2_enabled(turn_context) {
            let model_messages = ResolvedModelMessages::from_model(context.model_info);
            let spawn_agent_description =
                model_messages.multi_agent_tool_description_override("spawn_agent");
            let exposure = if turn_context.config.multi_agent_v2.non_code_mode_only {
                ToolExposure::DirectModelOnly
            } else {
                ToolExposure::Direct
            };
            let tool_namespace = turn_context.config.multi_agent_v2.tool_namespace.as_deref();
            let agent_type_description =
                agent_type_description(turn_context, context.default_agent_type_description);
            let hide_spawn_agent_metadata =
                turn_context.config.multi_agent_v2.hide_spawn_agent_metadata;
            registry.register_trusted_with_exposure(
                multi_agent_v2_handler(
                    SpawnAgentHandlerV2::new(
                        SpawnAgentToolOptions {
                            available_models: turn_context.available_models.clone(),
                            multi_agent_version: turn_context.multi_agent_version,
                            model_catalog_in_context: turn_context
                                .config
                                .features
                                .enabled(Feature::ModelCatalogInContext),
                            agent_type_description,
                            expose_agent_type: !turn_context.config.agent_roles.is_empty(),
                            hide_agent_type_model_reasoning: hide_spawn_agent_metadata,
                            expose_spawn_agent_model_overrides: turn_context
                                .config
                                .multi_agent_v2
                                .expose_spawn_agent_model_overrides,
                            usage_hint_text: turn_context
                                .config
                                .multi_agent_v2
                                .usage_hint_text
                                .clone(),
                        },
                        spawn_agent_description.map(str::to_owned),
                    ),
                    tool_namespace,
                    // Spawn composes the selected description with inheritance and usage guidance.
                    /*description_override*/
                    None,
                    model_messages.multi_agent_tool_parameters_override("spawn_agent"),
                ),
                exposure,
            );
            if !turn_context.config.multi_agent_v2.disable_direct_message {
                registry.register_trusted_with_exposure(
                    multi_agent_v2_handler(
                        SendMessageHandlerV2,
                        tool_namespace,
                        model_messages.multi_agent_tool_description_override("send_message"),
                        model_messages.multi_agent_tool_parameters_override("send_message"),
                    ),
                    exposure,
                );
                registry.register_trusted_with_exposure(
                    multi_agent_v2_handler(
                        FollowupTaskHandlerV2,
                        tool_namespace,
                        model_messages.multi_agent_tool_description_override("followup_task"),
                        model_messages.multi_agent_tool_parameters_override("followup_task"),
                    ),
                    exposure,
                );
            }
            if turn_context.config.multi_agent_v2.wait_agent_enabled {
                registry.register_trusted_with_exposure(
                    multi_agent_v2_handler(
                        WaitAgentHandlerV2::new(context.wait_agent_timeouts),
                        tool_namespace,
                        model_messages.multi_agent_tool_description_override("wait_agent"),
                        model_messages.multi_agent_tool_parameters_override("wait_agent"),
                    ),
                    exposure,
                );
            }
            registry.register_trusted_with_exposure(
                multi_agent_v2_handler(
                    InterruptAgentHandler,
                    tool_namespace,
                    model_messages.multi_agent_tool_description_override("interrupt_agent"),
                    model_messages.multi_agent_tool_parameters_override("interrupt_agent"),
                ),
                exposure,
            );
            registry.register_trusted_with_exposure(
                multi_agent_v2_handler(
                    ListAgentsHandlerV2,
                    tool_namespace,
                    model_messages.multi_agent_tool_description_override("list_agents"),
                    model_messages.multi_agent_tool_parameters_override("list_agents"),
                ),
                exposure,
            );
        } else {
            let agent_type_description =
                agent_type_description(turn_context, context.default_agent_type_description);
            let exposure = if context.model_info.supports_search_tool {
                ToolExposure::Deferred
            } else {
                ToolExposure::Direct
            };
            registry.add_with_exposure(
                SpawnAgentHandler::new(SpawnAgentToolOptions {
                    available_models: turn_context.available_models.clone(),
                    multi_agent_version: turn_context.multi_agent_version,
                    model_catalog_in_context: turn_context
                        .config
                        .features
                        .enabled(Feature::ModelCatalogInContext),
                    agent_type_description,
                    expose_agent_type: !turn_context.config.agent_roles.is_empty(),
                    hide_agent_type_model_reasoning: false,
                    expose_spawn_agent_model_overrides: true,
                    usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(),
                }),
                exposure,
            );
            registry.add_with_exposure(SendInputHandler, exposure);
            registry.add_with_exposure(ResumeAgentHandler, exposure);
            registry
                .add_with_exposure(WaitAgentHandler::new(context.wait_agent_timeouts), exposure);
            registry.add_with_exposure(CloseAgentHandler, exposure);
        }
    }
}

#[instrument(level = "trace", skip_all, fields(dynamic_tool_count = dynamic_tools.len()))]
fn append_dynamic_tool_runtimes(dynamic_tools: &[DynamicToolSpec], registry: &mut ToolRegistry) {
    for spec in dynamic_tools {
        match spec {
            DynamicToolSpec::Function(tool) => {
                let Some(handler) = DynamicToolHandler::new(tool) else {
                    tracing::error!(
                        "Failed to convert dynamic tool {:?} to OpenAI tool",
                        tool.name
                    );
                    continue;
                };
                registry.register_external(Arc::new(handler));
            }
            DynamicToolSpec::Namespace(namespace) => {
                for tool in &namespace.tools {
                    let DynamicToolNamespaceTool::Function(tool) = tool;
                    let Some(handler) = DynamicToolHandler::new_in_namespace(namespace, tool)
                    else {
                        tracing::error!(
                            "Failed to convert dynamic tool {:?}.{:?} to OpenAI tool",
                            namespace.name,
                            tool.name
                        );
                        continue;
                    };
                    registry.register_external(Arc::new(handler));
                }
            }
        }
    }
}

#[instrument(level = "trace", skip_all)]
fn append_tool_search_executor(
    turn_context: &TurnContext,
    registry: &mut ToolRegistry,
    tool_search_handler_cache: &ToolSearchHandlerCache,
    exposure: ToolExposure,
) {
    let source_listing = if turn_context
        .config
        .features
        .enabled(Feature::DeferredToolWorldState)
    {
        ToolSearchSourceListing::Omit
    } else {
        ToolSearchSourceListing::Include
    };
    let handler = tool_search_handler_cache.get_or_build(registry, source_listing);
    registry.register_trusted_with_exposure(handler, exposure);
}

fn append_extension_tool_executors(
    turn_context: &TurnContext,
    model_info: &ModelInfo,
    executors: impl IntoIterator<Item = Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>>>,
    registry: &mut ToolRegistry,
) -> Option<ToolName> {
    let standalone_web_search_enabled = standalone_web_search_enabled(turn_context, model_info);
    let web_search_mode_on = turn_context.config.web_search_mode.value() != WebSearchMode::Disabled;
    let mut standalone_web_search_tool = None;

    for executor in executors {
        let tool_name = executor.tool_name();
        let is_standalone_web_search = tool_name == ToolName::namespaced("web", "run");
        if is_standalone_web_search && (!standalone_web_search_enabled || !web_search_mode_on) {
            continue;
        }
        if tool_name == ToolName::namespaced(IMAGE_GEN_NAMESPACE, IMAGEGEN_TOOL_NAME)
            && !image_generation_available(turn_context, model_info)
        {
            continue;
        }
        let runtime = Arc::new(ExtensionToolAdapter::new(executor));
        if registry.register_external(runtime) && is_standalone_web_search {
            standalone_web_search_tool = Some(tool_name);
        }
    }

    standalone_web_search_tool
}

fn compare_code_mode_tools(
    left: &codex_code_mode::ToolDefinition,
    right: &codex_code_mode::ToolDefinition,
    namespace_descriptions: &BTreeMap<String, codex_code_mode::ToolNamespaceDescription>,
) -> std::cmp::Ordering {
    let left_namespace = code_mode_namespace_name(left, namespace_descriptions);
    let right_namespace = code_mode_namespace_name(right, namespace_descriptions);

    left_namespace
        .cmp(&right_namespace)
        .then_with(|| left.tool_name.name.cmp(&right.tool_name.name))
        .then_with(|| left.name.cmp(&right.name))
}

fn code_mode_namespace_name<'a>(
    tool: &codex_code_mode::ToolDefinition,
    namespace_descriptions: &'a BTreeMap<String, codex_code_mode::ToolNamespaceDescription>,
) -> Option<&'a str> {
    tool.tool_name
        .namespace
        .as_ref()
        .and_then(|namespace| namespace_descriptions.get(namespace))
        .map(|namespace_description| namespace_description.name.as_str())
}

#[cfg(test)]
#[path = "spec_plan_tests.rs"]
mod tests;
codex-rs/core/src/tools/orchestrator.rs
/*
Module: orchestrator

Central place for approvals + sandbox selection + retry semantics. Drives a
simple sequence for any ToolRuntime: approval → select sandbox → attempt →
retry with an escalated sandbox strategy on denial (no re‑approval thanks to
caching).
*/
use crate::config::NetworkProxySpec;
use crate::guardian::GuardianReviewContext;
use crate::network_policy_decision::network_approval_context_from_payload;
use crate::tools::approvals::ApprovalContext;
use crate::tools::flat_tool_name;
use crate::tools::network_approval::ActiveNetworkApproval;
use crate::tools::network_approval::DeferredNetworkApproval;
use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::network_approval::begin_network_approval;
use crate::tools::network_approval::finish_deferred_network_approval;
use crate::tools::sandboxing::ExecApprovalRequirement;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::SandboxOverride;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use crate::tools::sandboxing::ToolRuntime;
use crate::tools::sandboxing::default_exec_approval_requirement;
use crate::tools::sandboxing::ensure_native_sandbox;
use crate::tools::sandboxing::sandbox_override_for_first_attempt;
use crate::tools::sandboxing::unsandboxed_execution_allowed;
use codex_otel::ToolDecisionSource;
use codex_protocol::error::CodexErrorDetails;
use codex_protocol::error::SandboxErr;
use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::PROTECTED_METADATA_PATH_NAMES;
use codex_protocol::permissions::file_system_root;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::ReviewDecision;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use codex_sandboxing::policy_transforms::effective_network_sandbox_policy;
use std::sync::Arc;
use std::time::Instant;

pub(crate) struct ToolOrchestrator;

pub(crate) struct OrchestratorRunResult<Out> {
    pub output: Out,
    pub deferred_network_approval: Option<DeferredNetworkApproval>,
}

impl ToolOrchestrator {
    pub fn new() -> Self {
        Self
    }

    async fn run_attempt<Rq, Out, T>(
        tool: &mut T,
        req: &Rq,
        tool_ctx: &ToolCtx,
        attempt: &SandboxAttempt<'_>,
        network_approval_spec: Option<NetworkApprovalSpec>,
    ) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
    where
        T: ToolRuntime<Rq, Out>,
    {
        let network_approval = match begin_network_approval(
            &tool_ctx.session,
            &tool_ctx.step_context.turn,
            &tool_ctx.step_context.environments,
            attempt.enforce_managed_network,
            network_approval_spec,
        )
        .await
        {
            Ok(network_approval) => network_approval,
            Err(err) => return (Err(err), None),
        };

        let attempt_tool_ctx = ToolCtx {
            session: tool_ctx.session.clone(),
            step_context: Arc::clone(&tool_ctx.step_context),
            cancellation_token: tool_ctx.cancellation_token.clone(),
            call_id: tool_ctx.call_id.clone(),
            tool_name: tool_ctx.tool_name.clone(),
        };
        let attempt_with_network_approval = SandboxAttempt {
            sandbox_override: attempt.sandbox_override,
            sandbox: attempt.sandbox,
            sandbox_requested: attempt.sandbox_requested,
            permissions: attempt.permissions,
            exec_server_permissions: attempt.exec_server_permissions,
            enforce_managed_network: attempt.enforce_managed_network,
            manager: attempt.manager,
            sandbox_cwd: attempt.sandbox_cwd,
            workspace_roots: attempt.workspace_roots,
            sandbox_exe: attempt.sandbox_exe,
            use_legacy_landlock: attempt.use_legacy_landlock,
            windows_sandbox_type: attempt.windows_sandbox_type,
            windows_sandbox_level: attempt.windows_sandbox_level,
            network_denial_cancellation_token: network_approval
                .as_ref()
                .map(ActiveNetworkApproval::cancellation_token),
            network_proxy: network_approval
                .as_ref()
                .map(ActiveNetworkApproval::execution_proxy),
        };
        let run_result = tool
            .run(req, &attempt_with_network_approval, &attempt_tool_ctx)
            .await;

        let Some(network_approval) = network_approval else {
            return (run_result, None);
        };

        let deferred = network_approval.into_deferred();
        if run_result.is_err() {
            let finalize_result =
                finish_deferred_network_approval(&tool_ctx.session, deferred).await;
            if let Err(err) = finalize_result {
                return (Err(err), None);
            }
            return (run_result, None);
        }
        (run_result, deferred)
    }

    pub async fn run<Rq, Out, T>(
        &mut self,
        tool: &mut T,
        req: &Rq,
        tool_ctx: &ToolCtx,
    ) -> Result<OrchestratorRunResult<Out>, ToolError>
    where
        T: ToolRuntime<Rq, Out>,
    {
        let turn_ctx = tool_ctx.step_context.turn.as_ref();
        let approval_policy = tool_ctx.step_context.settings.approval_policy();
        let otel = turn_ctx.session_telemetry.clone();
        let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned();
        let otel_ci = &tool_ctx.call_id;
        let strict_auto_review = turn_ctx.strict_auto_review_enabled();
        // 1) Approval
        let mut already_approved = false;

        let environment = tool.turn_environment(req);
        let sandbox_manager = SandboxManager::new();
        #[cfg(target_os = "macos")]
        let sandbox_manager = sandbox_manager.with_allowed_symlinked_codex_home(
            environment
                .environment
                .local_runtime_paths()
                .and_then(|paths| paths.allowed_symlinked_codex_home.clone()),
        );
        let sandbox_config = environment.config();
        let owner_network_policy = sandbox_config.network_policy.is_some();
        let workspace_roots = environment.workspace_roots();
        let executor_managed_process_sandbox = tool.uses_executor_managed_process_sandbox(req);
        let permission_profile = environment.permission_profile();
        let permissions = if executor_managed_process_sandbox {
            // Executor-native roots remain symbolic until the executor applies its own sandbox.
            permission_profile.clone()
        } else {
            environment.permission_profile_with_workspace_roots()
        };
        let file_system_sandbox_policy = permissions.file_system_sandbox_policy();
        let requirement = tool.exec_approval_requirement(req).unwrap_or_else(|| {
            default_exec_approval_requirement(approval_policy, &file_system_sandbox_policy)
        });
        match &requirement {
            ExecApprovalRequirement::Skip { .. } => {
                if strict_auto_review {
                    let action = tool
                        .approval_action(req, &tool_ctx.call_id)
                        .map_err(|err| {
                            ToolError::Rejected(format!("could not prepare approval action: {err}"))
                        })?;
                    let approval_ctx = ApprovalContext {
                        review_context: GuardianReviewContext::from(&tool_ctx.step_context),
                        cancellation_token: Some(tool_ctx.cancellation_token.clone()),
                        call_id: tool_ctx.call_id.clone(),
                        tool_name: tool_ctx.tool_name.clone(),
                        strict_auto_review,
                        approval_reason: None,
                        retry_reason: None,
                        network_approval_context: None,
                    };
                    tool_ctx
                        .session
                        .request_approval(action, approval_ctx)
                        .await?;
                    already_approved = true;
                } else {
                    otel.tool_decision(
                        &tool_ctx.tool_name,
                        otel_ci,
                        &ReviewDecision::Approved,
                        Some(ToolDecisionSource::Config),
                    );
                }
            }
            ExecApprovalRequirement::Forbidden { reason } => {
                return Err(ToolError::Rejected(reason.clone()));
            }
            ExecApprovalRequirement::NeedsApproval { reason, .. } => {
                let action = tool
                    .approval_action(req, &tool_ctx.call_id)
                    .map_err(|err| {
                        ToolError::Rejected(format!("could not prepare approval action: {err}"))
                    })?;
                let approval_ctx = ApprovalContext {
                    review_context: GuardianReviewContext::from(&tool_ctx.step_context),
                    cancellation_token: Some(tool_ctx.cancellation_token.clone()),
                    call_id: tool_ctx.call_id.clone(),
                    tool_name: tool_ctx.tool_name.clone(),
                    strict_auto_review,
                    approval_reason: reason.clone(),
                    retry_reason: None,
                    network_approval_context: None,
                };
                tool_ctx
                    .session
                    .request_approval(action, approval_ctx)
                    .await?;
                already_approved = true;
            }
        }

        // 2) First attempt under the selected sandbox.
        let unsandboxed_allowed = unsandboxed_execution_allowed(&file_system_sandbox_policy);
        let sandbox_override = sandbox_override_for_first_attempt(
            tool.sandbox_permissions(req),
            &requirement,
            &file_system_sandbox_policy,
            already_approved,
        );
        let (escalated_profile, writable_root_metadata) = if sandbox_override
            == SandboxOverride::EscalatedSandboxWithRestrictions
        {
            let context = environment.sandbox_context(/*additional_permissions*/ None);
            let policy_context = context.policy_context();
            let baseline = permission_profile.file_system_sandbox_policy();
            let file_system = baseline.for_approved_command(&policy_context);
            let writable_root_metadata = file_system_root(&policy_context).is_some_and(|root| {
                file_system.can_write_path(&root, &policy_context)
                    && PROTECTED_METADATA_PATH_NAMES.iter().any(|name| {
                        root.join_descendant(name)
                            .is_ok_and(|path| file_system.can_write_path(&path, &policy_context))
                    })
            });
            let escalated_profile = (file_system != baseline
                && file_system
                    != baseline.materialize_project_roots_with_path_uris(workspace_roots))
            .then(|| {
                PermissionProfile::from_runtime_permissions_with_enforcement(
                    permission_profile.enforcement(),
                    &file_system,
                    permission_profile.network_sandbox_policy(),
                )
            });
            (escalated_profile, writable_root_metadata)
        } else {
            (None, false)
        };
        if (escalated_profile.is_some() || writable_root_metadata)
            && environment.environment.is_remote()
        {
            let info = environment.environment.info().await.map_err(|err| {
                ToolError::Rejected(format!(
                    "could not verify exec-server support for escalation with denied reads: {err}"
                ))
            })?;
            if !matches!(info.platform_os.as_deref(), Some("windows" | "macos"))
                && !info
                    .capabilities
                    .linux_approved_root_write_preserves_restrictions
            {
                return Err(ToolError::Rejected(
                    "this exec-server cannot safely escalate with denied reads; upgrade the exec-server and retry"
                        .to_string(),
                ));
            }
        }
        let escalated_local_profile = escalated_profile
            .as_ref()
            .filter(|_| !executor_managed_process_sandbox)
            .map(|profile| {
                profile
                    .clone()
                    .materialize_project_roots_with_path_uris(workspace_roots)
            });
        let initial_permissions = escalated_local_profile
            .as_ref()
            .or(escalated_profile.as_ref())
            .unwrap_or(&permissions);
        let network_approval_spec = tool.network_approval_spec(req, tool_ctx);
        // An explicit owner proxy requirement permits filtered egress, like an enabled
        // controller proxy. Traffic-only owner policies stay offline unless command
        // permissions grant networking.
        // Preserve this baseline even when escalation skips the execution proxy, so retained
        // terminals still record that their launch bypassed network restrictions.
        let managed_network_active = if owner_network_policy {
            turn_ctx
                .config
                .permissions
                .network
                .as_ref()
                .is_some_and(NetworkProxySpec::enabled)
                || sandbox_config
                    .network_policy
                    .as_ref()
                    .is_some_and(|policy| policy.requires_proxy)
                || (network_approval_spec.is_some()
                    || tool
                        .sandbox_permissions(req)
                        .requires_escalated_permissions())
                    && effective_network_sandbox_policy(
                        permission_profile.network_sandbox_policy(),
                        network_approval_spec
                            .as_ref()
                            .and_then(|spec| spec.trigger.additional_permissions.as_ref()),
                    )
                    .is_enabled()
        } else {
            turn_ctx.network.is_some()
        };
        let sandbox_preference = tool.sandbox_preference();
        let sandbox_requested = match sandbox_override {
            SandboxOverride::BypassSandboxFirstAttempt => false,
            SandboxOverride::NoOverride | SandboxOverride::EscalatedSandboxWithRestrictions => {
                sandbox_manager.should_sandbox(
                    initial_permissions,
                    sandbox_preference,
                    managed_network_active,
                )
            }
        };
        let windows_sandbox_type = codex_protocol::sandbox::effective_windows_sandbox_type(
            sandbox_config.windows_sandbox_type,
            sandbox_config.windows_sandbox_level,
        );
        let initial_sandbox = if sandbox_requested && !executor_managed_process_sandbox {
            sandbox_manager.select_initial(
                initial_permissions,
                sandbox_preference,
                windows_sandbox_type,
                managed_network_active,
            )
        } else {
            SandboxType::None
        };
        if !executor_managed_process_sandbox {
            ensure_native_sandbox(sandbox_override, initial_sandbox)?;
        }

        let sandbox_policy_cwd = tool
            .sandbox_cwd(req)
            .cloned()
            .unwrap_or_else(|| environment.cwd().clone());
        let codex_sandbox_exe = if cfg!(windows) {
            turn_ctx.config.codex_self_exe.as_ref()
        } else {
            turn_ctx.config.codex_linux_sandbox_exe.as_ref()
        };
        let initial_attempt = SandboxAttempt {
            sandbox_override,
            sandbox: initial_sandbox,
            sandbox_requested,
            permissions: initial_permissions,
            exec_server_permissions: escalated_profile.as_ref().unwrap_or(permission_profile),
            enforce_managed_network: managed_network_active,
            manager: &sandbox_manager,
            sandbox_cwd: &sandbox_policy_cwd,
            workspace_roots,
            sandbox_exe: codex_sandbox_exe,
            use_legacy_landlock: sandbox_config.use_legacy_landlock,
            windows_sandbox_type,
            windows_sandbox_level: sandbox_config.windows_sandbox_level,
            network_denial_cancellation_token: None,
            network_proxy: None,
        };

        let initial_attempt_start = Instant::now();
        let (first_result, first_deferred_network_approval) =
            Self::run_attempt(tool, req, tool_ctx, &initial_attempt, network_approval_spec).await;
        let initial_duration = initial_attempt_start.elapsed();
        match first_result {
            Ok(out) => {
                // We have a successful initial result
                Ok(OrchestratorRunResult {
                    output: out,
                    deferred_network_approval: first_deferred_network_approval,
                })
            }
            Err(ToolError::Codex(err)) => {
                let CodexErrorDetails::Sandbox(SandboxErr::Denied {
                    output,
                    network_policy_decision,
                }) = err.details()
                else {
                    let err = ToolError::Codex(err);
                    if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
                        otel.sandbox_outcome(
                            &otel_tn,
                            otel_ci,
                            outcome,
                            initial_duration,
                            /*escalated_duration*/ None,
                        );
                    }
                    return Err(err);
                };
                let network_approval_context = if managed_network_active {
                    network_policy_decision
                        .as_ref()
                        .and_then(network_approval_context_from_payload)
                } else {
                    None
                };
                if network_policy_decision.is_some() && network_approval_context.is_none() {
                    otel.sandbox_outcome(
                        &otel_tn,
                        otel_ci,
                        "denied",
                        initial_duration,
                        /*escalated_duration*/ None,
                    );
                    return Err(ToolError::Codex(err));
                }
                if !tool.escalate_on_failure() {
                    otel.sandbox_outcome(
                        &otel_tn,
                        otel_ci,
                        "denied",
                        initial_duration,
                        /*escalated_duration*/ None,
                    );
                    return Err(ToolError::Codex(err));
                }
                // Under `Never` or `OnRequest`, do not retry without sandbox;
                // surface a concise sandbox denial that preserves the
                // original output.
                if !tool.wants_no_sandbox_approval(approval_policy) {
                    let allow_on_request_network_prompt =
                        matches!(approval_policy, AskForApproval::OnRequest)
                            && network_approval_context.is_some()
                            && matches!(
                                default_exec_approval_requirement(
                                    approval_policy,
                                    &file_system_sandbox_policy
                                ),
                                ExecApprovalRequirement::NeedsApproval { .. }
                            );
                    if !allow_on_request_network_prompt {
                        otel.sandbox_outcome(
                            &otel_tn,
                            otel_ci,
                            "denied",
                            initial_duration,
                            /*escalated_duration*/ None,
                        );
                        return Err(ToolError::Codex(err));
                    }
                }
                if !unsandboxed_allowed && network_approval_context.is_none() {
                    otel.sandbox_outcome(
                        &otel_tn,
                        otel_ci,
                        "denied",
                        initial_duration,
                        /*escalated_duration*/ None,
                    );
                    return Err(ToolError::Codex(err));
                }
                let retry_reason =
                    if let Some(network_approval_context) = network_approval_context.as_ref() {
                        format!(
                            "Network access to \"{}\" is blocked by policy.",
                            network_approval_context.host
                        )
                    } else {
                        build_denial_reason_from_output(output.as_ref())
                    };

                // Strict auto-review approval covers the sandboxed attempt only;
                // retrying without the sandbox requires a fresh guardian review.
                let bypass_retry_approval = !strict_auto_review
                    && tool.should_bypass_approval(approval_policy, already_approved)
                    && network_approval_context.is_none();
                if !bypass_retry_approval {
                    let approval_reason = match &requirement {
                        ExecApprovalRequirement::NeedsApproval { reason, .. } => reason.clone(),
                        ExecApprovalRequirement::Skip { .. }
                        | ExecApprovalRequirement::Forbidden { .. } => None,
                    };
                    let action = tool
                        .approval_action(req, &tool_ctx.call_id)
                        .map_err(|err| {
                            ToolError::Rejected(format!("could not prepare approval action: {err}"))
                        })?;
                    let approval_ctx = ApprovalContext {
                        review_context: GuardianReviewContext::from(&tool_ctx.step_context),
                        cancellation_token: Some(tool_ctx.cancellation_token.clone()),
                        call_id: tool_ctx.call_id.clone(),
                        tool_name: tool_ctx.tool_name.clone(),
                        strict_auto_review,
                        approval_reason,
                        retry_reason: Some(retry_reason),
                        network_approval_context: network_approval_context.clone(),
                    };

                    tool_ctx
                        .session
                        .request_approval(action, approval_ctx)
                        .await?;
                }

                let retry_sandbox_requested = !unsandboxed_allowed
                    && sandbox_manager.should_sandbox(
                        initial_permissions,
                        sandbox_preference,
                        managed_network_active,
                    );
                let retry_sandbox = if retry_sandbox_requested && !executor_managed_process_sandbox
                {
                    sandbox_manager.select_initial(
                        initial_permissions,
                        sandbox_preference,
                        windows_sandbox_type,
                        managed_network_active,
                    )
                } else {
                    SandboxType::None
                };
                let retry_sandbox_exe = if unsandboxed_allowed {
                    None
                } else {
                    codex_sandbox_exe
                };
                let retry_attempt = SandboxAttempt {
                    sandbox_override,
                    sandbox: retry_sandbox,
                    sandbox_requested: retry_sandbox_requested,
                    permissions: initial_permissions,
                    exec_server_permissions: escalated_profile
                        .as_ref()
                        .unwrap_or(permission_profile),
                    enforce_managed_network: managed_network_active,
                    manager: &sandbox_manager,
                    sandbox_cwd: &sandbox_policy_cwd,
                    workspace_roots,
                    sandbox_exe: retry_sandbox_exe,
                    use_legacy_landlock: sandbox_config.use_legacy_landlock,
                    windows_sandbox_type,
                    windows_sandbox_level: sandbox_config.windows_sandbox_level,
                    network_denial_cancellation_token: None,
                    network_proxy: None,
                };

                // Second attempt.
                let network_approval_spec = tool.network_approval_spec(req, tool_ctx);
                let escalated_attempt_start = Instant::now();
                let (retry_result, retry_deferred_network_approval) =
                    Self::run_attempt(tool, req, tool_ctx, &retry_attempt, network_approval_spec)
                        .await;
                let escalated_duration = escalated_attempt_start.elapsed();
                match retry_result {
                    Ok(output) => {
                        otel.sandbox_outcome(
                            &otel_tn,
                            otel_ci,
                            "escalated",
                            initial_duration,
                            Some(escalated_duration),
                        );
                        Ok(OrchestratorRunResult {
                            output,
                            deferred_network_approval: retry_deferred_network_approval,
                        })
                    }
                    Err(err) => {
                        if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
                            otel.sandbox_outcome(
                                &otel_tn,
                                otel_ci,
                                outcome,
                                initial_duration,
                                Some(escalated_duration),
                            );
                        }
                        Err(err)
                    }
                }
            }
            Err(err) => {
                if let Some(outcome) = sandbox_outcome_from_tool_error(&err) {
                    otel.sandbox_outcome(
                        &otel_tn,
                        otel_ci,
                        outcome,
                        initial_duration,
                        /*escalated_duration*/ None,
                    );
                }
                Err(err)
            }
        }
    }
}

fn sandbox_outcome_from_tool_error(err: &ToolError) -> Option<&'static str> {
    match err {
        ToolError::Codex(err) => match err.details() {
            CodexErrorDetails::Sandbox(SandboxErr::Denied { .. }) => Some("denied"),
            CodexErrorDetails::Sandbox(SandboxErr::Timeout { .. }) => Some("timed_out"),
            CodexErrorDetails::Sandbox(SandboxErr::Signal(_)) => Some("signal"),
            _ => None,
        },
        ToolError::Rejected(_) => None,
    }
}

fn build_denial_reason_from_output(_output: &ExecToolCallOutput) -> String {
    // Keep approval reason terse and stable for UX/tests, but accept the
    // output so we can evolve heuristics later without touching call sites.
    "command failed; retry without sandbox?".to_string()
}