refactor(hive-ag3nt): split mcp into submodules
mod.rs keeps the #[tool_router] impl + server wiring untouched; rendering/format helpers move to mcp/render.rs, the 24 tool arg structs to mcp/args.rs
This commit is contained in:
parent
c84028ddcf
commit
b0736e6f3e
3 changed files with 827 additions and 797 deletions
448
hive-ag3nt/src/mcp/render.rs
Normal file
448
hive-ag3nt/src/mcp/render.rs
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
//! 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 }) => render_recv_messages(&messages, 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.
|
||||
Ok(hive_sh4re::Response::GracefulStop) => {
|
||||
render_recv_messages(&[graceful_stop_message()], 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).
|
||||
fn render_recv_messages(messages: &[hive_sh4re::DeliveredMessage], waited: bool) -> String {
|
||||
use std::fmt::Write as _;
|
||||
if messages.is_empty() {
|
||||
return if waited {
|
||||
format!("(empty){IDLE_WAIT_HINT}")
|
||||
} else {
|
||||
"(empty)".to_owned()
|
||||
};
|
||||
}
|
||||
if messages.len() == 1 {
|
||||
let m = &messages[0];
|
||||
let banner = if m.redelivered { REDELIVERY_HINT } else { "" };
|
||||
return format!("{banner}{}from: {}\n\n{}", msg_id_tag(m.id), m.from, m.body);
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/// `[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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<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};
|
||||
|
||||
#[test]
|
||||
fn empty_recv_after_wait_appends_idle_hint() {
|
||||
let out = format_recv(
|
||||
Ok(hive_sh4re::Response::Messages { messages: vec![] }),
|
||||
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![] }),
|
||||
false,
|
||||
);
|
||||
assert_eq!(out, "(empty)");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue