cap get_loose_ends todo output + add ack_todos_until bulk-clear (#2944)

This commit is contained in:
damocles 2026-08-02 14:37:22 +02:00 committed by mara
commit eb570c003d
7 changed files with 167 additions and 9 deletions

View file

@ -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

View file

@ -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: \"<name>\"` 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<AckTodosUntilArgs>) -> 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 \

View file

@ -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<LooseEnd>` 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<i64> = 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:<N> to clear individually"
);
}
out
}
@ -350,6 +385,13 @@ pub(super) async fn mark_local_todo_done(id: i64) -> Option<hive_agent_sock::Res
dial_agent_socket(&hive_agent_sock::Request::MarkTodoDone { id }).await
}
/// Bulk-ack every un-acked local todo with `id <= up_to`, via the harness's
/// in-agent socket — the todo-store analogue of `mark_local_todo_done`, for
/// the `ack_todos_until` tool.
pub(super) async fn mark_local_todos_done_until(up_to: i64) -> Option<hive_agent_sock::Response> {
dial_agent_socket(&hive_agent_sock::Request::MarkTodosDoneUntil { up_to }).await
}
/// Format a `Vec<MatrixRoomUnread>` into a per-room summary string.
/// Single room / single message collapses to one line; multi-room
/// expands to a bulleted list. Returns an empty string for empty input.