codex: require locked version for child agents

This commit is contained in:
Ahmed Ibrahim
2026-05-30 21:35:14 -07:00
parent d039da1a57
commit d3b7aecdeb
8 changed files with 126 additions and 85 deletions

View File

@@ -57,7 +57,6 @@ pub(crate) struct SpawnAgentOptions {
pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
pub(crate) multi_agent_version: Option<MultiAgentVersion>,
}
#[derive(Clone, Debug)]
@@ -191,18 +190,30 @@ impl AgentControl {
initial_operation: Op,
session_source: Option<SessionSource>,
) -> CodexResult<ThreadId> {
let multi_agent_version = session_source
.is_none()
.then(|| config.multi_agent_version_from_features())
.flatten();
let state = self.upgrade()?;
let multi_agent_version =
if let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
..
})) = session_source.as_ref()
{
state
.get_thread(*parent_thread_id)
.await?
.multi_agent_version()
.await
} else {
config.multi_agent_version_from_features()
}
.ok_or_else(|| {
CodexErr::InvalidRequest("multi-agent version is unresolved".to_string())
})?;
let spawned_agent = Box::pin(self.spawn_agent_internal(
config,
initial_operation,
session_source,
SpawnAgentOptions {
multi_agent_version,
..Default::default()
},
multi_agent_version,
SpawnAgentOptions::default(),
))
.await?;
Ok(spawned_agent.thread_id)
@@ -214,10 +225,17 @@ impl AgentControl {
config: crate::config::Config,
initial_operation: Op,
session_source: Option<SessionSource>,
multi_agent_version: MultiAgentVersion,
options: SpawnAgentOptions, // TODO(jif) drop with new fork.
) -> CodexResult<LiveAgent> {
Box::pin(self.spawn_agent_internal(config, initial_operation, session_source, options))
.await
Box::pin(self.spawn_agent_internal(
config,
initial_operation,
session_source,
multi_agent_version,
options,
))
.await
}
async fn spawn_agent_internal(
@@ -225,19 +243,10 @@ impl AgentControl {
config: crate::config::Config,
initial_operation: Op,
session_source: Option<SessionSource>,
multi_agent_version: MultiAgentVersion,
options: SpawnAgentOptions,
) -> CodexResult<LiveAgent> {
let state = self.upgrade()?;
let multi_agent_version = self
.inherited_multi_agent_version_for_source(
&state,
session_source.as_ref(),
options.multi_agent_version,
)
.await
.ok_or_else(|| {
CodexErr::InvalidRequest("multi-agent version is unresolved".to_string())
})?;
let agent_max_threads = config
.effective_agent_max_threads(Some(multi_agent_version))
.map_err(|err| CodexErr::InvalidRequest(err.to_string()))?;
@@ -281,7 +290,7 @@ impl AgentControl {
&options,
inherited_shell_snapshot,
inherited_exec_policy,
Some(multi_agent_version),
multi_agent_version,
))
.await?
}
@@ -395,7 +404,7 @@ impl AgentControl {
options: &SpawnAgentOptions,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
inherited_exec_policy: Option<Arc<crate::exec_policy::ExecPolicyManager>>,
inherited_multi_agent_version: Option<MultiAgentVersion>,
multi_agent_version: MultiAgentVersion,
) -> CodexResult<crate::thread_manager::NewThread> {
if options.fork_parent_spawn_call_id.is_none() {
return Err(CodexErr::Fatal(
@@ -439,18 +448,6 @@ impl AgentControl {
))
})?;
let multi_agent_version = if let Some(parent_thread) = parent_thread.as_ref() {
parent_thread.multi_agent_version().await
} else {
state
.resolve_multi_agent_version(
&config,
&InitialHistory::Forked(parent_history.items.clone()),
Some(parent_thread_id),
inherited_multi_agent_version,
)
.await
};
let mut forked_rollout_items = parent_history.items;
if let SpawnAgentForkMode::LastNTurns(last_n_turns) = fork_mode {
forked_rollout_items =
@@ -458,7 +455,7 @@ impl AgentControl {
}
let multi_agent_v2_usage_hint_texts_to_filter: Vec<String> =
if let Some(parent_thread) = parent_thread.as_ref() {
if multi_agent_version == Some(MultiAgentVersion::V2) {
if multi_agent_version == MultiAgentVersion::V2 {
let parent_config = parent_thread.codex.session.get_config().await;
[
parent_config
@@ -476,7 +473,7 @@ impl AgentControl {
} else {
Vec::new()
}
} else if multi_agent_version == Some(MultiAgentVersion::V2) {
} else if multi_agent_version == MultiAgentVersion::V2 {
[
config.multi_agent_v2.root_agent_usage_hint_text.clone(),
config.multi_agent_v2.subagent_usage_hint_text.clone(),
@@ -512,7 +509,7 @@ impl AgentControl {
}
}
if preserve_reference_context_item
&& multi_agent_version == Some(MultiAgentVersion::V2)
&& multi_agent_version == MultiAgentVersion::V2
&& let Some(subagent_usage_hint_text) =
config.multi_agent_v2.subagent_usage_hint_text.clone()
&& let Some(subagent_usage_hint_message) =
@@ -539,7 +536,7 @@ impl AgentControl {
inherited_shell_snapshot,
inherited_exec_policy,
options.environments.clone(),
multi_agent_version,
Some(multi_agent_version),
)
.await
}
@@ -628,13 +625,18 @@ impl AgentControl {
) -> CodexResult<ThreadId> {
let state = self.upgrade()?;
let state_db_ctx = state.state_db();
let inherited_multi_agent_version = self
.inherited_multi_agent_version_for_source(
&state,
Some(&session_source),
/*requested_multi_agent_version*/ None,
)
.await;
let inherited_multi_agent_version =
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
}) = &session_source
{
match state.get_thread(*parent_thread_id).await {
Ok(parent_thread) => parent_thread.multi_agent_version().await,
Err(_) => None,
}
} else {
None
};
let stored_thread = state
.read_stored_thread(ReadThreadParams {
thread_id,
@@ -1204,25 +1206,6 @@ impl AgentControl {
parent_thread.codex.session.user_shell().shell_snapshot()
}
async fn inherited_multi_agent_version_for_source(
&self,
state: &Arc<ThreadManagerState>,
session_source: Option<&SessionSource>,
requested_multi_agent_version: Option<MultiAgentVersion>,
) -> Option<MultiAgentVersion> {
if requested_multi_agent_version.is_some() {
return requested_multi_agent_version;
}
if let Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id, ..
})) = session_source
&& let Ok(parent_thread) = state.get_thread(*parent_thread_id).await
{
return parent_thread.multi_agent_version().await;
}
None
}
async fn inherited_exec_policy_for_source(
&self,
state: &Arc<ThreadManagerState>,

View File

@@ -719,6 +719,10 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
@@ -930,6 +934,10 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() {
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
@@ -1000,6 +1008,10 @@ async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::FullHistory),
@@ -1117,6 +1129,10 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() {
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
@@ -1228,6 +1244,10 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),
@@ -1338,6 +1358,10 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() {
agent_nickname: None,
agent_role: None,
})),
parent_thread
.multi_agent_version()
.await
.expect("parent thread should have a multi-agent version"),
SpawnAgentOptions {
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)),

View File

@@ -11,6 +11,7 @@ use crate::tools::handlers::parse_arguments;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::MultiAgentVersion;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SubAgentSource;
use codex_protocol::user_input::UserInput;
@@ -89,6 +90,7 @@ struct ReportAgentJobResultToolResult {
struct JobRunnerOptions {
max_concurrency: usize,
spawn_config: Config,
multi_agent_version: MultiAgentVersion,
}
#[derive(Debug, Clone)]
@@ -119,9 +121,14 @@ async fn build_runner_options(
"agent depth limit reached; this session cannot spawn more subagents".to_string(),
));
}
let multi_agent_version = turn.multi_agent_version.ok_or_else(|| {
FunctionCallError::RespondToModel(
"multi-agent version is unresolved; this session cannot spawn workers".to_string(),
)
})?;
let agent_max_threads = turn
.config
.effective_agent_max_threads(turn.multi_agent_version)
.effective_agent_max_threads(Some(multi_agent_version))
.map_err(|err| FunctionCallError::Fatal(err.to_string()))?;
if agent_max_threads == Some(0) {
return Err(FunctionCallError::RespondToModel(
@@ -134,6 +141,7 @@ async fn build_runner_options(
Ok(JobRunnerOptions {
max_concurrency,
spawn_config,
multi_agent_version,
})
}
@@ -213,10 +221,10 @@ async fn run_agent_job_loop(
Some(SessionSource::SubAgent(SubAgentSource::Other(format!(
"agent_job:{job_id}"
)))),
options.multi_agent_version,
SpawnAgentOptions {
parent_thread_id: Some(session.conversation_id),
environments: Some(turn.environments.to_selections()),
multi_agent_version: turn.multi_agent_version,
..Default::default()
},
)

View File

@@ -109,6 +109,9 @@ async fn handle_spawn_agent(
)
.await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
let multi_agent_version = turn
.multi_agent_version
.ok_or_else(|| FunctionCallError::Fatal("multi-agent version is unresolved".to_string()))?;
let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata(
config,
@@ -120,12 +123,12 @@ async fn handle_spawn_agent(
role_name,
/*task_name*/ None,
)?),
multi_agent_version,
SpawnAgentOptions {
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
parent_thread_id: Some(session.conversation_id),
environments: Some(turn.environments.to_selections()),
multi_agent_version: turn.multi_agent_version,
},
))
.await

View File

@@ -1348,18 +1348,23 @@ async fn multi_agent_v2_spawn_rejects_zero_fork_turns() {
async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
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.conversation_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 root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let multi_agent_version = root
.thread
.multi_agent_version()
.await
.expect("root thread should have a multi-agent version");
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
let child_thread_id = session
@@ -1379,6 +1384,7 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
agent_nickname: None,
agent_role: None,
})),
multi_agent_version,
crate::agent::control::SpawnAgentOptions::default(),
)
.await
@@ -1424,18 +1430,23 @@ async fn multi_agent_v2_send_message_accepts_root_target_from_child() {
async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
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.conversation_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 root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let multi_agent_version = root
.thread
.multi_agent_version()
.await
.expect("root thread should have a multi-agent version");
let child_path = AgentPath::try_from("/root/worker").expect("agent path");
let child_thread_id = session
@@ -1455,6 +1466,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
agent_nickname: None,
agent_role: None,
})),
multi_agent_version,
crate::agent::control::SpawnAgentOptions::default(),
)
.await
@@ -1601,15 +1613,20 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa
async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let mut config = (*turn.config).clone();
let _ = config.features.enable(Feature::MultiAgentV2);
set_turn_config(&mut turn, config.clone());
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = (*turn.config).clone();
let _ = config.features.enable(Feature::MultiAgentV2);
set_turn_config(&mut turn, config.clone());
let multi_agent_version = root
.thread
.multi_agent_version()
.await
.expect("root thread should have a multi-agent version");
let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path");
let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path");
@@ -1630,6 +1647,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
agent_nickname: None,
agent_role: None,
})),
multi_agent_version,
crate::agent::control::SpawnAgentOptions::default(),
)
.await
@@ -1651,6 +1669,7 @@ async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() {
agent_nickname: None,
agent_role: None,
})),
multi_agent_version,
crate::agent::control::SpawnAgentOptions::default(),
)
.await

