codex: select multi-agent version from model info

This commit is contained in:
Ahmed Ibrahim
2026-06-01 13:33:00 -07:00
parent 5125a231c6
commit 65651707f3
14 changed files with 194 additions and 0 deletions
@@ -54,6 +54,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}
@@ -100,6 +100,7 @@ async fn models_client_hits_models_endpoint() {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}],
};
+11
View File
@@ -428,11 +428,20 @@ pub(crate) struct CodexSpawnArgs {
fn resolve_multi_agent_version(
conversation_history: &InitialHistory,
inherited_multi_agent_version: Option<MultiAgentVersion>,
session_source: &SessionSource,
model_info: &ModelInfo,
config: &Config,
) -> Option<MultiAgentVersion> {
conversation_history
.get_multi_agent_version()
.or(inherited_multi_agent_version)
.or_else(|| {
if session_source.is_non_root_agent() {
None
} else {
model_info.multi_agent_version
}
})
.or_else(|| config.multi_agent_version_from_features())
}
@@ -554,6 +563,8 @@ impl Codex {
let multi_agent_version = resolve_multi_agent_version(
&conversation_history,
inherited_multi_agent_version,
&session_source,
&model_info,
&config,
);
let _ = config
@@ -1,4 +1,5 @@
use anyhow::Result;
use anyhow::bail;
use codex_core::config::Config;
use codex_features::Feature;
use codex_login::CodexAuth;
@@ -11,15 +12,18 @@ use codex_protocol::openai_models::ModelVisibility;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ToolMode;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call;
use core_test_support::responses::ev_response_created;
use core_test_support::responses::mount_models_once;
use core_test_support::responses::mount_sse_once;
use core_test_support::responses::mount_sse_once_match;
use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::submit_thread_settings;
@@ -27,9 +31,18 @@ use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::io::Cursor;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use wiremock::Request;
const CHILD_PROMPT: &str = "inspect the child runtime";
const CHILD_MODEL: &str = "test-multi-agent-child";
const ROOT_MODEL: &str = "test-multi-agent-root";
const ROOT_PROMPT: &str = "spawn a child";
const SPAWN_CALL_ID: &str = "spawn-call-1";
fn remote_model(slug: &str) -> ModelInfo {
ModelInfo {
@@ -39,6 +52,26 @@ fn remote_model(slug: &str) -> ModelInfo {
}
}
fn body_contains(req: &Request, text: &str) -> bool {
let is_zstd = req
.headers
.get("content-encoding")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim().eq_ignore_ascii_case("zstd"))
});
let bytes = if is_zstd {
zstd::stream::decode_all(Cursor::new(&req.body)).ok()
} else {
Some(req.body.clone())
};
bytes
.and_then(|body| String::from_utf8(body).ok())
.is_some_and(|body| body.contains(text))
}
fn tool_names(body: &Value) -> Vec<String> {
body.get("tools")
.and_then(Value::as_array)
@@ -171,3 +204,114 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn remote_multi_agent_selector_overrides_features_and_child_model_info() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = wiremock::MockServer::start().await;
let mut root_model = remote_model(ROOT_MODEL);
root_model.multi_agent_version = Some(MultiAgentVersion::V2);
let mut child_model = remote_model(CHILD_MODEL);
child_model.multi_agent_version = Some(MultiAgentVersion::V1);
let models_mock = mount_models_once(
&server,
ModelsResponse {
models: vec![root_model, child_model],
},
)
.await;
let spawn_args = serde_json::to_string(&json!({
"message": CHILD_PROMPT,
"task_name": "worker",
"model": CHILD_MODEL,
"fork_turns": "none",
}))?;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, ROOT_PROMPT),
sse(vec![
ev_response_created("resp-root-1"),
ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args),
ev_completed("resp-root-1"),
]),
)
.await;
mount_sse_once_match(
&server,
|req: &Request| body_contains(req, CHILD_PROMPT) && !body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-child-1"),
ev_assistant_message("msg-child-1", "child done"),
ev_completed("resp-child-1"),
]),
)
.await;
let root_followup_mock = mount_sse_once_match(
&server,
|req: &Request| body_contains(req, SPAWN_CALL_ID),
sse(vec![
ev_response_created("resp-root-2"),
ev_assistant_message("msg-root-2", "root done"),
ev_completed("resp-root-2"),
]),
)
.await;
let mut builder = test_codex()
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
.with_config(|config| {
config
.features
.enable(Feature::Collab)
.expect("test config should allow feature update");
config.model = Some(ROOT_MODEL.to_string());
});
let test = builder.build(&server).await?;
assert_eq!(
(
models_mock.requests().len(),
test.codex.multi_agent_version(),
),
(1, Some(MultiAgentVersion::V2))
);
test.submit_turn(ROOT_PROMPT).await?;
let deadline = Instant::now() + Duration::from_secs(2);
let child_id = loop {
if let Some(child_id) = test
.thread_manager
.list_thread_ids()
.await
.into_iter()
.find(|thread_id| *thread_id != test.session_configured.thread_id)
{
break child_id;
}
if Instant::now() >= deadline {
bail!(
"timed out waiting for spawn_agent to create a child thread: root lock {:?}, spawn output {:?}",
test.codex.multi_agent_version(),
root_followup_mock.function_call_output_text(SPAWN_CALL_ID),
);
}
sleep(Duration::from_millis(10)).await;
};
let child = test.thread_manager.get_thread(child_id).await?;
assert_eq!(
(
models_mock.requests().len(),
test.codex.multi_agent_version(),
child.config_snapshot().await.model,
child.multi_agent_version(),
),
(
1,
Some(MultiAgentVersion::V2),
CHILD_MODEL.to_string(),
Some(MultiAgentVersion::V2),
)
);
Ok(())
}
@@ -114,6 +114,7 @@ fn test_model_info(
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
@@ -933,6 +934,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
@@ -372,5 +372,6 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}
+2
View File
@@ -594,6 +594,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
};
let _models_mock = mount_models_once(
@@ -706,6 +707,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
};
let _models_mock = mount_models_once(
@@ -479,6 +479,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
@@ -730,6 +731,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
@@ -1215,6 +1217,7 @@ fn test_remote_model_with_policy(
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
+1
View File
@@ -1351,6 +1351,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}],
},
)
@@ -62,6 +62,7 @@ fn test_model_info(
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers,
+1
View File
@@ -1357,6 +1357,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
priority: 1,
additional_speed_tiers: Vec::new(),
service_tiers: Vec::new(),
@@ -101,6 +101,7 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}
+24
View File
@@ -22,6 +22,7 @@ use crate::config_types::ReasoningSummary;
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use crate::config_types::ServiceTier;
use crate::config_types::Verbosity;
use crate::protocol::MultiAgentVersion;
const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}";
pub const SPEED_TIER_FAST: &str = "fast";
@@ -347,6 +348,12 @@ pub struct ModelInfo {
deserialize_with = "deserialize_optional_model_selector"
)]
pub tool_mode: Option<ToolMode>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "deserialize_optional_model_selector"
)]
pub multi_agent_version: Option<MultiAgentVersion>,
}
impl ModelInfo {
@@ -643,6 +650,7 @@ mod tests {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}
@@ -901,6 +909,22 @@ mod tests {
assert!(!object.contains_key("tool_mode"));
}
#[test]
fn model_info_treats_unknown_multi_agent_version_as_omitted() {
let mut value =
serde_json::to_value(test_model(/*spec*/ None)).expect("serialize test model");
let object = value
.as_object_mut()
.expect("model info should be an object");
object.insert(
"multi_agent_version".to_string(),
serde_json::Value::String("future_multi_agent_version".to_string()),
);
let model = serde_json::from_value::<ModelInfo>(value).expect("deserialize model info");
assert_eq!(model.multi_agent_version, None);
}
#[test]
fn resolved_context_window_prefers_context_window() {
let model = ModelInfo {
+1
View File
@@ -46,6 +46,7 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo {
supports_search_tool: false,
auto_review_model_override: None,
tool_mode: None,
multi_agent_version: None,
}
}