diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index eb3a0a61d7..306c0d79f4 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -57,7 +57,6 @@ pub(crate) struct SpawnAgentOptions { pub(crate) fork_mode: Option, pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, - pub(crate) multi_agent_version: Option, } #[derive(Clone, Debug)] @@ -191,18 +190,30 @@ impl AgentControl { initial_operation: Op, session_source: Option, ) -> CodexResult { - 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, + multi_agent_version: MultiAgentVersion, options: SpawnAgentOptions, // TODO(jif) drop with new fork. ) -> CodexResult { - 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, + multi_agent_version: MultiAgentVersion, options: SpawnAgentOptions, ) -> CodexResult { 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>, inherited_exec_policy: Option>, - inherited_multi_agent_version: Option, + multi_agent_version: MultiAgentVersion, ) -> CodexResult { 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 = 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 { 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, - session_source: Option<&SessionSource>, - requested_multi_agent_version: Option, - ) -> Option { - 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, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 11f23c9350..171012257d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -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)), diff --git a/codex-rs/core/src/tools/handlers/agent_jobs.rs b/codex-rs/core/src/tools/handlers/agent_jobs.rs index 111c10273e..dc3357e56f 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs.rs @@ -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() }, ) diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 15990b4529..c099412987 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -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 diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 7cc9dbdf04..045e448d75 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -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 diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d7d6e9f555..5d3a5f550c 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -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, }, ), ) diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 48f5d7f8b9..4ad3928fa1 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -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, diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index b5bc9861e8..e0aa46c69c 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -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| {