//! 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 `hive-matrix-daemon`'s local //! `/unread-summary` status endpoint (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_core_agent_sock::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_core_agent_sock::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. 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) -> 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 #] ` 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. Approvals/questions/reminders stay naturally bounded (they're /// triaged interactively and don't self-multiply), but todos are pushed by /// unattended producers — matrix/bash/forge are the built-in ones, but any /// user-configured MCP server can push its own too — an agent that goes a long /// stretch without calling `get_loose_ends`, or whose producers outpace its /// triage, can accumulate hundreds of them. Rendering all of them /// unconditionally risks producing a tool result too large for the MCP /// transport to return at all, which is worse than a bug: it's a deadlock /// (the agent can't even see what's pending to start clearing it). Capping /// here guarantees `get_loose_ends` always returns successfully; the /// truncation summary tells the agent to review the shown batch, bulk-clear /// the reviewed ids via `mark_todos_done`, then call again for the next /// batch — deliberately NOT a "clear everything below id N" range escape /// hatch (a reviewer's call: a range-based bulk-ack risks silently /// acking something the agent never actually looked at, since todos are /// heterogeneous unrelated items, not a sequentially-read stream the way /// inbox messages are). const MAX_RENDERED_TODOS: usize = 40; /// Render one non-`Todo` [`hive_sh4re::inbox::LooseEnd`] variant onto `out`. Split /// out of `render_loose_ends` to keep that function under clippy's /// `too_many_lines` limit — `Todo` stays inline there since it also needs /// the shared `shown_todos` counter. fn render_one_loose_end(out: &mut String, t: &hive_sh4re::inbox::LooseEnd) { use std::fmt::Write as _; match t { hive_sh4re::inbox::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::inbox::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::inbox::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::inbox::LooseEnd::PendingMessages { count } => { let _ = writeln!( out, "- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)" ); } hive_sh4re::inbox::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" ); } } hive_sh4re::inbox::LooseEnd::Todo { .. } => { // Handled inline by the caller (needs the shared shown-count). } } } /// Render one `Todo` [`hive_sh4re::inbox::LooseEnd`] onto `out`. Split out for the /// same `too_many_lines` reason as [`render_one_loose_end`]. fn render_todo_loose_end( out: &mut String, id: i64, subsystem: &str, subsystem_key: Option<&str>, summary: &str, source: Option<&str>, age_seconds: u64, ) { use std::fmt::Write as _; let key = subsystem_key.map(|k| format!(" {k}")).unwrap_or_default(); let src = source.map(|s| format!(" — {s}")).unwrap_or_default(); let _ = writeln!( out, "- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \ (cancel_loose_end kind:\"todo\" id:{id} to clear)" ); } /// 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::inbox::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()); let mut shown_todos = 0usize; let mut hidden_todos = 0usize; for t in loose_ends { let hive_sh4re::inbox::LooseEnd::Todo { id, subsystem, subsystem_key, summary, source, age_seconds, } = t else { render_one_loose_end(&mut out, t); continue; }; if shown_todos >= MAX_RENDERED_TODOS { hidden_todos += 1; continue; } render_todo_loose_end( &mut out, *id, subsystem, subsystem_key.as_deref(), summary, source.as_deref(), *age_seconds, ); shown_todos += 1; } if hidden_todos > 0 { let _ = writeln!( out, "- {hidden_todos} more todo(s) not shown — clear reviewed ids with \ mark_todos_done(ids: [...]), then call get_loose_ends again for the rest" ); } 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, } /// Default port `hive-matrix-daemon` serves its MCP + status endpoints /// on (`hyperhive.mcp.matrixHttpPort`'s nix default). Overridable via /// `HIVE_MATRIX_HTTP_PORT` for parity with the port options nix already /// exposes; unset in practice since a single fixed port is safe (each /// agent container is its own network namespace — see docs/network.md). const DEFAULT_MATRIX_HTTP_PORT: u16 = 8792; /// Short request timeout for the local status query below — this must /// never stall a turn waiting on a wedged same-container daemon. const MATRIX_STATUS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); /// Query the local matrix daemon's `/unread-summary` status endpoint /// for per-room unread summaries. Returns `None` if the daemon isn't /// reachable or the query fails. Best-effort: agents without matrix /// configured are not penalised. pub(super) async fn matrix_unread_summary() -> Option> { let port = std::env::var("HIVE_MATRIX_HTTP_PORT") .unwrap_or_else(|_| DEFAULT_MATRIX_HTTP_PORT.to_string()); let url = format!("http://127.0.0.1:{port}/unread-summary"); let client = reqwest::Client::builder() .timeout(MATRIX_STATUS_TIMEOUT) .build() .ok()?; let val: serde_json::Value = client.get(&url).send().await.ok()?.json().await.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() } /// Dial the harness's in-agent socket (`HIVE_AGENT_SOCKET`) with a single /// one-shot JSON-line request and decode the response. Returns `None` when /// the socket is unset/absent or on any transport/decode failure — /// best-effort, single-shot (no retry, unlike the broker's /// `client::request_retried`): this socket lives in the SAME container, so /// a connect failure means the harness itself isn't up, which a retry /// won't fix within a tool call's budget. Shared by [`local_todos`], /// [`mark_local_todo_done`], and [`local_reminders`]; `remind`/ /// `cancel_loose_end`(reminder) in `mod.rs` dial it directly since their /// happy path is a plain `Ok`/`Err`, not a `Vec` to merge. pub(super) async fn dial_agent_socket( req: &hive_agent_sock::Request, ) -> Option { use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; let socket = std::env::var_os("HIVE_AGENT_SOCKET").map(std::path::PathBuf::from)?; if !socket.exists() { return None; } let mut stream = UnixStream::connect(&socket).await.ok()?; let mut line = serde_json::to_string(req).ok()?; line.push('\n'); stream.write_all(line.as_bytes()).await.ok()?; let mut lines = BufReader::new(stream).lines(); let resp_line = lines.next_line().await.ok()??; serde_json::from_str(&resp_line).ok() } /// Query the harness's in-agent socket for this agent's local todos /// (loose-ends v2). Returns `None` when `HIVE_AGENT_SOCKET` is unset / /// absent or the query fails — best-effort, like [`matrix_unread_summary`], /// so an agent without the socket is not penalised. pub(super) async fn local_todos() -> Option> { match dial_agent_socket(&hive_agent_sock::Request::ListTodos { subsystem: None }).await? { hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends), _ => None, } } /// Query the harness's in-agent socket for this agent's local pending /// reminders — was a broker query before reminders moved in-container. /// Same best-effort contract as [`local_todos`]. pub(super) async fn local_reminders() -> Option> { match dial_agent_socket(&hive_agent_sock::Request::ListReminders).await? { hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends), _ => None, } } /// Query the harness's in-agent socket for this agent's mirrored questions /// (both roles — asked and answering). Same best-effort /// contract as [`local_reminders`]; c0re stays the actual `Ask`/`Answer` /// routing, this only mirrors the durable "still owed a reply" view. pub(super) async fn local_questions() -> Option> { match dial_agent_socket(&hive_agent_sock::Request::ListQuestions).await? { hive_agent_sock::Response::LooseEnds { loose_ends } => Some(loose_ends), _ => None, } } /// Mark one of this agent's local todos (loose-ends v2) done by id, via /// the harness's in-agent socket — reachable through `cancel_loose_end` /// kind `"todo"` so clearing a todo never has to shell out through a /// tracked bash task (which would just spawn a fresh completion todo of /// its own). `None` means the socket is unset/absent or unreachable, /// distinct from a decoded `Err` response (unknown id) so the caller can /// tell the two apart. pub(super) async fn mark_local_todo_done(id: i64) -> Option { dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await } /// Bulk-ack an explicit list of local todo ids, via the harness's in-agent /// socket — the multi-id analogue of `mark_local_todo_done`, for the /// `mark_todos_done` tool. pub(super) async fn mark_local_todos_done(ids: Vec) -> Option { dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await } /// 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::inbox::CancelLooseEndKind::Question), "reminder" | "r" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Reminder), "approval" | "a" => Ok(hive_sh4re::inbox::CancelLooseEndKind::Approval), other => Err(format!( "cancel_loose_end: unknown kind '{other}' \ (expected \"question\", \"reminder\", \"approval\", or \"todo\")" )), } } /// 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::inbox::CancelLooseEndKind) -> &'static str { match kind { hive_sh4re::inbox::CancelLooseEndKind::Question => "question", hive_sh4re::inbox::CancelLooseEndKind::Reminder => "reminder", hive_sh4re::inbox::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_core_agent_sock::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::format_recv; 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}"))); } }