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
their parent without learning the label, so runtime reparenting
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
*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 `<children>` or `<parent>` 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

View file

@ -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: "<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 -->
- (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 -->

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
/// 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 == "<children>"`) / 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<Coordinator>,
agent: &str,
@ -338,11 +365,25 @@ fn handle_send(
}
};
}
// Resolve magic-recipient sentinels (currently `<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`.
// `<children>`: 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 (`<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);
match coord.broker.send(&Message {
from: agent.to_owned(),

View file

@ -44,6 +44,32 @@ pub fn parent_of(name: &str) -> Option<String> {
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
/// [`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());
}
}

View file

@ -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 = "<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.
/// Manager harness recognises this and parses the body as a `HelperEvent`.
pub const SYSTEM_SENDER: &str = "system";