mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Add spawn_agent model overrides (#14160)
- add `model` and `reasoning_effort` to the `spawn_agent` schema so the values pass through - validate requested models against `model.model` and only check that the selected model supports the requested reasoning effort --------- Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -13,6 +13,7 @@ use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -22,6 +23,8 @@ use crate::tools::registry::ToolKind;
|
||||
use async_trait::async_trait;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::openai_models::ReasoningEffortPreset;
|
||||
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
|
||||
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
|
||||
use codex_protocol::protocol::CollabAgentRef;
|
||||
@@ -113,6 +116,8 @@ mod spawn {
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
agent_type: Option<String>,
|
||||
model: Option<String>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
@@ -158,6 +163,14 @@ mod spawn {
|
||||
.await;
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort,
|
||||
)
|
||||
.await?;
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
@@ -963,6 +976,99 @@ fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_requested_spawn_agent_model_overrides(
|
||||
session: &Session,
|
||||
turn: &TurnContext,
|
||||
config: &mut Config,
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(requested_model) = requested_model {
|
||||
let available_models = session
|
||||
.services
|
||||
.models_manager
|
||||
.list_models(RefreshStrategy::Offline)
|
||||
.await;
|
||||
let selected_model_name = find_spawn_agent_model_name(&available_models, requested_model)?;
|
||||
let selected_model_info = session
|
||||
.services
|
||||
.models_manager
|
||||
.get_model_info(&selected_model_name, config)
|
||||
.await;
|
||||
|
||||
config.model = Some(selected_model_name.clone());
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&selected_model_name,
|
||||
&selected_model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
} else {
|
||||
config.model_reasoning_effort = selected_model_info.default_reasoning_level;
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(reasoning_effort) = requested_reasoning_effort {
|
||||
validate_spawn_agent_reasoning_effort(
|
||||
&turn.model_info.slug,
|
||||
&turn.model_info.supported_reasoning_levels,
|
||||
reasoning_effort,
|
||||
)?;
|
||||
config.model_reasoning_effort = Some(reasoning_effort);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_spawn_agent_model_name(
|
||||
available_models: &[codex_protocol::openai_models::ModelPreset],
|
||||
requested_model: &str,
|
||||
) -> Result<String, FunctionCallError> {
|
||||
available_models
|
||||
.iter()
|
||||
.find(|model| model.model == requested_model)
|
||||
.map(|model| model.model.clone())
|
||||
.ok_or_else(|| {
|
||||
let available = available_models
|
||||
.iter()
|
||||
.map(|model| model.model.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"Unknown model `{requested_model}` for spawn_agent. Available models: {available}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_spawn_agent_reasoning_effort(
|
||||
model: &str,
|
||||
supported_reasoning_levels: &[ReasoningEffortPreset],
|
||||
requested_reasoning_effort: ReasoningEffort,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == requested_reasoning_effort)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let supported = supported_reasoning_levels
|
||||
.iter()
|
||||
.map(|preset| preset.effort.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Err(FunctionCallError::RespondToModel(format!(
|
||||
"Reasoning effort `{requested_reasoning_effort}` is not supported for model `{model}`. Supported reasoning efforts: {supported}"
|
||||
)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -791,6 +791,24 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"model".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional model override for the new agent. Replaces the inherited model."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"reasoning_effort".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Optional reasoning effort override for the new agent. Replaces the inherited reasoning effort."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::ThreadConfigSnapshot;
|
||||
use codex_core::config::AgentRoleConfig;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -13,6 +17,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
@@ -25,6 +30,12 @@ const TURN_0_FORK_PROMPT: &str = "seed fork context";
|
||||
const TURN_1_PROMPT: &str = "spawn a child and continue";
|
||||
const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait";
|
||||
const CHILD_PROMPT: &str = "child: do work";
|
||||
const INHERITED_MODEL: &str = "gpt-5.2-codex";
|
||||
const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh;
|
||||
const REQUESTED_MODEL: &str = "gpt-5.1";
|
||||
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const ROLE_MODEL: &str = "gpt-5.1-codex-max";
|
||||
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
|
||||
fn body_contains(req: &wiremock::Request, text: &str) -> bool {
|
||||
let is_zstd = req
|
||||
@@ -89,9 +100,28 @@ async fn setup_turn_one_with_spawned_child(
|
||||
server: &MockServer,
|
||||
child_response_delay: Option<Duration>,
|
||||
) -> Result<(TestCodex, String)> {
|
||||
let spawn_args = serde_json::to_string(&json!({
|
||||
"message": CHILD_PROMPT,
|
||||
}))?;
|
||||
setup_turn_one_with_custom_spawned_child(
|
||||
server,
|
||||
json!({
|
||||
"message": CHILD_PROMPT,
|
||||
}),
|
||||
child_response_delay,
|
||||
true,
|
||||
|builder| builder,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn setup_turn_one_with_custom_spawned_child(
|
||||
server: &MockServer,
|
||||
spawn_args: serde_json::Value,
|
||||
child_response_delay: Option<Duration>,
|
||||
wait_for_parent_notification: bool,
|
||||
configure_test: impl FnOnce(
|
||||
core_test_support::test_codex::TestCodexBuilder,
|
||||
) -> core_test_support::test_codex::TestCodexBuilder,
|
||||
) -> Result<(TestCodex, String)> {
|
||||
let spawn_args = serde_json::to_string(&spawn_args)?;
|
||||
|
||||
mount_sse_once_match(
|
||||
server,
|
||||
@@ -141,15 +171,17 @@ async fn setup_turn_one_with_spawned_child(
|
||||
.await;
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
let mut builder = test_codex().with_config(|config| {
|
||||
let mut builder = configure_test(test_codex().with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Collab)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
config.model = Some(INHERITED_MODEL.to_string());
|
||||
config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT);
|
||||
}));
|
||||
let test = builder.build(server).await?;
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
if child_response_delay.is_none() {
|
||||
if child_response_delay.is_none() && wait_for_parent_notification {
|
||||
let _ = wait_for_requests(&child_request_log).await?;
|
||||
let rollout_path = test
|
||||
.codex
|
||||
@@ -176,6 +208,25 @@ async fn setup_turn_one_with_spawned_child(
|
||||
Ok((test, spawned_id))
|
||||
}
|
||||
|
||||
async fn spawn_child_and_capture_snapshot(
|
||||
server: &MockServer,
|
||||
spawn_args: serde_json::Value,
|
||||
configure_test: impl FnOnce(
|
||||
core_test_support::test_codex::TestCodexBuilder,
|
||||
) -> core_test_support::test_codex::TestCodexBuilder,
|
||||
) -> Result<ThreadConfigSnapshot> {
|
||||
let (test, spawned_id) =
|
||||
setup_turn_one_with_custom_spawned_child(server, spawn_args, None, false, configure_test)
|
||||
.await?;
|
||||
let thread_id = ThreadId::from_string(&spawned_id)?;
|
||||
Ok(test
|
||||
.thread_manager
|
||||
.get_thread(thread_id)
|
||||
.await?
|
||||
.config_snapshot()
|
||||
.await)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn subagent_notification_is_included_without_wait() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -316,3 +367,71 @@ async fn spawned_child_receives_forked_parent_context() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_without_role()
|
||||
-> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let child_snapshot = spawn_child_and_capture_snapshot(
|
||||
&server,
|
||||
json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"model": REQUESTED_MODEL,
|
||||
"reasoning_effort": REQUESTED_REASONING_EFFORT,
|
||||
}),
|
||||
|builder| builder,
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(child_snapshot.model, REQUESTED_MODEL);
|
||||
assert_eq!(
|
||||
child_snapshot.reasoning_effort,
|
||||
Some(REQUESTED_REASONING_EFFORT)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_role_overrides_requested_model_and_reasoning_settings() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let child_snapshot = spawn_child_and_capture_snapshot(
|
||||
&server,
|
||||
json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"agent_type": "custom",
|
||||
"model": REQUESTED_MODEL,
|
||||
"reasoning_effort": REQUESTED_REASONING_EFFORT,
|
||||
}),
|
||||
|builder| {
|
||||
builder.with_config(|config| {
|
||||
let role_path = config.codex_home.join("custom-role.toml");
|
||||
std::fs::write(
|
||||
&role_path,
|
||||
format!(
|
||||
"model = \"{ROLE_MODEL}\"\nmodel_reasoning_effort = \"{ROLE_REASONING_EFFORT}\"\n",
|
||||
),
|
||||
)
|
||||
.expect("write role config");
|
||||
config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: Some("Custom role".to_string()),
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
assert_eq!(child_snapshot.model, ROLE_MODEL);
|
||||
assert_eq!(child_snapshot.reasoning_effort, Some(ROLE_REASONING_EFFORT));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user