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 f0ebbd7369..0d50798d07 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -13,8 +13,10 @@ use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; 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; @@ -25,6 +27,15 @@ 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(multi_agent_version) +} pub(crate) fn function_arguments(payload: ToolPayload) -> Result { match payload { @@ -248,7 +259,11 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides( .models_manager .list_models(RefreshStrategy::Offline, config.http_client_factory()) .await; - let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?; + 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 @@ -338,16 +353,23 @@ pub(crate) async fn apply_spawn_agent_service_tier( } fn find_spawn_agent_model_name( - available_models: &[codex_protocol::openai_models::ModelPreset], + available_models: &[ModelPreset], requested_model: &str, + multi_agent_version: MultiAgentVersion, ) -> Result { available_models .iter() - .find(|model| model.model == requested_model) + .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(", "); 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 447e0136f0..88fa4daefb 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -1,4 +1,7 @@ +use super::multi_agents_common::MAX_SPAWN_AGENT_MODEL_OVERRIDES; +use super::multi_agents_common::model_supports_multi_agent_backend; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::protocol::MultiAgentVersion; use codex_tools::JsonSchema; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; @@ -16,18 +19,31 @@ const SPAWN_AGENT_MODEL_OVERRIDE_DESCRIPTION: &str = "Model override for the new agent. Omit unless an explicit override is needed."; const SPAWN_AGENT_SERVICE_TIER_OVERRIDE_DESCRIPTION: &str = "Service tier override for the new agent. Omit unless explicitly requested."; -const MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION: usize = 5; const MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION: usize = 64; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] 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 multi_agent_version: MultiAgentVersion, pub usage_hint_text: Option, } +impl Default for SpawnAgentToolOptions { + fn default() -> Self { + Self { + available_models: Vec::new(), + agent_type_description: String::new(), + hide_agent_type_model_reasoning: false, + expose_spawn_agent_model_overrides: false, + multi_agent_version: MultiAgentVersion::Disabled, + usage_hint_text: None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct WaitAgentTimeoutOptions { pub default_timeout_ms: i64, @@ -46,8 +62,9 @@ impl Default for WaitAgentTimeoutOptions { } pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { - let available_models_description = (!options.hide_agent_type_model_reasoning) - .then(|| spawn_agent_models_description(&options.available_models)); + let available_models_description = (!options.hide_agent_type_model_reasoning).then(|| { + spawn_agent_models_description(&options.available_models, options.multi_agent_version) + }); let inherited_model_guidance = (!options.hide_agent_type_model_reasoning).then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); let return_value_description = @@ -77,9 +94,9 @@ 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 - .expose_spawn_agent_model_overrides - .then(|| spawn_agent_models_description(&options.available_models)); + let available_models_description = options.expose_spawn_agent_model_overrides.then(|| { + spawn_agent_models_description(&options.available_models, options.multi_agent_version) + }); let inherited_model_guidance = (options.expose_spawn_agent_model_overrides && !options.hide_agent_type_model_reasoning) .then_some(SPAWN_AGENT_INHERITED_MODEL_GUIDANCE); @@ -748,11 +765,15 @@ Note that passing `fork_turns="none"` will not pass any surrounding context to t tool_description } -fn spawn_agent_models_description(models: &[ModelPreset]) -> String { +fn spawn_agent_models_description( + models: &[ModelPreset], + multi_agent_version: MultiAgentVersion, +) -> String { let visible_models: Vec<&ModelPreset> = models .iter() .filter(|model| model.show_in_picker) - .take(MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT_DESCRIPTION) + .filter(|model| model_supports_multi_agent_backend(model, multi_agent_version)) + .take(MAX_SPAWN_AGENT_MODEL_OVERRIDES) .collect(); if visible_models.is_empty() { return "No picker-visible model overrides are currently loaded.".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 e31e6bed1c..065c4742e9 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 @@ -30,6 +30,7 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { is_default: false, upgrade: None, show_in_picker, + multi_agent_version: Some(MultiAgentVersion::V2), availability_nux: None, supported_in_api: true, input_modalities: Vec::new(), @@ -38,14 +39,18 @@ fn model_preset(id: &str, show_in_picker: bool) -> ModelPreset { #[test] fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { + let mut incompatible = model_preset("incompatible", /*show_in_picker*/ true); + incompatible.multi_agent_version = Some(MultiAgentVersion::V1); 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), + incompatible, ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, usage_hint_text: None, }); @@ -78,6 +83,7 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { "- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority." )); assert!(!description.contains("hidden-model")); + assert!(!description.contains("incompatible-model")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); assert_eq!( @@ -128,6 +134,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V1, usage_hint_text: None, }); @@ -185,6 +192,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, + multi_agent_version: MultiAgentVersion::V2, usage_hint_text: None, }); @@ -214,7 +222,7 @@ fn spawn_agent_tool_caps_reasoning_effort_value_length() { }]; assert_eq!( - spawn_agent_models_description(&[model]), + spawn_agent_models_description(&[model], MultiAgentVersion::V2), format!( "Available model overrides (optional; inherited parent model is preferred):\n- `visible-model`: visible description Reasoning efforts: {} (default). Service tiers: priority.", "é".repeat(MAX_REASONING_EFFORT_CHARS_IN_SPAWN_AGENT_DESCRIPTION) @@ -229,6 +237,7 @@ fn spawn_agent_tool_keeps_model_controls_when_spawn_metadata_is_hidden() { agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: true, expose_spawn_agent_model_overrides: true, + multi_agent_version: MultiAgentVersion::V2, usage_hint_text: None, }); @@ -260,6 +269,7 @@ fn spawn_agent_tool_hides_model_controls_without_override_exposure() { agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: true, expose_spawn_agent_model_overrides: false, + multi_agent_version: MultiAgentVersion::V2, usage_hint_text: None, }); 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 2dcf864e0b..a232bd729b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -467,6 +467,41 @@ async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_over ); } +#[tokio::test] +async fn multi_agent_v2_spawn_rejects_child_model_from_different_backend() { + let (session, mut turn) = make_session_and_context().await; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + set_turn_config(&mut turn, config); + + let err = SpawnAgentHandlerV2::default() + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "incompatible_model", + "model": "gpt-5.4", + "fork_turns": "none" + })), + )) + .await + .err() + .expect("model from a different multi-agent backend should be rejected"); + + assert_eq!( + err, + FunctionCallError::RespondToModel( + "Unknown model `gpt-5.4` for spawn_agent. Available models: gpt-5.6-sol, gpt-5.6-terra" + .to_string() + ) + ); +} + #[tokio::test] async fn spawn_agent_service_tier_override_validates_the_effective_child_model() { #[derive(Debug, Deserialize)] diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 6216edbc3b..e64c63b843 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -787,6 +787,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .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(), }), tool_namespace, @@ -830,6 +831,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu agent_type_description, hide_agent_type_model_reasoning: false, expose_spawn_agent_model_overrides: true, + multi_agent_version: turn_context.multi_agent_version, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), exposure, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 75de5245bd..24b0e53775 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -230,6 +230,11 @@ pub struct ModelPreset { pub upgrade: Option, /// Whether this preset should appear in the picker UI. pub show_in_picker: bool, + /// Multi-agent backend selected when this model starts a new thread. + #[serde(default, skip_serializing, skip_deserializing)] + #[schemars(skip)] + #[ts(skip)] + pub multi_agent_version: Option, /// Availability NUX shown when this preset becomes accessible to the user. pub availability_nux: Option, /// whether this model is supported in the api @@ -606,6 +611,7 @@ impl From for ModelPreset { migration_markdown: Some(upgrade.migration_markdown.clone()), }), show_in_picker: info.visibility == ModelVisibility::List, + multi_agent_version: info.multi_agent_version, availability_nux: info.availability_nux, supported_in_api: info.supported_in_api, input_modalities: info.input_modalities, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1afa758908..3e365a9a9b 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -1272,6 +1272,7 @@ fn model_preset_from_api_model(model: ApiModel) -> ModelPreset { is_default: model.is_default, upgrade, show_in_picker: !model.hidden, + multi_agent_version: None, availability_nux: model.availability_nux.map(|nux| ModelAvailabilityNux { message: nux.message, }), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 72daaeeadb..d76286b830 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3128,6 +3128,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() { is_default: false, upgrade: None, show_in_picker, + multi_agent_version: None, availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(), @@ -3623,6 +3624,7 @@ async fn single_reasoning_option_skips_selection() { is_default: false, upgrade: None, show_in_picker: true, + multi_agent_version: None, availability_nux: None, supported_in_api: true, input_modalities: default_input_modalities(),