mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Honor configured model defaults for spawned agents (#33631)
## What changed - Apply `agents.default_subagent_model` and `agents.default_subagent_reasoning_effort` when a spawn request does not provide explicit values, including agent job workers. - Allow full-history forks to use configured defaults or explicit model and reasoning-effort overrides while retaining the parent conversation context. - Preserve the selected model and reasoning effort when an agent role does not override them. ## Testing - Cover configured defaults, explicit override precedence, full-history forks, role application, and agent job workers. GitOrigin-RevId: b1efa8c9abeee82355088fceef57560433144595
This commit is contained in:
@@ -688,9 +688,9 @@ pub struct AgentsToml {
|
||||
pub max_concurrent_threads_per_session: Option<usize>,
|
||||
/// Maximum nesting depth for V1 agent threads. Ignored by V2.
|
||||
pub max_depth: Option<i32>,
|
||||
/// Reserved default model for spawned subagents. Currently ignored.
|
||||
/// Default model for spawned subagents when the spawn call does not select one.
|
||||
pub default_subagent_model: Option<String>,
|
||||
/// Reserved default reasoning effort for spawned subagents. Currently ignored.
|
||||
/// Default reasoning effort for spawned subagents when the spawn call does not select one.
|
||||
pub default_subagent_reasoning_effort: Option<ReasoningEffort>,
|
||||
/// Default maximum runtime in seconds for agent job workers.
|
||||
#[schemars(range(min = 1))]
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"properties": {
|
||||
"default_subagent_model": {
|
||||
"description": "Reserved default model for spawned subagents. Currently ignored.",
|
||||
"description": "Default model for spawned subagents when the spawn call does not select one.",
|
||||
"type": "string"
|
||||
},
|
||||
"default_subagent_reasoning_effort": {
|
||||
@@ -46,7 +46,7 @@
|
||||
"$ref": "#/definitions/ReasoningEffort"
|
||||
}
|
||||
],
|
||||
"description": "Reserved default reasoning effort for spawned subagents. Currently ignored."
|
||||
"description": "Default reasoning effort for spawned subagents when the spawn call does not select one."
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Whether multi-agent tools are enabled. Defaults to true. An enabled `features.multi_agent_v2` setting takes precedence.",
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! Roles are selected at spawn time and are loaded with the same config machinery as
|
||||
//! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a
|
||||
//! high-precedence layer, and preserves the caller's current provider and service tier unless the
|
||||
//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the
|
||||
//! multi-agent tool handler owns that orchestration.
|
||||
//! high-precedence layer, and preserves the caller's current model, reasoning effort, provider,
|
||||
//! and service tier unless the role layer sets them. It does not decide when to spawn a sub-agent
|
||||
//! or which role to use; the multi-agent tool handler owns that orchestration.
|
||||
|
||||
use crate::config::AgentRoleConfig;
|
||||
use crate::config::Config;
|
||||
@@ -135,14 +135,18 @@ mod reload {
|
||||
preserve_current_provider: bool,
|
||||
preserve_current_service_tier: bool,
|
||||
) -> anyhow::Result<Config> {
|
||||
let preserve_current_model = role_layer_toml.get("model").is_none();
|
||||
let preserve_current_reasoning_effort =
|
||||
role_layer_toml.get("model_reasoning_effort").is_none();
|
||||
let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?;
|
||||
let merged_config = deserialize_effective_config(config, &config_layer_stack)?;
|
||||
|
||||
let next_config = Config::load_config_with_layer_stack(
|
||||
let mut next_config = Config::load_config_with_layer_stack(
|
||||
LOCAL_FS.as_ref(),
|
||||
merged_config,
|
||||
reload_overrides(
|
||||
config,
|
||||
preserve_current_model,
|
||||
preserve_current_provider,
|
||||
preserve_current_service_tier,
|
||||
),
|
||||
@@ -150,6 +154,11 @@ mod reload {
|
||||
config_layer_stack,
|
||||
)
|
||||
.await?;
|
||||
if preserve_current_reasoning_effort {
|
||||
next_config
|
||||
.model_reasoning_effort
|
||||
.clone_from(&config.model_reasoning_effort);
|
||||
}
|
||||
Ok(next_config)
|
||||
}
|
||||
|
||||
@@ -200,11 +209,15 @@ mod reload {
|
||||
|
||||
fn reload_overrides(
|
||||
config: &Config,
|
||||
preserve_current_model: bool,
|
||||
preserve_current_provider: bool,
|
||||
preserve_current_service_tier: bool,
|
||||
) -> ConfigOverrides {
|
||||
ConfigOverrides {
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model: preserve_current_model
|
||||
.then(|| config.model.clone())
|
||||
.flatten(),
|
||||
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
|
||||
service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
|
||||
@@ -183,8 +183,8 @@ async fn apply_role_preserves_unspecified_keys() {
|
||||
config.main_execve_wrapper_exe = Some(PathBuf::from("/tmp/codex-execve-wrapper"));
|
||||
let role_path = write_role_config(
|
||||
&home,
|
||||
"effort-only.toml",
|
||||
"developer_instructions = \"Stay focused\"\nmodel_reasoning_effort = \"high\"",
|
||||
"instructions-only.toml",
|
||||
"developer_instructions = \"Stay focused\"",
|
||||
)
|
||||
.await;
|
||||
config.agent_roles.insert(
|
||||
@@ -196,12 +196,17 @@ async fn apply_role_preserves_unspecified_keys() {
|
||||
},
|
||||
);
|
||||
|
||||
config.model = Some("spawn-model".to_string());
|
||||
config.model_reasoning_effort = Some(ReasoningEffort::Low);
|
||||
|
||||
apply_role_to_config(&mut config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
assert_eq!(config.model.as_deref(), Some("base-model"));
|
||||
assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High));
|
||||
assert_eq!(
|
||||
(config.model.as_deref(), config.model_reasoning_effort),
|
||||
(Some("spawn-model"), Some(ReasoningEffort::Low)),
|
||||
);
|
||||
assert_eq!(
|
||||
config.codex_linux_sandbox_exe,
|
||||
Some(PathBuf::from("/tmp/codex-linux-sandbox"))
|
||||
|
||||
@@ -863,10 +863,10 @@ pub struct Config {
|
||||
/// User-configured maximum number of spawned agent threads per session.
|
||||
pub agent_max_threads: Option<usize>,
|
||||
|
||||
/// Reserved default model for spawned subagents.
|
||||
/// Default model for spawned subagents when the spawn call does not select one.
|
||||
pub agent_default_subagent_model: Option<String>,
|
||||
|
||||
/// Reserved default reasoning effort for spawned subagents.
|
||||
/// Default reasoning effort for spawned subagents when the spawn call does not select one.
|
||||
pub agent_default_subagent_reasoning_effort: Option<ReasoningEffort>,
|
||||
|
||||
/// Maximum runtime in seconds for agent job workers before they are failed.
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::function_tool::FunctionCallError;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::tools::handlers::multi_agents::build_agent_spawn_config;
|
||||
use crate::tools::handlers::multi_agents_common::apply_requested_spawn_agent_model_overrides;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::error::CodexErr;
|
||||
@@ -124,7 +125,15 @@ async fn build_runner_options(
|
||||
}
|
||||
let max_concurrency = normalize_concurrency(requested_concurrency, agent_max_threads);
|
||||
let base_instructions = session.get_base_instructions().await;
|
||||
let spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?;
|
||||
let mut spawn_config = build_agent_spawn_config(&base_instructions, turn.as_ref())?;
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
session,
|
||||
turn,
|
||||
&mut spawn_config,
|
||||
/*requested_model*/ None,
|
||||
/*requested_reasoning_effort*/ None,
|
||||
)
|
||||
.await?;
|
||||
Ok(JobRunnerOptions {
|
||||
max_concurrency,
|
||||
spawn_config,
|
||||
|
||||
@@ -92,20 +92,17 @@ async fn handle_spawn_agent(
|
||||
config.service_tier = Some(service_tier.clone());
|
||||
}
|
||||
if args.fork_context {
|
||||
reject_full_fork_spawn_overrides(
|
||||
role_name,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)?;
|
||||
} else {
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)
|
||||
.await?;
|
||||
reject_full_fork_agent_type_override(role_name)?;
|
||||
}
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)
|
||||
.await?;
|
||||
if !args.fork_context {
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
|
||||
@@ -201,14 +201,12 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallE
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub(crate) fn reject_full_fork_spawn_overrides(
|
||||
pub(crate) fn reject_full_fork_agent_type_override(
|
||||
agent_type: Option<&str>,
|
||||
model: Option<&str>,
|
||||
reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
if agent_type.is_some() || model.is_some() || reasoning_effort.is_some() {
|
||||
if agent_type.is_some() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(),
|
||||
"Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
@@ -249,6 +247,9 @@ pub(crate) async fn apply_requested_spawn_agent_model_overrides(
|
||||
requested_model: Option<&str>,
|
||||
requested_reasoning_effort: Option<ReasoningEffort>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let requested_model = requested_model.or(turn.config.agent_default_subagent_model.as_deref());
|
||||
let requested_reasoning_effort = requested_reasoning_effort
|
||||
.or_else(|| turn.config.agent_default_subagent_reasoning_effort.clone());
|
||||
if requested_model.is_none() && requested_reasoning_effort.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -339,42 +339,7 @@ async fn spawn_agent_fork_context_rejects_agent_type_override() {
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_context_rejects_child_model_overrides() {
|
||||
let (mut session, turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.thread_id = root.thread_id;
|
||||
|
||||
let err = SpawnAgentHandler::default()
|
||||
.handle(invocation(
|
||||
Arc::new(session),
|
||||
Arc::new(turn),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "inspect this repo",
|
||||
"model": "gpt-5-child-override",
|
||||
"reasoning_effort": "low",
|
||||
"fork_context": true
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.err()
|
||||
.expect("forked spawn should reject child model overrides");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(),
|
||||
"Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -420,48 +385,7 @@ async fn multi_agent_v2_spawn_fork_turns_all_rejects_agent_type_override() {
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn multi_agent_v2_spawn_defaults_to_full_fork_and_rejects_child_model_overrides() {
|
||||
let (mut session, mut turn) = make_session_and_context().await;
|
||||
let manager = thread_manager();
|
||||
let root = manager
|
||||
.start_thread((*turn.config).clone())
|
||||
.await
|
||||
.expect("root thread should start");
|
||||
session.services.agent_control = manager.agent_control();
|
||||
session.thread_id = root.thread_id;
|
||||
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": "fork_context_v2",
|
||||
"model": "gpt-5-child-override",
|
||||
"reasoning_effort": "low"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.err()
|
||||
.expect("default full fork should reject child model overrides");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
FunctionCallError::RespondToModel(
|
||||
"Full-history forked agents inherit the parent agent type, model, and reasoning effort; omit agent_type, model, and reasoning_effort, or spawn without a full-history fork.".to_string(),
|
||||
"Full-history forked agents inherit the parent agent type; omit agent_type, or spawn without a full-history fork.".to_string(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,26 +61,22 @@ async fn handle_spawn_agent(
|
||||
let child_depth = next_thread_spawn_depth(&session_source);
|
||||
let mut config =
|
||||
build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?;
|
||||
// TODO(jif): Apply `agents.default_subagent_model` as the default model here.
|
||||
// TODO(jif): Apply `agents.default_subagent_reasoning_effort` as the default effort here.
|
||||
if let Some(service_tier) = args.service_tier.as_ref() {
|
||||
config.service_tier = Some(service_tier.clone());
|
||||
}
|
||||
if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) {
|
||||
reject_full_fork_spawn_overrides(
|
||||
role_name,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)?;
|
||||
} else {
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)
|
||||
.await?;
|
||||
let is_full_history_fork = matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory));
|
||||
if is_full_history_fork {
|
||||
reject_full_fork_agent_type_override(role_name)?;
|
||||
}
|
||||
apply_requested_spawn_agent_model_overrides(
|
||||
&session,
|
||||
turn.as_ref(),
|
||||
&mut config,
|
||||
args.model.as_deref(),
|
||||
args.reasoning_effort.clone(),
|
||||
)
|
||||
.await?;
|
||||
if !is_full_history_fork {
|
||||
apply_role_to_config(&mut config, role_name)
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_function_call;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
@@ -7,6 +8,7 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::sse_response;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use regex_lite::Regex;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -292,6 +294,8 @@ async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> {
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow feature update");
|
||||
config.agent_default_subagent_model = Some("gpt-5.4".to_string());
|
||||
config.agent_default_subagent_reasoning_effort = Some(ReasoningEffort::High);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -319,6 +323,27 @@ async fn spawn_agents_on_csv_runs_and_exports() -> Result<()> {
|
||||
assert!(output.contains("result_json"));
|
||||
assert!(output.contains("item_id"));
|
||||
assert!(output.contains("\"item_id\""));
|
||||
let worker_settings = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|request| {
|
||||
let body = serde_json::from_slice::<Value>(&decode_body_bytes(&request)).ok()?;
|
||||
if has_function_call_output(&body) {
|
||||
return None;
|
||||
}
|
||||
extract_job_and_item(&body)?;
|
||||
Some((
|
||||
body["model"].as_str().map(str::to_string),
|
||||
body["reasoning"]["effort"].as_str().map(str::to_string),
|
||||
))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
worker_settings,
|
||||
vec![(Some("gpt-5.4".to_string()), Some("high".to_string())); 2]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ const INHERITED_MODEL: &str = "gpt-5.2";
|
||||
const INHERITED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::XHigh;
|
||||
const REQUESTED_MODEL: &str = "gpt-5.4";
|
||||
const REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const V2_DEFAULT_MODEL: &str = "gpt-5.6-terra";
|
||||
const V2_DEFAULT_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
const V2_REQUESTED_MODEL: &str = "gpt-5.6-sol";
|
||||
const V2_REQUESTED_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::Low;
|
||||
const ROLE_MODEL: &str = "gpt-5.4";
|
||||
const ROLE_REASONING_EFFORT: ReasoningEffort = ReasoningEffort::High;
|
||||
const SUBAGENT_START_CONTEXT: &str = "subagent start context reaches child";
|
||||
@@ -355,6 +359,26 @@ async fn wait_for_requests(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_request_with_model(
|
||||
mock: &core_test_support::responses::ResponseMock,
|
||||
model: &str,
|
||||
) -> Result<ResponsesRequest> {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
loop {
|
||||
if let Some(request) = mock
|
||||
.requests()
|
||||
.into_iter()
|
||||
.find(|request| request.body_json()["model"] == model)
|
||||
{
|
||||
return Ok(request);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("timed out waiting for request using model {model}");
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_turn_one_with_spawned_child(
|
||||
server: &MockServer,
|
||||
child_response_delay: Option<Duration>,
|
||||
@@ -900,9 +924,11 @@ async fn spawned_child_receives_forked_parent_context(
|
||||
)
|
||||
.await;
|
||||
|
||||
let _child_request_log = mount_sse_once_match(
|
||||
let child_request_log = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, CHILD_PROMPT),
|
||||
|req: &wiremock::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"),
|
||||
@@ -929,6 +955,10 @@ async fn spawned_child_receives_forked_parent_context(
|
||||
.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);
|
||||
config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string());
|
||||
config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
@@ -938,26 +968,141 @@ async fn spawned_child_receives_forked_parent_context(
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
let _ = spawn_turn.single_request();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let child_request = loop {
|
||||
if let Some(request) = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find(|request| {
|
||||
body_contains(request, CHILD_PROMPT) && !body_contains(request, SPAWN_CALL_ID)
|
||||
})
|
||||
{
|
||||
break request;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("timed out waiting for forked child request");
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
let child_request = wait_for_request_with_model(&child_request_log, REQUESTED_MODEL).await?;
|
||||
assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT));
|
||||
let child_body = child_request.body_json();
|
||||
assert_eq!(
|
||||
(
|
||||
child_body["model"].clone(),
|
||||
child_body["reasoning"]["effort"].clone(),
|
||||
),
|
||||
(
|
||||
json!(REQUESTED_MODEL),
|
||||
json!(REQUESTED_REASONING_EFFORT.to_string()),
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum FullHistoryV2ModelSelection {
|
||||
ConfiguredDefault,
|
||||
ExplicitOverride,
|
||||
}
|
||||
|
||||
#[test_case(FullHistoryV2ModelSelection::ConfiguredDefault; "configured default with omitted fork_turns")]
|
||||
#[test_case(FullHistoryV2ModelSelection::ExplicitOverride; "explicit override with fork_turns all")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawned_full_history_v2_child_uses_model_precedence_without_dropping_context(
|
||||
selection: FullHistoryV2ModelSelection,
|
||||
) -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let seed_turn = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, TURN_0_FORK_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("resp-seed-1"),
|
||||
ev_assistant_message("msg-seed-1", "seeded"),
|
||||
ev_completed("resp-seed-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let (spawn_args, expected_model, expected_reasoning_effort) = match selection {
|
||||
FullHistoryV2ModelSelection::ConfiguredDefault => (
|
||||
json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "worker",
|
||||
}),
|
||||
V2_DEFAULT_MODEL,
|
||||
V2_DEFAULT_REASONING_EFFORT,
|
||||
),
|
||||
FullHistoryV2ModelSelection::ExplicitOverride => (
|
||||
json!({
|
||||
"message": CHILD_PROMPT,
|
||||
"task_name": "worker",
|
||||
"fork_turns": "all",
|
||||
"model": V2_REQUESTED_MODEL,
|
||||
"reasoning_effort": V2_REQUESTED_REASONING_EFFORT,
|
||||
}),
|
||||
V2_REQUESTED_MODEL,
|
||||
V2_REQUESTED_REASONING_EFFORT,
|
||||
),
|
||||
};
|
||||
assert!(body_contains(&child_request, TURN_0_FORK_PROMPT));
|
||||
assert!(!body_contains(&child_request, SPAWN_CALL_ID));
|
||||
let spawn_args = serde_json::to_string(&spawn_args)?;
|
||||
let spawn_turn = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, TURN_1_PROMPT),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-1"),
|
||||
ev_function_call_with_namespace(
|
||||
SPAWN_CALL_ID,
|
||||
MULTI_AGENT_V2_NAMESPACE,
|
||||
"spawn_agent",
|
||||
&spawn_args,
|
||||
),
|
||||
ev_completed("resp-turn1-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let child_request_log = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::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 _turn1_followup = mount_sse_once_match(
|
||||
&server,
|
||||
|req: &wiremock::Request| body_contains(req, SPAWN_CALL_ID),
|
||||
sse(vec![
|
||||
ev_response_created("resp-turn1-2"),
|
||||
ev_assistant_message("msg-turn1-2", "parent done"),
|
||||
ev_completed("resp-turn1-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let mut builder = test_codex().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.model = Some(INHERITED_MODEL.to_string());
|
||||
config.model_reasoning_effort = Some(INHERITED_REASONING_EFFORT);
|
||||
config.agent_default_subagent_model = Some(V2_DEFAULT_MODEL.to_string());
|
||||
config.agent_default_subagent_reasoning_effort = Some(V2_DEFAULT_REASONING_EFFORT);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn(TURN_0_FORK_PROMPT).await?;
|
||||
let _ = seed_turn.single_request();
|
||||
test.submit_turn(TURN_1_PROMPT).await?;
|
||||
let _ = spawn_turn.single_request();
|
||||
|
||||
let child_request = wait_for_request_with_model(&child_request_log, expected_model).await?;
|
||||
assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT));
|
||||
let child_body = child_request.body_json();
|
||||
assert_eq!(
|
||||
(
|
||||
child_body["model"].clone(),
|
||||
child_body["reasoning"]["effort"].clone(),
|
||||
),
|
||||
(
|
||||
json!(expected_model),
|
||||
json!(expected_reasoning_effort.to_string()),
|
||||
)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -975,7 +1120,12 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w
|
||||
"model": REQUESTED_MODEL,
|
||||
"reasoning_effort": REQUESTED_REASONING_EFFORT,
|
||||
}),
|
||||
|builder| builder,
|
||||
|builder| {
|
||||
builder.with_config(|config| {
|
||||
config.agent_default_subagent_model = Some(INHERITED_MODEL.to_string());
|
||||
config.agent_default_subagent_reasoning_effort = Some(ReasoningEffort::High);
|
||||
})
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -988,6 +1138,30 @@ async fn spawn_agent_requested_model_and_reasoning_override_inherited_settings_w
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn spawn_agent_uses_configured_subagent_defaults() -> 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 }), |builder| {
|
||||
builder.with_config(|config| {
|
||||
config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string());
|
||||
config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT);
|
||||
})
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
(child_snapshot.model, child_snapshot.reasoning_effort),
|
||||
(
|
||||
REQUESTED_MODEL.to_string(),
|
||||
Some(REQUESTED_REASONING_EFFORT)
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test_case(true, false; "unsupported child")]
|
||||
#[test_case(false, true; "supported child")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
|
||||
@@ -71,12 +71,12 @@ fn render_agents_config_lines(config: &Config) -> Vec<Line<'static>> {
|
||||
)
|
||||
.into(),
|
||||
format!(
|
||||
" - default_subagent_model = {} (not yet wired)",
|
||||
" - default_subagent_model = {}",
|
||||
format_optional(config.agent_default_subagent_model.as_deref())
|
||||
)
|
||||
.into(),
|
||||
format!(
|
||||
" - default_subagent_reasoning_effort = {} (not yet wired)",
|
||||
" - default_subagent_reasoning_effort = {}",
|
||||
format_optional(config.agent_default_subagent_reasoning_effort.as_ref())
|
||||
)
|
||||
.into(),
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ expression: render_to_text(&render_agents_config_lines(&config))
|
||||
- enabled = false
|
||||
- max_concurrent_threads_per_session = 7
|
||||
- max_depth = -2 (V1 only; ignored by V2)
|
||||
- default_subagent_model = gpt-5.6-terra (not yet wired)
|
||||
- default_subagent_reasoning_effort = high (not yet wired)
|
||||
- default_subagent_model = gpt-5.6-terra
|
||||
- default_subagent_reasoning_effort = high
|
||||
- job_max_runtime_seconds = 900
|
||||
- interrupt_message = false
|
||||
|
||||
Reference in New Issue
Block a user