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:
parent
d54999d006
commit
d87cf7794c
6 changed files with 86 additions and 16 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue