Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions gitbooks/developing/architecture/agent-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion src/openhuman/agent/harness/harness_gap_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/openhuman/agent/harness/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions src/openhuman/agent/harness/spawn_depth_context.rs
Original file line number Diff line number Diff line change
@@ -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;
}
}
44 changes: 41 additions & 3 deletions src/openhuman/agent/harness/subagent_runner/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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"
Expand All @@ -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?;

Expand Down Expand Up @@ -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(),
Expand Down
56 changes: 56 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]);
Expand Down
6 changes: 6 additions & 0 deletions src/openhuman/agent/harness/subagent_runner/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Loading