From 6d4d9442c7142c08ac5c5098dfd6e82d8cd9f65a Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 4 Aug 2026 12:31:18 +0000 Subject: [PATCH] Support leaf models in multi-agent v2 (#36892) ## What changed - Allow multi-agent v2 parents to spawn any visible model that has not explicitly disabled multi-agent support. - Expose collaboration tools to child agents only when their selected model supports multi-agent v2, keeping legacy models as leaf workers. - Preserve a worker's selected model when reloading it into residency. - Propagate multi-agent capability metadata to Amazon Bedrock model entries so delegation is gated consistently. ## Testing - Cover model selection, leaf-worker tool visibility, Bedrock capability handling, and model preservation after reload. GitOrigin-RevId: 1457adb1a09806d1f0621311d5a42a6815b9dd4e --- codex-rs/core/src/agent/control/spawn.rs | 4 ++ codex-rs/core/src/agent/control_tests.rs | 12 ++++- .../src/tools/handlers/multi_agents_common.rs | 2 +- .../tools/handlers/multi_agents_spec_tests.rs | 14 +++-- .../src/tools/handlers/multi_agents_tests.rs | 35 ------------ codex-rs/core/src/tools/spec_plan.rs | 5 +- codex-rs/core/src/tools/spec_plan_tests.rs | 53 +++++++++++++++++++ codex-rs/core/tests/suite/agent_execution.rs | 24 +++++---- .../core/tests/suite/multi_agent_resume.rs | 2 +- .../tests/suite/subagent_notifications.rs | 39 +++++++++++--- .../src/amazon_bedrock/catalog.rs | 2 + 11 files changed, 131 insertions(+), 61 deletions(-) diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 8645522497..4d057a42b5 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -268,6 +268,7 @@ impl AgentControl { include_history: false, }) .await?; + let stored_model = stored_thread.model.clone(); let stored_source = stored_thread.source.clone(); let stored_parent_thread_id = stored_thread.parent_thread_id; let history = load_agent_model_context(&state, thread_id, stored_thread.history_mode) @@ -320,6 +321,9 @@ impl AgentControl { CodexErr::InvalidRequest(format!("permission_profile is invalid: {err}")) })?; } + if let Some(model) = stored_model { + config.model = Some(model); + } let residency_slot = self .reserve_v2_residency_slot(&state, &config, Some(thread_id)) .await?; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 6f4cf55915..75ad1e7786 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -649,13 +649,16 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { let (home, mut config) = test_config().await; let _ = config.features.enable(Feature::MultiAgentV2); let _ = config.features.enable(Feature::Sqlite); + config.model = Some("gpt-5.6-sol".to_string()); let harness = AgentControlHarness::new_with_config(home, config).await; let (parent_thread_id, _parent_thread) = harness.start_paginated_thread().await; let agent_path = AgentPath::try_from("/root/worker").expect("agent path"); + let mut child_config = harness.config.clone(); + child_config.model = Some("gpt-5.6-luna".to_string()); let spawned_agent = harness .control .spawn_agent_with_metadata( - harness.config.clone(), + child_config, text_input("hello child"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -715,11 +718,16 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() { .ensure_v2_agent_loaded(harness.config.clone(), spawned_agent.thread_id) .await .expect("known v2 agent should reload"); - let _ = harness + let reloaded_child = harness .manager .get_thread(spawned_agent.thread_id) .await .expect("reloaded child thread should exist"); + assert_eq!( + reloaded_child.config_snapshot().await.model, + "gpt-5.6-luna", + "residency reload must preserve the worker model instead of inheriting its parent model", + ); let communication = InterAgentCommunication::new( AgentPath::root(), 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 f8569c441e..5b7eebae1b 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -37,7 +37,7 @@ pub(crate) fn model_supports_multi_agent_backend( multi_agent_version: MultiAgentVersion, ) -> bool { multi_agent_version != MultiAgentVersion::V2 - || model.multi_agent_version == Some(multi_agent_version) + || model.multi_agent_version != Some(MultiAgentVersion::Disabled) } pub(crate) fn function_arguments(payload: ToolPayload) -> Result { 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 0ea5775db2..13ff55db3e 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 @@ -39,13 +39,16 @@ 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 mut legacy = model_preset("legacy", /*show_in_picker*/ true); + 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), - incompatible, + legacy, + disabled, ], agent_type_description: "role help".to_string(), expose_agent_type: true, @@ -83,8 +86,11 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { assert!(description.contains( "- `visible-model`: visible description Reasoning efforts: medium (default). Service tiers: priority." )); + assert!(description.contains( + "- `legacy-model`: legacy description Reasoning efforts: medium (default). Service tiers: priority." + )); assert!(!description.contains("hidden-model")); - assert!(!description.contains("incompatible-model")); + assert!(!description.contains("disabled-model")); assert!(properties.contains_key("task_name")); assert!(properties.contains_key("message")); assert_eq!( 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 1a58b77c56..d322e02330 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -391,41 +391,6 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() { ); } -#[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 20ef7a1249..a76c58fa41 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -482,7 +482,10 @@ fn collab_tools_enabled(turn_context: &TurnContext) -> bool { next_thread_spawn_depth(&turn_context.session_source), turn_context.config.agent_max_depth, ), - MultiAgentVersion::V2 => true, + MultiAgentVersion::V2 => { + turn_context.session_source.get_agent_path().is_none() + || turn_context.model_info.multi_agent_version == Some(MultiAgentVersion::V2) + } } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 4bd8244ae7..7a2a5ccc48 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -6,8 +6,13 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_mcp::ToolInfo; use codex_model_provider::create_model_provider; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_5_MODEL_ID; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_6_LUNA_MODEL_ID; +use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID; use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::AgentPath; +use codex_protocol::ThreadId; use codex_protocol::config_types::WebSearchMode; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::openai_models::ApplyPatchToolType; @@ -15,6 +20,9 @@ use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ToolMode; use codex_protocol::openai_models::WebSearchToolType; +use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use codex_tools::DiscoverablePluginInfo; use codex_tools::DiscoverableTool; use codex_tools::ResponsesApiNamespaceTool; @@ -2051,6 +2059,51 @@ async fn multi_agent_v2_namespace_is_supported_by_bedrock_provider() { ); } +#[tokio::test] +async fn multi_agent_v2_bedrock_workers_only_delegate_when_model_supports_v2() { + for (model, model_multi_agent_version, supports_delegation) in [ + ( + AMAZON_BEDROCK_GPT_5_6_SOL_MODEL_ID, + Some(MultiAgentVersion::V2), + true, + ), + ( + AMAZON_BEDROCK_GPT_5_6_LUNA_MODEL_ID, + Some(MultiAgentVersion::V1), + false, + ), + (AMAZON_BEDROCK_GPT_5_5_MODEL_ID, None, false), + ] { + let plan = probe(|turn| { + set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); + update_config(turn, |config| { + config.multi_agent_v2.tool_namespace = Some("agents".to_string()); + }); + use_bedrock_provider(turn); + turn.model_info.slug = model.to_string(); + turn.model_info.multi_agent_version = model_multi_agent_version; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::new(), + depth: 1, + agent_path: Some(AgentPath::try_from("/root/worker").expect("valid agent path")), + agent_nickname: None, + agent_role: None, + }); + }) + .await; + + let spawn_agent_name = ToolName::namespaced("agents", "spawn_agent").to_string(); + let followup_task_name = ToolName::namespaced("agents", "followup_task").to_string(); + if supports_delegation { + plan.assert_visible_contains(&["agents"]); + plan.assert_registered_contains(&[&spawn_agent_name, &followup_task_name]); + } else { + plan.assert_visible_lacks(&["agents"]); + plan.assert_registered_lacks(&[&spawn_agent_name, &followup_task_name]); + } + } +} + #[tokio::test] async fn code_mode_only_can_expose_namespaced_multi_agent_v2_as_normal_tools() { let plan = probe(|turn| { diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index 69db20cc57..4ca825f6ea 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -100,17 +100,19 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> ) .await; - let mut builder = test_codex().with_model("koffing").with_config(|config| { - config - .features - .enable(Feature::Collab) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config.multi_agent_v2.max_concurrent_threads_per_session = 2; - }); + let mut builder = test_codex() + .with_model("gpt-5.6-sol") + .with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + config.multi_agent_v2.max_concurrent_threads_per_session = 2; + }); let test = builder.build(&server).await?; test.submit_turn(FIRST_PROMPT).await?; diff --git a/codex-rs/core/tests/suite/multi_agent_resume.rs b/codex-rs/core/tests/suite/multi_agent_resume.rs index 0ee40d1d79..7a4c7c738c 100644 --- a/codex-rs/core/tests/suite/multi_agent_resume.rs +++ b/codex-rs/core/tests/suite/multi_agent_resume.rs @@ -47,7 +47,7 @@ const SIBLING_FOLLOWUP_TASK: &str = "verify the surviving worker"; const INTERRUPT_PROMPT: &str = "release the interrupted worker"; const SIBLING_NAME: &str = "survivor"; const ROLE_NAME: &str = "durable_worker"; -const ROLE_MODEL: &str = "gpt-5.4"; +const ROLE_MODEL: &str = "gpt-5.6-sol"; const ROLE_MODEL_PROVIDER_ID: &str = "mock"; const ROLE_DEVELOPER_INSTRUCTIONS: &str = "Keep the durable worker role configuration."; const SUBAGENT_DEVELOPER_INSTRUCTIONS: &str = "Use the default durable worker instructions."; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 78574ba7bc..899a325acf 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1400,10 +1400,15 @@ async fn spawned_multi_agent_v2_child_inherits_parent_developer_context() -> Res Ok(()) } -#[test_case(false; "encrypted")] -#[test_case(true; "plaintext")] +#[test_case(None, false; "encrypted")] +#[test_case(None, true; "plaintext")] +#[test_case(Some("gpt-5.6-luna"), false; "luna encrypted leaf")] +#[test_case(Some("gpt-5.5"), false; "legacy encrypted leaf")] #[tokio::test] -async fn multi_agent_v2_spawn_sends_agent_message_to_child(plaintext: bool) -> Result<()> { +async fn multi_agent_v2_spawn_sends_agent_message_to_child( + model: Option<&str>, + plaintext: bool, +) -> Result<()> { let output: &'static Mutex> = Box::leak(Box::new(Mutex::new(Vec::new()))); let subscriber = tracing_subscriber::fmt() .with_ansi(false) @@ -1418,10 +1423,17 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(plaintext: bool) -> R } else { "opaque-encrypted-message" }; - let spawn_args = serde_json::to_string(&json!({ + let mut spawn_args = json!({ "message": message, "task_name": "worker", - }))?; + }); + if let Some(model) = model { + spawn_args["model"] = json!(model); + if model == "gpt-5.5" { + spawn_args["fork_turns"] = json!("none"); + } + } + let spawn_args = serde_json::to_string(&spawn_args)?; let mut spawn_event = ev_function_call_with_namespace( SPAWN_CALL_ID, MULTI_AGENT_V2_NAMESPACE, @@ -1463,7 +1475,12 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(plaintext: bool) -> R ) .await; - let mut builder = test_codex().with_model("koffing").with_config(|config| { + let parent_model = if model.is_some() { + "gpt-5.6-sol" + } else { + "koffing" + }; + let mut builder = test_codex().with_model(parent_model).with_config(|config| { config .features .enable(Feature::Collab) @@ -1524,6 +1541,16 @@ async fn multi_agent_v2_spawn_sends_agent_message_to_child(plaintext: bool) -> R "content": content, })]) ); + if let Some(model) = model { + assert_eq!(child_request.body_json()["model"], json!(model)); + assert!( + !child_request + .body_json() + .to_string() + .contains("\"name\":\"collaboration\""), + "leaf workers must not receive collaboration tools", + ); + } if plaintext { assert!( parent_request_log.requests().into_iter().any(|request| { diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index e1ddfd8dd7..4014355b65 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -97,6 +97,7 @@ fn gpt_5_6_bedrock_model( ); model.description = openai_model.description; model.default_reasoning_level = openai_model.default_reasoning_level; + model.multi_agent_version = openai_model.multi_agent_version; model .supported_reasoning_levels .push(ReasoningEffortPreset { @@ -210,6 +211,7 @@ mod tests { expected.display_name = display_name.to_string(); expected.description = openai_model.description; expected.default_reasoning_level = openai_model.default_reasoning_level; + expected.multi_agent_version = openai_model.multi_agent_version; expected.priority = priority; expected .supported_reasoning_levels