open_threads: new get_open_threads MCP tool on agent + manager surfaces

This commit is contained in:
damocles 2026-05-17 22:39:10 +02:00
parent 9ec0d60308
commit dc1ce1f236
11 changed files with 305 additions and 9 deletions

View file

@ -40,6 +40,7 @@ pub enum SocketReply {
QuestionQueued(i64),
Recent(Vec<hive_sh4re::InboxRow>),
Logs(String),
OpenThreads(Vec<hive_sh4re::OpenThread>),
}
impl From<hive_sh4re::AgentResponse> for SocketReply {
@ -52,6 +53,7 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread),
hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::AgentResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::AgentResponse::OpenThreads { threads } => Self::OpenThreads(threads),
}
}
}
@ -67,6 +69,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows),
hive_sh4re::ManagerResponse::Logs { content } => Self::Logs(content),
hive_sh4re::ManagerResponse::OpenThreads { threads } => Self::OpenThreads(threads),
}
}
}
@ -95,6 +98,57 @@ pub fn format_recv(resp: Result<SocketReply, anyhow::Error>) -> String {
}
}
/// Format helper for `get_open_threads`: renders a short bulleted list
/// of pending approvals + questions. Empty list collapses to a clear
/// marker so claude doesn't go hunting for a payload that isn't there.
pub fn format_open_threads(resp: Result<SocketReply, anyhow::Error>) -> String {
use std::fmt::Write as _;
let threads = match resp {
Ok(SocketReply::OpenThreads(t)) => t,
Ok(SocketReply::Err(m)) => return format!("get_open_threads failed: {m}"),
Ok(other) => return format!("get_open_threads unexpected response: {other:?}"),
Err(e) => return format!("get_open_threads transport error: {e:#}"),
};
if threads.is_empty() {
return "(no open threads)".to_owned();
}
let mut out = format!("{} open thread(s):\n", threads.len());
for t in &threads {
match t {
hive_sh4re::OpenThread::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::OpenThread::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}"
);
}
}
}
out
}
/// Common envelope around every MCP tool handler: pre-log → run →
/// post-log. The inbox-status hint used to be appended to every tool
/// result; that lives in the wake prompt + UI header now, so tool
@ -317,6 +371,23 @@ impl AgentServer {
.await
}
#[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."
)]
async fn get_open_threads(&self) -> String {
run_tool_envelope("get_open_threads", String::new(), async move {
let (resp, retries) = self.dispatch(hive_sh4re::AgentRequest::GetOpenThreads).await;
annotate_retries(format_open_threads(resp), 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 \
@ -784,6 +855,23 @@ impl ManagerServer {
.await
}
#[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."
)]
async fn get_open_threads(&self) -> String {
run_tool_envelope("get_open_threads", String::new(), async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::GetOpenThreads)
.await;
annotate_retries(format_open_threads(resp), 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 \
@ -826,8 +914,10 @@ impl ManagerServer {
approval), `kill` (graceful stop), `request_apply_commit` (config change for \
any agent including yourself), `ask` (structured question to the operator or a \
sub-agent non-blocking, answer arrives later as a `question_answered` event), \
`answer` (respond to a `question_asked` event directed at you). The manager's own \
config lives at `/agents/hm1nd/config/agent.nix`."
`answer` (respond to a `question_asked` event directed at you), \
`get_open_threads` (hive-wide loose ends pending approvals + unanswered \
questions across the swarm). The manager's own config lives at \
`/agents/hm1nd/config/agent.nix`."
)]
impl ServerHandler for ManagerServer {}
@ -861,7 +951,7 @@ pub enum Flavor {
#[must_use]
pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
let names: &[&str] = match flavor {
Flavor::Agent => &["send", "recv", "ask", "answer", "remind"],
Flavor::Agent => &["send", "recv", "ask", "answer", "remind", "get_open_threads"],
Flavor::Manager => &[
"send",
"recv",
@ -874,6 +964,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"ask",
"answer",
"get_logs",
"get_open_threads",
"remind",
],
};