mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
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:
@@ -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?;
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user