feat(#704): add <children> recipient sentinel — fan-out to direct descendants
This commit is contained in:
parent
7cc2690717
commit
36c683138d
5 changed files with 128 additions and 15 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue