ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A

This commit is contained in:
damocles 2026-05-17 12:10:49 +02:00
parent 87f8f8a123
commit 82b0877c47
21 changed files with 640 additions and 266 deletions

View file

@ -36,7 +36,7 @@ enum Cmd {
/// Run the manager MCP server on stdio. Spawned by claude via
/// `--mcp-config`; same shape as `hive-ag3nt mcp` but with the
/// manager tool surface (`request_spawn`, `kill`, `start`, `restart`,
/// `request_apply_commit`, `ask_operator`).
/// `request_apply_commit`, `ask`, `answer`, `remind`).
Mcp,
}

View file

@ -226,42 +226,74 @@ impl AgentServer {
}
#[tool(
description = "Surface a question to the operator on the dashboard. Returns immediately \
with a question id do NOT wait inline. When the operator answers, a system message \
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \
on a future turn. Use this when a decision needs human signal (ambiguous scope, \
permission to do something risky, choosing between options). `options` is advisory: \
pass a short fixed-choice list when applicable, otherwise leave empty for free text. \
Set `multi: true` to let the operator pick multiple options (checkboxes); the 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]` and the same \
`operator_answered` event fires."
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: \"<agent-name>\"` 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_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
async fn ask(&self, Parameters(args): Parameters<AskArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("ask_operator", log, async move {
run_tool_envelope("ask", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::AskOperator {
.dispatch(hive_sh4re::AgentRequest::Ask {
question: args.question,
options: args.options,
multi: args.multi,
ttl_seconds: args.ttl_seconds,
to: args.to,
})
.await;
let s = match resp {
Ok(SocketReply::QuestionQueued(id)) => format!(
"question queued (id={id}); operator's answer will arrive as a system \
`operator_answered` event in your inbox"
"question queued (id={id}); answer will arrive as a system \
`question_answered` event in your inbox"
),
Ok(SocketReply::Err(m)) => format!("ask_operator failed: {m}"),
Ok(other) => format!("ask_operator unexpected response: {other:?}"),
Err(e) => format!("ask_operator transport error: {e:#}"),
Ok(SocketReply::Err(m)) => format!("ask failed: {m}"),
Ok(other) => format!("ask unexpected response: {other:?}"),
Err(e) => format!("ask transport error: {e:#}"),
};
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: <your-name> }` event. \n\n\
Authorisation is strict you can only answer questions where you are the declared \
target (i.e. the asker did `ask(to: \"<your-name>\", ...)`). Trying to answer an \
operator-targeted question or a question addressed to a different agent will fail."
)]
async fn answer(&self, Parameters(args): Parameters<AnswerArgs>) -> String {
let log = format!("{args:?}");
let id = args.id;
run_tool_envelope("answer", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::AgentRequest::Answer {
id,
answer: args.answer,
})
.await;
annotate_retries(
format_ack(resp, "answer", format!("answered question {id}")),
retries,
)
})
.await
}
#[tool(
description = "Pop one message from this agent's inbox. Returns the sender and body, \
or an empty marker if nothing is waiting. Without `wait_seconds` (or with 0) the \
@ -389,25 +421,44 @@ pub struct UpdateArgs {
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct AskOperatorArgs {
/// The question to surface on the dashboard.
pub struct AskArgs {
/// The question to surface.
pub question: String,
/// Optional fixed-choice answers. The dashboard always renders a
/// free-text fallback ("Other…") so the operator is never trapped
/// by an incomplete list.
/// 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<String>,
/// When true, options are rendered as checkboxes — operator can pick
/// any subset. The answer comes back as a single string with
/// selections joined by ", ". Ignored when `options` is empty.
/// 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`. On expiry the question
/// resolves with answer `[expired]` and the manager receives the
/// usual `operator_answered` system event. `None` (default) =
/// wait indefinitely.
/// 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<u64>,
/// 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<String>,
}
#[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 1 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)]
@ -597,42 +648,71 @@ impl ManagerServer {
}
#[tool(
description = "Surface a question to the operator on the dashboard. Returns immediately \
with a question id do NOT wait inline. When the operator answers, a system message \
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \
on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \
request, policy call, scope clarification). `options` is advisory: pass a short \
fixed-choice list when applicable, otherwise leave empty for free text. Set \
`multi: true` to let the operator pick multiple options (checkboxes); the answer \
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \
no-longer-relevant question instead of blocking forever on expiry the answer \
is `[expired]` and the same `operator_answered` event fires."
description = "Surface a structured question to either the operator OR a sub-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: \"<agent-name>\"` to ask a sub-agent — they receive a \
`question_asked` event in their inbox and answer via their `mcp__hyperhive__answer` \
tool. Useful for delegating decisions / clarifications without losing the \
question id correlation. \n\n\
`options` is advisory: pass a short fixed-choice list when applicable, otherwise \
leave empty for free text. Set `multi: true` to render checkboxes; the answer \
comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel on \
expiry the answer is `[expired]` (with `answerer: \"ttl-watchdog\"`) and the same \
`question_answered` event fires."
)]
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
async fn ask(&self, Parameters(args): Parameters<AskArgs>) -> String {
let log = format!("{args:?}");
run_tool_envelope("ask_operator", log, async move {
run_tool_envelope("ask", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::AskOperator {
.dispatch(hive_sh4re::ManagerRequest::Ask {
question: args.question,
options: args.options,
multi: args.multi,
ttl_seconds: args.ttl_seconds,
to: args.to,
})
.await;
let s = match resp {
Ok(SocketReply::QuestionQueued(id)) => format!(
"question queued (id={id}); operator's answer will arrive as a system \
`operator_answered` event in your inbox"
"question queued (id={id}); answer will arrive as a system \
`question_answered` event in your inbox"
),
Ok(SocketReply::Err(m)) => format!("ask_operator failed: {m}"),
Ok(other) => format!("ask_operator unexpected response: {other:?}"),
Err(e) => format!("ask_operator transport error: {e:#}"),
Ok(SocketReply::Err(m)) => format!("ask failed: {m}"),
Ok(other) => format!("ask unexpected response: {other:?}"),
Err(e) => format!("ask transport error: {e:#}"),
};
annotate_retries(s, retries)
})
.await
}
#[tool(
description = "Answer a question that was routed to the manager via a `question_asked` \
system event in the manager's inbox (i.e. a sub-agent did `ask(to: \"manager\", \
...)`). Pass the `id` from the event and your `answer`. The answer surfaces in the \
asker's inbox as a `question_answered` event."
)]
async fn answer(&self, Parameters(args): Parameters<AnswerArgs>) -> String {
let log = format!("{args:?}");
let id = args.id;
run_tool_envelope("answer", log, async move {
let (resp, retries) = self
.dispatch(hive_sh4re::ManagerRequest::Answer {
id,
answer: args.answer,
})
.await;
annotate_retries(
format_ack(resp, "answer", format!("answered question {id}")),
retries,
)
})
.await
}
#[tool(
description = "Submit a config change for operator approval. Pass the agent name \
(e.g. `alice` or `hm1nd` for the manager's own config) and a commit sha in that \
@ -744,9 +824,10 @@ impl ManagerServer {
relay between them and the operator. Use `send` to talk to agents/operator, `recv` \
to drain your inbox. Privileged: `request_spawn` (new agent, gated on operator \
approval), `kill` (graceful stop), `request_apply_commit` (config change for \
any agent including yourself), `ask_operator` (block on a human answer via the \
dashboard). The manager's own config lives at \
`/agents/hm1nd/config/agent.nix`."
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`."
)]
impl ServerHandler for ManagerServer {}
@ -780,7 +861,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_operator", "remind"],
Flavor::Agent => &["send", "recv", "ask", "answer", "remind"],
Flavor::Manager => &[
"send",
"recv",
@ -790,7 +871,8 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
"restart",
"update",
"request_apply_commit",
"ask_operator",
"ask",
"answer",
"get_logs",
"remind",
],