//! 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, 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, 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, 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 { 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 { REDELIVERY_HINT } else { "" }; let _ = write!( out, "{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body ); } out }; out.push_str(&crate::serve_common::pending_hint(remaining)); out } /// `[msg #] ` 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() } } /// Header prepended to message bodies that were popped by a prior /// harness session, never acked (turn crash / OOM / restart), and /// resurfaced by `RequeueInflight` on this session's boot. Same /// string surfaces in the wake prompt (see the bin loops) and the /// in-turn `recv` tool result so claude sees the warning either way. pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n"; /// 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` 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, last_sender: Option, } /// 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> { 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` 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 { 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: `. /// 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 `` 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) -> 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(""); 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 `` 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: "), 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}"))); } }