Closes the #3110 split — lib.rs is now just the crate doc comment and the pub mod list. journal.rs's new doc comment fixes a pre-existing bug: the old JournalPriority doc text in lib.rs was actually half Capability's doc (a leftover from an earlier reorder that moved the code but not the comment above it).
654 lines
26 KiB
Rust
654 lines
26 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,
|
|
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,
|
|
}
|
|
}
|
|
|
|
/// 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<Vec<hive_sh4re::inbox::LooseEnd>> {
|
|
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<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_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}")));
|
|
}
|
|
}
|