fix(agent): route parallel/council fan-out to one concurrent spawn_parallel_agents call (#4754) (#4757)

This commit is contained in:
Mega Mind
2026-07-13 02:54:58 -07:00
committed by GitHub
parent bfa11313d2
commit 73edfc1783
3 changed files with 82 additions and 1 deletions
@@ -75,7 +75,16 @@ impl Tool for SpawnSubagentTool {
fn description(&self) -> &str {
"Delegate a task to a specialised sub-agent only when direct \
response or direct tools are insufficient. See the Delegation \
response or direct tools are insufficient. Handles ONE delegated task \
per call: by default it runs as a reusable async worker and returns \
immediately — pass `blocking: true` to run it inline and get the \
sub-agent's final output back in this turn. To run several independent \
workers at once (e.g. \"a separate researcher for each X\", a council \
of opinions, or \"fan out over N items\"), use `spawn_parallel_agents` \
with one task per worker — a SINGLE call that launches them \
concurrently. Do NOT call this tool in a loop to fan out: repeated \
`spawn_subagent` calls each delegate a single task and never launch \
workers concurrently, which serializes the whole request. See the Delegation \
Guide in the system prompt for available agent_ids and when to \
use each. When delegating to `integrations_agent`, you MUST also pass \
`toolkit=\"<name>\"` naming the Composio integration the \
@@ -115,6 +115,16 @@ succeeded. Do **not** use it when the subtasks depend on each other's output (se
those, or use `rhai_workflows` for real control flow), and don't fan out work that a
single delegation or a direct tool already covers.
**Fan-out is ONE `spawn_parallel_agents` call, never a loop of `spawn_subagent`.** When the
user asks for parallel work — "in parallel", "a separate researcher/agent for each X",
"convene a council / get multiple independent opinions", "fan out and summarize each of my
last N threads" — put **all** the workers into a **single** `spawn_parallel_agents` call
(one task per worker). Do **not** call `spawn_subagent` once per worker: those calls run
**strictly one-at-a-time** (each sub-agent finishes, ~145s+, before the next even starts),
which serializes the whole request and defeats the explicit parallel/council intent.
`spawn_parallel_agents` launches every worker at once and returns when the slowest is done.
If the user names N targets or asks for N opinions, that is N tasks in **one** call.
### Async background sub-agents
Use `spawn_async_subagent` only for low-attention background work where the current user
@@ -0,0 +1,62 @@
//! Pins the orchestrator's parallel/council fan-out routing (#4754).
//!
//! An agent-efficiency eval found the orchestrator never fanning out workers
//! concurrently: parallel/"separate researcher for each"/council prompts either
//! single-spawned or issued serial `spawn_subagent` calls 145-200s apart
//! (each sub-agent finishing before the next started), defeating the request.
//!
//! Root cause is routing, not harness concurrency: `spawn_parallel_agents`
//! already fans out concurrently (tinyagents `map_reduce` / `buffer_unordered`)
//! as a single tool call, but the orchestrator reached for serial
//! `spawn_subagent` instead. The fix steers parallel/council/fan-out requests
//! to ONE `spawn_parallel_agents` call — in both the system prompt and the
//! `spawn_subagent` tool description the model reads when choosing a tool.
//!
//! These assertions pin that guidance so a future edit can't silently drop it.
const ORCHESTRATOR_PROMPT: &str =
include_str!("../src/openhuman/agent_registry/agents/orchestrator/prompt.md");
const SPAWN_SUBAGENT_SRC: &str =
include_str!("../src/openhuman/agent_orchestration/tools/spawn_subagent.rs");
#[test]
fn prompt_routes_parallel_and_council_to_spawn_parallel_agents() {
// The parallel-fanout guidance must exist and name the concurrent primitive.
assert!(
ORCHESTRATOR_PROMPT.contains("spawn_parallel_agents"),
"orchestrator prompt must route fan-out to `spawn_parallel_agents` (#4754)"
);
// It must explicitly cover the council / multiple-independent-opinions case
// that the eval showed collapsing to a single spawn.
assert!(
ORCHESTRATOR_PROMPT.to_lowercase().contains("council"),
"orchestrator prompt must steer council / multiple-opinions requests to \
a parallel fan-out, not a single spawn (#4754)"
);
// It must warn against the serial anti-pattern (looping `spawn_subagent`),
// which is what produced the 145-200s serial gaps.
let p = ORCHESTRATOR_PROMPT.to_lowercase();
assert!(
p.contains("never a loop of") || p.contains("do **not** call `spawn_subagent` once per"),
"orchestrator prompt must warn that repeated `spawn_subagent` serializes \
fan-out and to use one `spawn_parallel_agents` call instead (#4754)"
);
}
#[test]
fn spawn_subagent_description_redirects_fanout_to_parallel() {
// The tool description is what the model reads while picking a tool, so the
// redirect has to live there, not only in the system prompt. Anchor on the
// description() body to avoid matching an unrelated mention elsewhere.
let desc_start = SPAWN_SUBAGENT_SRC
.find("fn description(&self)")
.expect("spawn_subagent must have a description()");
let desc = &SPAWN_SUBAGENT_SRC[desc_start..];
let desc_body = &desc[..desc.find("fn parameters_schema").unwrap_or(desc.len())];
assert!(
desc_body.contains("spawn_parallel_agents"),
"spawn_subagent's description must redirect concurrent fan-out to \
`spawn_parallel_agents` so the model picks the parallel tool (#4754)"
);
}