From 73edfc178308acadcbddd5fc4040f66c55e6b500 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:24:58 +0530 Subject: [PATCH] fix(agent): route parallel/council fan-out to one concurrent spawn_parallel_agents call (#4754) (#4757) --- .../tools/spawn_subagent.rs | 11 +++- .../agents/orchestrator/prompt.md | 10 +++ tests/orchestrator_parallel_fanout_routing.rs | 62 +++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/orchestrator_parallel_fanout_routing.rs diff --git a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs index 29da6ada6..42bc2af8f 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_subagent.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_subagent.rs @@ -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=\"\"` naming the Composio integration the \ diff --git a/src/openhuman/agent_registry/agents/orchestrator/prompt.md b/src/openhuman/agent_registry/agents/orchestrator/prompt.md index 3da92485b..c3502e3be 100644 --- a/src/openhuman/agent_registry/agents/orchestrator/prompt.md +++ b/src/openhuman/agent_registry/agents/orchestrator/prompt.md @@ -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 diff --git a/tests/orchestrator_parallel_fanout_routing.rs b/tests/orchestrator_parallel_fanout_routing.rs new file mode 100644 index 000000000..ec568e0df --- /dev/null +++ b/tests/orchestrator_parallel_fanout_routing.rs @@ -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)" + ); +}