Watch
0
0
Fork
You've already forked hyperhive
0

agents: remove send(operator) and the operator inbox; alert on turn failures

Agents reach the operator over matrix now (#5115). A `send` to
`operator` is refused, by the broker and by the MCP allow-list check,
with an error that names the matrix MCP tools. The dashboard's Y3R C4LL
tab goes with it: the operator inbox, `GET /api/operator-inbox`,
`Broker::unread_for_recipient`, and the browser notifications that only
ever fired on operator-bound messages.

The harness no longer posts turn failures and plugin-install failures
to the operator inbox. Both are already logged at warn; a new Grafana
rule, `hh-agent-turn-failed`, fires per agent on the harness's
`turn failed` line.

Dashboard-to-agent messaging (op-send, the agent web UI's message box,
mark-all-read for agent inboxes) is unchanged.

Decisions per mara on #5115: q1 a, q2 a plus a grafana alert, q3 keep
dashboard-to-agent messaging and leave swarm-level code alone.
This commit is contained in:
atlas 2026-10-12 00:44:11 +02:00 • committed by mara
commit 7e6a4c7727
37 changed files with 246 additions and 848 deletions

View file

@ -7,7 +7,7 @@ use rmcp::schemars;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SendArgs {
/// Logical agent name to deliver the message to (e.g. `"manager"`,
/// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box).
/// `"alice"`). `"operator"` is refused: reach the operator over matrix.
pub to: String,
/// Message body. Plain text; the broker doesn't parse it.
pub body: String,

View file

@ -141,8 +141,8 @@ impl AgentServer {
#[tool_router]
impl AgentServer {
#[tool(
description = "Send a message to another hyperhive agent (or to the operator). \
Use this to talk to peers or to surface output for the human at the dashboard."
description = "Send a message to another hyperhive agent on this hive. \
Not for the operator: reach them over matrix with the matrix MCP tools."
)]
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
let log = format!("{args:?}");
@ -500,7 +500,7 @@ impl AgentServer {
#[tool_handler(
instructions = "You are a hyperhive agent. Use `send` to talk to peers (by their logical \
name) or to the operator (recipient `operator`). Use `recv` to drain your inbox one \
name); reach the operator over matrix. Use `recv` to drain your inbox one \
message at a time. Use `remind` to schedule a future wake-up message for yourself."
)]
impl ServerHandler for AgentServer {}

View file

@ -12,24 +12,14 @@
const SEND_ALLOW_PATH: &str = "/etc/hyperhive/send-allow.json";
/// Enforce the per-agent send allow-list. Returns `Ok` when the
/// recipient is permitted (no list configured, the operator, or `to` is
/// in the list); returns `Err(refusal)` with a claude-readable string
/// when blocked — the harness surfaces the refusal as the tool result so
/// claude knows the message didn't land and can react (e.g. route to the
/// operator instead).
/// recipient is permitted (no list configured, or `to` is in the list);
/// returns `Err(refusal)` with a claude-readable string when blocked —
/// the harness surfaces the refusal as the tool result so claude knows
/// the message didn't land and can react. `operator` is refused whatever
/// the list says, with the same matrix-pointing text the broker returns.
pub fn check_send_allowed(to: &str) -> Result<(), String> {
if to == hive_sh4re::manager::OPERATOR_RECIPIENT {
// Always allow the operator — the allow-list constrains peer
// chatter, not the reporting line out, and an agent with no way
// to say "I am stuck" is an agent that fails silently.
//
// This bypass used to be spelled `<parent>`, which the broker
// resolved per `topology.json` and which fell back to `operator`
// for a root agent. The parent field is gone now, so every
// agent is what that fallback called a root — the exemption is
// now written as the name it always resolved to. Same reachable
// set, one fewer indirection.
return Ok(());
return Err(hive_sh4re::manager::OPERATOR_SEND_REFUSAL.to_owned());
}
let Ok(raw) = std::fs::read_to_string(SEND_ALLOW_PATH) else {
return Ok(()); // file missing → no policy configured → unrestricted
@ -53,9 +43,19 @@ pub fn check_send_allowed(to: &str) -> Result<(), String> {
}
Err(format!(
"send refused: recipient '{to}' not in services.hyperhive.agent.allowedRecipients \
(configured in agent.nix). Allowed: {allow:?}. The operator is always \
reachable — route through `send(to: \"{}\", …)` if you need to reach \
someone outside the allow-list.",
hive_sh4re::manager::OPERATOR_RECIPIENT
(configured in agent.nix). Allowed: {allow:?}. To reach the operator, use \
the matrix MCP tools."
))
}
#[cfg(test)]
mod tests {
use super::check_send_allowed;
#[test]
fn operator_is_refused_with_the_matrix_pointer() {
let refusal = check_send_allowed(hive_sh4re::manager::OPERATOR_RECIPIENT)
.expect_err("operator must be refused");
assert!(refusal.contains("mcp__matrix__"), "{refusal}");
}
}