drop the hyperhive send/recv/ack_until tools: agents message over matrix
This commit is contained in:
parent
ccac95fcf4
commit
bffb2c07a8
13 changed files with 52 additions and 397 deletions
|
|
@ -191,8 +191,8 @@ the full row taxonomy and dispatch logic):
|
|||
(`-` lines from `input.old_string`, `+` lines from
|
||||
`input.new_string` or every line of `input.content`).
|
||||
Summary carries the path + line counts.
|
||||
- others (`Read /path`, `Bash $ cmd`, `mcp__hyperhive__send →
|
||||
operator: "..."`, etc.): flat one-line per-tool format with
|
||||
- others (`Read /path`, `Bash $ cmd`, `mcp__matrix__send_dm →
|
||||
iris: "..."`, etc.): flat one-line per-tool format with
|
||||
per-tool salient-arg extraction (`fmtToolUse`).
|
||||
- `Stream` `tool_result` short → flat `← ...`; long → collapsed
|
||||
`<details>` `▸ ← Nl · headline` (select to expand full body).
|
||||
|
|
|
|||
|
|
@ -220,12 +220,8 @@ the `CLAUDE.md` (see `docs/2-handbook/agents/persistence.md` for the
|
|||
boot-time rename).
|
||||
|
||||
The wake prompt is intentionally minimal: the popped message's
|
||||
`from`/`body`, prefixed with a `[msg #<id>]` broker-row-id marker
|
||||
(so the agent knows what id to pass to `ack_until` for bulk triage;
|
||||
transient pings show no marker because their sentinel id 0 has nothing
|
||||
to ack), plus an inline `({unread} more pending — drain via …)` hint
|
||||
when `unread > 0`. Claude drives any further `recv`/`send` itself via
|
||||
the embedded MCP server.
|
||||
`from` and `body`, behind a redelivery or interruption banner when one applies.
|
||||
The harness drives one turn per inbox message.
|
||||
|
||||
`hive-agent` self-announces once on every boot:
|
||||
`boot_announce::announce` pushes a one-line "you were just
|
||||
|
|
|
|||
|
|
@ -36,41 +36,25 @@ runs on the change.
|
|||
|
||||
## Core tools (always available)
|
||||
|
||||
**Messaging** (`messaging` group): `send(to, body, in_reply_to?)`,
|
||||
`recv(max?)`, `ack_until(up_to)`.
|
||||
**Messaging.** There is no hyperhive messaging tool. Agents talk to peers and the
|
||||
operator over matrix ([matrix](../agent-tools/matrix.md)); `send_dm` takes a peer's agent
|
||||
name (`iris`, meaning that agent on the same homeserver) or a full user id. An agent
|
||||
without matrix tools is cut off from peers on purpose, and the operator reads its
|
||||
`set_status` line, its turn output on the dashboard, and its logs. The `messaging`
|
||||
permission remains an accepted value for stored permission sets, but grants no tool.
|
||||
|
||||
- `send` — hive-local broker message to a peer on this hive (logical name). With
|
||||
the matrix MCP tools, talk to peers over matrix instead (a DM to the `user_id` in
|
||||
`get_agent_meta`'s `matrix_accounts`); `send` is for peers without a matrix
|
||||
account and agents without matrix tools. Optional
|
||||
`in_reply_to: i64` links the message to a prior id for thread
|
||||
rendering. The broker refuses `to: "operator"`. The error
|
||||
points an agent that has the matrix MCP tools at matrix
|
||||
([matrix](../agent-tools/matrix.md)), and tells one without them that the
|
||||
operator reads its `set_status` line, its turn output on the dashboard,
|
||||
and its logs.
|
||||
- `recv` — drain inbox. Always an immediate peek, never blocks. `max`
|
||||
(default 1, cap 5) drains up to N rows. `recv` prefixes each returned row
|
||||
with `[msg #<id>]` (broker row id; note the highest id seen, then pass
|
||||
it to `ack_until` to bulk-triage the batch). **Graceful shutdown**: when the harness
|
||||
receives a stop signal, the inbox becomes fenced and `recv` returns an
|
||||
explicit `from: "graceful-stop"` message instead of an empty inbox.
|
||||
This unmissably directs the agent to flush durable state (`/state`
|
||||
files) and end the turn — the container exits when the turn completes.
|
||||
The graceful-stop turn takes the same post-turn compaction path as any
|
||||
other turn: if the context crossed the watermark the harness runs a
|
||||
notes-checkpoint turn and then `/compact`. Compacting before shutdown
|
||||
keeps a later cold start cheap instead of re-uploading a large transcript.
|
||||
- `ack_until(up_to)` — bulk-mark inbox rows handled: it stamps every row
|
||||
with broker id `<= up_to` as acked in a single UPDATE.
|
||||
Recipient-scoped (agents can only ack their own rows). Use when a
|
||||
restart redelivers a large backlog of already-handled messages: read
|
||||
the highest `[msg #N]` from the set you've actually processed, then
|
||||
`ack_until(N)` to prevent re-pop. Acked rows never redeliver.
|
||||
Transient pings (sentinel id 0) have nothing to ack and show no marker.
|
||||
The inbox itself is the harness's: it pops one message per turn and acks it when the
|
||||
turn ends, so there is no agent-facing `recv` or `ack_until`. **Graceful shutdown**:
|
||||
when the harness receives a stop signal, the inbox becomes fenced and the harness
|
||||
delivers an explicit `from: "graceful-stop"` message as the turn's prompt. It
|
||||
directs the agent to flush durable state (`/state` files) and end the turn; the
|
||||
container exits when the turn completes. The graceful-stop turn takes the same
|
||||
post-turn compaction path as any other turn: if the context crossed the watermark the
|
||||
harness runs a notes-checkpoint turn and then `/compact`. Compacting before shutdown
|
||||
keeps a later cold start cheap instead of re-uploading a large transcript.
|
||||
|
||||
**System messages** (from sender `system`): the broker delivers them as regular inbox
|
||||
messages (same `recv` path). A container crash never reaches an
|
||||
messages (the same path as every other inbox message). A container crash never reaches an
|
||||
agent — it only produces a log line + swarm-level grafana
|
||||
alert, no agent turn at all (`docs/4-implementation/scheduler/observability.md`). Lifecycle
|
||||
transitions the job-queue scheduler or crash watcher drive directly —
|
||||
|
|
|
|||
|
|
@ -123,8 +123,7 @@ request still carries an optional `wait_seconds` (long-poll the first
|
|||
message, once one arrives — or one is already pending — the call
|
||||
drains up to `max` in total): the harness's own turn-driving loop
|
||||
uses it internally (`hive-agent`'s `recv_next`, 180s). The
|
||||
agent-facing MCP `recv` tool doesn't expose this parameter — it
|
||||
always passes `wait_seconds: None`, an immediate peek.
|
||||
agent-facing MCP surface has no `recv` tool at all.
|
||||
|
||||
Per-row bookkeeping inside the broker:
|
||||
|
||||
|
|
@ -188,8 +187,8 @@ Per-variant fields:
|
|||
3339 on the wire, see *Timestamps on the wire* below); clients
|
||||
compute time-until-fire against it.
|
||||
- `PendingMessages { count }` — undelivered inbox messages the
|
||||
agent still owes itself a `recv` for. Informational + not
|
||||
cancellable (drain with `recv`); only emitted when `count > 0`,
|
||||
agent has not handled yet. Informational + not
|
||||
cancellable; only emitted when `count > 0`,
|
||||
and surfaced first in the list as the most
|
||||
actionable signal. Counted host-side from the broker
|
||||
(`count_pending`), so it reflects what's genuinely still queued —
|
||||
|
|
@ -283,7 +282,7 @@ host access it has.
|
|||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `messaging` | `send`, `recv`, `ack_until` |
|
||||
| `messaging` | nothing (kept as an accepted value; agents message over matrix) |
|
||||
| `meta` | `get_agent_meta` (`set_status` is always-on, see below) |
|
||||
| `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` |
|
||||
| `execution` | the `bash` MCP server (`mcp__bash__run`/`status`/`kill`), rendered into the agent's MCP config only with it; a subagent gets Claude's own `Bash` instead. See `docs/4-implementation/agent-tools/bash.md`. |
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ would hit. HTTP is the sole transport; there is no stdio mode here.
|
|||
|
||||
## When to use it
|
||||
|
||||
This is where the core hyperhive tool surface lives: `send`, `recv`,
|
||||
`remind`, `get_loose_ends`, `set_status`
|
||||
This is where the core hyperhive tool surface lives: `remind`, `get_loose_ends`, `set_status`
|
||||
and `get_agent_meta`. Reach
|
||||
for this crate when you're adding or changing a built-in tool rather
|
||||
than an `extraMcpServers` add-on — those are separate stdio bridges
|
||||
|
|
|
|||
|
|
@ -4,44 +4,6 @@
|
|||
|
||||
use rmcp::schemars;
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SendArgs {
|
||||
/// Logical agent name to deliver the message to (e.g. `"bob"`,
|
||||
/// `"alice"`). `"operator"` is refused: reach the operator over matrix
|
||||
/// if you have it.
|
||||
pub to: String,
|
||||
/// Message body. Plain text; the broker doesn't parse it.
|
||||
pub body: String,
|
||||
/// Optional broker row-id of the message this is a reply to. Lets
|
||||
/// the dashboard render conversation threads. Pass the `id` from the
|
||||
/// `DeliveredMessage` you're responding to; omit for new threads.
|
||||
/// Silently ignored if the id is unknown or out of retention.
|
||||
#[serde(default)]
|
||||
pub in_reply_to: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct RecvArgs {
|
||||
/// Maximum number of messages to pop in this round-trip. Default
|
||||
/// (None) is 1 (single-message behaviour — exactly what you want
|
||||
/// when you're called to drive a turn off the first wake). Pass
|
||||
/// a higher value (capped at 5 server-side) when you've been
|
||||
/// told the inbox has more queued (the wake prompt mentions
|
||||
/// pending count) and want to drain everything in one tool call.
|
||||
#[serde(default)]
|
||||
pub max: Option<u32>,
|
||||
}
|
||||
|
||||
/// MCP tool args for `ack_until`.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct AckUntilArgs {
|
||||
/// Highest broker message id to mark handled: every inbox message
|
||||
/// with `id <= up_to` (ids show as `[msg #<id>]` in recv output)
|
||||
/// is acked in one sweep. Pass the highest id you've actually
|
||||
/// seen/triaged — anything above it stays queued for later turns.
|
||||
pub up_to: i64,
|
||||
}
|
||||
|
||||
/// MCP tool args for `mark_todos_done`.
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct MarkTodosDoneArgs {
|
||||
|
|
|
|||
|
|
@ -24,10 +24,9 @@ mod args;
|
|||
mod render;
|
||||
|
||||
pub use args::{
|
||||
AckUntilArgs, CancelLooseEndArgs, CompactArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RecvArgs,
|
||||
RemindArgs, SendArgs, SetStatusArgs,
|
||||
CancelLooseEndArgs, CompactArgs, GetAgentMetaArgs, MarkTodosDoneArgs, RemindArgs, SetStatusArgs,
|
||||
};
|
||||
pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv};
|
||||
pub use render::{annotate_retries, format_ack, format_agent_meta};
|
||||
|
||||
use render::{
|
||||
dial_agent_socket, format_matrix_summary, local_reminders, local_todos, mark_local_todo_done,
|
||||
|
|
@ -140,92 +139,6 @@ impl AgentServer {
|
|||
// non-interactive `--print` mode with "permissions not granted yet".
|
||||
#[tool_router]
|
||||
impl AgentServer {
|
||||
#[tool(
|
||||
description = "Send a message through the hive-local broker to another hyperhive \
|
||||
agent on this hive. To talk to a peer agent, prefer matrix (a DM to the peer's \
|
||||
`user_id` from `get_agent_meta`) when you have the matrix MCP tools; use this \
|
||||
when you have none or the peer has no matrix account. Not for the operator: reach \
|
||||
them over matrix if you have the matrix MCP tools."
|
||||
)]
|
||||
async fn send(&self, Parameters(args): Parameters<SendArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
let to = args.to.clone();
|
||||
run_tool_envelope("send", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_core_agent_sock::Request::Send {
|
||||
to: args.to,
|
||||
body: args.body,
|
||||
in_reply_to: args.in_reply_to,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(format_ack(resp, "send", format!("sent to {to}")), retries)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Pop messages from this agent's inbox. Returns one or more messages, or \
|
||||
an empty marker if nothing is waiting. Always an immediate 'anything pending?' peek \
|
||||
— never blocks. \n\n\
|
||||
**Single-message default**: with no args (or `max: 1`) you get the next message — \
|
||||
same behaviour the harness uses to drive a turn. \n\n\
|
||||
**When idle, prefer ending the turn over polling here.** Ending the turn is the \
|
||||
ONLY path that observes an in-container todo wake (bash-task completions, matrix \
|
||||
unread, forge activity) — that signal reaches the harness loop between turns, never \
|
||||
a live `recv` call, so looping on `recv` while idle means sitting blind until \
|
||||
something else wakes you. Ending the turn also checkpoints your session, and costs \
|
||||
no latency vs. polling for a real inbox message either way — the broker wakes the \
|
||||
next turn just as fast. \n\n\
|
||||
**Batch drain**: pass `max: N` (capped at 5) to drain up to N messages in one \
|
||||
round-trip. Use this when the wake prompt told you the inbox has more queued, or \
|
||||
any time you expect a burst — one tool call beats N consecutive single recvs. Empty \
|
||||
result reported the same way regardless of `max`. \n\n\
|
||||
After popping, the result appends a `(N more message(s) pending …)` line whenever the \
|
||||
inbox still has queued messages — so you know whether to drain again (or `ack_until`) \
|
||||
without a separate status check. No line means the inbox is empty."
|
||||
)]
|
||||
async fn recv(&self, Parameters(args): Parameters<RecvArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("recv", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_core_agent_sock::Request::Recv {
|
||||
wait_seconds: None,
|
||||
max: args.max,
|
||||
})
|
||||
.await;
|
||||
annotate_retries(format_recv(resp), retries)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "Bulk-mark inbox messages handled: every message with broker id \
|
||||
<= `up_to` (ids show as `[msg #<id>]` in recv output and wake prompts) is \
|
||||
acked in one call — pending and already-delivered rows alike. Use this to \
|
||||
clear a backlog you've already triaged (e.g. a redelivered flood after a \
|
||||
container restart, or a pile of stale notifications) instead of draining it \
|
||||
one recv at a time: note the highest `[msg #N]` you've seen, then \
|
||||
`ack_until(up_to: N)`. Acked messages never redeliver. Only affects YOUR \
|
||||
inbox rows; messages newer than `up_to` stay queued. Returns how many rows \
|
||||
were newly acked."
|
||||
)]
|
||||
async fn ack_until(&self, Parameters(args): Parameters<AckUntilArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("ack_until", log, async move {
|
||||
let (resp, retries) = self
|
||||
.dispatch(hive_core_agent_sock::Request::AckUntil { up_to: args.up_to })
|
||||
.await;
|
||||
let rendered = match resp {
|
||||
Ok(hive_core_agent_sock::Response::Acked { count }) => {
|
||||
format!("acked {count} message(s) up to id {}", args.up_to)
|
||||
}
|
||||
other => reply_err(other, "ack_until"),
|
||||
};
|
||||
annotate_retries(rendered, retries)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tool(
|
||||
description = "List loose ends pending against this agent: pending reminders \
|
||||
you scheduled. Also lists active \
|
||||
|
|
|
|||
|
|
@ -49,108 +49,6 @@ pub fn format_ack(
|
|||
}
|
||||
}
|
||||
|
||||
/// Format helper for `recv`: renders zero, one, or many popped
|
||||
/// messages. Empty list collapses to "(empty)" so claude doesn't go
|
||||
/// hunting for content. A single message renders as the historical
|
||||
/// `from: X\n\nbody` block (banner first if `redelivered`). A
|
||||
/// multi-message batch renders with a `popped N message(s):` header
|
||||
/// and `---` separators between bodies so the model can tell where
|
||||
/// one ends and the next begins; per-message redelivery banners
|
||||
/// included.
|
||||
#[must_use]
|
||||
pub fn format_recv(resp: Result<hive_core_agent_sock::Response, anyhow::Error>) -> String {
|
||||
match resp {
|
||||
Ok(hive_core_agent_sock::Response::Messages {
|
||||
messages,
|
||||
remaining,
|
||||
}) => render_recv_messages(&messages, remaining),
|
||||
// A graceful stop is pending — the inbox is fenced. Render a single
|
||||
// explicit directive (not an empty inbox, which claude might just
|
||||
// recv again) so every recv during the stop unmissably tells claude
|
||||
// to flush + end. `remaining` is forced to 0 — the inbox is fenced,
|
||||
// so a "N more pending" hint would be misleading.
|
||||
Ok(hive_core_agent_sock::Response::GracefulStop) => {
|
||||
render_recv_messages(&[graceful_stop_message()], 0)
|
||||
}
|
||||
other => reply_err(other, "recv"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The synthetic single-message directive rendered for a fenced (graceful-stop)
|
||||
/// inbox — see the `GracefulStop` arm of [`format_recv`].
|
||||
fn graceful_stop_message() -> hive_sh4re::inbox::DeliveredMessage {
|
||||
hive_sh4re::inbox::DeliveredMessage {
|
||||
from: "graceful-stop".into(),
|
||||
body: "⛔ GRACEFUL STOP IN PROGRESS — the container shuts down as soon as this turn \
|
||||
ends. Flush anything worth keeping to your durable /state files, then END \
|
||||
YOUR TURN now. Do NOT call recv again: the inbox is fenced and recv will \
|
||||
only keep returning this same notice."
|
||||
.into(),
|
||||
id: 0,
|
||||
redelivered: false,
|
||||
in_reply_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the popped-message payload of a successful `recv` (see `format_recv`
|
||||
/// for the empty/single/batch shapes). `remaining` is the post-pop inbox
|
||||
/// depth; when non-zero a shared "(N more pending …)" hint (identical to the
|
||||
/// wake prompt's) is appended so an in-turn drain knows more is queued. The
|
||||
/// empty path never carries the hint (nothing was popped).
|
||||
fn render_recv_messages(
|
||||
messages: &[hive_sh4re::inbox::DeliveredMessage],
|
||||
remaining: u64,
|
||||
) -> String {
|
||||
use std::fmt::Write as _;
|
||||
if messages.is_empty() {
|
||||
return "(empty)".to_owned();
|
||||
}
|
||||
let mut out = if messages.len() == 1 {
|
||||
let m = &messages[0];
|
||||
let banner = if m.redelivered {
|
||||
hive_sh4re::inbox::REDELIVERY_HINT
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body)
|
||||
} else {
|
||||
let n = messages.len();
|
||||
let mut out = format!("popped {n} message(s):\n\n");
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push_str("\n---\n\n");
|
||||
}
|
||||
let banner = if m.redelivered {
|
||||
hive_sh4re::inbox::REDELIVERY_HINT
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let _ = write!(
|
||||
out,
|
||||
"{banner}{}from: {}\n\n{}",
|
||||
msg_id_tag(m.id),
|
||||
m.from,
|
||||
m.body
|
||||
);
|
||||
}
|
||||
out
|
||||
};
|
||||
out.push_str(&hive_sh4re::inbox::pending_hint(remaining));
|
||||
out
|
||||
}
|
||||
|
||||
/// `[msg #<id>] ` marker prefixed to each recv row so the agent knows
|
||||
/// what to pass to `ack_until` when bulk-triaging a backlog. Transient
|
||||
/// pings carry the sentinel id 0 (in-memory only, nothing in the
|
||||
/// broker to ack) and render without the marker.
|
||||
fn msg_id_tag(id: i64) -> String {
|
||||
if id > 0 {
|
||||
format!("[msg #{id}] ")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard cap on how many individual `Todo` lines `render_loose_ends` will
|
||||
/// emit. Reminders stay naturally bounded (they're triaged interactively
|
||||
/// and don't self-multiply), but todos are pushed by
|
||||
|
|
@ -559,7 +457,7 @@ pub fn annotate_retries(mut s: String, retries: u32) -> String {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{dial_socket, format_ack, format_recv};
|
||||
use super::{dial_socket, format_ack};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_socket_that_accepts_and_never_answers_is_given_up_on() {
|
||||
|
|
@ -590,70 +488,18 @@ mod tests {
|
|||
assert!(resp.is_none());
|
||||
}
|
||||
|
||||
fn msg(id: i64, from: &str, body: &str) -> hive_sh4re::inbox::DeliveredMessage {
|
||||
hive_sh4re::inbox::DeliveredMessage {
|
||||
from: from.to_owned(),
|
||||
body: body.to_owned(),
|
||||
id,
|
||||
redelivered: false,
|
||||
in_reply_to: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_recv_renders_bare_empty_marker() {
|
||||
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
|
||||
messages: vec![],
|
||||
remaining: 0,
|
||||
}));
|
||||
assert_eq!(out, "(empty)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_recv_with_remaining_appends_pending_hint() {
|
||||
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
|
||||
messages: vec![msg(7, "alice", "hi")],
|
||||
remaining: 3,
|
||||
}));
|
||||
assert!(out.starts_with("[msg #7] from: alice"));
|
||||
assert!(out.contains("3 more message(s) pending"));
|
||||
assert!(out.contains("max: 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_recv_no_remaining_has_no_pending_hint() {
|
||||
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
|
||||
messages: vec![msg(7, "alice", "hi")],
|
||||
remaining: 0,
|
||||
}));
|
||||
assert!(!out.contains("more message(s) pending"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batch_recv_with_remaining_appends_pending_hint_once() {
|
||||
let out = format_recv(Ok(hive_core_agent_sock::Response::Messages {
|
||||
messages: vec![msg(7, "alice", "hi"), msg(8, "bob", "yo")],
|
||||
remaining: 9,
|
||||
}));
|
||||
assert!(out.starts_with("popped 2 message(s):"));
|
||||
assert_eq!(out.matches("more message(s) pending").count(), 1);
|
||||
// `max` suggestion is clamped to the server-side recv cap.
|
||||
let batch = 9u64.min(u64::from(hive_sh4re::inbox::RECV_BATCH_MAX));
|
||||
assert!(out.contains(&format!("max: {batch}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_warn_keeps_the_success_line_and_appends_each_warning() {
|
||||
let out = format_ack(
|
||||
Ok(hive_core_agent_sock::Response::OkWarn {
|
||||
warnings: vec!["name is reserved".to_owned(), "second thing".to_owned()],
|
||||
}),
|
||||
"send",
|
||||
"sent to iris".to_owned(),
|
||||
"remind",
|
||||
"reminder set".to_owned(),
|
||||
);
|
||||
// The operation HAPPENED — dropping the success line would read as a
|
||||
// failure and invite a retry that sends a second message.
|
||||
assert!(out.starts_with("sent to iris"));
|
||||
// failure and invite a retry that repeats it.
|
||||
assert!(out.starts_with("reminder set"));
|
||||
assert!(out.contains("⚠️ name is reserved"));
|
||||
assert!(out.contains("⚠️ second thing"));
|
||||
}
|
||||
|
|
@ -664,7 +510,7 @@ mod tests {
|
|||
// warning marker would pass the test above.
|
||||
let out = format_ack(
|
||||
Ok(hive_core_agent_sock::Response::Ok),
|
||||
"send",
|
||||
"remind",
|
||||
"sent".to_owned(),
|
||||
);
|
||||
assert_eq!(out, "sent");
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system.
|
||||
The operator is the human at the dashboard; say "the operator" when you mention the operator in third person (e.g. when relaying to a peer or the manager).
|
||||
When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) in text, never as a `send` target, so the operator + peers can disambiguate.
|
||||
When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) in text, never as a `send_dm` recipient, so the operator + peers can disambiguate.
|
||||
Within your own hive the short form is fine.{operators}
|
||||
|
||||
Tools: the hyperhive MCP tools and any extra MCP servers describe themselves in their own tool schema, so this prompt does not repeat them.
|
||||
What the schema doesn't tell you:
|
||||
|
||||
- `send` is the hive-local broker: system notices, and peers on this hive.
|
||||
- **Talk to peers and the operator over matrix** if you have the matrix MCP tools (`mcp__matrix__send_dm` or `mcp__matrix__send_message`).
|
||||
- A peer's matrix user is the `user_id` in `matrix_accounts` from `get_agent_meta(name)`; a peer with no matrix account is reached with `send`.
|
||||
- Without matrix tools, use `send` for peers; the operator reads your `set_status` line and turn output on the dashboard, and your logs.
|
||||
- `send(to: "operator")` is refused.
|
||||
- Prefer ending the turn over repeatedly polling `recv` when idle.
|
||||
- **Talk to peers and the operator over matrix** (`mcp__matrix__send_dm`, or `mcp__matrix__send_message` for a room).
|
||||
- `send_dm` takes a peer's agent name (`iris`) or a full user id (`@iris:server`); a bare name means that agent on your own homeserver.
|
||||
- An agent without matrix tools is cut off from peers on purpose; the operator reads your `set_status` line and turn output on the dashboard, and your logs.
|
||||
- Prefer ending the turn over repeatedly polling for new messages when idle.
|
||||
Only turn-boundaries observe in-container todo wakes (bash-task completions, matrix unread, forge activity), and ending the turn is also your checkpoint.
|
||||
- For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time.
|
||||
There's no blind range-clear, only ids you've actually looked at.
|
||||
|
|
@ -93,7 +91,7 @@ Keep messages short — a few sentences each.
|
|||
For anything big (file listings, long diffs, transcripts, analysis), don't hand anyone a filesystem path — not your state dir; the operator can't open it and peers can't read your state.
|
||||
If it's about an issue or PR, attach it there (`hive-forge attach-issue` / `attach-comment`) or inline it in the comment.
|
||||
If it's a non-forge matter for the operator, send it over matrix (inline it; `send_file` for attachments).
|
||||
For a peer, inline it in the matrix message (the `send` if you have no matrix tools) or put it on the relevant forge thread, then keep the message to a short headline ("cluster audit attached to the PR, headline: 3 nodes over 80% mem").
|
||||
For a peer, inline it in the matrix message or put it on the relevant forge thread, then keep the message to a short headline ("cluster audit attached to the PR, headline: 3 nodes over 80% mem").
|
||||
Name any issue or PR to the operator as a full clickable URL, never a bare number.
|
||||
|
||||
When your inbox has a message, handle it and stop.
|
||||
|
|
@ -107,5 +105,5 @@ A long single turn that does step after step widens the window where a restart l
|
|||
|
||||
For multi-step work (long builds, sequential edits) that spans more than one turn: end the turn and let an external wake drive the next one — a new inbox message, a `remind` you scheduled, or a backgrounded bash task's completion.
|
||||
Ending the turn is never a same-turn continuation: it's the checkpoint itself, and the only place an in-container todo wake (bash-task completion, matrix unread, forge activity) can reach you.
|
||||
Polling `recv` in a loop happens _within_ the current turn instead and misses exactly those wakes, so it isn't a substitute for ending the turn.
|
||||
Polling for messages in a loop happens _within_ the current turn instead and misses exactly those wakes, so it isn't a substitute for ending the turn.
|
||||
**Keep `set_status` current whenever the work changes** — a stale status is what actually loses context across a restart, not turn length; a status set at the start of a task and never touched again is a bug, not a shortcut.
|
||||
|
|
|
|||
|
|
@ -994,7 +994,6 @@ async fn handle_turn<S: Surface>(
|
|||
let from = first.from;
|
||||
let body = first.body;
|
||||
let redelivered = first.redelivered;
|
||||
let msg_id = first.id;
|
||||
log_system_event(bus, &from, &body);
|
||||
tracing::info!(%from, %body, %redelivered, "inbox");
|
||||
let unread = S::inbox_unread(socket).await;
|
||||
|
|
@ -1011,14 +1010,7 @@ async fn handle_turn<S: Surface>(
|
|||
// the "you were interrupted" banner, then the flag resets so a later
|
||||
// ordinary turn doesn't repeat a stale notice.
|
||||
let was_interrupted = interrupted.swap(false, std::sync::atomic::Ordering::Relaxed);
|
||||
let prompt = serve_common::format_wake_prompt(
|
||||
msg_id,
|
||||
&from,
|
||||
&body,
|
||||
unread,
|
||||
redelivered,
|
||||
was_interrupted,
|
||||
);
|
||||
let prompt = serve_common::format_wake_prompt(&from, &body, redelivered, was_interrupted);
|
||||
let outcome = turn::drive_turn(&prompt, files, bus, session).await;
|
||||
turn::emit_turn_end(bus, &outcome);
|
||||
bus.set_state(TurnState::Idle);
|
||||
|
|
|
|||
|
|
@ -9,23 +9,13 @@ use crate::turn::{TurnError, TurnOutcome};
|
|||
use crate::turn_stats::TurnStatRow;
|
||||
|
||||
/// Assemble the per-turn wake prompt string. The role/tools/etc. live in the
|
||||
/// system prompt; this is just the wake signal body. `id` is the broker row
|
||||
/// id, rendered as a `[msg #<id>]` marker so the agent can reference it in
|
||||
/// `ack_until`. `unread` is the inbox depth after this message was popped.
|
||||
/// `redelivered` prepends a "may already be handled" banner; `interrupted`
|
||||
/// system prompt; this is just the wake signal body. `redelivered` prepends a "may already be handled" banner; `interrupted`
|
||||
/// prepends a "previous turn was /cancel'd" banner instead (the two are
|
||||
/// mutually exclusive in practice — a redelivered message means the harness
|
||||
/// itself restarted, which also clears the in-memory interrupted flag — so
|
||||
/// `redelivered` takes priority if both were somehow set).
|
||||
#[must_use]
|
||||
pub fn format_wake_prompt(
|
||||
id: i64,
|
||||
from: &str,
|
||||
body: &str,
|
||||
unread: u64,
|
||||
redelivered: bool,
|
||||
interrupted: bool,
|
||||
) -> String {
|
||||
pub fn format_wake_prompt(from: &str, body: &str, redelivered: bool, interrupted: bool) -> String {
|
||||
let banner = if redelivered {
|
||||
hive_sh4re::inbox::REDELIVERY_HINT
|
||||
} else if interrupted {
|
||||
|
|
@ -33,13 +23,7 @@ pub fn format_wake_prompt(
|
|||
} else {
|
||||
""
|
||||
};
|
||||
let tag = if id > 0 {
|
||||
format!("[msg #{id}] ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let pending = hive_sh4re::inbox::pending_hint(unread);
|
||||
format!("{banner}{tag}Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
format!("{banner}Incoming message from `{from}`:\n---\n{body}\n---")
|
||||
}
|
||||
|
||||
/// Field-named args for [`build_row`]. Mirrors the turn-stats row
|
||||
|
|
|
|||
|
|
@ -33,28 +33,6 @@ pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may al
|
|||
pub const INTERRUPTED_HINT: &str = "[your previous turn was interrupted by the operator (/cancel) \
|
||||
before it finished — check for new messages before resuming prior work]\n";
|
||||
|
||||
/// Shared "(N more message(s) pending …)" advisory appended after both the
|
||||
/// wake prompt body and the `recv` tool result whenever the inbox still has
|
||||
/// queued messages once the current message/batch is popped. Returns an empty
|
||||
/// string when `remaining == 0`. The leading `\n\n` separates it from the
|
||||
/// preceding body/message block, and the suggested `max` is clamped to the
|
||||
/// server-side recv cap so the hint never asks for more than one round-trip
|
||||
/// can deliver. One builder so the wake prompt (harness serve loop) and the
|
||||
/// in-turn recv result (MCP server) stay identical.
|
||||
#[must_use]
|
||||
pub fn pending_hint(remaining: u64) -> String {
|
||||
if remaining == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let batch = remaining.min(u64::from(RECV_BATCH_MAX));
|
||||
format!(
|
||||
"\n\n({remaining} more message(s) pending in your inbox — call `mcp__hyperhive__recv` \
|
||||
with `max: {batch}` to drain the next batch before acting. If the \
|
||||
backlog is stale/already handled, `ack_until(up_to: <highest [msg #N] seen>)` \
|
||||
clears everything up to that id in one call instead.)"
|
||||
)
|
||||
}
|
||||
|
||||
/// A logical message between agents.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Message {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ use serde::{Deserialize, Serialize};
|
|||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Permission {
|
||||
/// `send`, `recv`, `ack_until`
|
||||
/// Grants no tool any more: agents message each other over matrix, and
|
||||
/// the hyperhive `send` / `recv` / `ack_until` tools are gone. Kept as an
|
||||
/// accepted value because it is stored across the swarm (permission sets,
|
||||
/// imports, agent configs).
|
||||
Messaging,
|
||||
/// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`)
|
||||
Meta,
|
||||
|
|
@ -72,9 +75,9 @@ impl Permission {
|
|||
#[must_use]
|
||||
pub fn tools(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::Messaging => &["send", "recv", "ack_until"],
|
||||
Self::Meta => &["get_agent_meta"],
|
||||
Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"],
|
||||
// `Messaging` grants nothing now (agents message over matrix).
|
||||
// `Execution` grants the out-of-process `bash` MCP server,
|
||||
// gated at config-render time in `hive-agent/src/mcp_config.rs`
|
||||
// — an out-of-process server has no later enforcement point, so
|
||||
|
|
@ -82,7 +85,8 @@ impl Permission {
|
|||
// Claude built-ins (`builtin_tools()`); `ManageRootAgent` grants
|
||||
// bind mounts; `Logs` and `Metrics` are checked by the store
|
||||
// gateways, not by a tool allowlist.
|
||||
Self::Execution
|
||||
Self::Messaging
|
||||
| Self::Execution
|
||||
| Self::WebTools
|
||||
| Self::ManageRootAgent
|
||||
| Self::Logs
|
||||
|
|
|
|||
Loading…
Reference in a new issue