diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index aa121301c43..abcf9de4cb4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -13,6 +13,7 @@ use crate::config::Config; use crate::error::CodexErr; use crate::features::Feature; use crate::function_tool::FunctionCallError; +use crate::models_manager::manager::RefreshStrategy; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -22,6 +23,8 @@ use crate::tools::registry::ToolKind; use async_trait::async_trait; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; @@ -113,6 +116,8 @@ mod spawn { message: Option, items: Option>, agent_type: Option, + model: Option, + reasoning_effort: Option, #[serde(default)] fork_context: bool, } @@ -158,6 +163,14 @@ mod spawn { .await; let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; apply_role_to_config(&mut config, role_name) .await .map_err(FunctionCallError::RespondToModel)?; @@ -963,6 +976,99 @@ fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { } } +async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), FunctionCallError> { + if requested_model.is_none() && requested_reasoning_effort.is_none() { + return Ok(()); + } + + if let Some(requested_model) = requested_model { + let available_models = session + .services + .models_manager + .list_models(RefreshStrategy::Offline) + .await; + let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, config) + .await; + + config.model = Some(selected_model_name.clone()); + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &selected_model_name, + &selected_model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } else { + config.model_reasoning_effort = selected_model_info.default_reasoning_level; + } + + return Ok(()); + } + + if let Some(reasoning_effort) = requested_reasoning_effort { + validate_spawn_agent_reasoning_effort( + &turn.model_info.slug, + &turn.model_info.supported_reasoning_levels, + reasoning_effort, + )?; + config.model_reasoning_effort = Some(reasoning_effort); + } + + Ok(()) +} + +fn find_spawn_agent_model_name( + available_models: &[codex_protocol::openai_models::ModelPreset], + requested_model: &str, +) -> Result { + available_models + .iter() + .find(|model| model.model == requested_model) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_models + .iter() + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + FunctionCallError::RespondToModel(format!( + "Unknown model `{requested_model}` for spawn_agent. Available models: {available}" + )) + }) +} + +fn validate_spawn_agent_reasoning_effort( + model: &str, + supported_reasoning_levels: &[ReasoningEffortPreset], + requested_reasoning_effort: ReasoningEffort, +) -> Result<(), FunctionCallError> { + if supported_reasoning_levels + .iter() + .any(|preset| preset.effort == requested_reasoning_effort) + { + return Ok(()); + } + + let supported = supported_reasoning_levels + .iter() + .map(|preset| preset.effort.to_string()) + .collect::>() + .join(", "); + Err(FunctionCallError::RespondToModel(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + ))) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 51bc84b23f8..ce2107320d2 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -791,6 +791,24 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec { ), }, ), + ( + "model".to_string(), + JsonSchema::String { + description: Some( + "Optional model override for the new agent. Replaces the inherited model." + .to_string(), + ), + }, + ), + ( + "reasoning_effort".to_string(), + JsonSchema::String { + description: Some( + "Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort." + .to_string(), + ), + }, + ), ]); ToolSpec::Function(ResponsesApiTool { diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 5c154177a3b..b56f84d307a 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1,5 +1,9 @@ use anyhow::Result; +use codex_core::ThreadConfigSnapshot; +use codex_core::config::AgentRoleConfig; use codex_core::features::Feature; +use codex_protocol::ThreadId; +use codex_protocol::openai_models::ReasoningEffort; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -13,6 +17,7 @@ use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; +use pretty_assertions::assert_eq; use serde_json::json; use std::time::Duration; use tokio::time::Instant; @@ -25,6 +30,12 @@ const TURN_0_FORK_PROMPT: &str = "seed fork context"; const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; const CHILD_PROMPT: &str = "child: do work"; +const INHERITED_MODEL: &str = "gpt-5.2-codex"; +const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh; +const REQUESTED_MODEL: &str = "gpt-5.1"; +const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low; +const ROLE_MODEL: &str = "gpt-5.1-codex-max"; +const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High; fn body_contains(req: &wiremock::Request, text: &str) -> bool { let is_zstd = req @@ -89,9 +100,28 @@ async fn setup_turn_one_with_spawned_child( server: &MockServer, child_response_delay: Option, ) -> Result<(TestCodex, String)> { - let spawn_args = serde_json::to_string(&json!({ - "message": CHILD_PROMPT, - }))?; + setup_turn_one_with_custom_spawned_child( + server, + json!({ + "message": CHILD_PROMPT, + }), + child_response_delay, + true, + |builder| builder, + ) + .await +} + +async fn setup_turn_one_with_custom_spawned_child( + server: &MockServer, + spawn_args: serde_json::Value, + child_response_delay: Option, + wait_for_parent_notification: bool, + configure_test: impl FnOnce( + core_test_support::test_codex::TestCodexBuilder, + ) -> core_test_support::test_codex::TestCodexBuilder, +) -> Result<(TestCodex, String)> { + let spawn_args = serde_json::to_string(&spawn_args)?; mount_sse_once_match( server, @@ -141,15 +171,17 @@ async fn setup_turn_one_with_spawned_child( .await; #[allow(clippy::expect_used)] - let mut builder = test_codex().with_config(|config| { + let mut builder = configure_test(test_codex().with_config(|config| { config .features .enable(Feature::Collab) .expect("test config should allow feature update"); - }); + config.model = Some(INHERITED_MODEL.to_string()); + config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT); + })); let test = builder.build(server).await?; test.submit_turn(TURN_1_PROMPT).await?; - if child_response_delay.is_none() { + if child_response_delay.is_none() && wait_for_parent_notification { let _ = wait_for_requests(&child_request_log).await?; let rollout_path = test .codex @@ -176,6 +208,25 @@ async fn setup_turn_one_with_spawned_child( Ok((test, spawned_id)) } +async fn spawn_child_and_capture_snapshot( + server: &MockServer, + spawn_args: serde_json::Value, + configure_test: impl FnOnce( + core_test_support::test_codex::TestCodexBuilder, + ) -> core_test_support::test_codex::TestCodexBuilder, +) -> Result { + let (test, spawned_id) = + setup_turn_one_with_custom_spawned_child(server, spawn_args, None, false, configure_test) + .await?; + let thread_id = ThreadId::from_string(&spawned_id)?; + Ok(test + .thread_manager + .get_thread(thread_id) + .await? + .config_snapshot() + .await) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn subagent_notification_is_included_without_wait() -> Result<()> { skip_if_no_network!(Ok(())); @@ -316,3 +367,71 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_without_role() +-> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let child_snapshot = spawn_child_and_capture_snapshot( + &server, + json!({ + "message": CHILD_PROMPT, + "model": REQUESTED_MODEL, + "reasoning_effort": REQUESTED_REASONING_EFFORT, + }), + |builder| builder, + ) + .await?; + + assert_eq!(child_snapshot.model, REQUESTED_MODEL); + assert_eq!( + child_snapshot.reasoning_effort, + Some(REQUESTED_REASONING_EFFORT) + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let child_snapshot = spawn_child_and_capture_snapshot( + &server, + json!({ + "message": CHILD_PROMPT, + "agent_type": "custom", + "model": REQUESTED_MODEL, + "reasoning_effort": REQUESTED_REASONING_EFFORT, + }), + |builder| { + builder.with_config(|config| { + let role_path = config.codex_home.join("custom-role.toml"); + std::fs::write( + &role_path, + format!( + "model = \"{ROLE_MODEL}\"\nmodel_reasoning_effort = \"{ROLE_REASONING_EFFORT}\"\n", + ), + ) + .expect("write role config"); + config.agent_roles.insert( + "custom".to_string(), + AgentRoleConfig { + description: Some("Custom role".to_string()), + config_file: Some(role_path), + nickname_candidates: None, + }, + ); + }) + }, + ) + .await?; + + assert_eq!(child_snapshot.model, ROLE_MODEL); + assert_eq!(child_snapshot.reasoning_effort, Some(ROLE_REASONING_EFFORT)); + + Ok(()) +}