View File

@@ -108,6 +108,9 @@ async fn handle_spawn_agent(
)
.await?;
apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?;
let multi_agent_version = turn
.multi_agent_version
.ok_or_else(|| FunctionCallError::Fatal("multi-agent version is unresolved".to_string()))?;
let spawn_source = thread_spawn_source(
session.conversation_id,
@@ -140,12 +143,12 @@ async fn handle_spawn_agent(
(_, initial_operation) => initial_operation,
},
Some(spawn_source),
multi_agent_version,
SpawnAgentOptions {
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
fork_mode,
parent_thread_id: Some(session.conversation_id),
environments: Some(turn.environments.to_selections()),
multi_agent_version: turn.multi_agent_version,
},
),
)

View File

@@ -313,6 +313,7 @@ fn goal_tools_enabled(turn_context: &TurnContext) -> bool {
fn agent_jobs_tools_enabled(turn_context: &TurnContext) -> bool {
turn_context.features.get().enabled(Feature::SpawnCsv)
&& turn_context.multi_agent_version.is_some()
&& !exceeds_thread_spawn_depth_limit(
next_thread_spawn_depth(&turn_context.session_source),
turn_context.config.agent_max_depth,

View File

@@ -535,7 +535,7 @@ async fn host_context_gates_goal_and_agent_job_tools() {
turn.multi_agent_version = None;
})
.await;
agent_job_without_multi_agent.assert_visible_contains(&["spawn_agents_on_csv"]);
agent_job_without_multi_agent.assert_visible_lacks(&["spawn_agents_on_csv"]);
agent_job_without_multi_agent.assert_visible_lacks(&[MULTI_AGENT_V1_NAMESPACE, "spawn_agent"]);
let worker_agent_job = probe(|turn| {