diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index 2ff52f850..1ef70c85e 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -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 diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index d3348b4a7..bd8731bde 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -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. //! - `` XML attribute form — the parser does not parse attributes; diff --git a/src/openhuman/agent/harness/mod.rs b/src/openhuman/agent/harness/mod.rs index 882416fba..235b741d9 100644 --- a/src/openhuman/agent/harness/mod.rs +++ b/src/openhuman/agent/harness/mod.rs @@ -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; diff --git a/src/openhuman/agent/harness/spawn_depth_context.rs b/src/openhuman/agent/harness/spawn_depth_context.rs new file mode 100644 index 000000000..3f660ebe5 --- /dev/null +++ b/src/openhuman/agent/harness/spawn_depth_context.rs @@ -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(depth: usize, future: F) -> R +where + F: std::future::Future, +{ + 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; + } +} diff --git a/src/openhuman/agent/harness/subagent_runner/ops.rs b/src/openhuman/agent/harness/subagent_runner/ops.rs index 361b86f69..123995126 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops.rs @@ -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(), diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index cc9c76576..deb9896ff 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -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")]); diff --git a/src/openhuman/agent/harness/subagent_runner/types.rs b/src/openhuman/agent/harness/subagent_runner/types.rs index 4fbcbc9c5..74c5b3b32 100644 --- a/src/openhuman/agent/harness/subagent_runner/types.rs +++ b/src/openhuman/agent/harness/subagent_runner/types.rs @@ -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), }