ask_operator tool: non-blocking; operator answer arrives as helper event
new mcp tool on the manager surface that queues a question on the
dashboard and returns the question id immediately. operator submits an
answer via /answer-question/<id>; the dashboard fires
HelperEvent::OperatorAnswered { id, question, answer } into the manager
inbox so the next turn picks it up.
also: fix async-form button stuck on spinner after successful submit
(refreshState skipped re-rendering, so the button was never re-enabled).
This commit is contained in:
parent
abfd2cce4b
commit
2770630f33
17 changed files with 426 additions and 79 deletions
|
|
@ -48,6 +48,10 @@
|
|||
}
|
||||
// Clear text inputs the operator typed into (the form value was sent).
|
||||
f.querySelectorAll('input[type="text"], input:not([type])').forEach((i) => { i.value = ''; });
|
||||
// Re-enable the button — refreshState() often skips re-rendering the
|
||||
// form (status unchanged), so without this the spinner sticks and
|
||||
// the operator can't submit again.
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = original; }
|
||||
refreshState();
|
||||
} catch (err) {
|
||||
alert('action failed: ' + err);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ Tools (hyperhive surface):
|
|||
- `mcp__hyperhive__request_spawn(name)` — queue a brand-new sub-agent for operator approval (≤9 char name).
|
||||
- `mcp__hyperhive__kill(name)` — graceful stop on a sub-agent.
|
||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
|
||||
- `mcp__hyperhive__ask_operator(question, options?)` — 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. Do not poll inside the same turn — finish the current work and react when the event lands.
|
||||
|
||||
Your own editable config lives at `/agents/hm1nd/config/agent.nix`; every sub-agent's lives at `/agents/<name>/config/agent.nix`. Use file/git tools to edit + commit, then `request_apply_commit`.
|
||||
|
||||
|
|
@ -19,9 +20,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.
|
||||
|
||||
You can surface questions to the operator. (NOT YET IMPLEMENTED: a dedicated `mcp__hyperhive__ask_operator` tool will land soon — it pauses the turn, drops a prompt on the dashboard, and resumes with the answer.) For now, send to `operator` with a clear question and wait for the next turn to see their reply; the cadence is slower but the shape is the same.
|
||||
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.
|
||||
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or note the change to the operator.
|
||||
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `approval_resolved`, `spawned`, `rebuilt`, `killed`, `destroyed`, `operator_answered`. Use these to react to lifecycle changes — e.g. greet a freshly-spawned agent, retry a failed rebuild, or pick up the operator's answer to a question you previously asked.
|
||||
|
||||
Durable knowledge:
|
||||
|
||||
|
|
|
|||
|
|
@ -176,7 +176,9 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
|||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||
format!(
|
||||
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
|
||||
)
|
||||
};
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,7 +170,11 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
turn::emit_turn_end(&bus, &outcome);
|
||||
}
|
||||
Ok(ManagerResponse::Empty) => {}
|
||||
Ok(ManagerResponse::Ok | ManagerResponse::Status { .. }) => {
|
||||
Ok(
|
||||
ManagerResponse::Ok
|
||||
| ManagerResponse::Status { .. }
|
||||
| ManagerResponse::QuestionQueued { .. },
|
||||
) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
Ok(ManagerResponse::Err { message }) => {
|
||||
|
|
@ -184,7 +188,6 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// Per-turn user prompt. The role/tools/etc. is in the system prompt
|
||||
/// (`prompts/manager.md` → `claude --system-prompt-file`); this is just
|
||||
/// the wake signal. `unread` is the inbox depth after this message was
|
||||
|
|
@ -193,7 +196,9 @@ fn format_wake_prompt(from: &str, body: &str, unread: u64) -> String {
|
|||
let pending = if unread == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)")
|
||||
format!(
|
||||
"\n\n({unread} more message(s) pending in your inbox — drain via `mcp__hyperhive__recv` if relevant.)"
|
||||
)
|
||||
};
|
||||
format!("Incoming message from `{from}`:\n---\n{body}\n---{pending}")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ pub enum SocketReply {
|
|||
Message { from: String, body: String },
|
||||
Empty,
|
||||
Status(u64),
|
||||
QuestionQueued(i64),
|
||||
}
|
||||
|
||||
impl From<hive_sh4re::AgentResponse> for SocketReply {
|
||||
|
|
@ -59,6 +60,7 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
|
|||
hive_sh4re::ManagerResponse::Message { from, body } => Self::Message { from, body },
|
||||
hive_sh4re::ManagerResponse::Empty => Self::Empty,
|
||||
hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread),
|
||||
hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -209,6 +211,16 @@ pub struct KillArgs {
|
|||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct AskOperatorArgs {
|
||||
/// The question to surface on the dashboard.
|
||||
pub question: String,
|
||||
/// Optional fixed-choice answers. If empty, the dashboard renders a
|
||||
/// free-text input. Otherwise renders a select list of these options.
|
||||
#[serde(default)]
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct RequestApplyCommitArgs {
|
||||
/// Agent whose config repo the commit lives in (use `"hm1nd"` for the
|
||||
|
|
@ -310,6 +322,36 @@ impl ManagerServer {
|
|||
.await
|
||||
}
|
||||
|
||||
#[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."
|
||||
)]
|
||||
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
run_tool_envelope("ask_operator", log, async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::AskOperator {
|
||||
question: args.question,
|
||||
options: args.options,
|
||||
})
|
||||
.await;
|
||||
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"
|
||||
),
|
||||
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:#}"),
|
||||
}
|
||||
})
|
||||
.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 \
|
||||
|
|
@ -322,23 +364,19 @@ impl ManagerServer {
|
|||
let log = format!("{args:?}");
|
||||
let agent = args.agent.clone();
|
||||
let commit_ref = args.commit_ref.clone();
|
||||
run_tool_envelope(
|
||||
"request_apply_commit",
|
||||
log,
|
||||
async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
|
||||
agent: args.agent,
|
||||
commit_ref: args.commit_ref,
|
||||
})
|
||||
.await;
|
||||
format_ack(
|
||||
resp,
|
||||
"request_apply_commit",
|
||||
format!("apply approval queued for {agent} @ {commit_ref}"),
|
||||
)
|
||||
},
|
||||
)
|
||||
run_tool_envelope("request_apply_commit", log, async move {
|
||||
let resp = self
|
||||
.dispatch(hive_sh4re::ManagerRequest::RequestApplyCommit {
|
||||
agent: args.agent,
|
||||
commit_ref: args.commit_ref,
|
||||
})
|
||||
.await;
|
||||
format_ack(
|
||||
resp,
|
||||
"request_apply_commit",
|
||||
format!("apply approval queued for {agent} @ {commit_ref}"),
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
|
@ -348,7 +386,8 @@ 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). The manager's own config lives at \
|
||||
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`."
|
||||
)]
|
||||
impl ServerHandler for ManagerServer {}
|
||||
|
|
@ -388,6 +427,7 @@ pub fn allowed_mcp_tools(flavor: Flavor) -> Vec<String> {
|
|||
"request_spawn",
|
||||
"kill",
|
||||
"request_apply_commit",
|
||||
"ask_operator",
|
||||
],
|
||||
};
|
||||
names
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue