diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6cbc3ebdc11e..b624dc2bd789 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1734,6 +1734,10 @@ "enabled": { "type": "boolean" }, + "expose_spawn_agent_model_overrides": { + "description": "Exposes `model` and `reasoning_effort` on the multi-agent v2 spawn tool and adds corresponding guidance to root and subagent usage hints.", + "type": "boolean" + }, "hide_spawn_agent_metadata": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1df2bb3e3fc0..89f8c23ad810 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10314,6 +10314,7 @@ subagent_usage_hint_text = "Subagent guidance." multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true +expose_spawn_agent_model_overrides = false non_code_mode_only = true "#, )?; @@ -10357,6 +10358,7 @@ non_code_mode_only = true Some("agents") ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); + assert!(!config.multi_agent_v2.expose_spawn_agent_model_overrides); assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) @@ -10378,7 +10380,10 @@ enabled = true .build() .await?; - assert_eq!(config.multi_agent_v2, MultiAgentV2Config::default()); + assert_eq!( + config.multi_agent_v2, + resolve_multi_agent_v2_config(&ConfigToml::default()) + ); assert_eq!( ( config.agent_max_threads, @@ -10410,7 +10415,50 @@ max_concurrent_threads_per_session = 17 config.subagent_usage_hint_text, ] .into_iter() - .all(|hint| hint.is_some_and(|hint| hint.ends_with(expected_suffix.as_str()))) + .all(|hint| hint.is_some_and(|hint| hint.contains(expected_suffix.as_str()))) + ); +} + +#[test] +fn multi_agent_v2_model_override_exposure_preserves_configured_usage_hints() { + let config_toml = toml::from_str( + r#"[features.multi_agent_v2] +enabled = true +root_agent_usage_hint_text = "Root guidance." +subagent_usage_hint_text = "Subagent guidance." +expose_spawn_agent_model_overrides = true +"#, + ) + .expect("multi-agent v2 config should parse"); + + let config = resolve_multi_agent_v2_config(&config_toml); + assert!(config.expose_spawn_agent_model_overrides); + assert_eq!( + config.root_agent_usage_hint_text.as_deref(), + Some("Root guidance.") + ); + assert_eq!( + config.subagent_usage_hint_text.as_deref(), + Some("Subagent guidance.") + ); +} + +#[test] +fn multi_agent_v2_exposes_model_overrides_by_default() { + let config_toml = + toml::from_str(r#"[features.multi_agent_v2]"#).expect("multi-agent v2 config should parse"); + + let config = resolve_multi_agent_v2_config(&config_toml); + assert!(config.expose_spawn_agent_model_overrides); + assert!( + [ + config.root_agent_usage_hint_text, + config.subagent_usage_hint_text, + ] + .into_iter() + .all(|hint| hint.is_some_and(|hint| { + hint.ends_with(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT) + })) ); } @@ -10425,7 +10473,7 @@ multi_agent_mode_hint_text = "" let expected = MultiAgentV2Config { multi_agent_mode_hint_text: Some(String::new()), - ..Default::default() + ..resolve_multi_agent_v2_config(&ConfigToml::default()) }; assert_eq!(resolve_multi_agent_v2_config(&config_toml), expected); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 7b6ca9afa8fc..0bc79a88e066 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -248,6 +248,7 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; +const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. @@ -1150,6 +1151,7 @@ pub struct MultiAgentV2Config { pub multi_agent_mode_hint_text: Option, pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, + pub expose_spawn_agent_model_overrides: bool, pub non_code_mode_only: bool, } @@ -1172,6 +1174,7 @@ impl MultiAgentV2Config { multi_agent_mode_hint_text: None, tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, + expose_spawn_agent_model_overrides: true, non_code_mode_only: true, } } @@ -2516,13 +2519,31 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config .and_then(|config| config.usage_hint_text.as_ref()) .cloned() .or(default.usage_hint_text); + let hide_spawn_agent_metadata = base + .and_then(|config| config.hide_spawn_agent_metadata) + .unwrap_or(default.hide_spawn_agent_metadata); + let expose_spawn_agent_model_overrides = base + .and_then(|config| config.expose_spawn_agent_model_overrides) + .unwrap_or(default.expose_spawn_agent_model_overrides); + let mut default_root_agent_usage_hint_text = default.root_agent_usage_hint_text; + let mut default_subagent_usage_hint_text = default.subagent_usage_hint_text; + if expose_spawn_agent_model_overrides { + default_root_agent_usage_hint_text = Some(append_usage_hint_text( + default_root_agent_usage_hint_text.as_deref(), + DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT, + )); + default_subagent_usage_hint_text = Some(append_usage_hint_text( + default_subagent_usage_hint_text.as_deref(), + DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT, + )); + } let root_agent_usage_hint_text = resolve_optional_prompt_text( base.map(|config| &config.root_agent_usage_hint_text), - default.root_agent_usage_hint_text, + default_root_agent_usage_hint_text, ); let subagent_usage_hint_text = resolve_optional_prompt_text( base.map(|config| &config.subagent_usage_hint_text), - default.subagent_usage_hint_text, + default_subagent_usage_hint_text, ); let multi_agent_mode_hint_text = base .and_then(|config| config.multi_agent_mode_hint_text.as_ref()) @@ -2532,9 +2553,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config .and_then(|config| config.tool_namespace.as_ref()) .cloned() .or(default.tool_namespace); - let hide_spawn_agent_metadata = base - .and_then(|config| config.hide_spawn_agent_metadata) - .unwrap_or(default.hide_spawn_agent_metadata); let non_code_mode_only = base .and_then(|config| config.non_code_mode_only) .unwrap_or(default.non_code_mode_only); @@ -2550,6 +2568,7 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config multi_agent_mode_hint_text, tool_namespace, hide_spawn_agent_metadata, + expose_spawn_agent_model_overrides, non_code_mode_only, } } @@ -2734,6 +2753,13 @@ fn resolve_optional_prompt_text( } } +fn append_usage_hint_text(usage_hint_text: Option<&str>, additional_text: &str) -> String { + match usage_hint_text { + Some(usage_hint_text) => format!("{usage_hint_text}\n\n{additional_text}"), + None => additional_text.to_string(), + } +} + fn code_mode_toml_config(features: Option<&FeaturesToml>) -> Option<&CodeModeConfigToml> { match features?.code_mode.as_ref()? { FeatureToml::Enabled(_) => None, 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 23ac4e0bbcf6..447e0136f026 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -24,6 +24,7 @@ pub struct SpawnAgentToolOptions { pub available_models: Vec, pub agent_type_description: String, pub hide_agent_type_model_reasoning: bool, + pub expose_spawn_agent_model_overrides: bool, pub usage_hint_text: Option, } @@ -76,13 +77,20 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { } pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { - let available_models_description = (!options.hide_agent_type_model_reasoning) + let available_models_description = options + .expose_spawn_agent_model_overrides .then(|| spawn_agent_models_description(&options.available_models)); - let inherited_model_guidance = - (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); + let inherited_model_guidance = (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.hide_agent_type_model_reasoning { - hide_spawn_agent_metadata_options(&mut properties); + properties.remove("agent_type"); + properties.remove("service_tier"); + } + if !options.expose_spawn_agent_model_overrides { + properties.remove("model"); + properties.remove("reasoning_effort"); } properties.insert( "task_name".to_string(), 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 0228f7acb929..e31e6bed1c7c 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,6 +45,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: true, usage_hint_text: None, }); @@ -98,6 +99,12 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { .and_then(|schema| schema.description.as_deref()), Some(SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION) ); + assert_eq!( + properties + .get("reasoning_effort") + .and_then(|schema| schema.description.as_deref()), + Some("Reasoning effort override for the new agent. Omit to inherit the parent effort.") + ); assert_eq!( properties .get("service_tier") @@ -120,6 +127,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { available_models: Vec::new(), agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: true, usage_hint_text: None, }); @@ -176,6 +184,7 @@ fn spawn_agent_tool_caps_visible_model_summaries() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: true, usage_hint_text: None, }); @@ -214,11 +223,12 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() { } #[test] -fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { +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(), hide_agent_type_model_reasoning: true, + expose_spawn_agent_model_overrides: true, usage_hint_text: None, }); @@ -236,10 +246,40 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { .expect("spawn_agent should use object params"); assert!(!properties.contains_key("agent_type")); - assert!(!properties.contains_key("model")); - assert!(!properties.contains_key("reasoning_effort")); + assert!(properties.contains_key("model")); + assert!(properties.contains_key("reasoning_effort")); assert!(!properties.contains_key("service_tier")); assert!(!description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)); + assert!(description.contains("Available model overrides")); +} + +#[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(), + hide_agent_type_model_reasoning: true, + expose_spawn_agent_model_overrides: false, + usage_hint_text: None, + }); + + let ToolSpec::Function(ResponsesApiTool { + description, + parameters, + .. + }) = tool + else { + panic!("spawn_agent should be a function tool"); + }; + let properties = parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + + for property in ["agent_type", "model", "reasoning_effort", "service_tier"] { + assert!(!properties.contains_key(property)); + } + assert!(!description.contains(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE)); assert!(!description.contains("Available model overrides")); } diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2ae31ec677b6..6216edbc3b9b 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -783,6 +783,10 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .config .multi_agent_v2 .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(), }), tool_namespace, @@ -825,6 +829,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu available_models: turn_context.available_models.clone(), agent_type_description, 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, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index bd33336bcea7..e03a5c969fb4 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -1255,6 +1255,17 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { else { panic!("expected spawn_agent in {MULTI_AGENT_V2_NAMESPACE} namespace"); }; + let spawn_agent_properties = spawn_agent + .parameters + .properties + .as_ref() + .expect("spawn_agent should use object params"); + for property in ["model", "reasoning_effort"] { + assert!(spawn_agent_properties.contains_key(property)); + } + for property in ["agent_type", "service_tier"] { + assert!(!spawn_agent_properties.contains_key(property)); + } let spawn_agent_description = spawn_agent.description.as_str(); assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session")); assert!(spawn_agent_description.contains( diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 0100dc1d8387..8d006f255c8c 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -62,6 +62,10 @@ pub struct MultiAgentV2ConfigToml { pub tool_namespace: Option, #[serde(skip_serializing_if = "Option::is_none")] pub hide_spawn_agent_metadata: Option, + /// Exposes `model` and `reasoning_effort` on the multi-agent v2 spawn tool and adds + /// corresponding guidance to root and subagent usage hints. + #[serde(skip_serializing_if = "Option::is_none")] + pub expose_spawn_agent_model_overrides: Option, #[serde(skip_serializing_if = "Option::is_none")] pub non_code_mode_only: Option, } diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 50da143fbb15..f622b5e2c66a 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -651,6 +651,7 @@ subagent_usage_hint_text = "Subagent guidance." multi_agent_mode_hint_text = "Custom mode guidance." tool_namespace = "agents" hide_spawn_agent_metadata = true +expose_spawn_agent_model_overrides = true non_code_mode_only = true "#, ) @@ -675,6 +676,7 @@ non_code_mode_only = true multi_agent_mode_hint_text: Some("Custom mode guidance.".to_string()), tool_namespace: Some("agents".to_string()), hide_spawn_agent_metadata: Some(true), + expose_spawn_agent_model_overrides: Some(true), non_code_mode_only: Some(true), })) );