feat(agent): cap runtime subagent spawn depth at MAX_SPAWN_DEPTH=3 (#2234)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SRIKANTH A
2026-05-19 14:30:32 -07:00
committed by GitHub
co-authored by Cursor
parent 62a9633c2d
commit 73ea22e011
7 changed files with 176 additions and 6 deletions
@@ -199,9 +199,9 @@ Each `AgentDefinition` carries an `agent_tier` field (`chat` / `reasoning` / `wo
**Enforcement.** Two layers:
1. **Loader-time (static).** [`agents::loader::validate_tier_hierarchy`](../../../src/openhuman/agent/agents/loader.rs) runs over the merged registry (built-ins + workspace TOMLs) and refuses to boot a registry that lists a same-tier or worker-with-subagents entry. Built-in archetypes are checked at compile-test time; user-shipped TOMLs are checked at workspace load.
2. **Runtime depth gate (dynamic, planned).** Independent of tier, the sub-agent runner *will* cap total spawn chain depth at `MAX_SPAWN_DEPTH = 3` via a task-local counter incremented across `run_subagent`, surfaced as a new `SpawnDepthExceeded` agent error. This makes a user-shipped TOML that drops the tier annotation still unable to recurse past three hops. Tracked as the follow-up to the gap noted in `harness_gap_tests.rs`.
2. **Runtime depth gate (dynamic).** Independent of tier, the sub-agent runner caps total spawn chain depth at `MAX_SPAWN_DEPTH = 3` via a task-local counter incremented across `run_subagent`, surfaced as a `SpawnDepthExceeded` agent error. This makes a user-shipped TOML that drops the tier annotation still unable to recurse past three hops.
> **Status:** the loader-time tier check and `agent_tier` field are live (this section). The runtime depth-counter task-local is *not yet implemented* — it is the planned defence-in-depth layer described above. Until it lands, depth is bounded only by the static loader contract plus the prompt-level rules in the orchestrator and planner agents.
> **Status:** the loader-time tier check, `agent_tier` field, and runtime depth-counter task-local are live. Depth is bounded by both the static loader contract and the runtime `MAX_SPAWN_DEPTH = 3` guard.
### Toolkit-specific specialists
@@ -11,9 +11,11 @@
//! fallback formats).
//! 6. `DateTimeSection` produces an ISO-8601-like timestamp with a timezone token.
//! 7. `parse_tool_timeout_secs` default and boundary cases.
//! 8. Spawn-depth gate (`SpawnDepthExceeded`) is covered in
//! `subagent_runner/ops_tests.rs` because it lives at the `run_subagent`
//! boundary.
//!
//! Items that have NO underlying code and therefore cannot be tested:
//! - Spawn-depth gate (SpawnDepthExceeded) — no depth counter or variant exists.
//! - Follow-up resolution ("yes"/"no" disambiguation) — not implemented.
//! - Silence timer (SilenceTimeout, 600 s) — not implemented.
//! - `<invoke tool=…>` XML attribute form — the parser does not parse attributes;
+2
View File
@@ -34,6 +34,7 @@ pub mod sandbox_context;
pub(crate) mod self_healing;
pub mod session;
pub(crate) mod session_queue;
pub(crate) mod spawn_depth_context;
pub mod subagent_runner;
pub(crate) mod tool_filter;
mod tool_loop;
@@ -45,6 +46,7 @@ pub use definition::{
pub use fork_context::{current_parent, with_parent_context, ParentExecutionContext};
pub use interrupt::{check_interrupt, InterruptFence, InterruptedError};
pub use sandbox_context::{current_sandbox_mode, with_current_sandbox_mode};
pub(crate) use spawn_depth_context::{current_spawn_depth, with_spawn_depth, MAX_SPAWN_DEPTH};
pub use subagent_runner::{run_subagent, SubagentRunError, SubagentRunOptions};
pub(crate) use instructions::build_tool_instructions_filtered;
@@ -0,0 +1,66 @@
//! Task-local spawn-depth tracking for nested sub-agent delegation.
//!
//! Loader-time tier validation prevents built-in and workspace agent
//! definitions from declaring recursive hierarchies, but runtime calls can
//! still arrive through tools and MCP surfaces. This task-local is the
//! defence-in-depth layer that caps the active `run_subagent` chain.
/// Maximum number of nested `run_subagent` scopes allowed in one task.
///
/// Depth counts sub-agent runs, not the root user-facing agent turn, so the
/// intended deepest path is `chat -> reasoning -> worker`:
///
/// * reasoning sub-agent: depth 1
/// * worker spawned by reasoning: depth 2
/// * one final worker handoff: depth 3
pub const MAX_SPAWN_DEPTH: usize = 3;
tokio::task_local! {
/// Current active `run_subagent` nesting depth for this task.
static CURRENT_SPAWN_DEPTH: usize;
}
/// Return the active sub-agent nesting depth for this task.
///
/// Direct callers outside [`with_spawn_depth`] are at depth 0.
pub fn current_spawn_depth() -> usize {
CURRENT_SPAWN_DEPTH.try_with(|depth| *depth).unwrap_or(0)
}
/// Run `future` with a specific active sub-agent depth.
pub async fn with_spawn_depth<F, R>(depth: usize, future: F) -> R
where
F: std::future::Future<Output = R>,
{
CURRENT_SPAWN_DEPTH.scope(depth, future).await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn current_spawn_depth_defaults_to_zero() {
assert_eq!(current_spawn_depth(), 0);
}
#[tokio::test]
async fn with_spawn_depth_scopes_value_to_future() {
let observed = with_spawn_depth(2, async { current_spawn_depth() }).await;
assert_eq!(observed, 2);
assert_eq!(current_spawn_depth(), 0);
}
#[tokio::test]
async fn nested_spawn_depth_scope_restores_outer_value() {
with_spawn_depth(1, async {
assert_eq!(current_spawn_depth(), 1);
with_spawn_depth(2, async {
assert_eq!(current_spawn_depth(), 2);
})
.await;
assert_eq!(current_spawn_depth(), 1);
})
.await;
}
}
@@ -24,7 +24,9 @@ use super::tool_prep::{
};
use super::types::{SubagentMode, SubagentRunError, SubagentRunOptions, SubagentRunOutcome};
use crate::openhuman::agent::harness::definition::{AgentDefinition, PromptSource};
use crate::openhuman::agent::harness::with_current_sandbox_mode;
use crate::openhuman::agent::harness::{
current_spawn_depth, with_current_sandbox_mode, with_spawn_depth, MAX_SPAWN_DEPTH,
};
use crate::openhuman::agent::progress::AgentProgress;
use crate::openhuman::context::prompt::{
render_subagent_system_prompt, PromptContext, PromptTool, SubagentRenderOptions,
@@ -227,10 +229,29 @@ pub async fn run_subagent(
.clone()
.unwrap_or_else(|| format!("sub-{}", uuid::Uuid::new_v4()));
let started = Instant::now();
let current_depth = current_spawn_depth();
let attempted_depth = current_depth.saturating_add(1);
if attempted_depth > MAX_SPAWN_DEPTH {
tracing::warn!(
agent_id = %definition.id,
task_id = %task_id,
current_depth,
attempted_depth,
max_depth = MAX_SPAWN_DEPTH,
"[subagent_runner] spawn depth exceeded"
);
return Err(SubagentRunError::SpawnDepthExceeded {
attempted_depth,
max_depth: MAX_SPAWN_DEPTH,
});
}
tracing::info!(
agent_id = %definition.id,
task_id = %task_id,
spawn_depth = attempted_depth,
max_spawn_depth = MAX_SPAWN_DEPTH,
prompt_chars = task_prompt.chars().count(),
skill_filter = ?options.skill_filter_override.as_deref().or(definition.skill_filter.as_deref()),
"[subagent_runner] dispatching"
@@ -241,8 +262,24 @@ pub async fn run_subagent(
// want to gate on it (e.g. `composio_execute` rejecting
// Write/Admin slugs under `ReadOnly`) read it via
// `current_sandbox_mode()`; tools that don't care just ignore it.
let mut outcome = with_current_sandbox_mode(definition.sandbox_mode, async {
run_typed_mode(definition, task_prompt, &options, &parent, &task_id).await
// Box-pin the inner future so the large `run_typed_mode` state machine
// lives on the heap. Two stacked `task_local::scope` wrappers
// (`with_spawn_depth` + `with_current_sandbox_mode`) plus the deeply
// nested provider/tool loop inside `run_typed_mode` are otherwise large
// enough — under `cargo-llvm-cov` instrumentation in particular — to
// overflow tokio's 2 MiB per-thread test stack. See #2234 CI failure.
let mut outcome = with_spawn_depth(attempted_depth, async {
with_current_sandbox_mode(definition.sandbox_mode, async {
Box::pin(run_typed_mode(
definition,
task_prompt,
&options,
&parent,
&task_id,
))
.await
})
.await
})
.await?;
@@ -274,6 +311,7 @@ pub async fn run_subagent(
tracing::info!(
agent_id = %definition.id,
task_id = %task_id,
spawn_depth = attempted_depth,
elapsed_ms = outcome.elapsed.as_millis() as u64,
iterations = outcome.iterations,
output_chars = outcome.output.chars().count(),
@@ -597,6 +597,62 @@ async fn runner_errors_outside_parent_context() {
assert!(matches!(result, Err(SubagentRunError::NoParentContext)));
}
#[tokio::test]
async fn runner_allows_spawn_at_max_depth() {
let provider = ScriptedProvider::new(vec![text_response("ok")]);
let parent = make_parent(provider.clone(), vec![]);
let def = make_def_named_tools(&[]);
let outcome = with_parent_context(parent, async {
with_spawn_depth(MAX_SPAWN_DEPTH - 1, async {
run_subagent(&def, "x", SubagentRunOptions::default()).await
})
.await
})
.await
.expect("runner should allow the configured maximum depth");
assert_eq!(outcome.output, "ok");
assert_eq!(provider.captured.lock().len(), 1);
assert_eq!(
current_spawn_depth(),
0,
"depth task-local must not leak after the run"
);
}
#[tokio::test]
async fn runner_rejects_spawn_beyond_max_depth() {
let provider = ScriptedProvider::new(vec![text_response("should not be called")]);
let parent = make_parent(provider.clone(), vec![]);
let def = make_def_named_tools(&[]);
let result = with_parent_context(parent, async {
with_spawn_depth(MAX_SPAWN_DEPTH, async {
run_subagent(&def, "x", SubagentRunOptions::default()).await
})
.await
})
.await;
assert!(matches!(
result,
Err(SubagentRunError::SpawnDepthExceeded {
attempted_depth,
max_depth
}) if attempted_depth == MAX_SPAWN_DEPTH + 1 && max_depth == MAX_SPAWN_DEPTH
));
assert!(
provider.captured.lock().is_empty(),
"depth rejection must happen before provider dispatch"
);
assert_eq!(
current_spawn_depth(),
0,
"depth task-local must not leak after rejection"
);
}
#[tokio::test]
async fn typed_mode_model_override_pins_exact_model_for_spawn() {
let provider = ScriptedProvider::new(vec![text_response("ok")]);
@@ -100,6 +100,12 @@ pub enum SubagentRunError {
#[error("provider call failed: {0}")]
Provider(#[from] anyhow::Error),
#[error("sub-agent spawn depth exceeded: attempted depth {attempted_depth}, max {max_depth}")]
SpawnDepthExceeded {
attempted_depth: usize,
max_depth: usize,
},
#[error("sub-agent exceeded maximum iterations ({0})")]
MaxIterationsExceeded(usize),
}