diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index aef21f5d4..af2ba4565 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -29,6 +29,12 @@ "$ref": "#/definitions/AgentRoleToml" }, "properties": { + "max_depth": { + "description": "Maximum nesting depth allowed for spawned agent threads. Root sessions start at depth 0.", + "format": "int32", + "minimum": 1.0, + "type": "integer" + }, "max_threads": { "description": "Maximum number of agent threads that can be open concurrently. When unset, no limit is enforced.", "format": "uint", diff --git a/codex-rs/core/src/agent/guards.rs b/codex-rs/core/src/agent/guards.rs index 2f146f2f8..1964169cd 100644 --- a/codex-rs/core/src/agent/guards.rs +++ b/codex-rs/core/src/agent/guards.rs @@ -21,9 +21,6 @@ pub(crate) struct Guards { total_count: AtomicUsize, } -/// Initial agent is depth 0. -pub(crate) const MAX_THREAD_SPAWN_DEPTH: i32 = 1; - fn session_depth(session_source: &SessionSource) -> i32 { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth, @@ -36,8 +33,8 @@ pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 { session_depth(session_source).saturating_add(1) } -pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32) -> bool { - depth > MAX_THREAD_SPAWN_DEPTH +pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool { + depth > max_depth } impl Guards { @@ -136,7 +133,7 @@ mod tests { }); let child_depth = next_thread_spawn_depth(&session_source); assert_eq!(child_depth, 2); - assert!(exceeds_thread_spawn_depth_limit(child_depth)); + assert!(exceeds_thread_spawn_depth_limit(child_depth, 1)); } #[test] @@ -144,7 +141,7 @@ mod tests { let session_source = SessionSource::SubAgent(SubAgentSource::Review); assert_eq!(session_depth(&session_source), 0); assert_eq!(next_thread_spawn_depth(&session_source), 1); - assert!(!exceeds_thread_spawn_depth_limit(1)); + assert!(!exceeds_thread_spawn_depth_limit(1, 1)); } #[test] diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index d9a836184..15be909c3 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -5,7 +5,6 @@ pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use guards::MAX_THREAD_SPAWN_DEPTH; pub(crate) use guards::exceeds_thread_spawn_depth_limit; pub(crate) use guards::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d67318d12..0ff60c464 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -11,7 +11,6 @@ use crate::CodexAuth; use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; -use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; @@ -315,7 +314,7 @@ impl Codex { } if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source - && depth >= MAX_THREAD_SPAWN_DEPTH + && depth >= config.agent_max_depth { config.features.disable(Feature::Collab); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 0d4ee60f0..f11026ede 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -111,6 +111,7 @@ pub use codex_git::GhostSnapshotConfig; /// the context window. pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option = Some(6); +pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -309,6 +310,9 @@ pub struct Config { /// Maximum number of agent threads that can be open concurrently. pub agent_max_threads: Option, + /// Maximum nesting depth allowed for spawned agent threads. + pub agent_max_depth: i32, + /// User-defined role declarations keyed by role name. pub agent_roles: BTreeMap, @@ -1217,6 +1221,11 @@ pub struct AgentsToml { #[schemars(range(min = 1))] pub max_threads: Option, + /// Maximum nesting depth allowed for spawned agent threads. + /// Root sessions start at depth 0. + #[schemars(range(min = 1))] + pub max_depth: Option, + /// User-defined role declarations keyed by role name. /// /// Example: @@ -1692,6 +1701,17 @@ impl Config { "agents.max_threads must be at least 1", )); } + let agent_max_depth = cfg + .agents + .as_ref() + .and_then(|agents| agents.max_depth) + .unwrap_or(DEFAULT_AGENT_MAX_DEPTH); + if agent_max_depth < 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "agents.max_depth must be at least 1", + )); + } let agent_roles = cfg .agents .as_ref() @@ -1934,6 +1954,7 @@ impl Config { .collect(), tool_output_token_limit: cfg.tool_output_token_limit, agent_max_threads, + agent_max_depth, agent_roles, memories: cfg.memories.unwrap_or_default().into(), codex_home, @@ -4148,6 +4169,7 @@ model = "gpt-5.1-codex" let cfg = ConfigToml { agents: Some(AgentsToml { max_threads: None, + max_depth: None, roles: BTreeMap::from([( "researcher".to_string(), AgentRoleToml { @@ -4390,6 +4412,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), @@ -4506,6 +4529,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), @@ -4620,6 +4644,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), @@ -4720,6 +4745,7 @@ model_verbosity = "high" project_doc_fallback_filenames: Vec::new(), tool_output_token_limit: None, agent_max_threads: DEFAULT_AGENT_MAX_THREADS, + agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, agent_roles: BTreeMap::new(), memories: MemoriesConfig::default(), codex_home: fixture.codex_home(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 7a2b62d56..2180574cc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -124,7 +124,7 @@ mod spawn { let prompt = input_preview(&input_items); let session_source = turn.session_source.clone(); let child_depth = next_thread_spawn_depth(&session_source); - if exceeds_thread_spawn_depth_limit(child_depth) { + if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { return Err(FunctionCallError::RespondToModel( "Agent depth limit reached. Solve the task yourself.".to_string(), )); @@ -306,7 +306,7 @@ mod resume_agent { let args: ResumeAgentArgs = parse_arguments(&arguments)?; let receiver_thread_id = agent_id(&args.id)?; let child_depth = next_thread_spawn_depth(&turn.session_source); - if exceeds_thread_spawn_depth_limit(child_depth) { + if exceeds_thread_spawn_depth_limit(child_depth, turn.config.agent_max_depth) { return Err(FunctionCallError::RespondToModel( "Agent depth limit reached. Solve the task yourself.".to_string(), )); @@ -840,7 +840,7 @@ fn build_agent_shared_config( fn apply_spawn_agent_overrides(config: &mut Config, child_depth: i32) { config.permissions.approval_policy = Constrained::allow_only(AskForApproval::Never); - if exceeds_thread_spawn_depth_limit(child_depth + 1) { + if exceeds_thread_spawn_depth_limit(child_depth + 1, config.agent_max_depth) { config.features.disable(Feature::Collab); } } @@ -851,9 +851,9 @@ mod tests { use crate::AuthManager; use crate::CodexAuth; use crate::ThreadManager; - use crate::agent::MAX_THREAD_SPAWN_DEPTH; use crate::built_in_model_providers; use crate::codex::make_session_and_context; + use crate::config::DEFAULT_AGENT_MAX_DEPTH; use crate::config::types::ShellEnvironmentPolicy; use crate::function_tool::FunctionCallError; use crate::protocol::AskForApproval; @@ -1066,7 +1066,7 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, - depth: MAX_THREAD_SPAWN_DEPTH, + depth: DEFAULT_AGENT_MAX_DEPTH, }); let invocation = invocation( @@ -1086,6 +1086,49 @@ mod tests { ); } + #[tokio::test] + async fn spawn_agent_allows_depth_up_to_configured_max_depth() { + #[derive(Debug, Deserialize)] + struct SpawnAgentResult { + agent_id: String, + } + + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + session.services.agent_control = manager.agent_control(); + + let mut config = (*turn.config).clone(); + config.agent_max_depth = DEFAULT_AGENT_MAX_DEPTH + 1; + turn.config = Arc::new(config); + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: session.conversation_id, + depth: DEFAULT_AGENT_MAX_DEPTH, + }); + + let invocation = invocation( + Arc::new(session), + Arc::new(turn), + "spawn_agent", + function_payload(json!({"message": "hello"})), + ); + let output = MultiAgentHandler + .handle(invocation) + .await + .expect("spawn should succeed within configured depth"); + let ToolOutput::Function { + body: FunctionCallOutputBody::Text(content), + success, + .. + } = output + else { + panic!("expected function output"); + }; + let result: SpawnAgentResult = + serde_json::from_str(&content).expect("spawn_agent result should be json"); + assert!(!result.agent_id.is_empty()); + assert_eq!(success, Some(true)); + } + #[tokio::test] async fn send_input_rejects_empty_message() { let (session, turn) = make_session_and_context().await; @@ -1442,7 +1485,7 @@ mod tests { turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id: session.conversation_id, - depth: MAX_THREAD_SPAWN_DEPTH, + depth: DEFAULT_AGENT_MAX_DEPTH, }); let invocation = invocation(