From eb570c003d7025f8f07fb3036ee970541e5e5329 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 14:37:22 +0200 Subject: [PATCH 1/3] cap get_loose_ends todo output + add ack_todos_until bulk-clear (#2944) --- hive-agent-mcp/src/mcp/args.rs | 14 +++++++++ hive-agent-mcp/src/mcp/mod.rs | 49 ++++++++++++++++++++++++++------ hive-agent-mcp/src/mcp/render.rs | 42 +++++++++++++++++++++++++++ hive-agent-sock/src/lib.rs | 4 +++ hive-agent/prompts/system.md | 2 +- hive-agent/src/todo_server.rs | 16 +++++++++++ hive-agent/src/todos.rs | 49 ++++++++++++++++++++++++++++++++ 7 files changed, 167 insertions(+), 9 deletions(-) diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 9f7e4e54..9cef4d41 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -41,6 +41,20 @@ pub struct AckUntilArgs { pub up_to: i64, } +/// MCP tool args for `ack_todos_until`. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AckTodosUntilArgs { + /// Highest todo id to bulk-clear: every pending todo (bash-task + /// completions, matrix unread, forge activity) with `id <= up_to` is + /// acked in one call — the todo-store analogue of `ack_until` for the + /// message inbox. `get_loose_ends` suggests a value in its truncation + /// summary when there are more todos than it renders individually; + /// pass that value (or the highest id you've actually triaged) to + /// clear the backlog in one shot instead of cancelling ids one at a + /// time. + 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 diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index dd78d313..5b4b053c 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -24,18 +24,18 @@ mod args; mod render; pub use args::{ - AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, - CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, - GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestInitConfigArgs, - RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, - UpdateMetaInputsArgs, + AckTodosUntilArgs, AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, + CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, + FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, + RemindArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, + SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs, }; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; use render::{ dial_agent_socket, format_matrix_summary, local_questions, local_reminders, local_todos, - loose_end_kind_label, mark_local_todo_done, matrix_unread_summary, parse_loose_end_kind, - render_loose_ends, reply_err, + loose_end_kind_label, mark_local_todo_done, mark_local_todos_done_until, + 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. @@ -347,7 +347,11 @@ impl AgentServer { at turn start to remember what you owe / what's owed to you without scrolling \ inbox history. Output is a short bulleted list with ids, ages in seconds, and \ the relevant context. Each `question` or `reminder` row can be cancelled by \ - passing its id + kind to `cancel_loose_end`. Empty result is reported clearly.\n\ + passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. \ + Todos are capped at 40 rendered rows (newest first) so a large backlog never makes \ + this call fail — a trailer line reports how many more are pending and suggests an \ + `ack_todos_until` value to bulk-clear the rest in one call instead of triaging \ + hundreds of ids individually.\n\ Pass `agent: \"\"` to inspect a specific peer agent's threads. Direct \ child agents are always accessible. For non-children, the `query_agent_state` \ capability is required — without it the request is rejected with an error." @@ -547,6 +551,35 @@ impl AgentServer { .await } + #[tool( + description = "Bulk-clear local todos (loose-ends v2 — bash-task completions, matrix \ + unread, forge activity): every pending todo with `id <= up_to` is acked in one call, \ + same shape as `ack_until` for the message inbox. Use this when `get_loose_ends` \ + reports more todos than it renders individually (its truncation summary suggests a \ + value) or whenever a backlog has piled up past the point of clearing ids one at a \ + time — note the highest id you've actually triaged, or take the suggested value \ + verbatim to clear the whole shown-as-hidden tail. Only affects YOUR todos; ids above \ + `up_to` stay pending. Returns how many were newly acked." + )] + async fn ack_todos_until(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("ack_todos_until", log, async move { + match mark_local_todos_done_until(args.up_to).await { + Some(hive_agent_sock::Response::Acked { count }) => { + format!("acked {count} todo(s) up to id {}", args.up_to) + } + Some(hive_agent_sock::Response::Err { message }) => { + format!("ack_todos_until failed: {message}") + } + Some(other) => format!("ack_todos_until unexpected response: {other:?}"), + None => "ack_todos_until: local todo socket unavailable \ + (HIVE_AGENT_SOCKET unset or harness unreachable)" + .to_owned(), + } + }) + .await + } + #[tool( description = "Create a git repo through hive-c0re. You CANNOT create repos with your \ own forge token (creation is disabled) — this is the only path. The repo is created in \ diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index 8a06d98d..5c37c6df 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -136,6 +136,21 @@ fn msg_id_tag(id: i64) -> String { } } +/// 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) — 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 how to bulk-clear the rest via +/// `ack_todos_until` (see that tool's doc for why it's the intended escape +/// hatch, not a per-id triage loop). +const MAX_RENDERED_TODOS: usize = 40; + /// 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. @@ -145,7 +160,17 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { 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; + let mut hidden_min_id: Option = None; for t in loose_ends { + if let hive_sh4re::LooseEnd::Todo { id, .. } = t + && shown_todos >= MAX_RENDERED_TODOS + { + hidden_todos += 1; + hidden_min_id = Some(hidden_min_id.map_or(*id, |m| m.min(*id))); + continue; + } match t { hive_sh4re::LooseEnd::Approval { id, @@ -233,9 +258,19 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { "- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \ (cancel_loose_end kind:\"todo\" id:{id} to clear)" ); + shown_todos += 1; } } } + if hidden_todos > 0 { + let min_id = hidden_min_id.unwrap_or(0); + let _ = writeln!( + out, + "- {hidden_todos} more todo(s) not shown (oldest not shown: #{min_id}) — \ + call ack_todos_until(up_to: {min_id}) to bulk-clear the old backlog, or \ + cancel_loose_end kind:\"todo\" id: to clear individually" + ); + } out } @@ -350,6 +385,13 @@ pub(super) async fn mark_local_todo_done(id: i64) -> Option Option { + dial_agent_socket(&hive_agent_sock::Request::MarkTodosDoneUntil { up_to }).await +} + /// Format a `Vec` into a per-room summary string. /// Single room / single message collapses to one line; multi-room /// expands to a bulleted list. Returns an empty string for empty input. diff --git a/hive-agent-sock/src/lib.rs b/hive-agent-sock/src/lib.rs index e3cd83ed..8e82dc52 100644 --- a/hive-agent-sock/src/lib.rs +++ b/hive-agent-sock/src/lib.rs @@ -59,6 +59,10 @@ pub enum Request { }, /// The agent marks one of its own todos done, by id. MarkTodoDone { id: i64 }, + /// The agent bulk-acks every un-acked todo with `id <= up_to` in one + /// shot — the todo-store analogue of the message inbox's `ack_until`, + /// for clearing a backlog that piled up past the point of per-id triage. + MarkTodosDoneUntil { up_to: i64 }, /// Schedule a reminder that fires into this agent's own turn loop at /// `timing` (harness-local — no broker round-trip). Same semantics as /// the old broker `Remind` request. `file_path`, when set, is where the diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index b00a7389..617efde1 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: -- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. +- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__ack_todos_until`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to triage hundreds of ids by hand — call `ack_todos_until` with the suggested cutoff to bulk-clear the stale tail in one shot. - **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`. diff --git a/hive-agent/src/todo_server.rs b/hive-agent/src/todo_server.rs index c307e027..753a2a61 100644 --- a/hive-agent/src/todo_server.rs +++ b/hive-agent/src/todo_server.rs @@ -202,6 +202,7 @@ fn dispatch( } => clear_todo(store, &subsystem, key.as_deref(), all), Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()), Request::MarkTodoDone { id } => mark_todo_done(store, id), + Request::MarkTodosDoneUntil { up_to } => mark_todos_done_until(store, up_to), Request::StoreReminder { message, timing, @@ -417,6 +418,21 @@ fn mark_todo_done(store: &Todos, id: i64) -> Response { } } +/// `MarkTodosDoneUntil` handler: bulk-acks every un-acked todo id at or +/// below `up_to` — the escape hatch for a backlog too large to triage +/// one-by-one (see `Todos::mark_done_until`'s doc for why it's needed). +fn mark_todos_done_until(store: &Todos, up_to: i64) -> Response { + match store.mark_done_until(up_to) { + Ok(count) => { + tracing::debug!(up_to, count, "todo bulk mark-done"); + Response::Acked { + count: u64::try_from(count).unwrap_or(0), + } + } + Err(e) => err(&e), + } +} + /// `Request::Compact` handler: gate on context usage, then queue the same /// deferred `compact_pending` flag the operator's `/compact` button sets. /// Mirrors `hive-agent::web_ui::actions::post_compact` but reachable from diff --git a/hive-agent/src/todos.rs b/hive-agent/src/todos.rs index 4fc08465..415b8656 100644 --- a/hive-agent/src/todos.rs +++ b/hive-agent/src/todos.rs @@ -245,6 +245,34 @@ impl Todos { Ok(n) } + /// Bulk-ack every un-acked todo with `id <= up_to` in one shot — the + /// same "note the highest id you've seen, clear everything up to it" + /// pattern the message inbox's `ack_until` already gives agents, applied + /// to this store. Exists for the case a per-id triage loop isn't worth + /// it: an agent that hasn't called `get_loose_ends` in a long stretch + /// (or one whose producers pile up faster than it triages) can end up + /// with a backlog large enough that the rendered list itself becomes + /// unwieldy — clearing it in one call is the escape hatch, same + /// `acked`-not-deleted semantics as [`Self::mark_done`] (a reconciled + /// producer's next `upsert` still sees the row to compare against). + /// Returns the number of rows newly acked. + /// + /// # Errors + /// + /// Propagates the sqlite update failure. + /// + /// # Panics + /// + /// Panics if the connection mutex is poisoned. + pub fn mark_done_until(&self, up_to: i64) -> Result { + let conn = self.conn.lock().unwrap(); + let n = conn.execute( + "UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id <= ?2", + params![Utc::now().timestamp(), up_to], + )?; + Ok(n) + } + /// List todos, newest-updated first. `subsystem = Some(..)` filters to /// one producer's set; `None` returns all. Excludes acked rows — once /// the agent has dismissed a todo it stays out of its own list, even @@ -476,6 +504,27 @@ mod tests { assert!(!s.has_any().unwrap(), "acked-only table reads as empty"); } + /// `mark_done_until` acks everything at-or-below the threshold id and + /// leaves later rows untouched, mirroring `ack_until`'s inbox semantics. + #[test] + fn mark_done_until_acks_up_to_threshold_only() { + let (_dir, s) = store(); + let (id1, _) = s.upsert("bash", None, "task 1", None).unwrap(); + let (id2, _) = s.upsert("bash", None, "task 2", None).unwrap(); + let (id3, _) = s.upsert("bash", None, "task 3", None).unwrap(); + assert_eq!( + s.mark_done_until(id2).unwrap(), + 2, + "acks id1 and id2, not id3" + ); + let left = s.list(None).unwrap(); + assert_eq!(left.len(), 1); + assert_eq!(left[0].id, id3); + // Idempotent: re-running over the same range acks nothing new. + assert_eq!(s.mark_done_until(id2).unwrap(), 0); + let _ = id1; + } + /// `reap_acked` only removes acked rows past the cutoff — a recent ack /// and any un-acked row both survive. #[test] From bc69ee3b8f8b56d02fd51534ee24de2a652dfa8d Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 15:29:21 +0200 Subject: [PATCH 2/3] revise bulk-clear to explicit ids per mara's feedback, fix clippy line count --- hive-agent-mcp/src/mcp/args.rs | 19 +-- hive-agent-mcp/src/mcp/mod.rs | 53 +++---- hive-agent-mcp/src/mcp/render.rs | 245 +++++++++++++++++-------------- hive-agent-sock/src/lib.rs | 10 +- hive-agent/prompts/system.md | 2 +- hive-agent/src/todo_server.rs | 14 +- hive-agent/src/todos.rs | 66 +++++---- 7 files changed, 228 insertions(+), 181 deletions(-) diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index 9cef4d41..edde7736 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -41,18 +41,15 @@ pub struct AckUntilArgs { pub up_to: i64, } -/// MCP tool args for `ack_todos_until`. +/// MCP tool args for `mark_todos_done`. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct AckTodosUntilArgs { - /// Highest todo id to bulk-clear: every pending todo (bash-task - /// completions, matrix unread, forge activity) with `id <= up_to` is - /// acked in one call — the todo-store analogue of `ack_until` for the - /// message inbox. `get_loose_ends` suggests a value in its truncation - /// summary when there are more todos than it renders individually; - /// pass that value (or the highest id you've actually triaged) to - /// clear the backlog in one shot instead of cancelling ids one at a - /// time. - pub up_to: i64, +pub struct MarkTodosDoneArgs { + /// The specific todo ids to clear (from `get_loose_ends`'s `todo #N` + /// lines) — every id in the list is acked in one call. Deliberately a + /// list, not a range/threshold: only clears exactly what you pass, so + /// you don't risk acking a todo you haven't actually looked at. Unknown + /// or already-acked ids are silently skipped. + pub ids: Vec, } /// MCP tool args for `remind`. Exactly one of `delay_seconds` or diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 5b4b053c..0a51f791 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -24,18 +24,18 @@ mod args; mod render; pub use args::{ - AckTodosUntilArgs, AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, - CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, - FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, - RemindArgs, RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, - SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs, + AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, + CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, + GetHostJournalArgs, GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, + RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, + StartArgs, UpdateArgs, UpdateMetaInputsArgs, }; pub use render::{annotate_retries, format_ack, format_agent_meta, format_recv}; use render::{ dial_agent_socket, format_matrix_summary, local_questions, local_reminders, local_todos, - loose_end_kind_label, mark_local_todo_done, mark_local_todos_done_until, - matrix_unread_summary, parse_loose_end_kind, render_loose_ends, reply_err, + loose_end_kind_label, mark_local_todo_done, mark_local_todos_done, 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. @@ -349,9 +349,9 @@ impl AgentServer { the relevant context. Each `question` or `reminder` row can be cancelled by \ passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. \ Todos are capped at 40 rendered rows (newest first) so a large backlog never makes \ - this call fail — a trailer line reports how many more are pending and suggests an \ - `ack_todos_until` value to bulk-clear the rest in one call instead of triaging \ - hundreds of ids individually.\n\ + this call fail — a trailer line reports how many more are pending; review the shown \ + batch, clear reviewed ids with `mark_todos_done`, then call again for the next batch \ + instead of triaging hundreds of ids one `cancel_loose_end` at a time.\n\ Pass `agent: \"\"` to inspect a specific peer agent's threads. Direct \ child agents are always accessible. For non-children, the `query_agent_state` \ capability is required — without it the request is rejected with an error." @@ -552,27 +552,30 @@ impl AgentServer { } #[tool( - description = "Bulk-clear local todos (loose-ends v2 — bash-task completions, matrix \ - unread, forge activity): every pending todo with `id <= up_to` is acked in one call, \ - same shape as `ack_until` for the message inbox. Use this when `get_loose_ends` \ - reports more todos than it renders individually (its truncation summary suggests a \ - value) or whenever a backlog has piled up past the point of clearing ids one at a \ - time — note the highest id you've actually triaged, or take the suggested value \ - verbatim to clear the whole shown-as-hidden tail. Only affects YOUR todos; ids above \ - `up_to` stay pending. Returns how many were newly acked." + description = "Bulk-clear specific local todos (loose-ends v2 — bash-task completions, \ + matrix unread, forge activity) by id in one call, instead of `cancel_loose_end`ing \ + each one individually. Deliberately list-based, not range-based: pass exactly the ids \ + you've actually looked at (typically the ones `get_loose_ends` just rendered) — there \ + is no `ack_until`-style 'clear everything below id N' shortcut for todos, since unlike \ + the sequentially-read message inbox, todos are heterogeneous unrelated items and a \ + blind range-clear risks silently acking something you never saw. When \ + `get_loose_ends` reports more todos than it renders (its truncation trailer says so), \ + review the shown batch, clear the reviewed ids here, then call `get_loose_ends` again \ + for the next batch. Unknown/already-acked ids are silently skipped. Returns how many \ + were newly acked." )] - async fn ack_todos_until(&self, Parameters(args): Parameters) -> String { + async fn mark_todos_done(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); - run_tool_envelope("ack_todos_until", log, async move { - match mark_local_todos_done_until(args.up_to).await { + run_tool_envelope("mark_todos_done", log, async move { + match mark_local_todos_done(args.ids).await { Some(hive_agent_sock::Response::Acked { count }) => { - format!("acked {count} todo(s) up to id {}", args.up_to) + format!("acked {count} todo(s)") } Some(hive_agent_sock::Response::Err { message }) => { - format!("ack_todos_until failed: {message}") + format!("mark_todos_done failed: {message}") } - Some(other) => format!("ack_todos_until unexpected response: {other:?}"), - None => "ack_todos_until: local todo socket unavailable \ + Some(other) => format!("mark_todos_done unexpected response: {other:?}"), + None => "mark_todos_done: local todo socket unavailable \ (HIVE_AGENT_SOCKET unset or harness unreachable)" .to_owned(), } diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index 5c37c6df..b5dc1c10 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -146,11 +146,114 @@ fn msg_id_tag(id: i64) -> String { /// 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 how to bulk-clear the rest via -/// `ack_todos_until` (see that tool's doc for why it's the intended escape -/// hatch, not a per-id triage loop). +/// 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::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::LooseEnd) { + use std::fmt::Write as _; + 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" + ); + } + } + hive_sh4re::LooseEnd::Todo { .. } => { + // Handled inline by the caller (needs the shared shown-count). + } + } +} + +/// Render one `Todo` [`hive_sh4re::LooseEnd`] onto `out`. Split out for the +/// same `too_many_lines` reason as [`render_one_loose_end`]. +fn render_todo_loose_end( + out: &mut String, + id: i64, + subsystem: &str, + subsystem_key: Option<&str>, + summary: &str, + source: Option<&str>, + age_seconds: u64, +) { + use std::fmt::Write as _; + let key = subsystem_key.map(|k| format!(" {k}")).unwrap_or_default(); + let src = source.map(|s| format!(" — {s}")).unwrap_or_default(); + let _ = writeln!( + out, + "- todo #{id} [{subsystem}{key}, {age_seconds}s old]: {summary}{src} \ + (cancel_loose_end kind:\"todo\" id:{id} to clear)" + ); +} + /// Inner renderer for a `Vec` already extracted from the socket /// reply. Called by the `get_loose_ends` handler, which injects the /// `UnreadMatrix` entry before formatting. @@ -162,113 +265,41 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { let mut out = format!("{} loose end(s):\n", loose_ends.len()); let mut shown_todos = 0usize; let mut hidden_todos = 0usize; - let mut hidden_min_id: Option = None; for t in loose_ends { - if let hive_sh4re::LooseEnd::Todo { id, .. } = t - && shown_todos >= MAX_RENDERED_TODOS - { + let hive_sh4re::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; - hidden_min_id = Some(hidden_min_id.map_or(*id, |m| m.min(*id))); continue; } - 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" - ); - } - } - hive_sh4re::LooseEnd::Todo { - id, - subsystem, - subsystem_key, - summary, - source, - age_seconds, - } => { - let key = subsystem_key - .as_deref() - .map(|k| format!(" {k}")) - .unwrap_or_default(); - let src = source - .as_deref() - .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)" - ); - shown_todos += 1; - } - } + 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 min_id = hidden_min_id.unwrap_or(0); let _ = writeln!( out, - "- {hidden_todos} more todo(s) not shown (oldest not shown: #{min_id}) — \ - call ack_todos_until(up_to: {min_id}) to bulk-clear the old backlog, or \ - cancel_loose_end kind:\"todo\" id: to clear individually" + "- {hidden_todos} more todo(s) not shown — review the ones above, then clear \ + the ids you've actually looked at with mark_todos_done(ids: [...]) (or \ + cancel_loose_end kind:\"todo\" id: for just one) and call get_loose_ends \ + again to see the next batch; there is no blind bulk-clear-by-range for todos" ); } out @@ -385,11 +416,11 @@ pub(super) async fn mark_local_todo_done(id: i64) -> Option Option { - dial_agent_socket(&hive_agent_sock::Request::MarkTodosDoneUntil { up_to }).await +/// Bulk-ack an explicit list of local todo ids, via the harness's in-agent +/// socket — the multi-id analogue of `mark_local_todo_done`, for the +/// `mark_todos_done` tool. +pub(super) async fn mark_local_todos_done(ids: Vec) -> Option { + dial_agent_socket(&hive_agent_sock::Request::MarkTodosDone { ids }).await } /// Format a `Vec` into a per-room summary string. diff --git a/hive-agent-sock/src/lib.rs b/hive-agent-sock/src/lib.rs index 8e82dc52..294a4b43 100644 --- a/hive-agent-sock/src/lib.rs +++ b/hive-agent-sock/src/lib.rs @@ -59,10 +59,12 @@ pub enum Request { }, /// The agent marks one of its own todos done, by id. MarkTodoDone { id: i64 }, - /// The agent bulk-acks every un-acked todo with `id <= up_to` in one - /// shot — the todo-store analogue of the message inbox's `ack_until`, - /// for clearing a backlog that piled up past the point of per-id triage. - MarkTodosDoneUntil { up_to: i64 }, + /// The agent bulk-acks a specific, explicit list of its own todos in + /// one shot — for clearing a backlog that's piled up past the point of + /// per-id triage, without the range-based `ack_until`-style semantics + /// that risk silently acking something never actually looked at (see + /// `Todos::mark_done_many`'s doc for why it's ids, not a threshold). + MarkTodosDone { ids: Vec }, /// Schedule a reminder that fires into this agent's own turn loop at /// `timing` (harness-local — no broker round-trip). Same semantics as /// the old broker `Remind` request. `file_path`, when set, is where the diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index 617efde1..d62b35f6 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: -- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__ack_todos_until`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to triage hundreds of ids by hand — call `ack_todos_until` with the suggested cutoff to bulk-clear the stale tail in one shot. +- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to cancel hundreds of ids one at a time — review the shown batch and clear the ones you've actually looked at in one `mark_todos_done(ids: [...])` call, then call `get_loose_ends` again for the next batch. There's deliberately no blind range-clear for todos (unlike `ack_until` for messages) — only clear ids you've reviewed. - **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`. diff --git a/hive-agent/src/todo_server.rs b/hive-agent/src/todo_server.rs index 753a2a61..e5b845dc 100644 --- a/hive-agent/src/todo_server.rs +++ b/hive-agent/src/todo_server.rs @@ -202,7 +202,7 @@ fn dispatch( } => clear_todo(store, &subsystem, key.as_deref(), all), Request::ListTodos { subsystem } => list_todos(store, subsystem.as_deref()), Request::MarkTodoDone { id } => mark_todo_done(store, id), - Request::MarkTodosDoneUntil { up_to } => mark_todos_done_until(store, up_to), + Request::MarkTodosDone { ids } => mark_todos_done(store, &ids), Request::StoreReminder { message, timing, @@ -418,13 +418,13 @@ fn mark_todo_done(store: &Todos, id: i64) -> Response { } } -/// `MarkTodosDoneUntil` handler: bulk-acks every un-acked todo id at or -/// below `up_to` — the escape hatch for a backlog too large to triage -/// one-by-one (see `Todos::mark_done_until`'s doc for why it's needed). -fn mark_todos_done_until(store: &Todos, up_to: i64) -> Response { - match store.mark_done_until(up_to) { +/// `MarkTodosDone` handler: bulk-acks an explicit list of todo ids — the +/// escape hatch for a backlog too large to triage one-by-one (see +/// `Todos::mark_done_many`'s doc for why it's ids, not a threshold). +fn mark_todos_done(store: &Todos, ids: &[i64]) -> Response { + match store.mark_done_many(ids) { Ok(count) => { - tracing::debug!(up_to, count, "todo bulk mark-done"); + tracing::debug!(n_ids = ids.len(), count, "todo bulk mark-done"); Response::Acked { count: u64::try_from(count).unwrap_or(0), } diff --git a/hive-agent/src/todos.rs b/hive-agent/src/todos.rs index 415b8656..53e5702e 100644 --- a/hive-agent/src/todos.rs +++ b/hive-agent/src/todos.rs @@ -245,17 +245,23 @@ impl Todos { Ok(n) } - /// Bulk-ack every un-acked todo with `id <= up_to` in one shot — the - /// same "note the highest id you've seen, clear everything up to it" - /// pattern the message inbox's `ack_until` already gives agents, applied - /// to this store. Exists for the case a per-id triage loop isn't worth - /// it: an agent that hasn't called `get_loose_ends` in a long stretch - /// (or one whose producers pile up faster than it triages) can end up - /// with a backlog large enough that the rendered list itself becomes - /// unwieldy — clearing it in one call is the escape hatch, same - /// `acked`-not-deleted semantics as [`Self::mark_done`] (a reconciled - /// producer's next `upsert` still sees the row to compare against). - /// Returns the number of rows newly acked. + /// Bulk-ack a specific, explicit list of todo ids in one shot. Exists + /// for the case a per-id `mark_done` loop isn't worth the round-trips: + /// an agent that hasn't called `get_loose_ends` in a long stretch (or + /// one whose producers pile up faster than it triages) can end up with + /// a backlog large enough that clearing it one call at a time is + /// impractical. Deliberately **explicit ids, not a `<= threshold` + /// range** — a reviewer's call on the design: a range-based + /// bulk-ack risks silently acking something the agent never actually + /// looked at, since todos are heterogeneous unrelated items (bash / + /// matrix / forge) rather than a sequentially-read stream the way inbox + /// messages are. The caller is expected to have looked at each id + /// (typically the ids `get_loose_ends` just rendered) before passing + /// them here. Same `acked`-not-deleted semantics as [`Self::mark_done`] + /// (a reconciled producer's next `upsert` still sees the row to compare + /// against). Unknown/already-acked ids are silently skipped — same "not + /// a new action" idempotence as the single-id path. Returns the number + /// of rows newly acked. /// /// # Errors /// @@ -264,13 +270,20 @@ impl Todos { /// # Panics /// /// Panics if the connection mutex is poisoned. - pub fn mark_done_until(&self, up_to: i64) -> Result { + pub fn mark_done_many(&self, ids: &[i64]) -> Result { + if ids.is_empty() { + return Ok(0); + } let conn = self.conn.lock().unwrap(); - let n = conn.execute( - "UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id <= ?2", - params![Utc::now().timestamp(), up_to], - )?; - Ok(n) + let now = Utc::now().timestamp(); + let mut total = 0usize; + for id in ids { + total += conn.execute( + "UPDATE todos SET acked = 1, acked_at = ?1 WHERE acked = 0 AND id = ?2", + params![now, id], + )?; + } + Ok(total) } /// List todos, newest-updated first. `subsystem = Some(..)` filters to @@ -504,25 +517,26 @@ mod tests { assert!(!s.has_any().unwrap(), "acked-only table reads as empty"); } - /// `mark_done_until` acks everything at-or-below the threshold id and - /// leaves later rows untouched, mirroring `ack_until`'s inbox semantics. + /// `mark_done_many` acks exactly the ids passed, leaves the rest + /// untouched — no threshold/range semantics. #[test] - fn mark_done_until_acks_up_to_threshold_only() { + fn mark_done_many_acks_only_the_listed_ids() { let (_dir, s) = store(); let (id1, _) = s.upsert("bash", None, "task 1", None).unwrap(); let (id2, _) = s.upsert("bash", None, "task 2", None).unwrap(); let (id3, _) = s.upsert("bash", None, "task 3", None).unwrap(); assert_eq!( - s.mark_done_until(id2).unwrap(), + s.mark_done_many(&[id1, id3]).unwrap(), 2, - "acks id1 and id2, not id3" + "acks id1 and id3, not id2 — not a range" ); let left = s.list(None).unwrap(); assert_eq!(left.len(), 1); - assert_eq!(left[0].id, id3); - // Idempotent: re-running over the same range acks nothing new. - assert_eq!(s.mark_done_until(id2).unwrap(), 0); - let _ = id1; + assert_eq!(left[0].id, id2); + // Idempotent: re-running over the same ids acks nothing new. + assert_eq!(s.mark_done_many(&[id1, id3]).unwrap(), 0); + // Empty input is a no-op, not an error. + assert_eq!(s.mark_done_many(&[]).unwrap(), 0); } /// `reap_acked` only removes acked rows past the cutoff — a recent ack From 2b5e5a30f4d78993ec1f2f4efb89de9d7c3ffc68 Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 2 Aug 2026 16:30:22 +0200 Subject: [PATCH 3/3] trim verbose todo-tool descriptions, drop exhaustive subsystem list --- hive-agent-mcp/src/mcp/args.rs | 8 +++----- hive-agent-mcp/src/mcp/mod.rs | 33 ++++++++++++-------------------- hive-agent-mcp/src/mcp/render.rs | 6 ++---- hive-agent/prompts/system.md | 2 +- 4 files changed, 18 insertions(+), 31 deletions(-) diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index edde7736..dfb78a77 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -44,11 +44,9 @@ pub struct AckUntilArgs { /// MCP tool args for `mark_todos_done`. #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct MarkTodosDoneArgs { - /// The specific todo ids to clear (from `get_loose_ends`'s `todo #N` - /// lines) — every id in the list is acked in one call. Deliberately a - /// list, not a range/threshold: only clears exactly what you pass, so - /// you don't risk acking a todo you haven't actually looked at. Unknown - /// or already-acked ids are silently skipped. + /// Todo ids to clear (from `get_loose_ends`'s `todo #N` lines). Only + /// these ids are acked — not a range. Unknown/already-acked ids are + /// silently skipped. pub ids: Vec, } diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index 0a51f791..d8e4d64c 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -347,11 +347,9 @@ impl AgentServer { at turn start to remember what you owe / what's owed to you without scrolling \ inbox history. Output is a short bulleted list with ids, ages in seconds, and \ the relevant context. Each `question` or `reminder` row can be cancelled by \ - passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. \ - Todos are capped at 40 rendered rows (newest first) so a large backlog never makes \ - this call fail — a trailer line reports how many more are pending; review the shown \ - batch, clear reviewed ids with `mark_todos_done`, then call again for the next batch \ - instead of triaging hundreds of ids one `cancel_loose_end` at a time.\n\ + passing its id + kind to `cancel_loose_end`. Empty result is reported clearly. Todos \ + cap at 40 rows; a trailer line says how many more are pending — clear the shown ones \ + with `mark_todos_done`, then call again for the rest.\n\ Pass `agent: \"\"` to inspect a specific peer agent's threads. Direct \ child agents are always accessible. For non-children, the `query_agent_state` \ capability is required — without it the request is rejected with an error." @@ -475,11 +473,9 @@ impl AgentServer { `kind` may also be `\"approval\"` to withdraw a pending approval you submitted \ (before the operator acts on it) — root agent (`ruth`) only; the server rejects \ `approval` kind for all other callers.\n\ - `kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos \ - (bash-task completions, matrix unread, forge activity — the id in the \ - `get_loose_ends` `todo #N` line) — this dials the in-container socket directly, \ - no bash task involved, so it's safe to call repeatedly without spawning more \ - todos." + `kind` may also be `\"todo\"` to clear one of your own loose-ends-v2 todos (the id \ + from a `get_loose_ends` `todo #N` line) — dials the in-agent socket directly, safe \ + to call repeatedly." )] async fn cancel_loose_end(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -552,17 +548,12 @@ impl AgentServer { } #[tool( - description = "Bulk-clear specific local todos (loose-ends v2 — bash-task completions, \ - matrix unread, forge activity) by id in one call, instead of `cancel_loose_end`ing \ - each one individually. Deliberately list-based, not range-based: pass exactly the ids \ - you've actually looked at (typically the ones `get_loose_ends` just rendered) — there \ - is no `ack_until`-style 'clear everything below id N' shortcut for todos, since unlike \ - the sequentially-read message inbox, todos are heterogeneous unrelated items and a \ - blind range-clear risks silently acking something you never saw. When \ - `get_loose_ends` reports more todos than it renders (its truncation trailer says so), \ - review the shown batch, clear the reviewed ids here, then call `get_loose_ends` again \ - for the next batch. Unknown/already-acked ids are silently skipped. Returns how many \ - were newly acked." + description = "Bulk-clear specific todos by id in one call, instead of \ + `cancel_loose_end`ing each one. List-based, not range-based — no `ack_until`-style \ + 'clear below id N' for todos, since a blind range-clear risks acking something you \ + never saw. Pass the ids you've actually reviewed (typically what `get_loose_ends` \ + just showed); unknown/already-acked ids are silently skipped. Returns how many were \ + newly acked." )] async fn mark_todos_done(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); diff --git a/hive-agent-mcp/src/mcp/render.rs b/hive-agent-mcp/src/mcp/render.rs index b5dc1c10..085c54a2 100644 --- a/hive-agent-mcp/src/mcp/render.rs +++ b/hive-agent-mcp/src/mcp/render.rs @@ -296,10 +296,8 @@ pub(super) fn render_loose_ends(loose_ends: &[hive_sh4re::LooseEnd]) -> String { if hidden_todos > 0 { let _ = writeln!( out, - "- {hidden_todos} more todo(s) not shown — review the ones above, then clear \ - the ids you've actually looked at with mark_todos_done(ids: [...]) (or \ - cancel_loose_end kind:\"todo\" id: for just one) and call get_loose_ends \ - again to see the next batch; there is no blind bulk-clear-by-range for todos" + "- {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 diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md index d62b35f6..e545ec0b 100644 --- a/hive-agent/prompts/system.md +++ b/hive-agent/prompts/system.md @@ -2,7 +2,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity Tools (hyperhive surface). Full signature + behavior for each comes from the tool's own MCP description (you already received it via the MCP tool schema) — this is just the map of what exists and which ones are gated, so you know where to look: -- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. If `get_loose_ends` reports a large todo backlog (it caps rendered rows at 40), don't try to cancel hundreds of ids one at a time — review the shown batch and clear the ones you've actually looked at in one `mark_todos_done(ids: [...])` call, then call `get_loose_ends` again for the next batch. There's deliberately no blind range-clear for todos (unlike `ack_until` for messages) — only clear ids you've reviewed. +- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__ask`, `mcp__hyperhive__answer`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. Two habits worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint); and `ask`/`answer` are async — `ask` returns immediately with a question id, the reply lands later as a `question_answered` system event, never block a turn waiting on it inline. For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at. - **Extra MCP tools** (some agents only): `mcp____` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. First-class tools, already operator-approved at deploy time. - **Lifecycle** (_requires `lifecycle` tool group_, direct children only, no approval needed): `restart`, `kill`, `start`, `update`, `list_containers`. - **Approvals** (_requires `approvals` tool group_, queues an operator approval): `request_init_config`, `request_apply_commit`, `request_update_meta_inputs`.