feat(agent): codify chat → reasoning → worker spawn hierarchy (#2026)

This commit is contained in:
Steven Enamakel
2026-05-17 18:44:12 -07:00
committed by GitHub
parent 86b661d829
commit 0257b2e662
14 changed files with 344 additions and 4 deletions
@@ -167,6 +167,42 @@ When the orchestrator calls `spawn_subagent` (or one of the `delegate_*` conveni
For tasks that don't need to block the orchestrator's turn, `spawn_worker_thread` runs the sub-agent in the background and the orchestrator continues immediately.
### Spawn hierarchy and tiers
Not every agent is allowed to spawn every other agent. The harness models a three-tier hierarchy that mirrors the cost / latency / depth-of-thought split between models:
```text
Chat (fast, UX-focused — e.g. orchestrator on `chat` hint)
├─► Worker ◄─── fast path: one delegation, leaf does the work
└─► Reasoning (slow, deep-thinking — e.g. planner on `reasoning` hint)
└─► Worker ◄─── deep path: reasoning decomposes, workers execute
```
Each `AgentDefinition` carries an `agent_tier` field (`chat` / `reasoning` / `worker`, default `worker`). The contract:
| Tier | May spawn | Must NOT spawn | Typical members |
| ------------ | ----------------- | ---------------------------- | -------------------------------------------------------- |
| `chat` | `reasoning`, `worker` | another `chat` | `orchestrator` |
| `reasoning` | `worker` | another `reasoning`, any `chat` | `planner` (today the canonical one) |
| `worker` | nothing[^1] | anything | researcher, code_executor, critic, archivist, tool_maker, integrations_agent, … |
[^1]: Skill-wildcard entries (`{ skills = "*" }`) are exempt because they collapse to a single `delegate_to_integrations_agent` tool whose target is a worker — they're a fan-out delegation surface, not a recursive spawn.
**Why the rules.**
- *Chat → chat is meaningless.* The chat tier exists for snappy UX. A chat agent spawning another chat agent just doubles TTFT and burns tokens without buying any new capability.
- *Reasoning → reasoning blows up depth.* The reasoning tier is expensive. Chains of reasoning agents tend to re-decompose the same problem and create runaway hierarchies.
- *Worker → anything mixes execution and orchestration.* Workers are leaves so the parent always sees one compact result, not a transcript of nested delegations.
**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`.
> **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.
### Toolkit-specific specialists
For Composio toolkits with hundreds of actions (GitHub alone has 500+), loading every action into the sub-agent's tool set balloons prompt size. The harness ranks the toolkit's actions against the parent-refined task prompt with a cheap CPU-only filter (verb detection, token overlap, verb-alignment boost) and only loads the top-ranked subset into the sub-agent. No model call, pure heuristic - fast and explainable.
+190 -2
View File
@@ -35,9 +35,10 @@
//! collision.
use crate::openhuman::agent::harness::definition::{
AgentDefinition, DefinitionSource, PromptBuilder, PromptSource,
AgentDefinition, AgentTier, DefinitionSource, PromptBuilder, PromptSource, SubagentEntry,
};
use anyhow::{Context, Result};
use std::collections::HashMap;
/// A single built-in agent: its id plus the metadata TOML and a
/// function-driven prompt builder.
@@ -150,7 +151,92 @@ pub const BUILTINS: &[BuiltinAgent] = &[
/// baked into the binary and therefore must always be valid. Unit tests
/// below keep that invariant honest.
pub fn load_builtins() -> Result<Vec<AgentDefinition>> {
BUILTINS.iter().map(parse_builtin).collect()
let defs: Vec<AgentDefinition> = BUILTINS.iter().map(parse_builtin).collect::<Result<_>>()?;
validate_tier_hierarchy(&defs)
.context("built-in agents violate the spawn-hierarchy contract")?;
Ok(defs)
}
/// Validate the cross-agent spawn-hierarchy contract documented on
/// [`AgentTier`].
///
/// Rules enforced here:
///
/// * `Chat` agents MUST NOT list another `Chat` agent in `subagents`.
/// * `Reasoning` agents MUST NOT list another `Reasoning` agent in
/// `subagents`.
/// * `Worker` agents MUST NOT list any [`SubagentEntry::AgentId`]
/// entries. (Skill wildcards are allowed: they expand to the generic
/// `integrations_agent`, which is itself a `Worker`, and the call
/// happens via a single delegation tool rather than recursive spawn.)
///
/// Skill-wildcard entries (`{ skills = "*" }`) are intentionally
/// untouched: they collapse to one `delegate_to_integrations_agent`
/// tool whose target is a `Worker` and whose use sites are well
/// understood. Mis-tiering of the `integrations_agent` itself is still
/// caught because it appears as a normal entry elsewhere.
///
/// Called from [`load_builtins`] for the bundled archetype set and from
/// [`crate::openhuman::agent::harness::definition::AgentDefinitionRegistry::load`]
/// after workspace-local TOML overrides are merged, so custom user
/// agents that violate the contract fail the boot rather than crashing
/// at spawn time.
pub fn validate_tier_hierarchy(defs: &[AgentDefinition]) -> Result<()> {
let tier_by_id: HashMap<&str, AgentTier> =
defs.iter().map(|d| (d.id.as_str(), d.agent_tier)).collect();
for def in defs {
for entry in &def.subagents {
let child_id = match entry {
SubagentEntry::AgentId(id) => id.as_str(),
// Skill wildcards always route to `integrations_agent`
// (a Worker) via a single collapsed delegation tool —
// not subject to the tier-mismatch rule.
SubagentEntry::Skills(_) => continue,
};
// Worker leaves: no spawn surface at all.
if def.agent_tier == AgentTier::Worker {
anyhow::bail!(
"agent `{parent}` is a `worker` tier and must not list `{child}` (or any \
agent) in its subagents — workers are leaf executors. Either remove the \
entry or re-tier `{parent}` as `chat` / `reasoning`.",
parent = def.id,
child = child_id,
);
}
let Some(child_tier) = tier_by_id.get(child_id).copied() else {
// Unknown id — that's a separate `subagents` integrity
// concern (covered by existing tests / runtime spawn
// resolution); don't mask it as a tier error.
continue;
};
// Same-tier delegation is forbidden for chat and reasoning.
// (Chat→Chat would defeat the whole point of the fast tier;
// Reasoning→Reasoning produces a depth-blowing recursion of
// slow models.)
match (def.agent_tier, child_tier) {
(AgentTier::Chat, AgentTier::Chat) => anyhow::bail!(
"agent `{parent}` (chat) lists `{child}` (chat) in subagents — the chat tier \
is a leaf in its own dimension. Hand off to a `reasoning` or `worker` agent \
instead.",
parent = def.id,
child = child_id,
),
(AgentTier::Reasoning, AgentTier::Reasoning) => anyhow::bail!(
"agent `{parent}` (reasoning) lists `{child}` (reasoning) in subagents — \
reasoning agents compose downward into workers, not into each other.",
parent = def.id,
child = child_id,
),
_ => {}
}
}
}
Ok(())
}
/// Parse a single [`BuiltinAgent`] triple into a finished [`AgentDefinition`].
@@ -713,4 +799,106 @@ mod tests {
assert!(def.omit_identity);
assert_eq!(def.max_iterations, 12);
}
// ─────────────────────────────────────────────────────────────────────
// Spawn-hierarchy contract
// ─────────────────────────────────────────────────────────────────────
#[test]
fn orchestrator_is_chat_tier() {
assert_eq!(find("orchestrator").agent_tier, AgentTier::Chat);
}
#[test]
fn planner_is_reasoning_tier() {
assert_eq!(find("planner").agent_tier, AgentTier::Reasoning);
}
#[test]
fn other_builtins_default_to_worker_tier() {
for def in load_builtins().unwrap() {
if def.id == "orchestrator" || def.id == "planner" {
continue;
}
assert_eq!(
def.agent_tier,
AgentTier::Worker,
"{} should default to worker tier (only orchestrator/planner are non-worker today)",
def.id
);
}
}
#[test]
fn builtins_pass_tier_validation() {
// load_builtins() already calls validate_tier_hierarchy; this
// just makes the contract a named invariant in the test suite.
let defs = load_builtins().expect("built-ins must pass tier validation");
validate_tier_hierarchy(&defs).expect("explicit re-check must pass");
}
#[test]
fn rejects_chat_to_chat_delegation() {
let mut defs = load_builtins().unwrap();
// Add a synthetic second chat agent and have the orchestrator
// try to delegate to it.
let mut bad_chat = find("orchestrator");
bad_chat.id = "second_orchestrator".to_string();
defs.push(bad_chat);
let orch = defs.iter_mut().find(|d| d.id == "orchestrator").unwrap();
orch.subagents
.push(SubagentEntry::AgentId("second_orchestrator".into()));
let err = validate_tier_hierarchy(&defs).expect_err("chat→chat must be rejected");
let msg = err.to_string();
assert!(
msg.contains("chat") && msg.contains("leaf"),
"error should call out chat-tier leaf rule, got: {msg}"
);
}
#[test]
fn rejects_reasoning_to_reasoning_delegation() {
let mut defs = load_builtins().unwrap();
let mut bad_reasoning = find("planner");
bad_reasoning.id = "second_planner".to_string();
defs.push(bad_reasoning);
let planner = defs.iter_mut().find(|d| d.id == "planner").unwrap();
planner
.subagents
.push(SubagentEntry::AgentId("second_planner".into()));
let err = validate_tier_hierarchy(&defs).expect_err("reasoning→reasoning must be rejected");
assert!(err.to_string().contains("reasoning"));
}
#[test]
fn rejects_worker_with_subagents() {
let mut defs = load_builtins().unwrap();
let researcher = defs.iter_mut().find(|d| d.id == "researcher").unwrap();
researcher
.subagents
.push(SubagentEntry::AgentId("critic".into()));
let err = validate_tier_hierarchy(&defs)
.expect_err("worker with declared subagents must be rejected");
let msg = err.to_string();
assert!(
msg.contains("worker") && msg.contains("leaf"),
"error should call out worker leaf rule, got: {msg}"
);
}
#[test]
fn allows_skill_wildcards_on_any_non_worker_tier() {
// Skills wildcards collapse to delegate_to_integrations_agent
// and must not be policed by the tier check (it'd be a false
// positive — they fan out to a worker anyway).
let mut defs = load_builtins().unwrap();
let planner = defs.iter_mut().find(|d| d.id == "planner").unwrap();
planner.subagents.push(SubagentEntry::Skills(
crate::openhuman::agent::harness::definition::SkillsWildcard { skills: "*".into() },
));
validate_tier_hierarchy(&defs).expect("skill wildcards on reasoning tier must validate");
}
}
+1 -1
View File
@@ -22,4 +22,4 @@ pub mod trigger_reactor;
pub mod trigger_triage;
pub mod welcome;
pub use loader::{load_builtins, BuiltinAgent, BUILTINS};
pub use loader::{load_builtins, validate_tier_hierarchy, BuiltinAgent, BUILTINS};
@@ -4,6 +4,13 @@ when_to_use = "Staff Engineer — routes, judges quality, synthesises. Never wri
temperature = 0.4
max_iterations = 15
sandbox_mode = "none"
# Spawn hierarchy: this is the user-facing fast tier (chat model hint).
# Loader enforces: a `chat` agent must NOT list any other `chat` agent
# in `subagents` below — handoff goes to `reasoning` (e.g. planner) for
# long-running deep work, or directly to `worker` specialists for the
# fast path. See `AgentTier` in `src/openhuman/agent/harness/definition.rs`.
agent_tier = "chat"
omit_identity = true
omit_memory_context = true
omit_safety_preamble = true
@@ -38,7 +38,9 @@ Default bias: **do not spawn a sub-agent when a direct response or direct tool c
## Rules
- **Never spawn yourself** — You cannot delegate to another Orchestrator.
- **You are the chat tier.** You run on a fast UX-focused model (TTFT > deep reasoning). When a task needs sustained multi-step thinking — planning across many steps, comparing several non-obvious options, untangling ambiguous requirements — **delegate to the reasoning tier (`delegate_plan`)** rather than reasoning through it yourself. Your job at that point is to brief the planner well and synthesise its output back to the user.
- **Never spawn yourself** — You cannot delegate to another chat-tier agent (Orchestrator or otherwise). The chat tier is a leaf in its own dimension.
- **Spawn hierarchy (hard rule).** Allowed handoffs from here: `chat → worker` (fast path) or `chat → reasoning → worker` (deep path). Never `chat → chat` and never `chat → reasoning → reasoning`. The loader rejects same-tier delegation at boot; a runtime depth gate capping chains at 3 hops is a planned follow-up — until it lands, this rule is enforced by you, by the planner's matching rule, and by the static loader check.
- **Minimise sub-agents** — Use the fewest agents necessary. Simple questions don't need a DAG.
- **Direct-first always** — First try direct reply or direct tools; delegate only when required by task complexity/capability gaps.
- **Context is expensive** — Pass only relevant context to sub-agents, not everything.
@@ -11,6 +11,12 @@ omit_memory_context = false
omit_safety_preamble = true
omit_skills_catalog = true
# Spawn hierarchy: this is the deep-thinking tier (reasoning model hint).
# The planner produces a DAG of leaf tasks for downstream workers; it
# must NOT delegate to another `reasoning` agent. The loader rejects
# any same-tier entry added to a future `subagents` list.
agent_tier = "reasoning"
[model]
hint = "reasoning"
@@ -40,6 +40,7 @@ Return **only** valid JSON matching this schema:
## Rules
0. **You are the reasoning tier.** The chat-tier Orchestrator handed off to you because the task needs sustained thinking. Compose plans for the **worker tier**`code_executor`, `researcher`, `critic`, `integrations_agent`, `archivist`. **Never delegate to another reasoning agent** (no planner-spawns-planner, no planner-spawns-orchestrator); the loader rejects this at boot, and the planned runtime depth gate will reject it at spawn time. If a single worker can't cover a node, split the node — don't smuggle a second reasoning hop in.
1. **Gather before planning** — Search memory and the web first. Don't guess what you can look up.
2. **Minimise tasks** — Use the fewest nodes needed. Don't over-decompose.
3. **Dependencies matter** — Use `depends_on` to express ordering. Independent tasks run in parallel.
@@ -73,6 +73,7 @@ pub(crate) fn test_inherit_echo_def() -> AgentDefinition {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}
@@ -107,6 +108,7 @@ pub(crate) fn test_inherit_parallel_worker_def() -> AgentDefinition {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}
+93
View File
@@ -180,12 +180,90 @@ pub struct AgentDefinition {
#[serde(default)]
pub delegate_name: Option<String>,
// ── spawn hierarchy ────────────────────────────────────────────────
/// Tier this archetype occupies in the spawn hierarchy
/// (`chat` → `reasoning` → `worker`). Drives loader-time validation
/// of [`AgentDefinition::subagents`] and runtime depth gating in the
/// sub-agent runner. Defaults to [`AgentTier::Worker`] so existing
/// specialists fit the "leaf" role without per-file edits.
///
/// **Hierarchy contract** (enforced by
/// [`super::super::agents::loader`] at registry build time):
///
/// * `Chat` MUST NOT list another `Chat` agent in `subagents`. The
/// user-facing fast tier is a leaf in its own dimension — it
/// hands off to `Reasoning` or `Worker`, never to itself.
/// * `Reasoning` MUST NOT list another `Reasoning` agent in
/// `subagents`. Reasoning composes downward into `Worker`s.
/// * `Worker` MUST NOT list any subagents. Workers execute; they
/// do not orchestrate.
/// * `{ skills = "*" }` entries expand to the generic
/// `integrations_agent` (a `Worker`) so they are always allowed.
///
/// Combined with the harness's `MAX_SPAWN_DEPTH = 3` task-local
/// gate, this means any execution chain bottoms out within three
/// hops: `chat → reasoning → worker` (or `chat → worker` for the
/// fast path).
#[serde(default)]
pub agent_tier: AgentTier,
// ── source bookkeeping ──────────────────────────────────────────────
/// Tracks where the definition was loaded from (Builtin vs. File).
#[serde(skip)]
pub source: DefinitionSource,
}
// ─────────────────────────────────────────────────────────────────────────────
// Agent tier (spawn hierarchy)
// ─────────────────────────────────────────────────────────────────────────────
/// Role an agent plays in the spawn hierarchy.
///
/// See [`AgentDefinition::agent_tier`] for the full contract. In short:
///
/// ```text
/// Chat (fast, UX-focused)
/// └─► Reasoning (slow, deep-thinking)
/// └─► Worker (leaf executors)
/// └─► Worker (direct fast-path delegation)
/// ```
///
/// `Chat` and `Reasoning` are forbidden from spawning their own tier;
/// `Worker` is forbidden from spawning anything. Total depth is capped
/// at three hops by the harness regardless of tier (defence in depth
/// against custom TOMLs that drop the tier annotation).
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum AgentTier {
/// User-facing fast-tier agent (e.g. the Orchestrator on the
/// `chat` model hint). Optimised for TTFT, not for long-horizon
/// reasoning. May delegate to `Reasoning` or `Worker`; must NOT
/// delegate to another `Chat` agent.
Chat,
/// Deep-thinking agent on a `reasoning-v1`-style model (e.g. the
/// Planner). Decomposes long-running tasks and delegates execution
/// to one or more `Worker`s. Must NOT delegate to another
/// `Reasoning` agent.
Reasoning,
/// Leaf executor — researchers, code executors, critics, archivists,
/// integration specialists, etc. Workers do the actual work and must
/// NOT spawn further subagents (a `Worker` with a non-empty
/// `subagents` list is rejected by the loader).
#[default]
Worker,
}
impl AgentTier {
/// Human-readable tier name used in error messages.
pub fn as_str(self) -> &'static str {
match self {
Self::Chat => "chat",
Self::Reasoning => "reasoning",
Self::Worker => "worker",
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Subagent delegation entries
// ─────────────────────────────────────────────────────────────────────────────
@@ -489,6 +567,21 @@ impl AgentDefinitionRegistry {
);
reg.insert(def);
}
// Re-validate the tier hierarchy after custom overrides are
// merged in — a workspace TOML can legally replace a built-in
// (same id) and is held to the same spawn-hierarchy contract
// as the bundled set. See
// [`super::super::agents::loader::validate_tier_hierarchy`].
let snapshot: Vec<AgentDefinition> = reg.list().into_iter().cloned().collect();
super::super::agents::validate_tier_hierarchy(&snapshot).map_err(|e| {
anyhow::anyhow!(
"agent registry rejected after merging workspace overrides from {}: {}",
workspace.display(),
e
)
})?;
Ok(reg)
}
@@ -25,6 +25,7 @@ fn make_def(id: &str) -> AgentDefinition {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}
@@ -365,6 +365,7 @@ mod tests {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}
@@ -26,6 +26,7 @@ fn make_def_named_tools(names: &[&str]) -> AgentDefinition {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: crate::openhuman::agent::harness::definition::DefinitionSource::Builtin,
}
}
@@ -599,6 +599,7 @@ mod scoping_tests {
background: false,
subagents: vec![],
delegate_name: None,
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}
@@ -280,6 +280,7 @@ mod tests {
background: false,
subagents: vec![],
delegate_name: delegate_name.map(String::from),
agent_tier: crate::openhuman::agent::harness::definition::AgentTier::Worker,
source: DefinitionSource::Builtin,
}
}