cancel_thread: new mcp tool — unify reminder + question cancel on both surfaces

This commit is contained in:
damocles 2026-05-18 18:07:44 +02:00
parent fcd407da11
commit b1d0a62cb9
11 changed files with 331 additions and 25 deletions

View file

@ -174,11 +174,37 @@ pub fn format_open_threads(resp: Result<SocketReply, anyhow::Error>) -> String {
"- question #{id} ({asker} → {to}, {age_seconds}s old): {question}"
);
}
hive_sh4re::OpenThread::Reminder {
id,
owner,
message,
due_at,
age_seconds,
} => {
let _ = writeln!(
out,
"- reminder #{id} ({owner}, scheduled {age_seconds}s ago, due_at={due_at}): {message}"
);
}
}
}
out
}
/// Parse the user-facing `kind` string for `cancel_thread` 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_cancel_kind(raw: &str) -> Result<hive_sh4re::CancelThreadKind, String> {
match raw.trim().to_ascii_lowercase().as_str() {
"question" | "q" => Ok(hive_sh4re::CancelThreadKind::Question),
"reminder" | "r" => Ok(hive_sh4re::CancelThreadKind::Reminder),
other => Err(format!(
"cancel_thread: unknown kind '{other}' (expected \"question\" or \"reminder\")"
)),
}
}
/// Format helper for `whoami`: renders the identity block as a short
/// human-readable string. Skips fields that are `None` so the output
/// doesn't carry dead placeholders.
@ -423,11 +449,13 @@ impl AgentServer {
#[tool(
description = "List loose ends pending against this agent: unanswered questions \
where you are the asker (waiting on someone) or the target (someone's waiting on \
you), plus for the manager only pending approvals you submitted that the \
operator hasn't acted on yet. Cheap server-side sweep, no args. Useful 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. Empty result is reported clearly."
you), pending reminders you scheduled, plus for the manager only pending \
approvals you submitted that the operator hasn't acted on yet. Cheap server-side \
sweep, no args. Useful 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_thread`. Empty result \
is reported clearly."
)]
async fn get_open_threads(&self) -> String {
run_tool_envelope("get_open_threads", String::new(), async move {
@ -453,6 +481,34 @@ impl AgentServer {
.await
}
#[tool(
description = "Cancel an open thread you own — a `question` you asked (the \
asker gets `[cancelled by <you>]` as the answer and unblocks) or a `reminder` \
you scheduled (hard-deleted before it fires). `kind` is `\"question\"` or \
`\"reminder\"`; `id` is the row id from the matching `get_open_threads` entry \
or the `question_queued` reply you got when you submitted. Auth: you can only \
cancel rows where you're the asker / owner. Returns `ok` or an error string."
)]
async fn cancel_thread(&self, Parameters(args): Parameters<CancelThreadArgs>) -> String {
let log = format!("{args:?}");
let kind_label = args.kind.clone();
let id = args.id;
run_tool_envelope("cancel_thread", log, async move {
let kind = match parse_cancel_kind(&args.kind) {
Ok(k) => k,
Err(e) => return e,
};
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::CancelThread { kind, id })
.await;
annotate_retries(
format_ack(resp, "cancel_thread", format!("cancelled {kind_label} {id}")),
retries,
)
})
.await
}
#[tool(
description = "Schedule a reminder that lands in this agent's own inbox at a future \
time (sender will appear as `reminder`). Use for self-paced follow-ups: 'check task \
@ -597,6 +653,18 @@ pub struct AnswerArgs {
pub answer: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct CancelThreadArgs {
/// 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_open_threads` row.
pub kind: String,
/// Row id from the matching `get_open_threads` entry (or the
/// `question_queued` reply when you submitted it).
pub id: i64,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct RequestApplyCommitArgs {
/// Agent whose config repo the commit lives in (use `"hm1nd"` for the
@ -922,10 +990,12 @@ impl ManagerServer {
#[tool(
description = "Hive-wide loose ends: EVERY pending approval + EVERY unanswered \
question across the swarm. Use to scan for stalled coordination questions \
sub-agents asked each other that nobody's answering, approvals stuck waiting on \
the operator, etc. No args. The sub-agent flavour of this tool only returns the \
agent's own threads; the manager flavour is unfiltered."
question + EVERY pending reminder across the swarm. Use to scan for stalled \
coordination questions sub-agents asked each other that nobody's answering, \
approvals stuck waiting on the operator, reminders piling up on an offline \
agent, etc. No args. The sub-agent flavour only returns the agent's own \
threads; the manager flavour is unfiltered. Cancel any question or reminder \
row via `cancel_thread` (manager bypasses the owner check)."
)]
async fn get_open_threads(&self) -> String {
run_tool_envelope("get_open_threads", String::new(), async move {
@ -951,6 +1021,34 @@ impl ManagerServer {
.await
}
#[tool(
description = "Cancel any open thread in the swarm — a `question` (cancels \
with the operator-override sentinel so the asker unblocks) or a `reminder` \
(hard-deleted before fire). `kind` is `\"question\"` or `\"reminder\"`; `id` \
is the row id from `get_open_threads` or the original submission reply. \
Manager surface bypasses the owner check on the sub-agent flavour use for \
hive-wide cleanup of stuck or stale threads."
)]
async fn cancel_thread(&self, Parameters(args): Parameters<CancelThreadArgs>) -> String {
let log = format!("{args:?}");
let kind_label = args.kind.clone();
let id = args.id;
run_tool_envelope("cancel_thread", log, async move {
let kind = match parse_cancel_kind(&args.kind) {
Ok(k) => k,
Err(e) => return e,
};
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::CancelThread { kind, id })
.await;
annotate_retries(
format_ack(resp, "cancel_thread", format!("cancelled {kind_label} {id}")),
retries,
)
})
.await
}
#[tool(
description = "Fetch recent journal log lines for a sub-agent container. Useful \
for diagnosing MCP server registration failures, startup crashes, plugin install \
@ -995,8 +1093,9 @@ impl ManagerServer {
sub-agent non-blocking, answer arrives later as a `question_answered` event), \
`answer` (respond to a `question_asked` event directed at you), \
`get_open_threads` (hive-wide loose ends pending approvals + unanswered \
questions across the swarm), `whoami` (self-introspection canonical name, \
role, current hyperhive rev). The manager's own config lives at \
questions + pending reminders across the swarm), `cancel_thread` (cancel any \
question or reminder row by id), `whoami` (self-introspection canonical \
name, role, current hyperhive rev). The manager's own config lives at \
`/agents/hm1nd/config/agent.nix`."
)]
impl ServerHandler for ManagerServer {}
@ -1039,6 +1138,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"remind",
"get_open_threads",
"whoami",
"cancel_thread",
],
Flavor::Manager => &[
"send",
@ -1055,6 +1155,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"get_open_threads",
"remind",
"whoami",
"cancel_thread",
],
};
let mut out: Vec<String> = names