feat(#704): add <children> recipient sentinel — fan-out to direct descendants

This commit is contained in:
damocles 2026-05-31 20:41:34 +02:00 committed by mara
commit 36c683138d
5 changed files with 128 additions and 15 deletions

View file

@ -93,11 +93,18 @@ angle-bracket and asterisk shapes below are structurally safe.
a root agent (or absent from topology entirely). Lets agents address a root agent (or absent from topology entirely). Lets agents address
their parent without learning the label, so runtime reparenting their parent without learning the label, so runtime reparenting
propagates with zero agent-side restart. propagates with zero agent-side restart.
- `<children>` — 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 `<parent>`, the broker stores the When a `<children>` or `<parent>` send resolves to real recipients, the
*resolved* label as the message's recipient — the dashboard and broker stores the *resolved* label(s) as the message recipient(s) — the
recv side both see the real route. The sentinel is purely a dashboard and recv side see the real routes. The sentinels are purely
send-time addressing convenience. send-time addressing conveniences.
## Wire protocol ## Wire protocol

View file

@ -8,7 +8,7 @@ You are the hyperhive manager `{label}` (qualified: `{qualified_label}`){hive_id
Tools (hyperhive surface): 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__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: "<parent>"` 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: <message-id>` 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: "<parent>"` 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: "<children>"` 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: <message-id>` 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.
<!-- role:agent --> <!-- role:agent -->
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — 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. - (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — 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.
<!-- /role:agent --> <!-- /role:agent -->

View file

@ -313,11 +313,38 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
} }
} }
/// Fan out one message to each recipient in `targets`. Skips the sender
/// itself. Returns a list of `"<agent>: <error>"` strings for any delivery
/// failures (empty = all good).
fn fan_out_send(
coord: &Arc<Coordinator>,
from: &str,
body: &str,
in_reply_to: Option<i64>,
targets: &[String],
) -> Vec<String> {
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 /// Common Send handler shared between dispatch arms. Applies the
/// 4 KiB body cap, then routes broadcast (`to == "*"`) vs unicast /// 4 KiB body cap, then routes broadcast (`to == "*"`) / children fan-out
/// through their respective broker calls. Pulled out of `dispatch` /// (`to == "<children>"`) / unicast through their respective broker calls.
/// to keep that function under the clippy too-many-lines limit; the /// Pulled out of `dispatch` to keep that function under the clippy
/// behaviour is identical to inlining. /// too-many-lines limit; the behaviour is identical to inlining.
fn handle_send( fn handle_send(
coord: &Arc<Coordinator>, coord: &Arc<Coordinator>,
agent: &str, agent: &str,
@ -338,11 +365,25 @@ fn handle_send(
} }
}; };
} }
// Resolve magic-recipient sentinels (currently `<parent>`) against // `<children>`: fan out to every direct descendant of the sender per
// topology.json; no-op for ordinary names. Lets agents address // topology.json. Bypasses the allow-list check — structural fan-out
// structural roles without learning the label — runtime // targets are never user-listed peers. No-op (returns Ok) for leaf
// reparenting propagates for free. See `docs/conventions.md:: // agents that have no children.
// Recipient sentinels`. 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 (`<parent>`) 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); let resolved = crate::topology::resolve_recipient(agent, to);
match coord.broker.send(&Message { match coord.broker.send(&Message {
from: agent.to_owned(), from: agent.to_owned(),

View file

@ -44,6 +44,32 @@ pub fn parent_of(name: &str) -> Option<String> {
read().get(name).cloned().flatten() 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 `<children>`).
#[must_use]
pub fn children_of(name: &str) -> Vec<String> {
children_of_in(&read(), name)
}
/// Pure form of [`children_of`] for unit tests.
#[must_use]
pub fn children_of_in(
topo: &BTreeMap<String, Option<String>>,
name: &str,
) -> Vec<String> {
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 /// Resolve a magic recipient sentinel (currently just
/// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at /// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at
/// send time. Returns an owned `String` so callers can plug it /// send time. Returns an owned `String` so callers can plug it
@ -449,4 +475,35 @@ mod tests {
hive_sh4re::OPERATOR_RECIPIENT 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());
}
} }

View file

@ -489,6 +489,14 @@ pub const OPERATOR_RECIPIENT: &str = "operator";
/// `<`/`>`), so this name can never collide with a real recipient. /// `<`/`>`), so this name can never collide with a real recipient.
pub const PARENT_RECIPIENT: &str = "<parent>"; pub const PARENT_RECIPIENT: &str = "<parent>";
/// Reserved magic recipient — `send(to: "<children>", ...)` 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 = "<children>";
/// Sender hive-c0re uses for events it pushes into the manager's inbox. /// Sender hive-c0re uses for events it pushes into the manager's inbox.
/// Manager harness recognises this and parses the body as a `HelperEvent`. /// Manager harness recognises this and parses the body as a `HelperEvent`.
pub const SYSTEM_SENDER: &str = "system"; pub const SYSTEM_SENDER: &str = "system";