Add configurable agent spawn depth (#12251)

Summary
- expose `agents.max_depth` in config schema and toml parsing, with
defaults and validation
- thread-spawn depth guards and multi-agent handler now respect the
configured limit instead of a hardcoded value
- ensure documentation and helpers account for agent depth limits
This commit is contained in:
jif-oai 2026-02-19 18:40:41 +00:00 committed by GitHub
parent d54999d006
commit d87cf7794c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 86 additions and 16 deletions

View file

@ -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",

View file

@ -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]

View file

@ -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;

View file

@ -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);
}

View file

@ -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<usize> = 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<usize>,
/// 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<String, AgentRoleConfig>,
@ -1217,6 +1221,11 @@ pub struct AgentsToml {
#[schemars(range(min = 1))]
pub max_threads: Option<usize>,
/// Maximum nesting depth allowed for spawned agent threads.
/// Root sessions start at depth 0.
#[schemars(range(min = 1))]
pub max_depth: Option<i32>,
/// 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(),

View file

@ -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(