broker: resolve <parent> sentinel to topology parent at send time (#692)
This commit is contained in:
parent
1a9d940a4a
commit
7142e95c8f
7 changed files with 154 additions and 8 deletions
|
|
@ -21,6 +21,29 @@ bind-mount." A sub-agent only sees its own `/run/hive/mcp.sock`; the
|
||||||
manager has access to its privileged socket; hive-c0re owns the host
|
manager has access to its privileged socket; hive-c0re owns the host
|
||||||
admin socket.
|
admin socket.
|
||||||
|
|
||||||
|
## Recipient sentinels
|
||||||
|
|
||||||
|
A few recipient names are reserved by the broker and have special
|
||||||
|
meaning that ordinary agent labels can never collide with — agent
|
||||||
|
name validation rejects any character outside `[a-z0-9_-]`, so the
|
||||||
|
angle-bracket and asterisk shapes below are structurally safe.
|
||||||
|
|
||||||
|
- `*` — broadcast: deliver to every running agent except the sender
|
||||||
|
(`agent_server::handle_send` fans out via `Coordinator::broadcast_send`).
|
||||||
|
- `operator` — the human at the dashboard. Messages accumulate in the
|
||||||
|
inbox view; no agent ever `recv`'s them.
|
||||||
|
- `<parent>` — the sender's parent per `topology.json` (`#692`).
|
||||||
|
Rewritten at send time by `topology::resolve_recipient`: looks up
|
||||||
|
`parent_of(sender)` and falls back to `operator` when the sender is
|
||||||
|
a root agent (or absent from topology entirely). Lets agents address
|
||||||
|
their parent without learning the label, so runtime reparenting
|
||||||
|
(`#486`) propagates with zero agent-side restart.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Wire protocol
|
## Wire protocol
|
||||||
|
|
||||||
JSON line-delimited over unix sockets in both directions (host admin
|
JSON line-delimited over unix sockets in both directions (host admin
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ You are the hyperhive manager `{label}` (qualified: `{qualified_label}`) in a mu
|
||||||
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). 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. 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.
|
||||||
<!-- 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 -->
|
||||||
|
|
|
||||||
|
|
@ -1883,6 +1883,16 @@ fn check_send_allowed(to: &str) -> Result<(), String> {
|
||||||
// to ask for help.
|
// to ask for help.
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
if to == hive_sh4re::PARENT_RECIPIENT {
|
||||||
|
// Always allow `<parent>` — same escape-hatch rationale as
|
||||||
|
// the manager exception. The allow-list constrains peer
|
||||||
|
// chatter, not the structural reporting line; the operator
|
||||||
|
// can rewire who the parent IS via `set_parent` without
|
||||||
|
// having to remember to update the per-agent allow-list.
|
||||||
|
// The broker resolves the sentinel to the real parent label
|
||||||
|
// on the host side per topology.json (#692).
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
|
||||||
return Ok(()); // file missing → no policy configured → unrestricted
|
return Ok(()); // file missing → no policy configured → unrestricted
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -328,9 +328,14 @@ 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
|
||||||
|
// (#486) propagates for free (#692).
|
||||||
|
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(),
|
||||||
to: to.to_owned(),
|
to: resolved,
|
||||||
body: body.to_owned(),
|
body: body.to_owned(),
|
||||||
in_reply_to,
|
in_reply_to,
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,15 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Resolve magic-recipient sentinels (currently `<parent>`)
|
||||||
|
// against topology.json; no-op for ordinary names. The
|
||||||
|
// manager has no parent in topology, so `<parent>`
|
||||||
|
// resolves to OPERATOR_RECIPIENT — matching mara's
|
||||||
|
// "no parent → tell the operator" rule (#692).
|
||||||
|
let resolved = crate::topology::resolve_recipient(MANAGER_AGENT, to);
|
||||||
match coord.broker.send(&Message {
|
match coord.broker.send(&Message {
|
||||||
from: MANAGER_AGENT.to_owned(),
|
from: MANAGER_AGENT.to_owned(),
|
||||||
to: to.clone(),
|
to: resolved,
|
||||||
body: body.clone(),
|
body: body.clone(),
|
||||||
in_reply_to: *in_reply_to,
|
in_reply_to: *in_reply_to,
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
|
|
@ -58,15 +58,51 @@ pub fn read() -> BTreeMap<String, Option<String>> {
|
||||||
/// or absent from the file. Cheap convenience over `read()` for
|
/// or absent from the file. Cheap convenience over `read()` for
|
||||||
/// callers that want a single entry.
|
/// callers that want a single entry.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
#[allow(
|
|
||||||
dead_code,
|
|
||||||
reason = "convenience API; callers go through `read()` today, kept for the \
|
|
||||||
dashboard/manager-server surfaces landing in #361 follow-ups"
|
|
||||||
)]
|
|
||||||
pub fn parent_of(name: &str) -> Option<String> {
|
pub fn parent_of(name: &str) -> Option<String> {
|
||||||
read().get(name).cloned().flatten()
|
read().get(name).cloned().flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a magic recipient sentinel (currently just
|
||||||
|
/// [`hive_sh4re::PARENT_RECIPIENT`]) to a real broker recipient at
|
||||||
|
/// send time. Lets agents address structural roles
|
||||||
|
/// (`send(to: "<parent>", ...)`) without learning their parent's
|
||||||
|
/// label — runtime reparenting (#486) propagates for free.
|
||||||
|
///
|
||||||
|
/// Resolution rules:
|
||||||
|
/// - `<parent>` → `topology.json`'s parent for `sender` if some,
|
||||||
|
/// else [`hive_sh4re::OPERATOR_RECIPIENT`] (the "no parent → tell
|
||||||
|
/// mara" fallback from #692 / #8592).
|
||||||
|
/// - Anything else: returned unchanged.
|
||||||
|
///
|
||||||
|
/// Returns an owned `String` for the rewritten recipient so callers
|
||||||
|
/// can plug it straight into [`crate::broker::Broker::send`] without
|
||||||
|
/// borrow-juggling around the temporary lookup. Cheap — the resolved
|
||||||
|
/// path clones twice in the worst case (parent name + return), no-op
|
||||||
|
/// in the common case (recipient already a real label).
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_recipient(sender: &str, to: &str) -> String {
|
||||||
|
resolve_recipient_in(&read(), sender, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure form of [`resolve_recipient`] taking the topology map
|
||||||
|
/// explicitly. Split out so unit tests can exercise the sentinel
|
||||||
|
/// rules without writing a `topology.json` to disk.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve_recipient_in(
|
||||||
|
topo: &BTreeMap<String, Option<String>>,
|
||||||
|
sender: &str,
|
||||||
|
to: &str,
|
||||||
|
) -> String {
|
||||||
|
if to == hive_sh4re::PARENT_RECIPIENT {
|
||||||
|
topo.get(sender)
|
||||||
|
.cloned()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| hive_sh4re::OPERATOR_RECIPIENT.to_owned())
|
||||||
|
} else {
|
||||||
|
to.to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// True when `candidate` is `ancestor` or any descendant of
|
/// True when `candidate` is `ancestor` or any descendant of
|
||||||
/// `ancestor` per the current topology. Walks parents from
|
/// `ancestor` per the current topology. Walks parents from
|
||||||
/// `candidate` upward; the walk terminates at root or on a cycle
|
/// `candidate` upward; the walk terminates at root or on a cycle
|
||||||
|
|
@ -351,4 +387,61 @@ mod tests {
|
||||||
let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap();
|
let next = apply_set_parent(&topo_three_level(), "bob", Some("alice")).unwrap();
|
||||||
assert_eq!(next, topo_three_level());
|
assert_eq!(next, topo_three_level());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_recipient_passes_through_ordinary_names() {
|
||||||
|
let topo = topo_three_level();
|
||||||
|
// Real labels, broadcast, and the operator literal all
|
||||||
|
// shortcut through unchanged — no resolution magic.
|
||||||
|
assert_eq!(resolve_recipient_in(&topo, "bob", "alice"), "alice");
|
||||||
|
assert_eq!(resolve_recipient_in(&topo, "bob", "*"), "*");
|
||||||
|
assert_eq!(
|
||||||
|
resolve_recipient_in(&topo, "bob", hive_sh4re::OPERATOR_RECIPIENT),
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_recipient_rewrites_parent_sentinel_to_parent_label() {
|
||||||
|
let topo = topo_three_level();
|
||||||
|
// bob's parent is alice → `<parent>` from bob goes to alice.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_recipient_in(&topo, "bob", hive_sh4re::PARENT_RECIPIENT),
|
||||||
|
"alice"
|
||||||
|
);
|
||||||
|
// alice's parent is the manager — same one-hop rewrite.
|
||||||
|
assert_eq!(
|
||||||
|
resolve_recipient_in(&topo, "alice", hive_sh4re::PARENT_RECIPIENT),
|
||||||
|
crate::lifecycle::MANAGER_NAME
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_recipient_falls_back_to_operator_for_root_agent() {
|
||||||
|
let topo = topo_three_level();
|
||||||
|
// Manager is structurally root (parent = None) → `<parent>`
|
||||||
|
// resolves to the operator (matching the "no parent → tell mara"
|
||||||
|
// rule from #692#issuecomment-8592).
|
||||||
|
assert_eq!(
|
||||||
|
resolve_recipient_in(
|
||||||
|
&topo,
|
||||||
|
crate::lifecycle::MANAGER_NAME,
|
||||||
|
hive_sh4re::PARENT_RECIPIENT
|
||||||
|
),
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_recipient_falls_back_to_operator_for_unknown_sender() {
|
||||||
|
// Sender absent from topology entirely — defensive fallback
|
||||||
|
// covers the race window where an agent's spawn has registered
|
||||||
|
// its socket but the meta-flake `sync_agents` hasn't yet added
|
||||||
|
// its row.
|
||||||
|
let topo = topo_three_level();
|
||||||
|
assert_eq!(
|
||||||
|
resolve_recipient_in(&topo, "nobody", hive_sh4re::PARENT_RECIPIENT),
|
||||||
|
hive_sh4re::OPERATOR_RECIPIENT
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,15 @@ pub const MANAGER_AGENT: &str = "manager";
|
||||||
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
/// dashboard's inbox view — they are never `recv`'d by an agent harness.
|
||||||
pub const OPERATOR_RECIPIENT: &str = "operator";
|
pub const OPERATOR_RECIPIENT: &str = "operator";
|
||||||
|
|
||||||
|
/// Reserved magic recipient — `send(to: "<parent>", ...)` is rewritten
|
||||||
|
/// by hive-c0re at delivery time to whoever `topology::parent_of(sender)`
|
||||||
|
/// returns, or to [`OPERATOR_RECIPIENT`] when the sender is a root agent
|
||||||
|
/// (no parent). Lets agents address their parent without hardcoding the
|
||||||
|
/// label, so runtime reparenting (#486) requires no agent-side restart.
|
||||||
|
/// The angle brackets are not valid in agent names (validators reject
|
||||||
|
/// `<`/`>`), so this name can never collide with a real recipient.
|
||||||
|
pub const PARENT_RECIPIENT: &str = "<parent>";
|
||||||
|
|
||||||
/// 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