522 lines
20 KiB
Rust
522 lines
20 KiB
Rust
//! Formatting / render helpers for the MCP tool surface: ack / recv /
|
|
//! loose-end / agent-meta reply shaping plus the retry annotation.
|
|
//! Stateless string builders, with one exception —
|
|
//! [`matrix_unread_summary`] queries the local matrix daemon socket
|
|
//! (best-effort) so `get_loose_ends` can prepend an unread-rooms entry.
|
|
|
|
/// Render the three identical failure arms every data-returning tool handler
|
|
/// repeats: a broker `Err` → `"{tool} failed: {m}"`, an unexpected `Ok` variant
|
|
/// → `"{tool} unexpected response: …"`, and a transport error → `"{tool}
|
|
/// transport error: …"`. Handlers match their own happy-path variant and route
|
|
/// everything else here via a catch-all arm (`other => reply_err(other, tool)`),
|
|
/// so the triplet lives in exactly one place.
|
|
pub(super) fn reply_err(resp: Result<hive_sh4re::Response, anyhow::Error>, tool: &str) -> String {
|
|
match resp {
|
|
Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"),
|
|
Ok(other) => format!("{tool} unexpected response: {other:?}"),
|
|
Err(e) => format!("{tool} transport error: {e:#}"),
|
|
}
|
|
}
|
|
|
|
/// Format helper for "send-like" tools (anything that expects an `Ok`).
|
|
/// `tool` and `ok_msg` only appear in the result string; they don't change
|
|
/// behavior.
|
|
#[must_use]
|
|
pub fn format_ack(
|
|
resp: Result<hive_sh4re::Response, anyhow::Error>,
|
|
tool: &str,
|
|
ok_msg: String,
|
|
) -> String {
|
|
match resp {
|
|
Ok(hive_sh4re::Response::Ok) => ok_msg,
|
|
other => reply_err(other, tool),
|
|
}
|
|
}
|
|
|
|
/// Format helper for `recv`: renders zero, one, or many popped
|
|
/// messages. Empty list collapses to "(empty)" so claude doesn't go
|
|
/// hunting for content; when `waited` is set (the call parked on a
|
|
/// long-poll that timed out) the empty result also carries
|
|
/// [`IDLE_WAIT_HINT`] nudging the model toward other work. 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_sh4re::Response, anyhow::Error>, waited: bool) -> String {
|
|
match resp {
|
|
Ok(hive_sh4re::Response::Messages {
|
|
messages,
|
|
remaining,
|
|
}) => render_recv_messages(&messages, remaining, waited),
|
|
// A graceful stop is pending — the inbox is fenced. Render a single
|
|
// explicit directive (not an empty inbox, which claude's "park on recv"
|
|
// habit would long-poll again, stalling the stop-checkpoint turn until
|
|
// the drain wait times out into a hard stop) 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_sh4re::Response::GracefulStop) => {
|
|
render_recv_messages(&[graceful_stop_message()], 0, waited)
|
|
}
|
|
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::DeliveredMessage {
|
|
hive_sh4re::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::DeliveredMessage],
|
|
remaining: u64,
|
|
waited: bool,
|
|
) -> String {
|
|
use std::fmt::Write as _;
|
|
if messages.is_empty() {
|
|
return if waited {
|
|
format!("(empty){IDLE_WAIT_HINT}")
|
|
} else {
|
|
"(empty)".to_owned()
|
|
};
|
|
}
|
|
let mut out = if messages.len() == 1 {
|
|
let m = &messages[0];
|
|
let banner = if m.redelivered {
|
|
hive_sh4re::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::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::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()
|
|
}
|
|
}
|
|
|
|
/// Appended to the `recv` empty result when the agent parked on a
|
|
/// long-poll (`wait_seconds > 0`) that timed out with nothing new.
|
|
/// Nudges the model to spend the idle time on other useful work
|
|
/// instead of immediately re-blocking on `recv`.
|
|
pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \
|
|
If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \
|
|
notes to update), do that now rather than immediately parking on recv again.";
|
|
|
|
/// Inner renderer for a `Vec<LooseEnd>` already extracted from the socket
|
|
/// reply. Called by the `get_loose_ends` handler, which injects the
|
|
/// `UnreadMatrix` entry before formatting.
|
|
pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String {
|
|
use std::fmt::Write as _;
|
|
if loose_ends.is_empty() {
|
|
return "(no loose ends)".to_owned();
|
|
}
|
|
let mut out = format!("{} loose end(s):\n", loose_ends.len());
|
|
for t in loose_ends {
|
|
match t {
|
|
hive_sh4re::LooseEnd::Approval {
|
|
id,
|
|
agent,
|
|
commit_ref,
|
|
description,
|
|
age_seconds,
|
|
} => {
|
|
let desc = description
|
|
.as_deref()
|
|
.map(|d| format!(" — {d}"))
|
|
.unwrap_or_default();
|
|
let _ = writeln!(
|
|
out,
|
|
"- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}"
|
|
);
|
|
}
|
|
hive_sh4re::LooseEnd::Question {
|
|
id,
|
|
asker,
|
|
target,
|
|
question,
|
|
age_seconds,
|
|
} => {
|
|
let to = target.as_deref().unwrap_or("operator");
|
|
let _ = writeln!(
|
|
out,
|
|
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
|
|
);
|
|
}
|
|
hive_sh4re::LooseEnd::Reminder {
|
|
id,
|
|
owner,
|
|
message,
|
|
due_at,
|
|
age_seconds,
|
|
} => {
|
|
let _ = writeln!(
|
|
out,
|
|
"- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}"
|
|
);
|
|
}
|
|
hive_sh4re::LooseEnd::PendingMessages { count } => {
|
|
let _ = writeln!(
|
|
out,
|
|
"- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)"
|
|
);
|
|
}
|
|
hive_sh4re::LooseEnd::UnreadMatrix { rooms, summary } => {
|
|
let _ = write!(out, "- unread matrix messages in {rooms} room(s)");
|
|
if summary.is_empty() {
|
|
let _ = writeln!(
|
|
out,
|
|
" — use list_rooms + read_room to view, mark_read to clear"
|
|
);
|
|
} else {
|
|
let _ = writeln!(out, ":");
|
|
for line in summary.lines() {
|
|
let _ = writeln!(out, " {line}");
|
|
}
|
|
let _ = writeln!(
|
|
out,
|
|
" use list_rooms + read_room to view, mark_read to clear"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Per-room unread entry returned by `matrix_unread_summary`. Mirrors
|
|
/// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a
|
|
/// cross-crate dep on the matrix-sdk crate tree.
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub(super) struct MatrixRoomUnread {
|
|
label: String,
|
|
count: u32,
|
|
last_body: Option<String>,
|
|
last_sender: Option<String>,
|
|
}
|
|
|
|
/// Query the local matrix daemon for per-room unread summaries. Returns
|
|
/// `None` if the daemon socket is absent or the query fails. Best-effort:
|
|
/// agents without matrix configured are not penalised.
|
|
pub(super) async fn matrix_unread_summary() -> Option<Vec<MatrixRoomUnread>> {
|
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
|
use tokio::net::UnixStream;
|
|
let socket = std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else(
|
|
|| std::path::PathBuf::from("/run/hive-matrix/socket"),
|
|
std::path::PathBuf::from,
|
|
);
|
|
if !socket.exists() {
|
|
return None;
|
|
}
|
|
let mut stream = UnixStream::connect(&socket).await.ok()?;
|
|
stream
|
|
.write_all(b"{\"method\":\"unread_summary\"}\n")
|
|
.await
|
|
.ok()?;
|
|
let mut lines = BufReader::new(stream).lines();
|
|
let line = lines.next_line().await.ok()??;
|
|
let val: serde_json::Value = serde_json::from_str(&line).ok()?;
|
|
// Response: {"kind":"ok","payload":[{label, count, last_body?, last_sender?}]}
|
|
let arr = val.get("payload")?.as_array()?;
|
|
serde_json::from_value(serde_json::Value::Array(arr.clone())).ok()
|
|
}
|
|
|
|
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
|
|
/// Single room / single message collapses to one line; multi-room
|
|
/// expands to a bulleted list. Returns an empty string for empty input.
|
|
pub(super) fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String {
|
|
use std::fmt::Write as _;
|
|
if rooms.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut out = String::new();
|
|
for r in rooms {
|
|
if r.count == 1
|
|
&& let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender)
|
|
{
|
|
let _ = writeln!(out, "- {}: {sender}: {body}", r.label);
|
|
continue;
|
|
}
|
|
let _ = writeln!(out, "- {}: {} unread", r.label, r.count);
|
|
}
|
|
// Remove trailing newline.
|
|
if out.ends_with('\n') {
|
|
out.pop();
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parse the user-facing `kind` string for `cancel_loose_end` into the
|
|
/// wire enum. Accepts a small alias set so claude doesn't have to
|
|
/// remember the exact spelling (`"q"` / `"r"` shorthand falls out
|
|
/// for free).
|
|
pub(super) fn parse_loose_end_kind(raw: &str) -> Result<hive_sh4re::CancelLooseEndKind, String> {
|
|
match raw.trim().to_ascii_lowercase().as_str() {
|
|
"question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question),
|
|
"reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder),
|
|
"approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval),
|
|
other => Err(format!(
|
|
"cancel_loose_end: unknown kind '{other}' \
|
|
(expected \"question\", \"reminder\", or \"approval\")"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Canonical user-facing label for a `CancelLooseEndKind` — used in
|
|
/// the success ack so the caller always sees `"question"` /
|
|
/// `"reminder"` instead of whatever alias they passed in (`"q"` /
|
|
/// `"r"`).
|
|
pub(super) fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str {
|
|
match kind {
|
|
hive_sh4re::CancelLooseEndKind::Question => "question",
|
|
hive_sh4re::CancelLooseEndKind::Reminder => "reminder",
|
|
hive_sh4re::CancelLooseEndKind::Approval => "approval",
|
|
}
|
|
}
|
|
|
|
/// Format helper for `get_agent_meta`: renders an agent's identity +
|
|
/// current status as a short human-readable block. `name`,
|
|
/// `hyperhive_rev`, and `running` are always shown; `status` only
|
|
/// appears when one is set, otherwise the line reads `status: <none>`.
|
|
/// When `running` is false the host has already cleared `status_text`
|
|
/// (it would be a stale snapshot from before the stop) so the status
|
|
/// line is implicitly `<none>` in that case — but the explicit
|
|
/// `running: no` line tells the caller WHY. See
|
|
/// `docs/turn-loop/mcp.md::Core tools` (`get_agent_meta`).
|
|
#[must_use]
|
|
pub fn format_agent_meta(resp: Result<hive_sh4re::Response, anyhow::Error>) -> String {
|
|
match resp {
|
|
Ok(hive_sh4re::Response::AgentMeta {
|
|
name,
|
|
running,
|
|
hyperhive_rev,
|
|
status_text,
|
|
status_set_at,
|
|
hive_name,
|
|
swarm_name,
|
|
matrix_accounts,
|
|
}) => {
|
|
let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>");
|
|
let run = if running { "yes" } else { "no" };
|
|
let mut out = format!("name: {name}\nhyperhive_rev: {rev}\nrunning: {run}");
|
|
// Surface hive + swarm display names only when set, so
|
|
// single-hive deployments don't see noisy `<none>` lines.
|
|
if let Some(hn) = hive_name.as_deref() {
|
|
use std::fmt::Write as _;
|
|
let _ = write!(out, "\nhive_name: {hn}");
|
|
}
|
|
if let Some(sn) = swarm_name.as_deref() {
|
|
use std::fmt::Write as _;
|
|
let _ = write!(out, "\nswarm_name: {sn}");
|
|
}
|
|
match status_text {
|
|
None => out.push_str("\nstatus: <none>"),
|
|
Some(s) => {
|
|
use std::fmt::Write as _;
|
|
let age = status_set_at.and_then(|ts| {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.ok()?
|
|
.as_secs();
|
|
// `ts` is a unix epoch second the agent itself
|
|
// sourced from `SystemTime` — always positive
|
|
// in normal operation. Clamp the negative
|
|
// (clock-skew) edge to 0 before the unsigned
|
|
// cast so the cast loses no real precision.
|
|
let ts_secs = u64::try_from(ts).unwrap_or(0);
|
|
let secs = now.saturating_sub(ts_secs);
|
|
Some(format_age_secs(secs))
|
|
});
|
|
// `write!` into the buffer instead of `push_str(&format!(…))` —
|
|
// avoids the intermediate allocation clippy::format_push_string
|
|
// flags. The infallible `String` writer makes this safe to
|
|
// `let _ =`-ignore.
|
|
match age {
|
|
Some(a) => {
|
|
let _ = write!(out, "\nstatus: {s} (set {a} ago)");
|
|
}
|
|
None => {
|
|
let _ = write!(out, "\nstatus: {s}");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Matrix identities the agent can act as (the `account` arg on
|
|
// the matrix tools). Listed only when matrix is provisioned, so
|
|
// non-matrix agents don't see an empty line.
|
|
if !matrix_accounts.is_empty() {
|
|
use std::fmt::Write as _;
|
|
out.push_str("\nmatrix_accounts:");
|
|
for acct in &matrix_accounts {
|
|
let uid = acct.user_id.as_deref().unwrap_or("?");
|
|
let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
other => reply_err(other, "get_agent_meta"),
|
|
}
|
|
}
|
|
|
|
/// Format a duration in seconds as a human-readable age string.
|
|
fn format_age_secs(secs: u64) -> String {
|
|
if secs < 60 {
|
|
format!("{secs}s")
|
|
} else if secs < 3600 {
|
|
format!("{}m", secs / 60)
|
|
} else if secs < 86400 {
|
|
format!("{}h", secs / 3600)
|
|
} else {
|
|
format!("{}d", secs / 86400)
|
|
}
|
|
}
|
|
|
|
/// Append a short note to a tool result when the underlying socket call
|
|
/// took retries to land. Lets claude distinguish "my request was wrong"
|
|
/// from "c0re flickered and the harness rode it out" — without the
|
|
/// hint, a tool result that took 30s to come back looks identical to a
|
|
/// content failure and the model would burn a turn retrying it.
|
|
#[must_use]
|
|
pub fn annotate_retries(mut s: String, retries: u32) -> String {
|
|
if retries > 0 {
|
|
use std::fmt::Write as _;
|
|
let suffix = if retries == 1 { "retry" } else { "retries" };
|
|
let _ = write!(
|
|
s,
|
|
"\n\n(note: hive socket connect needed {retries} {suffix} — c0re likely \
|
|
restarted. Your request did succeed on the final attempt; no action needed.)"
|
|
);
|
|
}
|
|
s
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{IDLE_WAIT_HINT, format_recv};
|
|
|
|
fn msg(id: i64, from: &str, body: &str) -> hive_sh4re::DeliveredMessage {
|
|
hive_sh4re::DeliveredMessage {
|
|
from: from.to_owned(),
|
|
body: body.to_owned(),
|
|
id,
|
|
redelivered: false,
|
|
in_reply_to: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_recv_after_wait_appends_idle_hint() {
|
|
let out = format_recv(
|
|
Ok(hive_sh4re::Response::Messages {
|
|
messages: vec![],
|
|
remaining: 0,
|
|
}),
|
|
true,
|
|
);
|
|
assert!(out.starts_with("(empty)"));
|
|
assert!(out.contains(IDLE_WAIT_HINT));
|
|
}
|
|
|
|
#[test]
|
|
fn empty_recv_without_wait_has_no_hint() {
|
|
let out = format_recv(
|
|
Ok(hive_sh4re::Response::Messages {
|
|
messages: vec![],
|
|
remaining: 0,
|
|
}),
|
|
false,
|
|
);
|
|
assert_eq!(out, "(empty)");
|
|
}
|
|
|
|
#[test]
|
|
fn single_recv_with_remaining_appends_pending_hint() {
|
|
let out = format_recv(
|
|
Ok(hive_sh4re::Response::Messages {
|
|
messages: vec![msg(7, "alice", "hi")],
|
|
remaining: 3,
|
|
}),
|
|
false,
|
|
);
|
|
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_sh4re::Response::Messages {
|
|
messages: vec![msg(7, "alice", "hi")],
|
|
remaining: 0,
|
|
}),
|
|
false,
|
|
);
|
|
assert!(!out.contains("more message(s) pending"));
|
|
}
|
|
|
|
#[test]
|
|
fn batch_recv_with_remaining_appends_pending_hint_once() {
|
|
let out = format_recv(
|
|
Ok(hive_sh4re::Response::Messages {
|
|
messages: vec![msg(7, "alice", "hi"), msg(8, "bob", "yo")],
|
|
remaining: 9,
|
|
}),
|
|
false,
|
|
);
|
|
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::RECV_BATCH_MAX));
|
|
assert!(out.contains(&format!("max: {batch}")));
|
|
}
|
|
}
|