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
This commit is contained in:
jif
2026-08-04 12:36:14 +00:00
committed by copyberry
parent 1669c2403f
commit 6d4d9442c7
11 changed files with 131 additions and 61 deletions
+4
View File
@@ -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?;
+10 -2
View File
@@ -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(),
@@ -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<String, FunctionCallError> {
@@ -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!(
@@ -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)]
+4 -1
View File
@@ -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)
}
}
}
@@ -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| {
+13 -11
View File
@@ -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?;
@@ -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.";
@@ -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<Vec<u8>> = 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| {
@@ -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