684 lines
27 KiB
Rust
684 lines
27 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 `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<hive_core_agent_sock::Response, anyhow::Error>,
|
|
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<hive_core_agent_sock::Response, anyhow::Error>,
|
|
tool: &str,
|
|
ok_msg: String,
|
|
) -> String {
|
|
match resp {
|
|
Ok(hive_core_agent_sock::Response::Ok) => ok_msg,
|
|
// Succeeded, with something the caller needs to read. Rendered
|
|
// after the success line rather than instead of it: the operation
|
|
// DID happen, and a warning shown as though it were a failure
|
|
// invites a retry that would only queue a second one.
|
|
Ok(hive_core_agent_sock::Response::OkWarn { warnings }) => {
|
|
let mut out = ok_msg;
|
|
for w in &warnings {
|
|
out.push_str("\n⚠️ ");
|
|
out.push_str(w);
|
|
}
|
|
out
|
|
}
|
|
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<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. 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<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::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<String>,
|
|
last_sender: Option<String>,
|
|
}
|
|
|
|
/// 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<Vec<MatrixRoomUnread>> {
|
|
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<LooseEnd>` to merge.
|
|
pub(super) async fn dial_agent_socket(
|
|
req: &hive_agent_sock::Request,
|
|
) -> Option<hive_agent_sock::Response> {
|
|
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<Vec<hive_sh4re::inbox::LooseEnd>> {
|
|
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<Vec<hive_sh4re::inbox::LooseEnd>> {
|
|
match dial_agent_socket(&hive_agent_sock::Request::ListReminders).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<hive_agent_sock::Response> {
|
|
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<i64>) -> Option<hive_agent_sock::Response> {
|
|
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await
|
|
}
|
|
|
|
/// 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::inbox::CancelLooseEndKind, String> {
|
|
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: <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_core_agent_sock::Response, anyhow::Error>) -> 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("<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::{format_ack, 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}")));
|
|
}
|
|
|
|
#[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()],
|
|
}),
|
|
"request_init_config",
|
|
"init_config approval queued for forge".to_owned(),
|
|
);
|
|
// The operation HAPPENED — dropping the success line would read as a
|
|
// failure and invite a retry that queues a second approval.
|
|
assert!(out.starts_with("init_config approval queued for forge"));
|
|
assert!(out.contains("⚠️ name is reserved"));
|
|
assert!(out.contains("⚠️ second thing"));
|
|
}
|
|
|
|
#[test]
|
|
fn plain_ok_is_untouched_by_the_warning_path() {
|
|
// Absence arm: without it, a renderer that always appended a
|
|
// warning marker would pass the test above.
|
|
let out = format_ack(
|
|
Ok(hive_core_agent_sock::Response::Ok),
|
|
"request_init_config",
|
|
"queued".to_owned(),
|
|
);
|
|
assert_eq!(out, "queued");
|
|
assert!(!out.contains('⚠'));
|
|
}
|
|
}
|