diff --git a/docs/conventions.md b/docs/conventions.md index 20c1ca4a..806c95b0 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -93,11 +93,18 @@ angle-bracket and asterisk shapes below are structurally safe. a root agent (or absent from topology entirely). Lets agents address their parent without learning the label, so runtime reparenting propagates with zero agent-side restart. +- `` — fan-out to every direct descendant of the sender per + `topology.json`. Resolved in `agent_server::handle_send` via + `topology::children_of(sender)`: one message is delivered to each + child, bypassing the allow-list check (structural fan-out targets are + never user-listed peers). No-op for leaf agents (returns `Ok` when the + child set is empty). Lets a sub-manager nudge its subtree without + enumerating labels. -When the resolver rewrites ``, the broker stores the -*resolved* label as the message's recipient — the dashboard and -recv side both see the real route. The sentinel is purely a -send-time addressing convenience. +When a `` or `` send resolves to real recipients, the +broker stores the *resolved* label(s) as the message recipient(s) — the +dashboard and recv side see the real routes. The sentinels are purely +send-time addressing conveniences. ## Wire protocol diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index 2cdb47b0..355265aa 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -8,7 +8,7 @@ You are the hyperhive manager `{label}` (qualified: `{qualified_label}`){hive_id Tools (hyperhive surface): - `mcp__hyperhive__recv(wait_seconds?, max?)` — drain inbox messages (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop. `max` (default 1, cap 32) drains several queued messages in one call — the wake prompt tells you the pending count. -- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: ""` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Lets the operator reparent you at runtime with zero change on your side. Optional `in_reply_to: ` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable. +- `mcp__hyperhive__send(to, body, in_reply_to?)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Use `to: ""` to address your structural parent without hardcoding their name — hive-c0re rewrites it at delivery time per `topology.json`, falling back to `operator` if you're a root agent. Use `to: ""` to fan-out to every direct child of yours per `topology.json` (no-op for leaf agents). Both sentinels let the operator reparent at runtime with zero change on your side. Optional `in_reply_to: ` threads this message under a prior one — the dashboard and per-agent inbox render it with a `↳ reply` link. Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable. - (some agents only) **extra MCP tools** surfaced as `mcp____` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time. diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 70299b15..c658fb21 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -313,11 +313,38 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } } +/// Fan out one message to each recipient in `targets`. Skips the sender +/// itself. Returns a list of `": "` strings for any delivery +/// failures (empty = all good). +fn fan_out_send( + coord: &Arc, + from: &str, + body: &str, + in_reply_to: Option, + targets: &[String], +) -> Vec { + let mut errors = Vec::new(); + for target in targets { + if target == from { + continue; + } + if let Err(e) = coord.broker.send(&Message { + from: from.to_owned(), + to: target.clone(), + body: body.to_owned(), + in_reply_to, + }) { + errors.push(format!("{target}: {e}")); + } + } + errors +} + /// Common Send handler shared between dispatch arms. Applies the -/// 4 KiB body cap, then routes broadcast (`to == "*"`) vs unicast -/// through their respective broker calls. Pulled out of `dispatch` -/// to keep that function under the clippy too-many-lines limit; the -/// behaviour is identical to inlining. +/// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out +/// (`to == ""`) / unicast through their respective broker calls. +/// Pulled out of `dispatch` to keep that function under the clippy +/// too-many-lines limit; the behaviour is identical to inlining. fn handle_send( coord: &Arc, agent: &str, @@ -338,11 +365,25 @@ fn handle_send( } }; } - // Resolve magic-recipient sentinels (currently ``) against - // topology.json; no-op for ordinary names. Lets agents address - // structural roles without learning the label — runtime - // reparenting propagates for free. See `docs/conventions.md:: - // Recipient sentinels`. + // ``: fan out to every direct descendant of the sender per + // topology.json. Bypasses the allow-list check — structural fan-out + // targets are never user-listed peers. No-op (returns Ok) for leaf + // agents that have no children. + if to == hive_sh4re::CHILDREN_RECIPIENT { + let children = crate::topology::children_of(agent); + let errors = fan_out_send(coord, agent, body, in_reply_to, &children); + return if errors.is_empty() { + AgentResponse::Ok + } else { + AgentResponse::Err { + message: format!("children fan-out failed for agents: {}", errors.join(", ")), + } + }; + } + // Resolve magic-recipient sentinels (``) against topology.json; + // no-op for ordinary names. Lets agents address structural roles without + // learning the label — runtime reparenting propagates for free. See + // `docs/conventions.md::Recipient sentinels`. let resolved = crate::topology::resolve_recipient(agent, to); match coord.broker.send(&Message { from: agent.to_owned(), diff --git a/hive-c0re/src/topology.rs b/hive-c0re/src/topology.rs index 49a2ddd9..898bb433 100644 --- a/hive-c0re/src/topology.rs +++ b/hive-c0re/src/topology.rs @@ -44,6 +44,32 @@ pub fn parent_of(name: &str) -> Option { read().get(name).cloned().flatten() } +/// Return the direct children of `name` — agents whose `topology.json` +/// entry has `name` as their parent. Reads the map once and scans all +/// entries; cheap enough for the fan-out path (one disk read per send +/// to ``). +#[must_use] +pub fn children_of(name: &str) -> Vec { + children_of_in(&read(), name) +} + +/// Pure form of [`children_of`] for unit tests. +#[must_use] +pub fn children_of_in( + topo: &BTreeMap>, + name: &str, +) -> Vec { + topo.iter() + .filter_map(|(agent, parent)| { + if parent.as_deref() == Some(name) { + Some(agent.clone()) + } else { + None + } + }) + .collect() +} + /// Resolve a magic recipient sentinel (currently just /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// send time. Returns an owned `String` so callers can plug it @@ -449,4 +475,35 @@ mod tests { hive_sh4re::OPERATOR_RECIPIENT ); } + + #[test] + fn children_of_in_returns_direct_descendants() { + let topo = topo_three_level(); + // alice's children: bob, carol. + let mut children = children_of_in(&topo, "alice"); + children.sort(); + assert_eq!(children, vec!["bob", "carol"]); + } + + #[test] + fn children_of_in_manager_returns_root_level_agents() { + let topo = topo_three_level(); + // Only alice's parent is manager; bob+carol are under alice. + let children = children_of_in(&topo, crate::lifecycle::MANAGER_NAME); + assert_eq!(children, vec!["alice"]); + } + + #[test] + fn children_of_in_leaf_returns_empty() { + let topo = topo_three_level(); + // bob and carol have no children. + assert!(children_of_in(&topo, "bob").is_empty()); + assert!(children_of_in(&topo, "carol").is_empty()); + } + + #[test] + fn children_of_in_unknown_sender_returns_empty() { + let topo = topo_three_level(); + assert!(children_of_in(&topo, "nobody").is_empty()); + } } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 0ac1e875..160ee5c9 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -489,6 +489,14 @@ pub const OPERATOR_RECIPIENT: &str = "operator"; /// `<`/`>`), so this name can never collide with a real recipient. pub const PARENT_RECIPIENT: &str = ""; +/// Reserved magic recipient — `send(to: "", ...)` fans out to +/// every agent whose direct parent (per `topology.json`) is the sender. +/// Lets a sub-manager nudge its subtree without enumerating labels at +/// call-time; topology changes propagate for free. The angle brackets +/// are structurally safe — agent name validation rejects `<`/`>`. +/// Delivers to an empty set (no-op) for leaf agents that have no children. +pub const CHILDREN_RECIPIENT: &str = ""; + /// Sender hive-c0re uses for events it pushes into the manager's inbox. /// Manager harness recognises this and parses the body as a `HelperEvent`. pub const SYSTEM_SENDER: &str = "system";