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
|
|
@ -5,7 +5,8 @@ Tools (hyperhive surface):
|
|||
- `mcp__hyperhive__recv(wait_seconds?)` — drain one more message from your inbox (returns `(empty)` if nothing pending). Without `wait_seconds` (or with `0`) it returns immediately — a cheap "anything pending?" peek you can sprinkle between tool calls. To **wait** for work when you have nothing else useful to do this turn, call with a long wait (e.g. `wait_seconds: 180`, the max) — incoming messages wake you instantly, otherwise the call returns empty at the timeout. That's strictly better than a fixed `sleep` shell command: lower latency on new work, no busy-loop.
|
||||
- `mcp__hyperhive__send(to, body)` — message a peer (by their name) or the operator (recipient `operator`, surfaces in the dashboard). Use `to: "*"` to broadcast to all agents (they receive a hint that it's a broadcast and may not need action). Some agents have a per-agent allow-list (`hyperhive.allowedRecipients` in their `agent.nix`) — if so the tool refuses recipients outside the list with a clear error; route through the manager (`send(to: "manager", …)`) which is always reachable.
|
||||
- (some agents only) **extra MCP tools** surfaced as `mcp__<server>__<tool>` — these are agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `hyperhive.extraMcpServers`. Treat them as first-class tools alongside the hyperhive surface; the operator already auto-approved them at deploy time.
|
||||
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question to the human 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 for clarifications, permission for risky actions, or choice between options. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the operator pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` when the decision becomes moot.
|
||||
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the human operator (default, or `to: "operator"`) OR a peer agent (`to: "<agent-name>"`). 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. Use this for clarifications, permission for risky actions, choice between options, or peer Q&A without burning regular inbox slots. `options` is advisory: a short fixed-choice list when applicable, otherwise leave empty for free text. `multi: true` lets the answerer pick multiple (checkboxes), answer comes back comma-joined. `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: "ttl-watchdog"`) when the decision becomes moot.
|
||||
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU. You'll see one in your inbox as a `question_asked { id, asker, question, options, multi }` system event when a peer or the manager calls `ask(to: "<your-name>", ...)`. The answer surfaces in the asker's inbox as a `question_answered` event. Strict authorisation: you can only answer questions where you are the declared target.
|
||||
|
||||
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — message the manager (recipient `manager`) describing what you need + why. The manager evaluates the request (it doesn't rubber-stamp), edits `/agents/{label}/config/agent.nix` on your behalf, commits, and submits an approval that the operator can accept on the dashboard; on approve hive-c0re rebuilds your container with the new config.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ Tools (hyperhive surface):
|
|||
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
|
||||
- `mcp__hyperhive__update(name)` — rebuild a sub-agent (re-applies the current hyperhive flake + agent.nix, restarts the container). No approval required — idempotent. Use when you receive a `needs_update` system event.
|
||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref, description?)` — submit a config change for any agent (`hm1nd` for self) for operator approval. Pass an optional `description` and it appears on the dashboard approval card so the operator knows what changed without opening the diff. At submit time hive-c0re fetches your commit into the agent's applied repo and pins it as `proposal/<id>`; from that moment your proposed-side commit can be amended or force-pushed freely without changing what the operator will build.
|
||||
- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline — useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn — finish the current work and react when the event lands.
|
||||
- `mcp__hyperhive__ask(question, options?, multi?, ttl_seconds?, to?)` — surface a structured question to the operator (default, or `to: "operator"`) OR a sub-agent (`to: "<agent-name>"`). Returns immediately with a question id; the answer arrives later as a system `question_answered { id, question, answer, answerer }` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline (capped at 6h server-side) — on expiry the answer is `[expired]` and `answerer` is `"ttl-watchdog"`. Do not poll inside the same turn — finish the current work and react when the event lands.
|
||||
- `mcp__hyperhive__answer(id, answer)` — answer a question that was routed to YOU (a sub-agent did `ask(to: "manager", ...)`). The triggering event in your inbox is `question_asked { id, asker, question, options, multi }`. The answer surfaces in the asker's inbox as a `question_answered` event.
|
||||
|
||||
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.
|
||||
|
||||
|
|
@ -62,9 +63,9 @@ Sub-agents are NOT trusted by default. When one asks for a config change (new pa
|
|||
|
||||
You're the policy gate between sub-agents and the operator's approval queue — the operator clicks ◆ APPR0VE on your commits, so don't submit changes you wouldn't defend.
|
||||
|
||||
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask_operator(question, options?)` when you need a decision. `ask_operator` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as an `operator_answered` system event. Prefer `ask_operator` over an open-ended `send` for anything you actually need to wait on.
|
||||
Two ways to talk to the operator: `send(to: "operator", ...)` for fire-and-forget status / pointers (surfaces in the operator inbox), or `ask(question, options?)` when you need a decision (omit `to`, or pass `to: "operator"`). `ask` is non-blocking — it queues the question and returns an id immediately; the answer arrives on a future turn as a `question_answered` system event. Prefer `ask` over an open-ended `send` for anything you actually need to wait on. Same primitive can target a sub-agent (`to: "<agent>"`) when you need a structured answer from a peer rather than free-form chat.
|
||||
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `operator_answered`. Use these to react to lifecycle changes:
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `container_crash`, `needs_login`, `logged_in`, `needs_update`, `question_asked`, `question_answered`. Use these to react to lifecycle changes:
|
||||
|
||||
- `needs_login` — agent has no claude session yet. You can't help directly (login is interactive OAuth on the operator side); flag the operator if it's been long.
|
||||
- `logged_in` — agent just completed login; first useful turn is imminent. Good time to brief them on what to do.
|
||||
|
|
|
|||
|
|
@ -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