diff --git a/hive-ag3nt/src/mcp/args.rs b/hive-ag3nt/src/mcp/args.rs new file mode 100644 index 00000000..007504cf --- /dev/null +++ b/hive-ag3nt/src/mcp/args.rs @@ -0,0 +1,359 @@ +//! Argument structs for the MCP tools: `serde::Deserialize` + +//! `schemars::JsonSchema` derives whose field doc-comments become the +//! parameter descriptions claude sees in each tool's input schema. + +use rmcp::schemars; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SendArgs { + /// Logical agent name to deliver the message to (e.g. `"manager"`, + /// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box). + pub to: String, + /// Message body. Plain text; the broker doesn't parse it. + pub body: String, + /// Optional broker row-id of the message this is a reply to. Lets + /// the dashboard render conversation threads. Pass the `id` from the + /// `DeliveredMessage` you're responding to; omit for new threads. + /// Silently ignored if the id is unknown or out of retention. + #[serde(default)] + pub in_reply_to: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RecvArgs { + /// How long to long-poll for the FIRST message before returning + /// the empty marker. Capped at 60s server-side. Default (None) + /// is 30s. Useful when an agent wants to park its turn waiting + /// for any new work — pick a longer wait to coalesce bursts. + #[serde(default)] + pub wait_seconds: Option, + /// Maximum number of messages to pop in this round-trip. Default + /// (None) is 1 (single-message behaviour — exactly what you want + /// when you're called to drive a turn off the first wake). Pass + /// a higher value (capped at 5 server-side) when you've been + /// told the inbox has more queued (the wake prompt mentions + /// pending count) and want to drain everything in one tool call. + /// Once the long-poll wakes up, the call drains up to `max` in + /// total before returning — no extra round-trip needed. + #[serde(default)] + pub max: Option, +} + +/// MCP tool args for `ack_until`. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AckUntilArgs { + /// Highest broker message id to mark handled: every inbox message + /// with `id <= up_to` (ids show as `[msg #]` in recv output) + /// is acked in one sweep. Pass the highest id you've actually + /// seen/triaged — anything above it stays queued for later turns. + pub up_to: i64, +} + +/// MCP tool args for `remind`. Exactly one of `delay_seconds` or +/// `at_unix_timestamp` must be set; both / neither is a tool-side error. +/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the +/// model picks one field instead of building `{"timing_type": "in_seconds", +/// "seconds": 60}` shaped objects. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RemindArgs { + /// Body that lands in your inbox when the reminder fires (sender + /// will appear as `reminder`). Soft cap at 4 KiB inline — anything + /// larger gets auto-persisted to a file under + /// `/agents//state/reminders/auto-.md` and the inbox + /// message becomes a short pointer. Pass `file_path` if you want + /// to control the destination yourself. + pub message: String, + /// Fire `delay_seconds` from now (relative). Set this OR + /// `at_unix_timestamp`, not both. + #[serde(default)] + pub delay_seconds: Option, + /// Fire at this absolute unix timestamp (seconds since epoch). Set + /// this OR `delay_seconds`, not both. + #[serde(default)] + pub at_unix_timestamp: Option, + /// Optional path to a file the scheduler should reference instead of + /// inlining a long `message`. Use this for large payloads (research + /// notes, file lists, intermediate state). Path must be reachable from + /// the agent's container — typically under `/agents//state/`. + #[serde(default)] + pub file_path: Option, +} + +// ----------------------------------------------------------------------------- +// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics) +// ----------------------------------------------------------------------------- + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RequestInitConfigArgs { + /// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on + /// approval hive-c0re seeds the proposed config repo at + /// `/agents//config/agent.nix` with the default template. After + /// the approval the manager edits + commits the config and calls + /// `request_apply_commit` to pin the customised sha for the container's + /// first build. + pub name: String, + /// Optional description shown on the dashboard approval card. + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct KillArgs { + /// Sub-agent name (without the `h-` container prefix). + pub name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct SetStatusArgs { + /// Status text to display on the dashboard card. Pass an empty string to clear. + pub text: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CreateRepoArgs { + /// Repo name — a single segment of letters, digits, `-`, `_`, `.` + /// (no leading `-`/`.`). The repo is created as `agents/`. + pub repo: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetAgentMetaArgs { + /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). + /// Omit to query your own identity + status — replaces the + /// previous `whoami` self-introspection tool. + #[serde(default)] + pub name: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct StartArgs { + /// Sub-agent name (without the `h-` container prefix). + pub name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RestartArgs { + /// Sub-agent name (without the `h-` container prefix). + pub name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateArgs { + /// Sub-agent name (without the `h-` container prefix). + pub name: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AskArgs { + /// The question to surface. + pub question: String, + /// Optional fixed-choice answers. The dashboard renders these as + /// chips alongside a free-text fallback ("Other…") so the operator + /// is never trapped by an incomplete list; peer-agent recipients + /// see the list in their inbox event and can return any string. + #[serde(default)] + pub options: Vec, + /// When true, options are rendered as checkboxes — the answerer + /// can pick any subset. The answer comes back as a single string + /// with selections joined by ", ". Ignored when `options` is empty. + #[serde(default)] + pub multi: bool, + /// Optional auto-cancel after `ttl_seconds` (capped server-side at + /// 6 hours). On expiry the question resolves with answer + /// `[expired]` and the asker receives the usual + /// `question_answered` system event (with `answerer: + /// "ttl-watchdog"`). `None` (default) = wait indefinitely. + #[serde(default)] + pub ttl_seconds: Option, + /// Recipient. Omit (or pass `"operator"`) to ask the human + /// operator via the dashboard. Pass another agent's logical name + /// to ask that peer — they receive a `question_asked` event in + /// their inbox and answer via `mcp__hyperhive__answer`. + #[serde(default)] + pub to: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AnswerArgs { + /// Id of the question being answered — comes from the + /// `question_asked` event in your inbox. + pub id: i64, + /// Free-text answer body. Soft-capped at 4 KiB by the same + /// `MESSAGE_MAX_BYTES` limit as `send`; keep it short or write the + /// detail to a file and pass a path. + pub answer: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CancelLooseEndArgs { + /// Which kind of thread to cancel — `"question"` for an open + /// `ask` that's still waiting on an answer, `"reminder"` for a + /// scheduled `remind` that hasn't fired yet. Use the `kind` + /// field straight off the `get_loose_ends` row. + pub kind: String, + /// Row id from the matching `get_loose_ends` entry (or the + /// `question_queued` reply when you submitted it). + pub id: i64, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AgentGetLooseEndsArgs { + /// Whose loose ends to list. Omit (or `null`) for your own. You may + /// also pass a direct child agent's name without any extra capability. + /// Pass any other agent name to inspect their threads — requires the + /// `query_agent_state` capability; without it the request is rejected + /// with an error. The `"*"` hive-wide value is not available on the + /// agent socket. + #[serde(default)] + pub agent: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RequestApplyCommitArgs { + /// Logical agent name whose config repo the commit lives in. + pub agent: String, + /// Commit sha (full or short, 7-40 hex chars) in that agent's + /// proposed config repo. Must be a sha — a branch or tag name + /// (e.g. `main`) is rejected; the approval pins the exact commit. + pub commit_ref: String, + /// Optional description shown on the dashboard approval card so the + /// operator knows what the change does without opening the diff. + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateMetaInputsArgs { + /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). + /// Pass an empty list to update ALL inputs. + #[serde(default)] + pub inputs: Vec, + /// Optional description shown on the dashboard approval card. + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RequestSchedulePromptArgs { + /// Recipient agents — one schedule fires to many inboxes at the + /// scheduled time. `operator` is a legitimate target (mara: "we + /// want to get rid of the manager special case so yes manager + /// can be recipient" — the operator slot follows the same rule). + pub targets: Vec, + /// Message body delivered to each target's inbox at fire time. + /// Same size budget as `send` bodies. + pub body: String, + /// Absolute unix timestamp (seconds) for the FIRST fire. For + /// recurring schedules the worker re-arms in + /// `interval_seconds` steps from this point on. + pub first_fire_at_unix: i64, + /// `None` / absent = one-shot. `Some(n > 0)` = recurring every + /// `n` seconds. The worker clamps catch-up so a long downtime + /// fires ONCE on resume (skipped-cycle count surfaces in the + /// per-target `last_result`), not N delayed pulses in a row. + #[serde(default)] + pub interval_seconds: Option, + /// Optional description shown on the dashboard approval card + + /// preserved on the schedule row for later operator reference. + #[serde(default)] + pub description: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct FireScheduleNowArgs { + /// Schedule id to fire out of band. Get this from a prior + /// `list_schedules` call or the approval-resolved event for + /// the originating `request_schedule_prompt`. + pub id: i64, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CancelScheduleArgs { + /// Schedule id from a prior `list_schedules` call or the + /// approval-resolved event for a `request_schedule_prompt`. + pub id: i64, + /// Optional target list. `None` / empty = cancel the entire + /// schedule. `Some(["alice", "bob"])` = cancel just those + /// recipients (the schedule keeps firing for any remaining + /// active targets, and auto-cancels its parent row when every + /// target is gone). + #[serde(default)] + pub targets: Option>, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct EditScheduleArgs { + /// Schedule id from a prior `list_schedules` call or the + /// approval-resolved event for a `request_schedule_prompt`. + pub id: i64, + /// New body text. Omit to keep the existing one. + #[serde(default)] + pub body: Option, + /// New description. Omit to keep the existing one. (To CLEAR + /// the description, use the dashboard PATCH endpoint + /// directly — the agent surface intentionally keeps the args + /// flat / non-nullable to dodge the doubly-wrapped Option + /// schemars quirk; clearing fields is rare and operator-side.) + #[serde(default)] + pub description: Option, + /// Recurring interval in seconds. Omit to keep the existing + /// cadence; pass an explicit value to set a new one. Toggling + /// recurring↔one-shot (clearing the interval) is operator-only + /// for the same reason as `description` above. + #[serde(default)] + pub interval_seconds: Option, + /// New absolute unix timestamp for the next fire. Omit to + /// leave the schedule on its current cadence. + #[serde(default)] + pub next_fire_at_unix: Option, + /// Names of new targets to add. Replace-on-conflict: re-adding + /// a previously cancelled target resets its history (operator + /// intent on re-add = "this target is active again"). + #[serde(default)] + pub targets_add: Option>, + /// Names of targets to cancel. Tombstones preserve per-target + /// audit; when no active targets remain the schedule + /// auto-cancels. + #[serde(default)] + pub targets_remove: Option>, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetLogsArgs { + /// Logical agent name to fetch logs for (e.g. `gui`, `iris`). + /// hive-c0re maps it to the underlying machine name (`h-gui`) + /// itself — pass the plain agent name, not the `h-` form. + pub agent: String, + /// How many journal lines to return (default: 50, max: 500). + #[serde(default)] + pub lines: Option, +} + +/// Arguments for `get_host_journal` (capability-gated: `read_host_journal`). +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetHostJournalArgs { + /// Systemd unit to filter (e.g. `hive-c0re.service`). Omit for all units. + #[serde(default)] + pub unit: Option, + /// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. + /// Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure + /// containers use their full name (e.g. `hive-ci`, `hive-forge`, + /// `hive-matrix`, `hive-gateway`). + #[serde(default)] + pub container: Option, + /// Number of lines to return (default 30, max 100). + #[serde(default)] + pub lines: Option, + /// Minimum syslog priority level. + #[serde(default)] + pub priority: Option, + /// Regex to match against log message fields (journalctl --grep). + #[serde(default)] + pub grep: Option, + /// Show entries on or newer than this timestamp (e.g. `-1h`). + #[serde(default)] + pub since: Option, + /// Show entries on or older than this timestamp. + #[serde(default)] + pub until: Option, +} diff --git a/hive-ag3nt/src/mcp/mod.rs b/hive-ag3nt/src/mcp/mod.rs index 2f7fd95b..7bd00c49 100644 --- a/hive-ag3nt/src/mcp/mod.rs +++ b/hive-ag3nt/src/mcp/mod.rs @@ -18,12 +18,31 @@ use std::path::PathBuf; use anyhow::Result; use rmcp::{ - ServerHandler, ServiceExt, handler::server::wrapper::Parameters, schemars, tool, tool_handler, + ServerHandler, ServiceExt, handler::server::wrapper::Parameters, tool, tool_handler, tool_router, transport::stdio, }; use crate::client; +mod args; +mod render; + +pub use args::{ + AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, + CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, + GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs, + RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, + StartArgs, UpdateArgs, UpdateMetaInputsArgs, +}; +pub use render::{ + IDLE_WAIT_HINT, REDELIVERY_HINT, annotate_retries, format_ack, format_agent_meta, format_recv, +}; + +use render::{ + format_matrix_summary, loose_end_kind_label, matrix_unread_summary, parse_loose_end_kind, + render_loose_ends, reply_err, +}; + /// Write (or remove) the status file in the agent's own `state/` directory. /// Called by `AgentServer::set_status` for both agent and manager flavors /// before dispatching the wire `SetStatus` request (which only triggers a @@ -69,406 +88,6 @@ fn write_status_file(text: &str) -> Result<(), String> { result.map_err(|e| format!("set_status write failed: {e}")) } -/// 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. -fn reply_err(resp: Result, tool: &str) -> String { - match resp { - Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"), - Ok(other) => format!("{tool} unexpected response: {other:?}"), - Err(e) => format!("{tool} transport error: {e:#}"), - } -} - -/// Format helper for "send-like" tools (anything that expects an `Ok`). -/// `tool` and `ok_msg` only appear in the result string; they don't change -/// behavior. -#[must_use] -pub fn format_ack( - resp: Result, - tool: &str, - ok_msg: String, -) -> String { - match resp { - Ok(hive_sh4re::Response::Ok) => ok_msg, - other => reply_err(other, tool), - } -} - -/// Format helper for `recv`: renders zero, one, or many popped -/// messages. Empty list collapses to "(empty)" so claude doesn't go -/// hunting for content; when `waited` is set (the call parked on a -/// long-poll that timed out) the empty result also carries -/// [`IDLE_WAIT_HINT`] nudging the model toward other work. A single -/// message renders as the historical `from: X\n\nbody` block (banner -/// first if `redelivered`). A multi-message batch renders with a -/// `popped N message(s):` header and `---` separators between bodies -/// so the model can tell where one ends and the next begins; -/// per-message redelivery banners included. -#[must_use] -pub fn format_recv(resp: Result, waited: bool) -> String { - match resp { - Ok(hive_sh4re::Response::Messages { messages }) => 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 #] ` marker prefixed to each recv row so the agent knows -/// what to pass to `ack_until` when bulk-triaging a backlog. Transient -/// pings carry the sentinel id 0 (in-memory only, nothing in the -/// broker to ack) and render without the marker. -fn msg_id_tag(id: i64) -> String { - if id > 0 { - format!("[msg #{id}] ") - } else { - String::new() - } -} - -/// Header prepended to message bodies that were popped by a prior -/// harness session, never acked (turn crash / OOM / restart), and -/// resurfaced by `RequeueInflight` on this session's boot. Same -/// string surfaces in the wake prompt (see the bin loops) and the -/// in-turn `recv` tool result so claude sees the warning either way. -pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n"; - -/// Appended to the `recv` empty result when the agent parked on a -/// long-poll (`wait_seconds > 0`) that timed out with nothing new. -/// Nudges the model to spend the idle time on other useful work -/// instead of immediately re-blocking on `recv`. -pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \ -If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \ -notes to update), do that now rather than immediately parking on recv again."; - -/// Inner renderer for a `Vec` already extracted from the socket -/// reply. Called by the `get_loose_ends` handler, which injects the -/// `UnreadMatrix` entry before formatting. -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)] -struct MatrixRoomUnread { - label: String, - count: u32, - last_body: Option, - last_sender: Option, -} - -/// Query the local matrix daemon for per-room unread summaries. Returns -/// `None` if the daemon socket is absent or the query fails. Best-effort: -/// agents without matrix configured are not penalised. -async fn matrix_unread_summary() -> Option> { - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use tokio::net::UnixStream; - let socket = std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else( - || std::path::PathBuf::from("/run/hive-matrix/socket"), - std::path::PathBuf::from, - ); - if !socket.exists() { - return None; - } - let mut stream = UnixStream::connect(&socket).await.ok()?; - stream - .write_all(b"{\"method\":\"unread_summary\"}\n") - .await - .ok()?; - let mut lines = BufReader::new(stream).lines(); - let line = lines.next_line().await.ok()??; - let val: serde_json::Value = serde_json::from_str(&line).ok()?; - // Response: {"kind":"ok","payload":[{label, count, last_body?, last_sender?}]} - let arr = val.get("payload")?.as_array()?; - serde_json::from_value(serde_json::Value::Array(arr.clone())).ok() -} - -/// Format a `Vec` into a per-room summary string. -/// Single room / single message collapses to one line; multi-room -/// expands to a bulleted list. Returns an empty string for empty input. -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). -fn parse_loose_end_kind(raw: &str) -> Result { - match raw.trim().to_ascii_lowercase().as_str() { - "question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question), - "reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder), - "approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval), - other => Err(format!( - "cancel_loose_end: unknown kind '{other}' \ - (expected \"question\", \"reminder\", or \"approval\")" - )), - } -} - -/// Canonical user-facing label for a `CancelLooseEndKind` — used in -/// the success ack so the caller always sees `"question"` / -/// `"reminder"` instead of whatever alias they passed in (`"q"` / -/// `"r"`). -fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { - match kind { - hive_sh4re::CancelLooseEndKind::Question => "question", - hive_sh4re::CancelLooseEndKind::Reminder => "reminder", - hive_sh4re::CancelLooseEndKind::Approval => "approval", - } -} - -/// Format helper for `get_agent_meta`: renders an agent's identity + -/// current status as a short human-readable block. `name`, -/// `hyperhive_rev`, and `running` are always shown; `status` only -/// appears when one is set, otherwise the line reads `status: `. -/// When `running` is false the host has already cleared `status_text` -/// (it would be a stale snapshot from before the stop) so the status -/// line is implicitly `` in that case — but the explicit -/// `running: no` line tells the caller WHY. See -/// `docs/turn-loop/mcp.md::Core tools` (`get_agent_meta`). -#[must_use] -pub fn format_agent_meta(resp: Result) -> String { - match resp { - Ok(hive_sh4re::Response::AgentMeta { - name, - running, - hyperhive_rev, - status_text, - status_set_at, - hive_name, - swarm_name, - matrix_accounts, - }) => { - let rev = hyperhive_rev.as_deref().unwrap_or(""); - let run = if running { "yes" } else { "no" }; - let mut out = format!("name: {name}\nhyperhive_rev: {rev}\nrunning: {run}"); - // Surface hive + swarm display names only when set, so - // single-hive deployments don't see noisy `` lines. - if let Some(hn) = hive_name.as_deref() { - use std::fmt::Write as _; - let _ = write!(out, "\nhive_name: {hn}"); - } - if let Some(sn) = swarm_name.as_deref() { - use std::fmt::Write as _; - let _ = write!(out, "\nswarm_name: {sn}"); - } - match status_text { - None => out.push_str("\nstatus: "), - Some(s) => { - use std::fmt::Write as _; - let age = status_set_at.and_then(|ts| { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok()? - .as_secs(); - // `ts` is a unix epoch second the agent itself - // sourced from `SystemTime` — always positive - // in normal operation. Clamp the negative - // (clock-skew) edge to 0 before the unsigned - // cast so the cast loses no real precision. - let ts_secs = u64::try_from(ts).unwrap_or(0); - let secs = now.saturating_sub(ts_secs); - Some(format_age_secs(secs)) - }); - // `write!` into the buffer instead of `push_str(&format!(…))` — - // avoids the intermediate allocation clippy::format_push_string - // flags. The infallible `String` writer makes this safe to - // `let _ =`-ignore. - match age { - Some(a) => { - let _ = write!(out, "\nstatus: {s} (set {a} ago)"); - } - None => { - let _ = write!(out, "\nstatus: {s}"); - } - } - } - } - // Matrix identities the agent can act as (the `account` arg on - // the matrix tools). Listed only when matrix is provisioned, so - // non-matrix agents don't see an empty line. - if !matrix_accounts.is_empty() { - use std::fmt::Write as _; - out.push_str("\nmatrix_accounts:"); - for acct in &matrix_accounts { - let uid = acct.user_id.as_deref().unwrap_or("?"); - let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver); - } - } - out - } - other => reply_err(other, "get_agent_meta"), - } -} - -/// Format a duration in seconds as a human-readable age string. -fn format_age_secs(secs: u64) -> String { - if secs < 60 { - format!("{secs}s") - } else if secs < 3600 { - format!("{}m", secs / 60) - } else if secs < 86400 { - format!("{}h", secs / 3600) - } else { - format!("{}d", secs / 86400) - } -} - /// Common envelope around every MCP tool handler: pre-log → run → /// post-log. Tool results stay clean — the inbox-status hint lives in /// the wake prompt + UI header, not appended here. @@ -482,100 +101,6 @@ where result } -/// 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 -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SendArgs { - /// Logical agent name to deliver the message to (e.g. `"manager"`, - /// `"alice"`, or the literal `"operator"` for the dashboard's T4LK box). - pub to: String, - /// Message body. Plain text; the broker doesn't parse it. - pub body: String, - /// Optional broker row-id of the message this is a reply to. Lets - /// the dashboard render conversation threads. Pass the `id` from the - /// `DeliveredMessage` you're responding to; omit for new threads. - /// Silently ignored if the id is unknown or out of retention. - #[serde(default)] - pub in_reply_to: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RecvArgs { - /// How long to long-poll for the FIRST message before returning - /// the empty marker. Capped at 60s server-side. Default (None) - /// is 30s. Useful when an agent wants to park its turn waiting - /// for any new work — pick a longer wait to coalesce bursts. - #[serde(default)] - pub wait_seconds: Option, - /// Maximum number of messages to pop in this round-trip. Default - /// (None) is 1 (single-message behaviour — exactly what you want - /// when you're called to drive a turn off the first wake). Pass - /// a higher value (capped at 5 server-side) when you've been - /// told the inbox has more queued (the wake prompt mentions - /// pending count) and want to drain everything in one tool call. - /// Once the long-poll wakes up, the call drains up to `max` in - /// total before returning — no extra round-trip needed. - #[serde(default)] - pub max: Option, -} - -/// MCP tool args for `ack_until`. -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct AckUntilArgs { - /// Highest broker message id to mark handled: every inbox message - /// with `id <= up_to` (ids show as `[msg #]` in recv output) - /// is acked in one sweep. Pass the highest id you've actually - /// seen/triaged — anything above it stays queued for later turns. - pub up_to: i64, -} - -/// MCP tool args for `remind`. Exactly one of `delay_seconds` or -/// `at_unix_timestamp` must be set; both / neither is a tool-side error. -/// Hides the tagged `ReminderTiming` enum behind a flatter schema so the -/// model picks one field instead of building `{"timing_type": "in_seconds", -/// "seconds": 60}` shaped objects. -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RemindArgs { - /// Body that lands in your inbox when the reminder fires (sender - /// will appear as `reminder`). Soft cap at 4 KiB inline — anything - /// larger gets auto-persisted to a file under - /// `/agents//state/reminders/auto-.md` and the inbox - /// message becomes a short pointer. Pass `file_path` if you want - /// to control the destination yourself. - pub message: String, - /// Fire `delay_seconds` from now (relative). Set this OR - /// `at_unix_timestamp`, not both. - #[serde(default)] - pub delay_seconds: Option, - /// Fire at this absolute unix timestamp (seconds since epoch). Set - /// this OR `delay_seconds`, not both. - #[serde(default)] - pub at_unix_timestamp: Option, - /// Optional path to a file the scheduler should reference instead of - /// inlining a long `message`. Use this for large payloads (research - /// notes, file lists, intermediate state). Path must be reachable from - /// the agent's container — typically under `/agents//state/`. - #[serde(default)] - pub file_path: Option, -} - /// Unified MCP tool surface for both sub-agent and manager roles. /// /// `AgentRequest = ManagerRequest = Request` and `AgentResponse = @@ -1531,305 +1056,3 @@ pub async fn serve_http(socket: PathBuf, addr: std::net::SocketAddr) -> Result<( axum::serve(listener, app).await?; Ok(()) } - -// ----------------------------------------------------------------------------- -// Privileged tool arg types (lifecycle, approvals, scheduling, diagnostics) -// ----------------------------------------------------------------------------- - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RequestInitConfigArgs { - /// New sub-agent name (≤9 chars). Queues an `InitConfig` approval; on - /// approval hive-c0re seeds the proposed config repo at - /// `/agents//config/agent.nix` with the default template. After - /// the approval the manager edits + commits the config and calls - /// `request_apply_commit` to pin the customised sha for the container's - /// first build. - pub name: String, - /// Optional description shown on the dashboard approval card. - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct KillArgs { - /// Sub-agent name (without the `h-` container prefix). - pub name: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SetStatusArgs { - /// Status text to display on the dashboard card. Pass an empty string to clear. - pub text: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct CreateRepoArgs { - /// Repo name — a single segment of letters, digits, `-`, `_`, `.` - /// (no leading `-`/`.`). The repo is created as `agents/`. - pub repo: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct GetAgentMetaArgs { - /// Logical name of the agent to query (e.g. `"iris"`, `"manager"`). - /// Omit to query your own identity + status — replaces the - /// previous `whoami` self-introspection tool. - #[serde(default)] - pub name: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct StartArgs { - /// Sub-agent name (without the `h-` container prefix). - pub name: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RestartArgs { - /// Sub-agent name (without the `h-` container prefix). - pub name: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct UpdateArgs { - /// Sub-agent name (without the `h-` container prefix). - pub name: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct AskArgs { - /// The question to surface. - pub question: String, - /// Optional fixed-choice answers. The dashboard renders these as - /// chips alongside a free-text fallback ("Other…") so the operator - /// is never trapped by an incomplete list; peer-agent recipients - /// see the list in their inbox event and can return any string. - #[serde(default)] - pub options: Vec, - /// When true, options are rendered as checkboxes — the answerer - /// can pick any subset. The answer comes back as a single string - /// with selections joined by ", ". Ignored when `options` is empty. - #[serde(default)] - pub multi: bool, - /// Optional auto-cancel after `ttl_seconds` (capped server-side at - /// 6 hours). On expiry the question resolves with answer - /// `[expired]` and the asker receives the usual - /// `question_answered` system event (with `answerer: - /// "ttl-watchdog"`). `None` (default) = wait indefinitely. - #[serde(default)] - pub ttl_seconds: Option, - /// Recipient. Omit (or pass `"operator"`) to ask the human - /// operator via the dashboard. Pass another agent's logical name - /// to ask that peer — they receive a `question_asked` event in - /// their inbox and answer via `mcp__hyperhive__answer`. - #[serde(default)] - pub to: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct AnswerArgs { - /// Id of the question being answered — comes from the - /// `question_asked` event in your inbox. - pub id: i64, - /// Free-text answer body. Soft-capped at 4 KiB by the same - /// `MESSAGE_MAX_BYTES` limit as `send`; keep it short or write the - /// detail to a file and pass a path. - pub answer: String, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct CancelLooseEndArgs { - /// Which kind of thread to cancel — `"question"` for an open - /// `ask` that's still waiting on an answer, `"reminder"` for a - /// scheduled `remind` that hasn't fired yet. Use the `kind` - /// field straight off the `get_loose_ends` row. - pub kind: String, - /// Row id from the matching `get_loose_ends` entry (or the - /// `question_queued` reply when you submitted it). - pub id: i64, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct AgentGetLooseEndsArgs { - /// Whose loose ends to list. Omit (or `null`) for your own. You may - /// also pass a direct child agent's name without any extra capability. - /// Pass any other agent name to inspect their threads — requires the - /// `query_agent_state` capability; without it the request is rejected - /// with an error. The `"*"` hive-wide value is not available on the - /// agent socket. - #[serde(default)] - pub agent: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RequestApplyCommitArgs { - /// Logical agent name whose config repo the commit lives in. - pub agent: String, - /// Commit sha (full or short, 7-40 hex chars) in that agent's - /// proposed config repo. Must be a sha — a branch or tag name - /// (e.g. `main`) is rejected; the approval pins the exact commit. - pub commit_ref: String, - /// Optional description shown on the dashboard approval card so the - /// operator knows what the change does without opening the diff. - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct UpdateMetaInputsArgs { - /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). - /// Pass an empty list to update ALL inputs. - #[serde(default)] - pub inputs: Vec, - /// Optional description shown on the dashboard approval card. - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RequestSchedulePromptArgs { - /// Recipient agents — one schedule fires to many inboxes at the - /// scheduled time. `operator` is a legitimate target (mara: "we - /// want to get rid of the manager special case so yes manager - /// can be recipient" — the operator slot follows the same rule). - pub targets: Vec, - /// Message body delivered to each target's inbox at fire time. - /// Same size budget as `send` bodies. - pub body: String, - /// Absolute unix timestamp (seconds) for the FIRST fire. For - /// recurring schedules the worker re-arms in - /// `interval_seconds` steps from this point on. - pub first_fire_at_unix: i64, - /// `None` / absent = one-shot. `Some(n > 0)` = recurring every - /// `n` seconds. The worker clamps catch-up so a long downtime - /// fires ONCE on resume (skipped-cycle count surfaces in the - /// per-target `last_result`), not N delayed pulses in a row. - #[serde(default)] - pub interval_seconds: Option, - /// Optional description shown on the dashboard approval card + - /// preserved on the schedule row for later operator reference. - #[serde(default)] - pub description: Option, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct FireScheduleNowArgs { - /// Schedule id to fire out of band. Get this from a prior - /// `list_schedules` call or the approval-resolved event for - /// the originating `request_schedule_prompt`. - pub id: i64, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct CancelScheduleArgs { - /// Schedule id from a prior `list_schedules` call or the - /// approval-resolved event for a `request_schedule_prompt`. - pub id: i64, - /// Optional target list. `None` / empty = cancel the entire - /// schedule. `Some(["alice", "bob"])` = cancel just those - /// recipients (the schedule keeps firing for any remaining - /// active targets, and auto-cancels its parent row when every - /// target is gone). - #[serde(default)] - pub targets: Option>, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct EditScheduleArgs { - /// Schedule id from a prior `list_schedules` call or the - /// approval-resolved event for a `request_schedule_prompt`. - pub id: i64, - /// New body text. Omit to keep the existing one. - #[serde(default)] - pub body: Option, - /// New description. Omit to keep the existing one. (To CLEAR - /// the description, use the dashboard PATCH endpoint - /// directly — the agent surface intentionally keeps the args - /// flat / non-nullable to dodge the doubly-wrapped Option - /// schemars quirk; clearing fields is rare and operator-side.) - #[serde(default)] - pub description: Option, - /// Recurring interval in seconds. Omit to keep the existing - /// cadence; pass an explicit value to set a new one. Toggling - /// recurring↔one-shot (clearing the interval) is operator-only - /// for the same reason as `description` above. - #[serde(default)] - pub interval_seconds: Option, - /// New absolute unix timestamp for the next fire. Omit to - /// leave the schedule on its current cadence. - #[serde(default)] - pub next_fire_at_unix: Option, - /// Names of new targets to add. Replace-on-conflict: re-adding - /// a previously cancelled target resets its history (operator - /// intent on re-add = "this target is active again"). - #[serde(default)] - pub targets_add: Option>, - /// Names of targets to cancel. Tombstones preserve per-target - /// audit; when no active targets remain the schedule - /// auto-cancels. - #[serde(default)] - pub targets_remove: Option>, -} - -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct GetLogsArgs { - /// Logical agent name to fetch logs for (e.g. `gui`, `iris`). - /// hive-c0re maps it to the underlying machine name (`h-gui`) - /// itself — pass the plain agent name, not the `h-` form. - pub agent: String, - /// How many journal lines to return (default: 50, max: 500). - #[serde(default)] - pub lines: Option, -} - -/// Arguments for `get_host_journal` (capability-gated: `read_host_journal`). -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct GetHostJournalArgs { - /// Systemd unit to filter (e.g. `hive-c0re.service`). Omit for all units. - #[serde(default)] - pub unit: Option, - /// nspawn machine name verbatim (e.g. `h-iris`). Omit for host journal. - /// Agent containers use the `h-` prefix (e.g. `h-iris`); infrastructure - /// containers use their full name (e.g. `hive-ci`, `hive-forge`, - /// `hive-matrix`, `hive-gateway`). - #[serde(default)] - pub container: Option, - /// Number of lines to return (default 30, max 100). - #[serde(default)] - pub lines: Option, - /// Minimum syslog priority level. - #[serde(default)] - pub priority: Option, - /// Regex to match against log message fields (journalctl --grep). - #[serde(default)] - pub grep: Option, - /// Show entries on or newer than this timestamp (e.g. `-1h`). - #[serde(default)] - pub since: Option, - /// Show entries on or older than this timestamp. - #[serde(default)] - pub until: Option, -} -#[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)"); - } -} diff --git a/hive-ag3nt/src/mcp/render.rs b/hive-ag3nt/src/mcp/render.rs new file mode 100644 index 00000000..0fe22983 --- /dev/null +++ b/hive-ag3nt/src/mcp/render.rs @@ -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, tool: &str) -> String { + match resp { + Ok(hive_sh4re::Response::Err { message }) => format!("{tool} failed: {message}"), + Ok(other) => format!("{tool} unexpected response: {other:?}"), + Err(e) => format!("{tool} transport error: {e:#}"), + } +} + +/// Format helper for "send-like" tools (anything that expects an `Ok`). +/// `tool` and `ok_msg` only appear in the result string; they don't change +/// behavior. +#[must_use] +pub fn format_ack( + resp: Result, + tool: &str, + ok_msg: String, +) -> String { + match resp { + Ok(hive_sh4re::Response::Ok) => ok_msg, + other => reply_err(other, tool), + } +} + +/// Format helper for `recv`: renders zero, one, or many popped +/// messages. Empty list collapses to "(empty)" so claude doesn't go +/// hunting for content; when `waited` is set (the call parked on a +/// long-poll that timed out) the empty result also carries +/// [`IDLE_WAIT_HINT`] nudging the model toward other work. A single +/// message renders as the historical `from: X\n\nbody` block (banner +/// first if `redelivered`). A multi-message batch renders with a +/// `popped N message(s):` header and `---` separators between bodies +/// so the model can tell where one ends and the next begins; +/// per-message redelivery banners included. +#[must_use] +pub fn format_recv(resp: Result, waited: bool) -> String { + match resp { + Ok(hive_sh4re::Response::Messages { messages }) => 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 #] ` marker prefixed to each recv row so the agent knows +/// what to pass to `ack_until` when bulk-triaging a backlog. Transient +/// pings carry the sentinel id 0 (in-memory only, nothing in the +/// broker to ack) and render without the marker. +fn msg_id_tag(id: i64) -> String { + if id > 0 { + format!("[msg #{id}] ") + } else { + String::new() + } +} + +/// Header prepended to message bodies that were popped by a prior +/// harness session, never acked (turn crash / OOM / restart), and +/// resurfaced by `RequeueInflight` on this session's boot. Same +/// string surfaces in the wake prompt (see the bin loops) and the +/// in-turn `recv` tool result so claude sees the warning either way. +pub const REDELIVERY_HINT: &str = "[redelivered after harness restart — may already be handled]\n"; + +/// Appended to the `recv` empty result when the agent parked on a +/// long-poll (`wait_seconds > 0`) that timed out with nothing new. +/// Nudges the model to spend the idle time on other useful work +/// instead of immediately re-blocking on `recv`. +pub const IDLE_WAIT_HINT: &str = " — nothing arrived before the wait timed out. \ +If you have other useful work (assigned issues, in-flight PRs, a docs sweep, \ +notes to update), do that now rather than immediately parking on recv again."; + +/// Inner renderer for a `Vec` already extracted from the socket +/// reply. Called by the `get_loose_ends` handler, which injects the +/// `UnreadMatrix` entry before formatting. +pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { + use std::fmt::Write as _; + if loose_ends.is_empty() { + return "(no loose ends)".to_owned(); + } + let mut out = format!("{} loose end(s):\n", loose_ends.len()); + for t in loose_ends { + match t { + hive_sh4re::LooseEnd::Approval { + id, + agent, + commit_ref, + description, + age_seconds, + } => { + let desc = description + .as_deref() + .map(|d| format!(" — {d}")) + .unwrap_or_default(); + let _ = writeln!( + out, + "- approval #{id} ({agent} @ {commit_ref}, {age_seconds}s old){desc}" + ); + } + hive_sh4re::LooseEnd::Question { + id, + asker, + target, + question, + age_seconds, + } => { + let to = target.as_deref().unwrap_or("operator"); + let _ = writeln!( + out, + "- question #{id} ({asker} → {to}, {age_seconds}s old): {question}" + ); + } + hive_sh4re::LooseEnd::Reminder { + id, + owner, + message, + due_at, + age_seconds, + } => { + let _ = writeln!( + out, + "- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}" + ); + } + hive_sh4re::LooseEnd::PendingMessages { count } => { + let _ = writeln!( + out, + "- {count} pending inbox message(s) — drain with recv (recv(max: {count}) to batch)" + ); + } + hive_sh4re::LooseEnd::UnreadMatrix { rooms, summary } => { + let _ = write!(out, "- unread matrix messages in {rooms} room(s)"); + if summary.is_empty() { + let _ = writeln!( + out, + " — use list_rooms + read_room to view, mark_read to clear" + ); + } else { + let _ = writeln!(out, ":"); + for line in summary.lines() { + let _ = writeln!(out, " {line}"); + } + let _ = writeln!( + out, + " use list_rooms + read_room to view, mark_read to clear" + ); + } + } + } + } + out +} + +/// Per-room unread entry returned by `matrix_unread_summary`. Mirrors +/// `hive-matrix-mcp`'s `RoomUnread` but defined locally to avoid a +/// cross-crate dep on the matrix-sdk crate tree. +#[derive(Debug, serde::Deserialize)] +pub(super) struct MatrixRoomUnread { + label: String, + count: u32, + last_body: Option, + last_sender: Option, +} + +/// Query the local matrix daemon for per-room unread summaries. Returns +/// `None` if the daemon socket is absent or the query fails. Best-effort: +/// agents without matrix configured are not penalised. +pub(super) async fn matrix_unread_summary() -> Option> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + use tokio::net::UnixStream; + let socket = std::env::var_os("HIVE_MATRIX_SOCKET").map_or_else( + || std::path::PathBuf::from("/run/hive-matrix/socket"), + std::path::PathBuf::from, + ); + if !socket.exists() { + return None; + } + let mut stream = UnixStream::connect(&socket).await.ok()?; + stream + .write_all(b"{\"method\":\"unread_summary\"}\n") + .await + .ok()?; + let mut lines = BufReader::new(stream).lines(); + let line = lines.next_line().await.ok()??; + let val: serde_json::Value = serde_json::from_str(&line).ok()?; + // Response: {"kind":"ok","payload":[{label, count, last_body?, last_sender?}]} + let arr = val.get("payload")?.as_array()?; + serde_json::from_value(serde_json::Value::Array(arr.clone())).ok() +} + +/// Format a `Vec` into a per-room summary string. +/// Single room / single message collapses to one line; multi-room +/// expands to a bulleted list. Returns an empty string for empty input. +pub(super) fn format_matrix_summary(rooms: &[MatrixRoomUnread]) -> String { + use std::fmt::Write as _; + if rooms.is_empty() { + return String::new(); + } + let mut out = String::new(); + for r in rooms { + if r.count == 1 + && let (Some(body), Some(sender)) = (&r.last_body, &r.last_sender) + { + let _ = writeln!(out, "- {}: {sender}: {body}", r.label); + continue; + } + let _ = writeln!(out, "- {}: {} unread", r.label, r.count); + } + // Remove trailing newline. + if out.ends_with('\n') { + out.pop(); + } + out +} + +/// Parse the user-facing `kind` string for `cancel_loose_end` into the +/// wire enum. Accepts a small alias set so claude doesn't have to +/// remember the exact spelling (`"q"` / `"r"` shorthand falls out +/// for free). +pub(super) fn parse_loose_end_kind(raw: &str) -> Result { + match raw.trim().to_ascii_lowercase().as_str() { + "question" | "q" => Ok(hive_sh4re::CancelLooseEndKind::Question), + "reminder" | "r" => Ok(hive_sh4re::CancelLooseEndKind::Reminder), + "approval" | "a" => Ok(hive_sh4re::CancelLooseEndKind::Approval), + other => Err(format!( + "cancel_loose_end: unknown kind '{other}' \ + (expected \"question\", \"reminder\", or \"approval\")" + )), + } +} + +/// Canonical user-facing label for a `CancelLooseEndKind` — used in +/// the success ack so the caller always sees `"question"` / +/// `"reminder"` instead of whatever alias they passed in (`"q"` / +/// `"r"`). +pub(super) fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { + match kind { + hive_sh4re::CancelLooseEndKind::Question => "question", + hive_sh4re::CancelLooseEndKind::Reminder => "reminder", + hive_sh4re::CancelLooseEndKind::Approval => "approval", + } +} + +/// Format helper for `get_agent_meta`: renders an agent's identity + +/// current status as a short human-readable block. `name`, +/// `hyperhive_rev`, and `running` are always shown; `status` only +/// appears when one is set, otherwise the line reads `status: `. +/// When `running` is false the host has already cleared `status_text` +/// (it would be a stale snapshot from before the stop) so the status +/// line is implicitly `` in that case — but the explicit +/// `running: no` line tells the caller WHY. See +/// `docs/turn-loop/mcp.md::Core tools` (`get_agent_meta`). +#[must_use] +pub fn format_agent_meta(resp: Result) -> String { + match resp { + Ok(hive_sh4re::Response::AgentMeta { + name, + running, + hyperhive_rev, + status_text, + status_set_at, + hive_name, + swarm_name, + matrix_accounts, + }) => { + let rev = hyperhive_rev.as_deref().unwrap_or(""); + let run = if running { "yes" } else { "no" }; + let mut out = format!("name: {name}\nhyperhive_rev: {rev}\nrunning: {run}"); + // Surface hive + swarm display names only when set, so + // single-hive deployments don't see noisy `` lines. + if let Some(hn) = hive_name.as_deref() { + use std::fmt::Write as _; + let _ = write!(out, "\nhive_name: {hn}"); + } + if let Some(sn) = swarm_name.as_deref() { + use std::fmt::Write as _; + let _ = write!(out, "\nswarm_name: {sn}"); + } + match status_text { + None => out.push_str("\nstatus: "), + Some(s) => { + use std::fmt::Write as _; + let age = status_set_at.and_then(|ts| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_secs(); + // `ts` is a unix epoch second the agent itself + // sourced from `SystemTime` — always positive + // in normal operation. Clamp the negative + // (clock-skew) edge to 0 before the unsigned + // cast so the cast loses no real precision. + let ts_secs = u64::try_from(ts).unwrap_or(0); + let secs = now.saturating_sub(ts_secs); + Some(format_age_secs(secs)) + }); + // `write!` into the buffer instead of `push_str(&format!(…))` — + // avoids the intermediate allocation clippy::format_push_string + // flags. The infallible `String` writer makes this safe to + // `let _ =`-ignore. + match age { + Some(a) => { + let _ = write!(out, "\nstatus: {s} (set {a} ago)"); + } + None => { + let _ = write!(out, "\nstatus: {s}"); + } + } + } + } + // Matrix identities the agent can act as (the `account` arg on + // the matrix tools). Listed only when matrix is provisioned, so + // non-matrix agents don't see an empty line. + if !matrix_accounts.is_empty() { + use std::fmt::Write as _; + out.push_str("\nmatrix_accounts:"); + for acct in &matrix_accounts { + let uid = acct.user_id.as_deref().unwrap_or("?"); + let _ = write!(out, "\n {} ({uid}) on {}", acct.name, acct.homeserver); + } + } + out + } + other => reply_err(other, "get_agent_meta"), + } +} + +/// Format a duration in seconds as a human-readable age string. +fn format_age_secs(secs: u64) -> String { + if secs < 60 { + format!("{secs}s") + } else if secs < 3600 { + format!("{}m", secs / 60) + } else if secs < 86400 { + format!("{}h", secs / 3600) + } else { + format!("{}d", secs / 86400) + } +} + +/// Append a short note to a tool result when the underlying socket call +/// took retries to land. Lets claude distinguish "my request was wrong" +/// from "c0re flickered and the harness rode it out" — without the +/// hint, a tool result that took 30s to come back looks identical to a +/// content failure and the model would burn a turn retrying it. +#[must_use] +pub fn annotate_retries(mut s: String, retries: u32) -> String { + if retries > 0 { + use std::fmt::Write as _; + let suffix = if retries == 1 { "retry" } else { "retries" }; + let _ = write!( + s, + "\n\n(note: hive socket connect needed {retries} {suffix} — c0re likely \ + restarted. Your request did succeed on the final attempt; no action needed.)" + ); + } + s +} + +#[cfg(test)] +mod tests { + use super::{IDLE_WAIT_HINT, format_recv}; + + #[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)"); + } +}