diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 4098be8f70..f50766b1c8 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod handlers; pub(crate) mod hook_names; pub(crate) mod hosted_spec; pub(crate) mod lifecycle; +mod multi_agent_tool; pub(crate) mod network_approval; pub(crate) mod orchestrator; pub(crate) mod parallel; diff --git a/codex-rs/core/src/tools/multi_agent_tool.rs b/codex-rs/core/src/tools/multi_agent_tool.rs new file mode 100644 index 0000000000..941b9e0cf2 --- /dev/null +++ b/codex-rs/core/src/tools/multi_agent_tool.rs @@ -0,0 +1,102 @@ +//! Applies captured Multi-Agent V2 description and namespace overrides to tool specifications. +//! Execution and parameter schemas are delegated unchanged to the underlying runtime. + +use crate::session::session::Session; +use crate::tools::context::ToolInvocation; +use crate::tools::registry::CoreToolRuntime; +use codex_tools::ResponsesApiNamespace; +use codex_tools::ResponsesApiNamespaceTool; +use codex_tools::ToolExecutor; +use codex_tools::ToolExposure; +use codex_tools::ToolName; +use codex_tools::ToolSearchInfo; +use codex_tools::ToolSpec; +use futures::future::BoxFuture; +use std::sync::Arc; + +const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; + +pub(super) fn multi_agent_v2_handler( + handler: impl CoreToolRuntime + 'static, + namespace: Option<&str>, + description_override: Option<&str>, +) -> Arc { + if namespace.is_none() && description_override.is_none() { + return Arc::new(handler); + } + Arc::new(MultiAgentV2ToolOverrides { + handler: Arc::new(handler), + namespace: namespace.map(str::to_owned), + description_override: description_override.map(str::to_owned), + }) +} + +struct MultiAgentV2ToolOverrides { + handler: Arc, + namespace: Option, + description_override: Option, +} + +impl ToolExecutor for MultiAgentV2ToolOverrides { + fn tool_name(&self) -> ToolName { + let tool_name = self.handler.tool_name(); + match &self.namespace { + Some(namespace) => ToolName::namespaced(namespace.clone(), tool_name.name), + None => tool_name, + } + } + + fn spec(&self) -> ToolSpec { + let mut spec = self.handler.spec(); + if let ToolSpec::Function(tool) = &mut spec + && let Some(description) = &self.description_override + { + tool.description.clone_from(description); + } + match (&self.namespace, spec) { + (Some(namespace), ToolSpec::Function(tool)) => { + ToolSpec::Namespace(ResponsesApiNamespace { + name: namespace.clone(), + description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(), + tools: vec![ResponsesApiNamespaceTool::Function(tool)], + }) + } + (_, spec) => spec, + } + } + + fn exposure(&self) -> ToolExposure { + self.handler.exposure() + } + + fn supports_parallel_tool_calls(&self) -> bool { + self.handler.supports_parallel_tool_calls() + } + + fn search_info(&self) -> Option { + self.handler.search_info() + } + + fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> + where + ToolInvocation: 'a, + { + self.handler.handle(invocation) + } +} + +impl CoreToolRuntime for MultiAgentV2ToolOverrides { + fn wait_until_ready<'a>(&'a self, session: &'a Arc) -> Option> { + self.handler.wait_until_ready(session) + } + + fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool { + self.handler.matches_kind(payload) + } + + fn create_diff_consumer( + &self, + ) -> Option> { + self.handler.create_diff_consumer() + } +} diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 1191336949..e471bb90e7 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -5,7 +5,6 @@ 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::context::ToolInvocation; use crate::tools::effective_tool_mode; use crate::tools::handlers::ApplyPatchHandler; use crate::tools::handlers::CodeModeExecuteHandler; @@ -54,7 +53,7 @@ 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::registry::CoreToolRuntime; +use crate::tools::multi_agent_tool::multi_agent_v2_handler; #[cfg(test)] use crate::tools::registry::RegisteredTool; use crate::tools::registry::ToolExposure; @@ -81,7 +80,6 @@ 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::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::TOOL_SEARCH_TOOL_NAME; use codex_tools::ToolCall as ExtensionToolCall; @@ -89,7 +87,6 @@ use codex_tools::ToolEnvironmentMode; use codex_tools::ToolExecutor; use codex_tools::ToolExposures; use codex_tools::ToolName; -use codex_tools::ToolSearchInfo; use codex_tools::ToolSpec; use codex_tools::UnifiedExecShellMode; use codex_tools::can_request_original_image_detail; @@ -97,7 +94,6 @@ 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 futures::future::BoxFuture; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; @@ -105,7 +101,6 @@ use std::collections::btree_map::Entry; use std::sync::Arc; use tracing::instrument; -const MULTI_AGENT_V2_NAMESPACE_DESCRIPTION: &str = "Tools for spawning and managing sub-agents."; const IMAGE_GEN_NAMESPACE: &str = "image_gen"; const IMAGEGEN_TOOL_NAME: &str = "imagegen"; @@ -1248,8 +1243,9 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too let turn_context = context.turn_context; if collab_tools_enabled(turn_context, context.model_info) { if multi_agent_v2_enabled(turn_context) { - let spawn_agent_description = ResolvedModelMessages::from_model(context.model_info) - .spawn_agent_description_override(); + 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 { @@ -1284,15 +1280,26 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too spawn_agent_description.map(str::to_owned), ), tool_namespace, + // Spawn composes the selected description with runtime model and usage guidance. + /*description_override*/ + None, ), exposure, ); registry.register_trusted_with_exposure( - multi_agent_v2_handler(SendMessageHandlerV2, tool_namespace), + multi_agent_v2_handler( + SendMessageHandlerV2, + tool_namespace, + model_messages.multi_agent_tool_description_override("send_message"), + ), exposure, ); registry.register_trusted_with_exposure( - multi_agent_v2_handler(FollowupTaskHandlerV2, tool_namespace), + multi_agent_v2_handler( + FollowupTaskHandlerV2, + tool_namespace, + model_messages.multi_agent_tool_description_override("followup_task"), + ), exposure, ); if turn_context.config.multi_agent_v2.wait_agent_enabled { @@ -1300,16 +1307,25 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too multi_agent_v2_handler( WaitAgentHandlerV2::new(context.wait_agent_timeouts), tool_namespace, + model_messages.multi_agent_tool_description_override("wait_agent"), ), exposure, ); } registry.register_trusted_with_exposure( - multi_agent_v2_handler(InterruptAgentHandler, tool_namespace), + multi_agent_v2_handler( + InterruptAgentHandler, + tool_namespace, + model_messages.multi_agent_tool_description_override("interrupt_agent"), + ), exposure, ); registry.register_trusted_with_exposure( - multi_agent_v2_handler(ListAgentsHandlerV2, tool_namespace), + multi_agent_v2_handler( + ListAgentsHandlerV2, + tool_namespace, + model_messages.multi_agent_tool_description_override("list_agents"), + ), exposure, ); } else { @@ -1423,76 +1439,6 @@ fn append_extension_tool_executors( standalone_web_search_tool } -fn multi_agent_v2_handler( - handler: impl CoreToolRuntime + 'static, - namespace: Option<&str>, -) -> Arc { - match namespace { - Some(namespace) => Arc::new(MultiAgentV2NamespaceOverride { - handler: Arc::new(handler), - namespace: namespace.to_string(), - }), - None => Arc::new(handler), - } -} - -struct MultiAgentV2NamespaceOverride { - handler: Arc, - namespace: String, -} - -impl ToolExecutor for MultiAgentV2NamespaceOverride { - fn tool_name(&self) -> ToolName { - ToolName::namespaced(self.namespace.clone(), self.handler.tool_name().name) - } - - fn spec(&self) -> ToolSpec { - match self.handler.spec() { - ToolSpec::Function(tool) => ToolSpec::Namespace(ResponsesApiNamespace { - name: self.namespace.clone(), - description: MULTI_AGENT_V2_NAMESPACE_DESCRIPTION.to_string(), - tools: vec![ResponsesApiNamespaceTool::Function(tool)], - }), - spec => spec, - } - } - - fn exposure(&self) -> ToolExposure { - self.handler.exposure() - } - - fn supports_parallel_tool_calls(&self) -> bool { - self.handler.supports_parallel_tool_calls() - } - - fn search_info(&self) -> Option { - self.handler.search_info() - } - - fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> - where - ToolInvocation: 'a, - { - self.handler.handle(invocation) - } -} - -impl CoreToolRuntime for MultiAgentV2NamespaceOverride { - fn wait_until_ready<'a>(&'a self, session: &'a Arc) -> Option> { - self.handler.wait_until_ready(session) - } - - fn matches_kind(&self, payload: &crate::tools::context::ToolPayload) -> bool { - self.handler.matches_kind(payload) - } - - fn create_diff_consumer( - &self, - ) -> Option> { - self.handler.create_diff_consumer() - } -} - fn compare_code_mode_tools( left: &codex_code_mode::ToolDefinition, right: &codex_code_mode::ToolDefinition, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5208956150..3e7d8588e7 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -131,6 +131,7 @@ mod models_cache_ttl; mod models_etag_responses; mod multi_agent_mode; mod multi_agent_resume; +mod multi_agent_tool_descriptions; #[cfg(unix)] mod multi_exec_server_sandbox; mod network_approval; @@ -184,7 +185,6 @@ mod skill_approval; mod skills; mod skills_extension; mod spawn_agent_description; -mod spawn_agent_tool_descriptions; mod sqlite_state; mod startup_cancellation; mod step_settings; diff --git a/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs b/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs new file mode 100644 index 0000000000..4a2484bde2 --- /dev/null +++ b/codex-rs/core/tests/suite/multi_agent_tool_descriptions.rs @@ -0,0 +1,208 @@ +//! Verifies catalog descriptions reach V2 tools without changing their schemas or availability. + +use anyhow::Result; +use codex_core::config::AgentRoleConfig; +use codex_features::Feature; +use codex_protocol::openai_models::ToolMessages; +use codex_protocol::protocol::MultiAgentVersion; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse_completed; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use test_case::test_case; + +const TOOL_NAMES: [&str; 6] = [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", +]; + +#[derive(Clone, Copy)] +enum Exposure { + Namespaced, + Plain, + CodeMode, + V1, +} + +fn all_tool_messages(message: Value) -> Value { + json!({ + "multi_agent": TOOL_NAMES + .map(|name| { + let mut message = message.clone(); + if let Some(description) = message["description"].as_str() { + message["description"] = json!(description.replace("TOOL_NAME", name)); + } + (name.to_string(), message) + }) + .into_iter() + .collect::>() + }) +} + +#[test_case(json!(null), Exposure::Namespaced; "missing_tools")] +#[test_case(json!({}), Exposure::Namespaced; "missing_multi_agent")] +#[test_case(json!({"multi_agent": null}), Exposure::Namespaced; "null_multi_agent")] +#[test_case(json!({"multi_agent": {}}), Exposure::Namespaced; "missing_tools_in_family")] +#[test_case(all_tool_messages(json!(null)), Exposure::Namespaced; "null_tool")] +#[test_case(all_tool_messages(json!({})), Exposure::Namespaced; "missing_description")] +#[test_case(all_tool_messages(json!({"description": null})), Exposure::Namespaced; "null_description")] +#[test_case(all_tool_messages(json!({"description": " Catalog TOOL_NAME description.\n{{literal_placeholder}} "})), Exposure::Namespaced; "catalog_description")] +#[test_case(all_tool_messages(json!({"description": ""})), Exposure::Namespaced; "empty_description")] +#[test_case(json!({"multi_agent": {"send_message": {"description": "Catalog send."}}}), Exposure::Namespaced; "sparse_sibling_fallback")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::Plain; "plain_tools")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::CodeMode; "code_mode_declarations")] +#[test_case(all_tool_messages(json!({"description": ""})), Exposure::CodeMode; "empty_code_mode_descriptions")] +#[test_case(all_tool_messages(json!({"description": "Catalog TOOL_NAME description."})), Exposure::V1; "v1_unchanged")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_catalog_descriptions_preserve_outbound_schema( + tool_messages: Value, + exposure: Exposure, +) -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = mount_sse_sequence( + &server, + vec![sse_completed("resp-default"), sse_completed("resp-catalog")], + ) + .await; + for messages in [ + None, + serde_json::from_value::>(tool_messages.clone())?, + ] { + let test = test_codex() + .with_model_info_override("gpt-5.2", move |model| { + model.multi_agent_version = Some(if matches!(exposure, Exposure::V1) { + MultiAgentVersion::V1 + } else { + MultiAgentVersion::V2 + }); + model.model_messages.as_mut().expect("model messages").tools = messages; + }) + .with_config(move |config| { + if matches!(exposure, Exposure::V1) { + config.features.enable(Feature::Collab).expect("enable V1"); + config + .features + .disable(Feature::MultiAgentV2) + .expect("disable V2"); + } else { + config + .features + .enable(Feature::MultiAgentV2) + .expect("enable V2"); + } + if matches!(exposure, Exposure::CodeMode) { + config + .features + .enable(Feature::CodeMode) + .expect("enable Code Mode"); + // Inspect declarations even when the test has no Code Mode host to execute them. + config.code_mode.disable_in_process_fallback = true; + config.multi_agent_v2.non_code_mode_only = false; + } + config.multi_agent_v2.tool_namespace = + (!matches!(exposure, Exposure::Plain)).then(|| "delegation".to_string()); + config.multi_agent_v2.hide_spawn_agent_metadata = false; + config.multi_agent_v2.expose_spawn_agent_model_overrides = true; + config.multi_agent_v2.usage_hint_text = Some("Local delegation hint.".to_string()); + config.agent_roles.insert( + "researcher".to_string(), + AgentRoleConfig { + description: Some("Research the assigned question.".to_string()), + config_file: None, + nickname_candidates: None, + }, + ); + }) + .build_with_auto_env(&server) + .await?; + test.submit_turn("Inspect the available tools.").await?; + } + + let requests = response.requests(); + assert_eq!(requests.len(), 2); + let mut expected = requests[0].body_json()["tools"].clone(); + let actual = requests[1].body_json()["tools"].clone(); + if !matches!(exposure, Exposure::V1) { + let tools = if matches!(exposure, Exposure::Plain) { + expected.as_array_mut().expect("plain tools") + } else { + expected + .as_array_mut() + .expect("tools") + .iter_mut() + .find(|tool| tool["name"] == "delegation") + .expect("delegation namespace")["tools"] + .as_array_mut() + .expect("namespace tools") + }; + let actual_tools = if matches!(exposure, Exposure::Plain) { + actual.as_array().expect("plain tools") + } else { + actual + .as_array() + .expect("tools") + .iter() + .find(|tool| tool["name"] == "delegation") + .expect("delegation namespace")["tools"] + .as_array() + .expect("namespace tools") + }; + for name in TOOL_NAMES { + let expected_tool = tools + .iter_mut() + .find(|tool| tool["name"] == name) + .expect(name); + let actual_tool = actual_tools + .iter() + .find(|tool| tool["name"] == name) + .expect(name); + if let Some(description) = tool_messages["multi_agent"][name]["description"].as_str() { + let bundled = expected_tool["description"] + .as_str() + .expect("bundled description"); + let declaration = bundled + .find("\n\nexec tool declaration:") + .map(|index| &bundled[index..]) + .unwrap_or_default(); + if matches!(exposure, Exposure::CodeMode) { + assert!( + declaration.contains(&format!("delegation__{name}")), + "Code Mode description for {name}: {bundled}" + ); + } + let replacement = if name == "spawn_agent" { + let actual_description = actual_tool["description"] + .as_str() + .expect("spawn description"); + assert!(actual_description.contains(description)); + assert!( + !actual_description + .contains("Spawns an agent to work on the specified task.") + ); + assert!( + actual_description + .strip_suffix(declaration) + .expect("unchanged declaration") + .ends_with("Local delegation hint.") + ); + actual_description.to_string() + } else { + format!("{description}{declaration}") + }; + expected_tool["description"] = json!(replacement); + } + } + } + assert_eq!(actual, expected); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs b/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs deleted file mode 100644 index f2c4fb8ab1..0000000000 --- a/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs +++ /dev/null @@ -1,90 +0,0 @@ -//! Verifies catalog tool descriptions reach the V2 outbound tool without changing its parameters. - -use anyhow::Result; -use codex_core::config::AgentRoleConfig; -use codex_features::Feature; -use codex_protocol::openai_models::ToolMessages; -use codex_protocol::protocol::MultiAgentVersion; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::namespace_child_tool; -use core_test_support::responses::sse_completed; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::Value; -use serde_json::json; -use test_case::test_case; - -#[test_case(json!(null); "missing_tools")] -#[test_case(json!({}); "missing_multi_agent")] -#[test_case(json!({"multi_agent": null}); "null_multi_agent")] -#[test_case(json!({"multi_agent": {}}); "missing_spawn_agent")] -#[test_case(json!({"multi_agent": {"spawn_agent": null}}); "null_spawn_agent")] -#[test_case(json!({"multi_agent": {"spawn_agent": {}}}); "missing_description")] -#[test_case(json!({"multi_agent": {"spawn_agent": {"description": null}}}); "null_description")] -#[test_case(json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn."}}}); "catalog_description")] -#[test_case(json!({"multi_agent": {"spawn_agent": {"description": ""}}}); "empty_description")] -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn spawn_agent_catalog_descriptions_preserve_outbound_schema( - tool_messages: Value, -) -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let response = mount_sse_sequence( - &server, - vec![sse_completed("resp-default"), sse_completed("resp-catalog")], - ) - .await; - for messages in [ - None, - serde_json::from_value::>(tool_messages.clone())?, - ] { - let test = test_codex() - .with_model_info_override("gpt-5.2", move |model| { - model.multi_agent_version = Some(MultiAgentVersion::V2); - model.model_messages.as_mut().expect("model messages").tools = messages; - }) - .with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("enable V2"); - config.multi_agent_v2.tool_namespace = Some("delegation".to_string()); - config.multi_agent_v2.hide_spawn_agent_metadata = false; - config.multi_agent_v2.expose_spawn_agent_model_overrides = true; - config.multi_agent_v2.usage_hint_text = Some("Local delegation hint.".to_string()); - config.agent_roles.insert( - "researcher".to_string(), - AgentRoleConfig { - description: Some("Research the assigned question.".to_string()), - config_file: None, - nickname_candidates: None, - }, - ); - }) - .build_with_auto_env(&server) - .await?; - test.submit_turn("Inspect the available tools.").await?; - } - - let requests = response.requests(); - assert_eq!(requests.len(), 2); - let mut expected = namespace_child_tool(&requests[0].body_json(), "delegation", "spawn_agent") - .expect("default spawn agent tool") - .clone(); - let actual = namespace_child_tool(&requests[1].body_json(), "delegation", "spawn_agent") - .expect("catalog spawn agent tool") - .clone(); - let messages = &tool_messages["multi_agent"]["spawn_agent"]; - if let Some(description) = messages["description"].as_str() { - let actual_description = actual["description"].as_str().expect("tool description"); - assert!(actual_description.contains(description)); - assert!(!actual_description.contains("Spawns an agent to work on the specified task.")); - assert!(actual_description.ends_with("Local delegation hint.")); - expected["description"] = actual["description"].clone(); - } - assert_eq!(actual, expected); - Ok(()) -} diff --git a/codex-rs/core/tests/suite/step_settings.rs b/codex-rs/core/tests/suite/step_settings.rs index c92f547930..78dd10e39a 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -2229,6 +2229,15 @@ async fn model_activation_uses_destination_metadata_defaults( async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { skip_if_no_network!(Ok(())); + const MULTI_AGENT_TOOLS: [&str; 6] = [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", + ]; + let server = start_mock_server().await; let response_mock = mount_sse_sequence( &server, @@ -2254,18 +2263,24 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { model .experimental_supported_tools .push("send_user_message_async".to_string()); + let description = |name| { + Some(ToolMessage { + description: Some(format!("{name} description for {}.", model.slug)), + }) + }; model .model_messages .as_mut() .expect("model instruction metadata") .tools = Some(ToolMessages { - send_user_message_async: Some(ToolMessage { - description: Some(format!("Async message description for {}.", model.slug)), - }), + send_user_message_async: description("Async message"), multi_agent: Some(MultiAgentToolMessages { - spawn_agent: Some(ToolMessage { - description: Some(format!("Spawn description for {}.", model.slug)), - }), + spawn_agent: description("spawn_agent"), + send_message: description("send_message"), + followup_task: description("followup_task"), + wait_agent: description("wait_agent"), + interrupt_agent: description("interrupt_agent"), + list_agents: description("list_agents"), }), }); } @@ -2302,12 +2317,14 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { .iter() .find(|tool| tool["name"] == "request_user_input_async") .expect("async message tool"); - let spawn = namespace_child_tool(&body, "collaboration", "spawn_agent") - .expect("spawn agent tool"); + let descriptions = MULTI_AGENT_TOOLS.map(|name| { + let tool = namespace_child_tool(&body, "collaboration", name).expect(name); + (name.to_string(), json!(tool["description"].as_str().expect("tool description").trim())) + }).into_iter().collect::>(); json!({ "model": body["model"], "async_description": tool["description"], - "spawn_description": spawn["description"].as_str().expect("spawn description").trim(), + "multi_agent_descriptions": descriptions, }) }) .collect::>(), @@ -2315,7 +2332,10 @@ async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { .map(|model| json!({ "model": model, "async_description": format!("Async message description for {model}."), - "spawn_description": format!("Spawn description for {model}."), + "multi_agent_descriptions": MULTI_AGENT_TOOLS + .map(|name| (name.to_string(), json!(format!("{name} description for {model}.")))) + .into_iter() + .collect::>(), })) .to_vec(), ); diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 07e25b8957..b8734c4c72 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -91,6 +91,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { spawn_agent: Some(ToolMessage { description: Some("Catalog spawn description.".to_string()), }), + ..Default::default() }), }), instructions_template: Some("template".to_string()), @@ -161,6 +162,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { spawn_agent: Some(ToolMessage { description: Some(String::new()), }), + ..Default::default() }), }), approvals: Some(ApprovalMessages { diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index 14f6cbd9e5..afcdb4cc04 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -151,13 +151,23 @@ impl<'a> ResolvedModelMessages<'a> { .unwrap_or(REQUEST_USER_INPUT_ASYNC_DESCRIPTION) } - /// Returns the V2 spawn tool's static description override, leaving missing text to the tool. - pub fn spawn_agent_description_override(&self) -> Option<&'a str> { - self.catalog_messages + /// Selects a V2 tool's static description by its name, independently of its runtime namespace. + /// Missing text retains the tool's bundled description; an empty string replaces it. + pub fn multi_agent_tool_description_override(&self, tool_name: &str) -> Option<&'a str> { + let tools = self + .catalog_messages .and_then(|messages| messages.tools.as_ref()) - .and_then(|tools| tools.multi_agent.as_ref()) - .and_then(|tools| tools.spawn_agent.as_ref()) - .and_then(|tool| tool.description.as_deref()) + .and_then(|tools| tools.multi_agent.as_ref())?; + let tool = match tool_name { + "spawn_agent" => &tools.spawn_agent, + "send_message" => &tools.send_message, + "followup_task" => &tools.followup_task, + "wait_agent" => &tools.wait_agent, + "interrupt_agent" => &tools.interrupt_agent, + "list_agents" => &tools.list_agents, + _ => return None, + }; + tool.as_ref()?.description.as_deref() } /// Resolves persistent-mode instructions without deciding whether the mode is active. diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 577242287b..195de60c65 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -583,8 +583,8 @@ pub struct ToolMessages { /// Model-owned messages for a built-in tool. #[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ToolMessage { - /// Missing or null uses the built-in description; an empty string leaves the description - /// empty without disabling the tool. + /// Missing or null uses the built-in description; an empty string suppresses its static + /// text without disabling the tool. Tool-owned runtime guidance is retained. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, } @@ -596,6 +596,16 @@ pub struct MultiAgentToolMessages { /// suppresses it. Generated model information and local usage hints are retained. #[serde(default, skip_serializing_if = "Option::is_none")] pub spawn_agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub send_message: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub followup_task: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub wait_agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub interrupt_agent: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub list_agents: Option, } /// Model-owned defaults for the context-window token-budget feature. @@ -1387,6 +1397,7 @@ mod tests { spawn_agent: Some(ToolMessage { description: Some("Catalog spawn description".to_string()), }), + ..Default::default() }), }), instructions_template: None,