From 70ac0f123c4b1869c9069d5b34e367b96c28bfad Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 29 Apr 2026 12:23:00 +0200 Subject: [PATCH] Make multi-agent v2 ignore agents.max_depth (#20180) ## Why `agents.max_depth` is a legacy multi-agent v1 guard. Multi-agent v2 uses task-path routing and its own session/thread limits, so v2 should not reject nested `spawn_agent` calls just because the thread-spawn depth has reached the v1 maximum. Keeping the v1 depth guard active in v2 prevents deeper task trees even though the v2 path still needs the depth value only for lineage and task-path metadata. ## What Changed - Removed the depth-limit rejection from the multi-agent v2 `spawn_agent` handler while still computing child depth for lineage/path metadata. - Made the depth-based disabling of legacy `SpawnCsv`/`Collab` tools apply only when `Feature::MultiAgentV2` is disabled. - Added `multi_agent_v2_spawn_agent_ignores_configured_max_depth` to cover a v2 child spawning another agent when `agent_max_depth = 1`, while the existing v1 depth-limit tests continue to enforce the legacy behavior. ## Verification - `cargo test -p codex-core multi_agent_v2_spawn_agent_ignores_configured_max_depth -- --nocapture` - `cargo test -p codex-core depth_limit -- --nocapture` - `cargo test -p codex-core tools::handlers::multi_agents::tests -- --nocapture` --- codex-rs/core/src/agent/control.rs | 1 + codex-rs/core/src/session/mod.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 54 +++++++++++++++++++ .../src/tools/handlers/multi_agents_v2.rs | 1 - .../tools/handlers/multi_agents_v2/spawn.rs | 6 --- 5 files changed, 56 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 19ef2a54fe..339b99787a 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -521,6 +521,7 @@ impl AgentControl { ) -> CodexResult { if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source && *depth >= config.agent_max_depth + && !config.features.enabled(Feature::MultiAgentV2) { let _ = config.features.disable(Feature::SpawnCsv); let _ = config.features.disable(Feature::Collab); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 415b89c0de..0c5af3fc5e 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -489,6 +489,7 @@ impl Codex { if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source && depth >= config.agent_max_depth + && !config.features.enabled(Feature::MultiAgentV2) { let _ = config.features.disable(Feature::SpawnCsv); let _ = config.features.disable(Feature::Collab); 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 0b53fb9735..8804e6d258 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1870,6 +1870,60 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() { assert_eq!(success, Some(true)); } +#[tokio::test] +async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + task_name: String, + nickname: Option, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let mut config = (*turn.config).clone(); + config.agent_max_depth = 1; + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + let root = manager + .start_thread(config.clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + turn.config = Arc::new(config); + let parent_path = AgentPath::try_from("/root/parent").expect("agent path"); + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(parent_path), + agent_nickname: None, + agent_role: None, + }); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({ + "message": "hello", + "task_name": "child", + "fork_turns": "none" + })), + ); + let output = SpawnAgentHandlerV2 + .handle(invocation) + .await + .expect("multi-agent v2 spawn should ignore max depth"); + let (content, success) = expect_text_output(output); + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + assert_eq!(result.task_name, "/root/parent/child"); + assert!(result.nickname.is_some()); + assert_eq!(success, Some(true)); +} + #[tokio::test] async fn send_input_rejects_empty_message() { let (session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 51b80e4a7b..b561c5acb4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -2,7 +2,6 @@ use crate::agent::AgentStatus; use crate::agent::agent_resolver::resolve_agent_target; -use crate::agent::exceeds_thread_spawn_depth_limit; use crate::function_tool::FunctionCallError; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; 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 21b4638c01..26b6750c46 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 @@ -45,12 +45,6 @@ impl ToolHandler for Handler { let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } session .send_event( &turn,