mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user