From b13164d86f9a70adc48d22f4a5a07ed0c001a1d0 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 15 Sep 2026 09:59:03 +0000 Subject: [PATCH] Centralize child agent configuration in the agent module (#45669) ## What changed Move shared spawn and resume configuration helpers into `agent::child_config` and route both multi-agent versions through `prepare_agent_spawn_config`. Preserve each version's role and model precedence, full-history inheritance, service-tier selection, runtime policy, and default-role metadata for reloads. Return configuration errors as strings and convert them to `FunctionCallError::RespondToModel` at tool-handler boundaries. GitOrigin-RevId: 9d539050aa44f94043a6487f1f6f1037de1d15ca --- codex-rs/core/src/agent/child_config.rs | 357 ++++++++++++++++++ codex-rs/core/src/agent/control/spawn.rs | 2 +- codex-rs/core/src/agent/mod.rs | 1 + .../handlers/multi_agents/resume_agent.rs | 4 +- .../tools/handlers/multi_agents/send_input.rs | 4 +- .../src/tools/handlers/multi_agents/spawn.rs | 32 +- .../src/tools/handlers/multi_agents_common.rs | 295 --------------- .../src/tools/handlers/multi_agents_spec.rs | 4 +- .../src/tools/handlers/multi_agents_tests.rs | 3 + .../handlers/multi_agents_v2/message_tool.rs | 4 +- .../tools/handlers/multi_agents_v2/spawn.rs | 45 +-- 11 files changed, 404 insertions(+), 347 deletions(-) create mode 100644 codex-rs/core/src/agent/child_config.rs diff --git a/codex-rs/core/src/agent/child_config.rs b/codex-rs/core/src/agent/child_config.rs new file mode 100644 index 0000000000..1493378590 --- /dev/null +++ b/codex-rs/core/src/agent/child_config.rs @@ -0,0 +1,357 @@ +//! Prepares child configuration from the invoking turn and requested overrides. +//! +//! Spawn and reload share live runtime policy; role and model precedence, full-history +//! inheritance, and validation messages remain the same for each multi-agent version. + +use crate::agent::role::DEFAULT_ROLE_NAME; +use crate::agent::role::apply_role_to_config; +use crate::config::Config; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use codex_models_manager::manager::RefreshStrategy; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::models::BaseInstructions; +use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::MultiAgentVersion; + +pub(crate) const MAX_SPAWN_AGENT_MODEL_OVERRIDES: usize = 5; + +pub(crate) fn model_supports_multi_agent_backend( + model: &ModelPreset, + multi_agent_version: MultiAgentVersion, +) -> bool { + multi_agent_version != MultiAgentVersion::V2 + || model.multi_agent_version != Some(MultiAgentVersion::Disabled) +} + +/// Selects the existing spawn tool's role-inheritance rules. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum SpawnConfigVersion { + V1, + V2, +} + +pub(crate) struct SpawnConfigOptions<'a> { + pub(crate) version: SpawnConfigVersion, + pub(crate) full_history_fork: bool, + pub(crate) role_name: Option<&'a str>, + pub(crate) model: Option<&'a str>, + pub(crate) reasoning_effort: Option, +} + +pub(crate) struct PreparedSpawnConfig { + pub(crate) config: Config, + pub(crate) role_name: Option, +} + +/// Resolves child settings before starting the thread, retaining the invoking tool's precedence. +pub(crate) async fn prepare_agent_spawn_config( + session: &Session, + turn: &TurnContext, + options: SpawnConfigOptions<'_>, +) -> Result { + let mut config = build_agent_spawn_config(&session.get_base_instructions().await, turn)?; + if options.version == SpawnConfigVersion::V1 && options.full_history_fork { + reject_full_fork_agent_type_override(options.role_name)?; + } + apply_requested_spawn_agent_model_overrides( + session, + turn, + &mut config, + options.model, + options.reasoning_effort, + ) + .await?; + if !options.full_history_fork + || (options.version == SpawnConfigVersion::V2 && options.role_name.is_some()) + { + apply_spawn_agent_role(session, &mut config, options.role_name).await?; + if options.version == SpawnConfigVersion::V2 + && options.full_history_fork + && config.developer_instructions.is_none() + { + config + .developer_instructions + .clone_from(&turn.developer_instructions); + } + } + apply_spawn_agent_service_tier(session, &mut config).await?; + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + // Remember an applied configured default so cold reload reapplies its restrictions. + let role_name = options + .role_name + .or_else(|| { + (options.version == SpawnConfigVersion::V2 + && !options.full_history_fork + && config + .agent_roles + .get(DEFAULT_ROLE_NAME) + .is_some_and(|role| role.config_file.is_some())) + .then_some(DEFAULT_ROLE_NAME) + }) + .map(str::to_owned); + Ok(PreparedSpawnConfig { config, role_name }) +} + +/// Builds the base config snapshot for a newly spawned sub-agent. +/// +/// The returned config starts from the parent's effective config and then refreshes the +/// runtime-owned fields carried by the turn, including model selection, reasoning settings, +/// approval policy, sandbox, and cwd. Role-specific overrides are layered +/// after this step; skipping this helper and cloning stale config state directly can send the child +/// agent out with the wrong provider or runtime policy. +pub(crate) fn build_agent_spawn_config( + base_instructions: &BaseInstructions, + turn: &TurnContext, +) -> Result { + let mut config = build_agent_shared_config(turn)?; + config.base_instructions = Some(base_instructions.text.clone()); + config.base_instructions_provenance = base_instructions.provenance.clone(); + Ok(config) +} + +pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result { + let mut config = build_agent_shared_config(turn)?; + // For resume, keep base instructions sourced from rollout/session metadata. + config.base_instructions = None; + config.base_instructions_provenance = None; + Ok(config) +} + +fn build_agent_shared_config(turn: &TurnContext) -> Result { + let base_config = turn.config.clone(); + let mut config = (*base_config).clone(); + // Preserve activation for history forks without freezing the parent's model-owned prompts. + // Fresh child startup restores configured preferences from the retained snapshot. + config.token_budget = turn.configured_token_budget.clone(); + config.model = Some(turn.model_info().slug.clone()); + config.model_provider = turn.provider.info().clone(); + config.model_reasoning_effort = turn + .reasoning_effort() + .or(turn.model_info().default_reasoning_level.as_ref()) + .cloned(); + config.model_reasoning_summary = Some(turn.reasoning_summary()); + config.developer_instructions = turn.developer_instructions.clone(); + if turn.multi_agent_version == MultiAgentVersion::V2 + && let Some(developer_instructions) = turn + .config + .multi_agent_v2 + .subagent_developer_instructions + .clone() + { + config.developer_instructions = Some(developer_instructions); + } + apply_spawn_agent_runtime_overrides(&mut config, turn)?; + + Ok(config) +} + +fn reject_full_fork_agent_type_override(agent_type: Option<&str>) -> Result<(), String> { + if agent_type.is_some() { + return Err( + "Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string()); + } + Ok(()) +} + +/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. +/// +/// These values are chosen by the live turn rather than persisted config, so leaving them stale can +/// make a child agent disagree with its parent about approval policy, cwd, or sandboxing. +fn apply_spawn_agent_runtime_overrides( + config: &mut Config, + turn: &TurnContext, +) -> Result<(), String> { + config + .permissions + .approval_policy + .set(turn.approval_policy()) + .map_err(|err| format!("approval_policy is invalid: {err}"))?; + config.approvals_reviewer = turn.config.approvals_reviewer; + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); + config.cwd = turn_cwd; + config + .permissions + .set_permission_profile_from_session_snapshot( + turn.config + .permissions + .permission_profile_state() + .snapshot(), + ) + .map_err(|err| format!("permission_profile is invalid: {err}"))?; + Ok(()) +} + +async fn apply_requested_spawn_agent_model_overrides( + session: &Session, + turn: &TurnContext, + config: &mut Config, + requested_model: Option<&str>, + requested_reasoning_effort: Option, +) -> Result<(), String> { + let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref()); + let requested_reasoning_effort = requested_reasoning_effort + .or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone()); + 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, config.http_client_factory()) + .await; + let selected_model_name = find_spawn_agent_model_name( + &available_models, + requested_model, + turn.multi_agent_version, + )?; + let selected_model_info = session + .services + .models_manager + .get_model_info(&selected_model_name, &config.to_models_manager_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(()) +} + +pub(crate) async fn apply_spawn_agent_service_tier( + session: &Session, + config: &mut Config, +) -> Result<(), String> { + let Some(service_tier) = session.services.agent_control.root_service_tier() else { + config.service_tier = None; + return Ok(()); + }; + if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { + config.service_tier = Some(service_tier); + return Ok(()); + } + + let model = config.model.clone().ok_or_else(|| { + "spawn_agent could not resolve the child model for service tier validation".to_string() + })?; + let model_info = session + .services + .models_manager + .get_model_info(model.as_str(), &config.to_models_manager_config()) + .await; + + config.service_tier = model_info + .supports_service_tier(service_tier.as_str()) + .then_some(service_tier); + Ok(()) +} + +async fn apply_spawn_agent_role( + session: &Session, + config: &mut Config, + role_name: Option<&str>, +) -> Result<(), String> { + let previous_model = config.model.clone(); + let previous_reasoning_effort = config.model_reasoning_effort.clone(); + apply_role_to_config(config, role_name).await?; + if config.model == previous_model && config.model_reasoning_effort == previous_reasoning_effort + { + return Ok(()); + } + + let Some(reasoning_effort) = config.model_reasoning_effort.clone() else { + return Ok(()); + }; + let model = config.model.clone().ok_or_else(|| { + "spawn_agent could not resolve the child model for reasoning effort validation".to_string() + })?; + let model_info = session + .services + .models_manager + .get_model_info(&model, &config.to_models_manager_config()) + .await; + if model_info.used_fallback_model_metadata { + return Ok(()); + } + + validate_spawn_agent_reasoning_effort( + &model, + &model_info.supported_reasoning_levels, + &reasoning_effort, + ) +} + +fn find_spawn_agent_model_name( + available_models: &[ModelPreset], + requested_model: &str, + multi_agent_version: MultiAgentVersion, +) -> Result { + available_models + .iter() + .find(|model| { + model.model == requested_model + && model_supports_multi_agent_backend(model, multi_agent_version) + }) + .map(|model| model.model.clone()) + .ok_or_else(|| { + let available = available_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) + .map(|model| model.model.as_str()) + .collect::>() + .join(", "); + 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<(), String> { + 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(format!( + "Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}" + )) +} diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3da7924509..77d4726de6 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -1,5 +1,6 @@ use super::residency::is_v2_resident_session_source; use super::*; +use crate::agent::child_config::build_agent_resume_config; use crate::agent::role::apply_role_to_config; use crate::codex_thread::CodexThread; use crate::config::PermissionProfileSnapshot; @@ -12,7 +13,6 @@ use crate::context::MultiAgentModeInstructions; use crate::context::MultiAgentRoleInstructions; use crate::context::world_state::PersistentModeState; use crate::session::multi_agents::resolve_usage_hints; -use crate::tools::handlers::multi_agents_common::build_agent_resume_config; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_extension_api::ExtensionDataInit; diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index 350962dc08..2cd8c08ac3 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod agent_resolver; +pub(crate) mod child_config; pub(crate) mod control; mod registry; pub(crate) mod role; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index e2fe5801d9..2b517f36f3 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agent::child_config::build_agent_resume_config; use crate::agent::next_thread_spawn_depth; use crate::session::session::Session; use crate::session::turn_context::TurnContext; @@ -194,7 +195,8 @@ async fn try_resume_closed_agent( receiver_thread_id: ThreadId, child_depth: i32, ) -> Result<(), FunctionCallError> { - let config = build_agent_resume_config(turn.as_ref())?; + let config = + build_agent_resume_config(turn.as_ref()).map_err(FunctionCallError::RespondToModel)?; Box::pin(session.services.agent_control.resume_agent_from_rollout( config, receiver_thread_id, diff --git a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs index 51b902c89c..1548d01bc8 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/send_input.rs @@ -1,4 +1,5 @@ use super::*; +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_tools::ToolSpec; @@ -51,7 +52,8 @@ impl Handler { .agent_control .get_agent_metadata(receiver_thread_id); if receiver_agent.is_some() { - let resume_config = build_agent_resume_config(turn.as_ref())?; + let resume_config = build_agent_resume_config(turn.as_ref()) + .map_err(FunctionCallError::RespondToModel)?; session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index a9615aeca8..73d63fab42 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -1,4 +1,7 @@ use super::*; +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::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::control::render_input_preview; @@ -89,25 +92,20 @@ async fn handle_spawn_agent( }), ) .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if args.fork_context { - reject_full_fork_agent_type_override(role_name)?; - } - apply_requested_spawn_agent_model_overrides( + let prepared = prepare_agent_spawn_config( &session, turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort.clone(), + SpawnConfigOptions { + version: SpawnConfigVersion::V1, + full_history_fork: args.fork_context, + role_name, + model: args.model.as_deref(), + reasoning_effort: args.reasoning_effort.clone(), + }, ) - .await?; - if !args.fork_context { - apply_spawn_agent_role(&session, &mut config, role_name).await?; - } - apply_spawn_agent_service_tier(&session, &mut config).await?; - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - + .await + .map_err(FunctionCallError::RespondToModel)?; + let config = prepared.config; let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( config, input_items, @@ -115,7 +113,7 @@ async fn handle_spawn_agent( session.thread_id, &turn.session_source, child_depth, - role_name, + prepared.role_name.as_deref(), /*task_name*/ None, )?), SpawnAgentOptions { diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index 9cbe72df88..715a02be4c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -1,25 +1,14 @@ -use crate::agent::role::apply_role_to_config; -use crate::config::Config; 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::session::session::Session; -use crate::session::turn_context::TurnContext; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; -use codex_models_manager::manager::RefreshStrategy; use codex_protocol::AgentPath; use codex_protocol::ThreadId; -use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::error::CodexErr; use codex_protocol::error::CodexErrorDetails; -use codex_protocol::models::BaseInstructions; use codex_protocol::models::ResponseInputItem; -use codex_protocol::openai_models::ModelPreset; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::ReasoningEffortPreset; -use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::user_input::UserInput; @@ -30,16 +19,6 @@ use serde_json::Value as JsonValue; 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) const MAX_SPAWN_AGENT_MODEL_OVERRIDES: usize = 5; - -pub(crate) fn model_supports_multi_agent_backend( - model: &ModelPreset, - multi_agent_version: MultiAgentVersion, -) -> bool { - multi_agent_version != MultiAgentVersion::V2 - || model.multi_agent_version != Some(MultiAgentVersion::Disabled) -} - pub(crate) fn function_arguments(payload: ToolPayload) -> Result { match payload { ToolPayload::Function { arguments } => Ok(arguments), @@ -166,277 +145,3 @@ pub(crate) fn parse_collab_input( } } } - -/// Builds the base config snapshot for a newly spawned sub-agent. -/// -/// The returned config starts from the parent's effective config and then refreshes the -/// runtime-owned fields carried by the turn, including model selection, reasoning settings, -/// approval policy, sandbox, and cwd. Role-specific overrides are layered -/// after this step; skipping this helper and cloning stale config state directly can send the child -/// agent out with the wrong provider or runtime policy. -pub(crate) fn build_agent_spawn_config( - base_instructions: &BaseInstructions, - turn: &TurnContext, -) -> Result { - let mut config = build_agent_shared_config(turn)?; - config.base_instructions = Some(base_instructions.text.clone()); - config.base_instructions_provenance = base_instructions.provenance.clone(); - Ok(config) -} - -pub(crate) fn build_agent_resume_config(turn: &TurnContext) -> Result { - let mut config = build_agent_shared_config(turn)?; - // For resume, keep base instructions sourced from rollout/session metadata. - config.base_instructions = None; - config.base_instructions_provenance = None; - Ok(config) -} - -fn build_agent_shared_config(turn: &TurnContext) -> Result { - let base_config = turn.config.clone(); - let mut config = (*base_config).clone(); - // Preserve activation for history forks without freezing the parent's model-owned prompts. - // Fresh child startup restores configured preferences from the retained snapshot. - config.token_budget = turn.configured_token_budget.clone(); - config.model = Some(turn.model_info().slug.clone()); - config.model_provider = turn.provider.info().clone(); - config.model_reasoning_effort = turn - .reasoning_effort() - .or(turn.model_info().default_reasoning_level.as_ref()) - .cloned(); - config.model_reasoning_summary = Some(turn.reasoning_summary()); - config.developer_instructions = turn.developer_instructions.clone(); - if turn.multi_agent_version == MultiAgentVersion::V2 - && let Some(developer_instructions) = turn - .config - .multi_agent_v2 - .subagent_developer_instructions - .clone() - { - config.developer_instructions = Some(developer_instructions); - } - apply_spawn_agent_runtime_overrides(&mut config, turn)?; - - Ok(config) -} - -pub(crate) fn reject_full_fork_agent_type_override( - agent_type: Option<&str>, -) -> Result<(), FunctionCallError> { - if agent_type.is_some() { - return Err(FunctionCallError::RespondToModel( - "Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(), - )); - } - Ok(()) -} - -/// Copies runtime-only turn state onto a child config before it is handed to `AgentControl`. -/// -/// These values are chosen by the live turn rather than persisted config, so leaving them stale can -/// make a child agent disagree with its parent about approval policy, cwd, or sandboxing. -pub(crate) fn apply_spawn_agent_runtime_overrides( - config: &mut Config, - turn: &TurnContext, -) -> Result<(), FunctionCallError> { - config - .permissions - .approval_policy - .set(turn.approval_policy()) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("approval_policy is invalid: {err}")) - })?; - config.approvals_reviewer = turn.config.approvals_reviewer; - #[allow(deprecated)] - let turn_cwd = turn.cwd.clone(); - config.cwd = turn_cwd; - config - .permissions - .set_permission_profile_from_session_snapshot( - turn.config - .permissions - .permission_profile_state() - .snapshot(), - ) - .map_err(|err| { - FunctionCallError::RespondToModel(format!("permission_profile is invalid: {err}")) - })?; - Ok(()) -} - -pub(crate) 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> { - let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref()); - let requested_reasoning_effort = requested_reasoning_effort - .or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone()); - 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, config.http_client_factory()) - .await; - let selected_model_name = find_spawn_agent_model_name( - &available_models, - requested_model, - turn.multi_agent_version, - )?; - let selected_model_info = session - .services - .models_manager - .get_model_info(&selected_model_name, &config.to_models_manager_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(()) -} - -pub(crate) async fn apply_spawn_agent_service_tier( - session: &Session, - config: &mut Config, -) -> Result<(), FunctionCallError> { - let Some(service_tier) = session.services.agent_control.root_service_tier() else { - config.service_tier = None; - return Ok(()); - }; - if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE { - config.service_tier = Some(service_tier); - return Ok(()); - } - - let model = config.model.clone().ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawn_agent could not resolve the child model for service tier validation".to_string(), - ) - })?; - let model_info = session - .services - .models_manager - .get_model_info(model.as_str(), &config.to_models_manager_config()) - .await; - - config.service_tier = model_info - .supports_service_tier(service_tier.as_str()) - .then_some(service_tier); - Ok(()) -} - -pub(crate) async fn apply_spawn_agent_role( - session: &Session, - config: &mut Config, - role_name: Option<&str>, -) -> Result<(), FunctionCallError> { - let previous_model = config.model.clone(); - let previous_reasoning_effort = config.model_reasoning_effort.clone(); - apply_role_to_config(config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - if config.model == previous_model && config.model_reasoning_effort == previous_reasoning_effort - { - return Ok(()); - } - - let Some(reasoning_effort) = config.model_reasoning_effort.clone() else { - return Ok(()); - }; - let model = config.model.clone().ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawn_agent could not resolve the child model for reasoning effort validation" - .to_string(), - ) - })?; - let model_info = session - .services - .models_manager - .get_model_info(&model, &config.to_models_manager_config()) - .await; - if model_info.used_fallback_model_metadata { - return Ok(()); - } - - validate_spawn_agent_reasoning_effort( - &model, - &model_info.supported_reasoning_levels, - &reasoning_effort, - ) -} - -fn find_spawn_agent_model_name( - available_models: &[ModelPreset], - requested_model: &str, - multi_agent_version: MultiAgentVersion, -) -> Result { - available_models - .iter() - .find(|model| { - model.model == requested_model - && model_supports_multi_agent_backend(model, multi_agent_version) - }) - .map(|model| model.model.clone()) - .ok_or_else(|| { - let available = available_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) - .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}" - ))) -} 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 2e11d899b7..4361161812 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -1,5 +1,5 @@ -use super::multi_agents_common::MAX_SPAWN_AGENT_MODEL_OVERRIDES; -use super::multi_agents_common::model_supports_multi_agent_backend; +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; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 1f34d69085..75b57dfe1e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1,6 +1,9 @@ use super::*; use crate::StartThreadOptions; use crate::ThreadManager; +use crate::agent::child_config::apply_spawn_agent_service_tier; +use crate::agent::child_config::build_agent_resume_config; +use crate::agent::child_config::build_agent_spawn_config; use crate::config::AgentRoleConfig; use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::PermissionProfileSnapshot; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index a88c765125..1c7e9c1e99 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -5,6 +5,7 @@ use super::analytics::ToolCallAnalytics; use super::*; +use crate::agent::child_config::build_agent_resume_config; use crate::agent_communication::AgentCommunicationContext; use crate::agent_communication::AgentCommunicationKind; use crate::tools::context::FunctionToolOutput; @@ -85,7 +86,8 @@ pub(super) async fn handle_message_string_tool( let receiver_agent_path = receiver_agent.agent_path.clone().ok_or_else(|| { FunctionCallError::RespondToModel("target agent is missing an agent_path".to_string()) })?; - let resume_config = build_agent_resume_config(turn.as_ref())?; + let resume_config = + build_agent_resume_config(turn.as_ref()).map_err(FunctionCallError::RespondToModel)?; session .services .agent_control 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 41d394243f..9baf45b8ff 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 @@ -1,4 +1,7 @@ use super::*; +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::SpawnAgentForkMode; use crate::agent::control::SpawnAgentOptions; use crate::agent::next_thread_spawn_depth; @@ -122,42 +125,26 @@ async fn handle_spawn_agent( let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)); - apply_requested_spawn_agent_model_overrides( + let prepared = prepare_agent_spawn_config( &session, turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort.clone(), + 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?; - if !is_full_history_fork || role_name.is_some() { - apply_spawn_agent_role(&session, &mut config, role_name).await?; - if is_full_history_fork && config.developer_instructions.is_none() { - config - .developer_instructions - .clone_from(&turn.developer_instructions); - } - } - apply_spawn_agent_service_tier(&session, &mut config).await?; - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - - // Remember an applied configured default so cold reload reapplies its restrictions. - let persisted_role_name = role_name.or_else(|| { - (!is_full_history_fork - && config - .agent_roles - .get(DEFAULT_ROLE_NAME) - .is_some_and(|role| role.config_file.is_some())) - .then_some(DEFAULT_ROLE_NAME) - }); + .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, - persisted_role_name, + prepared.role_name.as_deref(), Some(args.task_name.clone()), )?; let new_agent_path = spawn_source.get_agent_path().ok_or_else(|| {