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
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 -->
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue