From c5d079470eeaf9502080faa0697aade481242081 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Thu, 17 Sep 2026 05:05:47 +0000 Subject: [PATCH] Allow model catalogs to override the V2 `spawn_agent` description (#46123) ## What changed Read the static V2 `spawn_agent` description from `model_messages.tools.multi_agent.spawn_agent.description`, independently of the runtime tool namespace. Missing or null values retain the bundled description; an empty string suppresses it. Preserve generated model guidance, local usage hints, and tool parameters when applying an override. Resolve the description from the active model so it follows mid-turn model changes. ## Testing Add coverage for sparse and empty catalog values, preservation of generated context and outbound tool schemas, and description updates after mid-turn model changes. GitOrigin-RevId: 96cfa180f8f374dc868e4fabc337e49380e405da --- .../src/tools/handlers/multi_agents_spec.rs | 22 ++- .../tools/handlers/multi_agents_spec_tests.rs | 149 ++++++++++++------ .../tools/handlers/multi_agents_v2/spawn.rs | 13 +- codex-rs/core/src/tools/spec_plan.rs | 33 ++-- codex-rs/core/tests/suite/mod.rs | 1 + .../tests/suite/request_user_input_async.rs | 10 +- .../suite/spawn_agent_tool_descriptions.rs | 90 +++++++++++ codex-rs/core/tests/suite/step_settings.rs | 25 ++- .../models-manager/src/model_info_tests.rs | 16 ++ codex-rs/prompts/src/model_messages.rs | 9 ++ codex-rs/protocol/src/openai_models.rs | 55 +++++++ 11 files changed, 349 insertions(+), 74 deletions(-) create mode 100644 codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 4361161812..0507b28336 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -97,7 +97,10 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { }) } -pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { +pub fn create_spawn_agent_tool_v2( + options: SpawnAgentToolOptions, + description_override: Option<&str>, +) -> ToolSpec { let available_models_description = options.expose_spawn_agent_model_overrides.then(|| { spawn_agent_models_description(&options.available_models, options.multi_agent_version) }); @@ -126,6 +129,7 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { available_models_description.as_deref(), inherited_model_guidance, options.usage_hint_text, + description_override, ), strict: false, defer_loading: None, @@ -738,12 +742,21 @@ fn spawn_agent_tool_description_v2( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, usage_hint_text: Option, + description: Option<&str>, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); let inherited_model_guidance = inherited_model_guidance.unwrap_or_default(); - let tool_description = format!( - r#" + let tool_description = if let Some(description) = description { + format!( + r#" + {agent_role_guidance} + {description} +{inherited_model_guidance}"# + ) + } else { + format!( + r#" {agent_role_guidance} 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`. @@ -754,7 +767,8 @@ It will be able to send you and other running agents messages, and its final ans 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!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 888a11376a..e2f943c7ab 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -45,20 +45,23 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { legacy.multi_agent_version = Some(MultiAgentVersion::V1); let mut disabled = model_preset("disabled", /*show_in_picker*/ true); disabled.multi_agent_version = Some(MultiAgentVersion::Disabled); - let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { - available_models: vec![ - model_preset("visible", /*show_in_picker*/ true), - model_preset("hidden", /*show_in_picker*/ false), - legacy, - disabled, - ], - agent_type_description: "role help".to_string(), - expose_agent_type: true, - hide_agent_type_model_reasoning: false, - expose_spawn_agent_model_overrides: true, - multi_agent_version: MultiAgentVersion::V2, - usage_hint_text: None, - }); + let tool = create_spawn_agent_tool_v2( + SpawnAgentToolOptions { + available_models: vec![ + model_preset("visible", /*show_in_picker*/ true), + model_preset("hidden", /*show_in_picker*/ false), + legacy, + disabled, + ], + agent_type_description: "role help".to_string(), + expose_agent_type: true, + hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, + usage_hint_text: None, + }, + /*description_override*/ None, + ); let ToolSpec::Function(ResponsesApiTool { description, @@ -127,6 +130,47 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { ); } +#[test] +fn spawn_agent_catalog_description_preserves_generated_context() { + let options = SpawnAgentToolOptions { + available_models: vec![model_preset("visible", /*show_in_picker*/ true)], + agent_type_description: "Available agent roles: explorer".to_string(), + expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, + usage_hint_text: Some("Local usage hint.".to_string()), + ..Default::default() + }; + let ToolSpec::Function(default_tool) = + create_spawn_agent_tool_v2(options.clone(), /*description_override*/ None) + else { + panic!("spawn_agent should be a function tool"); + }; + let ToolSpec::Function(mut configured_tool) = + create_spawn_agent_tool_v2(options, Some("Catalog spawning guidance.")) + else { + panic!("spawn_agent should be a function tool"); + }; + assert!( + configured_tool + .description + .contains("Catalog spawning guidance.") + ); + assert!(configured_tool.description.contains("`visible-model`")); + assert!( + configured_tool + .description + .contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE) + ); + assert!(configured_tool.description.ends_with("Local usage hint.")); + assert!( + !configured_tool + .description + .contains("Spawns an agent to work on the specified task.") + ); + configured_tool.description = default_tool.description.clone(); + assert_eq!(configured_tool, default_tool); +} + #[test] fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { let tool = create_spawn_agent_tool_v1(SpawnAgentToolOptions { @@ -182,22 +226,25 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { #[test] fn spawn_agent_tool_caps_visible_model_summaries() { - let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { - available_models: vec![ - model_preset("first", /*show_in_picker*/ true), - model_preset("second", /*show_in_picker*/ true), - model_preset("third", /*show_in_picker*/ true), - model_preset("fourth", /*show_in_picker*/ true), - model_preset("fifth", /*show_in_picker*/ true), - model_preset("sixth", /*show_in_picker*/ true), - ], - agent_type_description: "role help".to_string(), - expose_agent_type: true, - hide_agent_type_model_reasoning: false, - expose_spawn_agent_model_overrides: true, - multi_agent_version: MultiAgentVersion::V2, - usage_hint_text: None, - }); + let tool = create_spawn_agent_tool_v2( + SpawnAgentToolOptions { + available_models: vec![ + model_preset("first", /*show_in_picker*/ true), + model_preset("second", /*show_in_picker*/ true), + model_preset("third", /*show_in_picker*/ true), + model_preset("fourth", /*show_in_picker*/ true), + model_preset("fifth", /*show_in_picker*/ true), + model_preset("sixth", /*show_in_picker*/ true), + ], + agent_type_description: "role help".to_string(), + expose_agent_type: true, + hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, + usage_hint_text: None, + }, + /*description_override*/ None, + ); let ToolSpec::Function(ResponsesApiTool { description, .. }) = tool else { panic!("spawn_agent should be a function tool"); @@ -235,15 +282,18 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() { #[test] fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() { - let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { - available_models: vec![model_preset("visible", /*show_in_picker*/ true)], - agent_type_description: "role help".to_string(), - expose_agent_type: false, - hide_agent_type_model_reasoning: true, - expose_spawn_agent_model_overrides: true, - multi_agent_version: MultiAgentVersion::V2, - usage_hint_text: None, - }); + let tool = create_spawn_agent_tool_v2( + SpawnAgentToolOptions { + available_models: vec![model_preset("visible", /*show_in_picker*/ true)], + agent_type_description: "role help".to_string(), + expose_agent_type: false, + hide_agent_type_model_reasoning: true, + expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, + usage_hint_text: None, + }, + /*description_override*/ None, + ); let ToolSpec::Function(ResponsesApiTool { description, @@ -268,15 +318,18 @@ fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() { #[test] fn spawn_agent_tool_hides_model_controls_without_override_exposure() { - let tool = create_spawn_agent_tool_v2(SpawnAgentToolOptions { - available_models: vec![model_preset("visible", /*show_in_picker*/ true)], - agent_type_description: "role help".to_string(), - expose_agent_type: false, - hide_agent_type_model_reasoning: true, - expose_spawn_agent_model_overrides: false, - multi_agent_version: MultiAgentVersion::V2, - usage_hint_text: None, - }); + let tool = create_spawn_agent_tool_v2( + SpawnAgentToolOptions { + available_models: vec![model_preset("visible", /*show_in_picker*/ true)], + agent_type_description: "role help".to_string(), + expose_agent_type: false, + hide_agent_type_model_reasoning: true, + expose_spawn_agent_model_overrides: false, + multi_agent_version: MultiAgentVersion::V2, + usage_hint_text: None, + }, + Some(""), + ); let ToolSpec::Function(ResponsesApiTool { description, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 72c5ccd527..22589681d1 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -25,11 +25,18 @@ use codex_tools::ToolSpec; #[derive(Default)] pub(crate) struct Handler { options: SpawnAgentToolOptions, + description_override: Option, } impl Handler { - pub(crate) fn new(options: SpawnAgentToolOptions) -> Self { - Self { options } + pub(crate) fn new( + options: SpawnAgentToolOptions, + description_override: Option, + ) -> Self { + Self { + options, + description_override, + } } } @@ -39,7 +46,7 @@ impl ToolExecutor for Handler { } fn spec(&self) -> ToolSpec { - create_spawn_agent_tool_v2(self.options.clone()) + create_spawn_agent_tool_v2(self.options.clone(), self.description_override.as_deref()) } fn handle<'a>(&'a self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'a> diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 3cfee595e4..1191336949 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1248,6 +1248,8 @@ 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 exposure = if turn_context.config.multi_agent_v2.non_code_mode_only { ToolExposure::DirectModelOnly } else { @@ -1262,18 +1264,25 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too 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(), - 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, - multi_agent_version: turn_context.multi_agent_version, - usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), - }), + SpawnAgentHandlerV2::new( + SpawnAgentToolOptions { + available_models: turn_context.available_models.clone(), + 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, + multi_agent_version: turn_context.multi_agent_version, + usage_hint_text: turn_context + .config + .multi_agent_v2 + .usage_hint_text + .clone(), + }, + spawn_agent_description.map(str::to_owned), + ), tool_namespace, ), exposure, diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index e751f952ec..973de946c0 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -182,6 +182,7 @@ 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/request_user_input_async.rs b/codex-rs/core/tests/suite/request_user_input_async.rs index dba6dfba1e..5c4eaccfe9 100644 --- a/codex-rs/core/tests/suite/request_user_input_async.rs +++ b/codex-rs/core/tests/suite/request_user_input_async.rs @@ -76,6 +76,7 @@ async fn persistent_async_message_guidance_follows_tool_availability( send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()), }), + ..Default::default() }); }) .with_config(|config| { @@ -248,6 +249,7 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn( send_user_message_async: Some(ToolMessage { description: Some("Questions only.".to_string()), }), + ..Default::default() }); }) .with_config(move |config| { @@ -340,10 +342,10 @@ async fn freeform_async_message_emits_an_item_without_ending_the_turn( #[test_case(None, "send_user_message_async"; "fallback_description")] #[test_case(None, "request_user_input_async"; "current_catalog_name")] -#[test_case(Some(ToolMessages { send_user_message_async: None }), "send_user_message_async"; "missing_tool")] -#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::default()) }), "send_user_message_async"; "missing_description")] -#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()) }) }), "send_user_message_async"; "catalog_description")] -#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }) }), "send_user_message_async"; "empty_description")] +#[test_case(Some(ToolMessages::default()), "send_user_message_async"; "missing_tool")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage::default()), ..Default::default() }), "send_user_message_async"; "missing_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some("Catalog async message description.".to_string()) }), ..Default::default() }), "send_user_message_async"; "catalog_description")] +#[test_case(Some(ToolMessages { send_user_message_async: Some(ToolMessage { description: Some(String::new()) }), ..Default::default() }), "send_user_message_async"; "empty_description")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn request_user_input_async_emits_item_and_does_not_end_the_turn( tool_messages: Option, diff --git a/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs b/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs new file mode 100644 index 0000000000..f2c4fb8ab1 --- /dev/null +++ b/codex-rs/core/tests/suite/spawn_agent_tool_descriptions.rs @@ -0,0 +1,90 @@ +//! 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 04938cd41a..c92f547930 100644 --- a/codex-rs/core/tests/suite/step_settings.rs +++ b/codex-rs/core/tests/suite/step_settings.rs @@ -44,6 +44,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::MultiAgentMessages; use codex_protocol::openai_models::MultiAgentModeMessages; use codex_protocol::openai_models::MultiAgentRoleMessages; +use codex_protocol::openai_models::MultiAgentToolMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::ToolMessage; @@ -86,6 +87,7 @@ use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_response_sequence; use core_test_support::responses::mount_sse_once; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::sse_completed; use core_test_support::responses::sse_response; @@ -2224,7 +2226,7 @@ async fn model_activation_uses_destination_metadata_defaults( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn request_user_input_async_description_follows_mid_turn_model_changes() -> Result<()> { +async fn tool_descriptions_follow_mid_turn_model_changes() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -2238,6 +2240,11 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() - .await; let test = step_settings_test() .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.expose_spawn_agent_model_overrides = false; for model in &mut config .model_catalog .as_mut() @@ -2255,6 +2262,11 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() - send_user_message_async: Some(ToolMessage { description: Some(format!("Async message description for {}.", model.slug)), }), + multi_agent: Some(MultiAgentToolMessages { + spawn_agent: Some(ToolMessage { + description: Some(format!("Spawn description for {}.", model.slug)), + }), + }), }); } }) @@ -2290,13 +2302,20 @@ async fn request_user_input_async_description_follows_mid_turn_model_changes() - .iter() .find(|tool| tool["name"] == "request_user_input_async") .expect("async message tool"); - json!({"model": body["model"], "description": tool["description"]}) + let spawn = namespace_child_tool(&body, "collaboration", "spawn_agent") + .expect("spawn agent tool"); + json!({ + "model": body["model"], + "async_description": tool["description"], + "spawn_description": spawn["description"].as_str().expect("spawn description").trim(), + }) }) .collect::>(), [MODEL_A, MODEL_B] .map(|model| json!({ "model": model, - "description": format!("Async message description for {model}."), + "async_description": format!("Async message description for {model}."), + "spawn_description": format!("Spawn description for {model}."), })) .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 071975b133..07e25b8957 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -12,6 +12,7 @@ use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::MultiAgentMessages; use codex_protocol::openai_models::MultiAgentModeMessages; use codex_protocol::openai_models::MultiAgentRoleMessages; +use codex_protocol::openai_models::MultiAgentToolMessages; use codex_protocol::openai_models::PermissionMessages; use codex_protocol::openai_models::ToolMessage; use codex_protocol::openai_models::ToolMessages; @@ -86,6 +87,11 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { send_user_message_async: Some(ToolMessage { description: Some(async_message_description.to_string()), }), + multi_agent: Some(MultiAgentToolMessages { + spawn_agent: Some(ToolMessage { + description: Some("Catalog spawn description.".to_string()), + }), + }), }), instructions_template: Some("template".to_string()), instructions_variables: Some(ModelInstructionsVariables { @@ -147,6 +153,16 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { personality_pragmatic: Some("pragmatic".to_string()), }), persistent_instructions: Some(String::new()), + tools: Some(ToolMessages { + send_user_message_async: Some(ToolMessage { + description: Some(String::new()), + }), + multi_agent: Some(MultiAgentToolMessages { + spawn_agent: Some(ToolMessage { + description: Some(String::new()), + }), + }), + }), approvals: Some(ApprovalMessages { on_request: Some("user approvals".to_string()), on_request_auto_review: None, diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs index faf7a8a176..14f6cbd9e5 100644 --- a/codex-rs/prompts/src/model_messages.rs +++ b/codex-rs/prompts/src/model_messages.rs @@ -151,6 +151,15 @@ 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 + .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()) + } + /// Resolves persistent-mode instructions without deciding whether the mode is active. pub fn persistent_instructions(&self) -> &'a str { self.catalog_messages diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 00155ec414..1c0c74ab88 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -576,6 +576,8 @@ pub struct ConfirmationPolicies { pub struct ToolMessages { #[serde(default, skip_serializing_if = "Option::is_none")] pub send_user_message_async: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub multi_agent: Option, } /// Model-owned messages for a built-in tool. @@ -587,6 +589,15 @@ pub struct ToolMessage { pub description: Option, } +/// Model-owned descriptions for Multi-Agent V2 tools, independent of their runtime namespace. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct MultiAgentToolMessages { + /// Replaces the static description. Missing or null uses the bundled text; an empty string + /// suppresses it. Generated model information and local usage hints are retained. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub spawn_agent: Option, +} + /// Model-owned defaults for the context-window token-budget feature. #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] pub struct ModelTokenBudgetConfig { @@ -1022,6 +1033,7 @@ mod tests { ( expected.as_ref().map(|tool| ToolMessages { send_user_message_async: tool.clone(), + ..Default::default() }), expected.map(|tool| tool.map(|tool| match tool.description { Some(description) => serde_json::json!({"description": description}), @@ -1032,6 +1044,43 @@ mod tests { } } + #[test] + fn spawn_agent_messages_preserve_sparse_and_empty_values() { + for (value, expected) in [ + (serde_json::json!({}), serde_json::json!({})), + ( + serde_json::json!({"multi_agent": null}), + serde_json::json!({}), + ), + ( + serde_json::json!({"multi_agent": {"spawn_agent": null}}), + serde_json::json!({"multi_agent": {}}), + ), + ( + serde_json::json!({"multi_agent": {"spawn_agent": {"description": null}}}), + serde_json::json!({"multi_agent": {"spawn_agent": {}}}), + ), + ( + serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}), + serde_json::json!({"multi_agent": {"spawn_agent": {"description": "Catalog spawn"}}}), + ), + ( + serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}), + serde_json::json!({"multi_agent": {"spawn_agent": {"description": ""}}}), + ), + ] { + let tools: ToolMessages = + serde_json::from_value(value).expect("deserialize tool messages"); + assert_eq!( + serde_json::to_value(&tools).expect("serialize tool messages"), + expected + ); + let restored: ToolMessages = + serde_json::from_value(expected).expect("restore tool messages"); + assert_eq!(restored, tools); + } + } + #[test] fn approval_messages_preserve_missing_and_empty_values() { let messages: ModelMessages = from_str( @@ -1331,6 +1380,11 @@ mod tests { send_user_message_async: Some(ToolMessage { description: Some("Catalog description".to_string()), }), + multi_agent: Some(MultiAgentToolMessages { + spawn_agent: Some(ToolMessage { + description: Some("Catalog spawn description".to_string()), + }), + }), }), instructions_template: None, instructions_variables: None, @@ -1403,6 +1457,7 @@ mod tests { send_user_message_async: Some(ToolMessage { description: Some(String::new()), }), + ..Default::default() }), instructions_template: Some("canonical instructions".to_string()), ..Default::default()