diff --git a/docs/conventions.md b/docs/conventions.md index f434cc68..c19fedfe 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -348,7 +348,7 @@ binary flavor. | Group | Tools | |---|---| -| `messaging` | `send`, `recv`, `ack_until` | +| `messaging` | `send`, `recv`, `ack_until`, `ask`, `answer` | | `meta` | `get_agent_meta` (`set_status` is always-on, see below) | | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind` | | `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | diff --git a/hive-agent-mcp/src/mcp/args.rs b/hive-agent-mcp/src/mcp/args.rs index f64ac080..1f828e76 100644 --- a/hive-agent-mcp/src/mcp/args.rs +++ b/hive-agent-mcp/src/mcp/args.rs @@ -157,6 +157,47 @@ pub struct UpdateArgs { 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 diff --git a/hive-agent-mcp/src/mcp/mod.rs b/hive-agent-mcp/src/mcp/mod.rs index c7a800c5..b233e03b 100644 --- a/hive-agent-mcp/src/mcp/mod.rs +++ b/hive-agent-mcp/src/mcp/mod.rs @@ -24,11 +24,11 @@ mod args; mod render; pub use args::{ - AckUntilArgs, AgentGetLooseEndsArgs, CancelLooseEndArgs, CancelScheduleArgs, CompactArgs, - CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, - GetLogsArgs, KillArgs, MarkTodosDoneArgs, RecvArgs, RemindArgs, RequestInitConfigArgs, - RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, StartArgs, UpdateArgs, - UpdateMetaInputsArgs, + AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, + CancelScheduleArgs, CompactArgs, 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}; @@ -169,6 +169,111 @@ impl AgentServer { .await } + #[tool( + description = "Surface a structured question to either the operator OR a peer agent. \ + Returns immediately with a question id — do NOT wait inline. When the recipient \ + answers, a system message with event `question_answered { id, question, answer, \ + answerer }` lands in your inbox; handle it on a future turn. \n\n\ + Recipient: omit `to` (or set `to: \"operator\"`) for the human operator on the \ + dashboard. Set `to: \"\"` to ask a peer agent — they receive a \ + `question_asked { id, asker, question, options, multi }` event in their inbox \ + and answer via `mcp__hyperhive__answer`. \n\n\ + `options` is advisory: pass a short fixed-choice list when applicable, otherwise \ + leave empty for free text. Set `multi: true` to let the answerer pick multiple \ + options (checkboxes on the dashboard, hint to the agent otherwise) — answer comes \ + back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ + no-longer-relevant question — on expiry the answer is `[expired]` (with \ + `answerer: \"ttl-watchdog\"`) and the same `question_answered` event fires." + )] + async fn ask(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + run_tool_envelope("ask", log, async move { + let to = match args.to.map(|t| hive_types::Ident::parse(&t)).transpose() { + Ok(to) => to, + Err(reason) => return format!("invalid `to` agent name: {reason}"), + }; + let question = args.question; + let target = to + .as_ref() + .map_or_else(|| "operator".to_owned(), std::string::ToString::to_string); + let (resp, retries) = self + .dispatch(hive_core_agent_sock::Request::Ask { + question: question.clone(), + options: args.options, + multi: args.multi, + ttl_seconds: args.ttl_seconds, + to, + }) + .await; + let s = match resp { + Ok(hive_core_agent_sock::Response::QuestionQueued { id }) => { + // Best-effort local questions-mirror record — a dial + // failure just means `get_loose_ends` won't show this + // row locally; the actual question is already queued + // in c0re regardless. + let _ = dial_agent_socket(&hive_agent_sock::Request::RecordAskedQuestion { + id, + target, + question, + }) + .await; + format!( + "question queued (id={id}); answer will arrive as a system \ + `question_answered` event in your inbox" + ) + } + other => reply_err(other, "ask"), + }; + annotate_retries(s, retries) + }) + .await + } + + #[tool( + description = "Answer a question that was routed to YOU via a `question_asked` system \ + event in your inbox. Pass the `id` from that event and your `answer` string. The \ + answer will surface in the asker's inbox as a `question_answered { id, question, \ + answer, answerer: }` event. \n\n\ + Authorisation is strict — you can only answer questions where you are the declared \ + target (i.e. the asker did `ask(to: \"\", ...)`). Trying to answer an \ + operator-targeted question or a question addressed to a different agent will fail." + )] + async fn answer(&self, Parameters(args): Parameters) -> String { + let log = format!("{args:?}"); + let id = args.id; + run_tool_envelope("answer", log, async move { + let (resp, retries) = self + .dispatch(hive_core_agent_sock::Request::Answer { + id, + answer: args.answer, + }) + .await; + // Clear the local `answering` mirror row whenever this agent is + // done owing a reply for `id`: either a genuine success, or + // c0re telling us the question already resolved without us + // (the asker cancelled/answered it first, surfacing as an + // "already answered"/"not found" rejection) — in both cases + // nothing is still owed, so the row would otherwise linger + // stale. Any other rejection (e.g. wrong answerer) means the + // question is still genuinely outstanding, so leave it be. + let should_clear = match &resp { + Ok(hive_core_agent_sock::Response::Ok) => true, + Ok(hive_core_agent_sock::Response::Err { message }) => { + message.contains("not found") || message.contains("already answered") + } + _ => false, + }; + if should_clear { + let _ = dial_agent_socket(&hive_agent_sock::Request::ClearQuestion { id }).await; + } + annotate_retries( + format_ack(resp, "answer", format!("answered question {id}")), + retries, + ) + }) + .await + } + #[tool( description = "Pop messages from this agent's inbox. Returns one or more messages, or \ an empty marker if nothing is waiting. Always an immediate 'anything pending?' peek \ @@ -433,8 +538,8 @@ impl AgentServer { // the c0re side, so a successful cancel here always means // *this* agent's own `asked` mirror row for `id`. Known gap // (documented on `Questions`): the target isn't notified, - // so their `answering` row always lingers until it goes - // stale — `answer()` no longer exists as an option. + // so their `answering` row lingers until they call + // `answer()` or it goes stale. let _ = dial_agent_socket(&hive_agent_sock::Request::ClearQuestion { id }).await; } annotate_retries( diff --git a/hive-sh4re/src/permissions.rs b/hive-sh4re/src/permissions.rs index 42a35878..cde4906c 100644 --- a/hive-sh4re/src/permissions.rs +++ b/hive-sh4re/src/permissions.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ToolGroup { - /// `send`, `recv`, `ack_until` + /// `send`, `recv`, `ask`, `answer` Messaging, /// `get_agent_meta` (`set_status` is always-on — see `ALWAYS_ON_TOOLS`) Meta, @@ -51,7 +51,7 @@ impl ToolGroup { #[must_use] pub fn tools(self) -> &'static [&'static str] { match self { - Self::Messaging => &["send", "recv", "ack_until"], + Self::Messaging => &["send", "recv", "ack_until", "ask", "answer"], Self::Meta => &["get_agent_meta"], Self::Inbox => &["get_loose_ends", "cancel_loose_end", "remind"], Self::Lifecycle => &["kill", "start", "restart", "update", "list_containers"], @@ -163,7 +163,7 @@ impl ToolGroup { #[must_use] pub fn description(self) -> &'static str { match self { - Self::Messaging => "send, recv, ack_until — core agent communication", + Self::Messaging => "send, recv, ask, answer — core agent communication", Self::Meta => { "get_agent_meta — identity introspection (set_status is always available)" }