ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A
This commit is contained in:
parent
87f8f8a123
commit
82b0877c47
21 changed files with 640 additions and 266 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue