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
@@ -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| {