hyperhive/hive-subagent-mcp/src/mcp.rs
atlas b18348bc9a subagent: give a run a goal, turns toward it, and a reason it stopped
`start` takes an optional `goal`. With one set a session stops being a
single turn: when a turn ends and nothing has said to stop, the daemon
spawns another turn re-prompting the subagent toward that goal, up to
`max_turns` (default 5, per-session). Without a goal nothing changes —
one turn, one todo, same as before.

Four things end a run, each recorded distinctly and reported by `status`:
the turn ending with no goal, `goal_reached`, `need_help`, and the turn
cap. The last says so out loud rather than stopping quietly — the todo
states the harness limit was reached and the goal was never reported
reached. Every stop extends the done message rather than replacing it,
and lands in the session's report file when it has one. The path is
never inferred: it comes from `start`'s `report_file` or from the
subagent naming where it wrote.

`goal_reached` and `need_help` are the subagent's own, served on a second
route (`/signal/mcp`) that carries those two tools and nothing else, so
reporting on a run can't become starting one. `goal_reached` is built as
a label, never a gate: it is self-reported by a subagent that has just
been re-prompted with "you haven't reached the goal", which is exactly
the incentive to claim it — the same failure class as a build report
asserting the tests pass. Every surface that renders it says so.
`need_help` is the blocking signal, and shows in `status` as its own
state so a parent polling it sees the block without reading a file.

`status` also carries `turn N of M`: with 4330's last-event age, that
separates working from wedged from out of turns off one answer.

Two bugs the new tests caught: a `tokio::fs::File` was dropped without
flushing, so the report line was written to nothing, and the plain idle
answer dropped the turn counter.

Also documents `await_resume`'s third case — a closed channel with no
send, which fails open the same as `Underway` — per argus on #4411.

Refs #4403
2026-09-14 21:46:59 +02:00

393 lines
17 KiB
Rust

//! The MCP tool surface: `start` / `continue` / `status` / `interrupt`,
//! served directly over streamable-http — no stdio bridge, no round-trip
//! socket.
//!
//! Two surfaces, two routes. `/mcp` is the parent's: the four tools above.
//! `/signal/mcp` is the *subagent's*, and carries `goal_reached` and
//! `need_help` only — it is what a subagent is handed in its own
//! `--mcp-config` (see [`crate::mcp_config`]), so the ability to report on
//! its own run can't be the ability to spawn a nested one.
use std::sync::Arc;
use rmcp::{
ServerHandler,
handler::server::wrapper::Parameters,
schemars::{self, JsonSchema},
tool, tool_handler, tool_router,
};
use serde::Deserialize;
use crate::session::{self, State};
#[derive(Debug, Deserialize, JsonSchema)]
struct StartArgs {
/// Session name — this daemon's tracking key while it's alive, and the
/// identity to `continue`/`status`/`interrupt` it by afterward. Same
/// identifier rules as the `bash` server's task names: lowercase,
/// digits, hyphen, max 63 chars. Reusable once a prior *finished*
/// session under that name is done — rejected while one under the same
/// name is still running.
name: String,
/// Which model the subagent's own session runs. Omit for claude's own
/// default. The `base:claude-subagents` skill's "cheaper-than-you"
/// guidance still applies here.
#[serde(default)]
model: Option<String>,
/// Which reasoning effort level the subagent's own session runs at
/// (`--effort`). Omit to default to `medium` — a deliberate hive
/// policy for subagent work, not claude's own default (`high` on most
/// models). Independent of `model`, so a cheap model at high effort or
/// an expensive one at low effort are both valid combinations, not
/// just the two extremes. See the `base:claude-subagents` skill for
/// Anthropic's own guidance on choosing between levels.
#[serde(default)]
effort: Option<String>,
/// Path to a file holding the subagent's actual task instructions. A
/// file, not an inline string, so a large recipe can't blow past a
/// shell argument length limit.
prompt_file: String,
/// Written to the subagent's stdin as its first turn's prompt. Default:
/// a generic "carry out your instructions" nudge — the real task detail
/// belongs in `prompt_file`, not here.
#[serde(default = "default_trigger")]
trigger: String,
/// Working directory for the subagent's session — e.g. a git worktree
/// you've already prepared for it, so a parallel batch of subagents
/// never race on the same working tree. Must exist. Omit to inherit
/// this daemon's own working directory (today's default). The daemon
/// remembers whichever `dir` you give here against `name`, so a later
/// `continue`/`status` for the same name doesn't need to repeat it —
/// only pass it there again if you want to point at a *different*
/// directory. Forgotten on a daemon restart, same as everything else
/// this daemon tracks in memory.
#[serde(default)]
dir: Option<String>,
/// What this subagent is working *toward*, in its own words — set it and
/// the daemon keeps giving it turns until it says it's done, says it's
/// stuck, or runs out. Omit it and the session is a single turn, exactly
/// as before. Written for the subagent to read: it's quoted back at it
/// verbatim at the start of every continued turn, so "get `cargo clippy
/// --workspace` to pass with no warnings" continues far better than "fix
/// the lints".
#[serde(default)]
goal: Option<String>,
/// How many turns the continuation may spend before the harness stops it
/// itself. Default 5. Only meaningful alongside `goal` — without one
/// there's nothing to re-prompt toward, so nothing to cap. The cap
/// bounds *unattended* re-prompting: a `continue` you issue yourself
/// starts the allowance over.
#[serde(default)]
max_turns: Option<u32>,
/// Where the instructions in `prompt_file` told this subagent to write
/// its report. The daemon appends the run's stop reason to that file when
/// the run ends, so the artifact you were going to read anyway also says
/// how it stopped. Nothing is inferred: if you don't pass it (and the
/// subagent doesn't name it when it signals), no file is touched.
#[serde(default)]
report_file: Option<String>,
}
fn default_trigger() -> String {
"Carry out the task described in your instructions.".to_owned()
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GoalReachedArgs {
/// Your own session name — the one your brief and your continuation
/// prompts address you by.
name: String,
/// Optionally, what you did. It's shown to whoever spawned you.
#[serde(default)]
msg: Option<String>,
/// Optionally, the path you wrote your report to, so the stop reason
/// gets appended to it.
#[serde(default)]
report_file: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct NeedHelpArgs {
/// Your own session name — the one your brief and your continuation
/// prompts address you by.
name: String,
/// What is blocking you, specifically enough for someone else to act on
/// it. This is the whole content of the signal, which is why it's
/// required.
msg: String,
/// Optionally, the path you wrote your report to, so the stop reason
/// gets appended to it.
#[serde(default)]
report_file: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ContinueArgs {
/// The existing session's name (from a prior `start`).
name: String,
/// The new turn's prompt, written to the subagent's stdin.
prompt: String,
/// Which model this turn runs. Omit to let claude fall back to its own
/// default — this does not have to match whatever model `start` used.
#[serde(default)]
model: Option<String>,
/// Which reasoning effort level this turn runs at. Omit to default to
/// `medium` (this daemon's own default, not claude's) — this does not
/// have to match whatever effort `start` (or a prior `continue`) used.
#[serde(default)]
effort: Option<String>,
/// Omit to reuse whatever `dir` `start` (or a prior `continue`) used for
/// this name — the daemon remembers it until it restarts, and a restart
/// is exactly when you're most likely to be reaching for `continue`. So
/// pass it when pointing the session at a *different* directory than
/// last time, and pass it again after a restart if the session lives
/// anywhere other than the daemon's own working directory. A resume that
/// finds nothing says which directory it searched.
#[serde(default)]
dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct StatusArgs {
/// The subagent name to check.
name: String,
/// Omit to use whatever `dir` was last remembered for this name (see
/// `start`'s `dir` doc) — you only need this if nothing's running or
/// reserved for `name` right now (the common "is it running" case never
/// even looks at it) *and* you want to check a different directory's
/// session than the one last remembered.
#[serde(default)]
dir: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct InterruptArgs {
/// The running session's name to signal.
name: String,
/// `true` sends SIGKILL immediately; `false` (default) sends SIGINT,
/// letting claude shut down cleanly if it's already mid-response.
#[serde(default)]
force: bool,
}
#[derive(Clone)]
struct SubagentMcp {
state: Arc<State>,
}
#[tool_router]
impl SubagentMcp {
#[tool(
description = "Start a fresh claude subagent session under `name`, running in the \
background. Returns as soon as the process is confirmed running — not once it \
finishes; this daemon pushes a todo when the turn ends, or use `continue` later to \
give it another turn. A prior *finished* session under the same name is archived \
first (real fresh start, not a silent resume); a *currently running* one is \
refused. Runs unattended — every tool-call permission prompt is pre-approved rather \
than interactively confirmed — with its MCP server set fixed to what this daemon \
configures for it. Pass `goal` to make this a multi-turn run: the daemon re-prompts \
the subagent toward that goal each time a turn ends, up to `max_turns` (default 5), \
stopping early when the subagent reports the goal reached or asks for help. Whichever \
way it stops, one todo is pushed at the end and `status` says which. See the \
`base:claude-subagents` skill for when to reach for this."
)]
fn start(&self, Parameters(args): Parameters<StartArgs>) -> String {
match session::start(
&self.state,
session::StartRequest {
name: args.name,
model: args.model,
effort: args.effort,
prompt_file: args.prompt_file,
trigger: args.trigger,
dir: args.dir,
goal: args.goal,
max_turns: args.max_turns,
report_file: args.report_file,
},
) {
Ok(msg) => msg,
Err(e) => format!("start error: {e:#}"),
}
}
#[tool(
name = "continue",
description = "Give an existing named subagent session a new turn — whether that's \
because its previous turn finished and you have a follow-up instruction, or you're \
reattaching after this daemon restarted (the session itself survives independently \
of the daemon that spawned it). Returns once the turn is underway rather than the \
instant the process exists — a second or so, not the length of the turn — so a \
reply saying the turn started means it started; a name already running is refused. \
A name with no session to resume is not refused up front, because claude's own \
`--resume` decides that: a miss comes back as this call's own error, naming the \
directory that was searched, so check `dir` before concluding the session is gone. \
Resuming a session whose last turn was killed is allowed — the reply says so, since \
that turn's work stopped wherever it had got to."
)]
async fn r#continue(&self, Parameters(args): Parameters<ContinueArgs>) -> String {
match session::continue_(
&self.state,
&args.name,
args.prompt,
args.model,
args.effort,
args.dir.as_deref(),
)
.await
{
Ok(msg) => msg,
Err(e) => format!("continue error: {e:#}"),
}
}
#[tool(
description = "Signal a currently-running subagent session to stop. Only works once \
it's actually running — a `start`/`continue` still in its brief window before the \
process is confirmed spawned refuses interrupt too (nothing to signal yet; retry \
shortly), same as a name with nothing tracked at all. `force: true` for SIGKILL, \
otherwise SIGINT."
)]
fn interrupt(&self, Parameters(args): Parameters<InterruptArgs>) -> String {
match session::interrupt(&self.state, &args.name, args.force) {
Ok(msg) => msg,
Err(e) => format!("interrupt error: {e:#}"),
}
}
#[tool(
description = "Report whether a subagent is currently running — a zero-cost check that \
never launches a process, unlike `continue`. The answer says what state it found \
and what to do about it. For a running one it also reports how long since that turn \
last produced any output, which is how you tell a subagent that's working from one \
that has wedged without resorting to `ps`. For a session started with a `goal` it \
reports which turn of the budget it is on, and — once the run has stopped — why it \
stopped: goal reported reached, blocked and needing help, or out of turns."
)]
fn status(&self, Parameters(args): Parameters<StatusArgs>) -> String {
match session::status(&self.state, &args.name, args.dir.as_deref()) {
Ok(msg) => msg,
Err(e) => format!("status error: {e:#}"),
}
}
}
#[tool_handler]
impl ServerHandler for SubagentMcp {}
/// The surface a *subagent* gets, served on its own route: two tools, both
/// of them ways for a running subagent to say how its own run should end.
/// Separate handler rather than two more tools on [`SubagentMcp`] so the
/// split is structural — there is no route a subagent holds that `start` is
/// reachable from.
#[derive(Clone)]
struct SubagentSignalMcp {
state: Arc<State>,
}
#[tool_router]
impl SubagentSignalMcp {
#[tool(
description = "Report that you have reached the goal you were given. Stops the harness \
from starting another turn to re-prompt you toward it, and extends the \"subagent \
done\" message your parent gets with what you say here. This records a claim, not a \
result: whoever spawned you reads the diff and the gate output regardless, so \
calling it does not make unfinished work finished. Call it when the goal is actually \
met — otherwise keep working, or call `need_help` if you can't proceed."
)]
fn goal_reached(&self, Parameters(args): Parameters<GoalReachedArgs>) -> String {
match session::goal_reached(
&self.state,
&args.name,
args.msg,
args.report_file.as_deref(),
) {
Ok(msg) => msg,
Err(e) => format!("goal_reached error: {e:#}"),
}
}
#[tool(
description = "Report that you cannot proceed, and why. Stops the harness from starting \
another turn to re-prompt you toward your goal, marks your session as blocked so \
whoever spawned you sees it in `status` without reading any file, and extends the \
\"subagent done\" message with your reason. Use it for a genuine block — a missing \
credential, a decision that isn't yours, an instruction that contradicts what you \
found — not for work that is merely hard. Say enough that someone else can act on it."
)]
fn need_help(&self, Parameters(args): Parameters<NeedHelpArgs>) -> String {
match session::need_help(
&self.state,
&args.name,
args.msg,
args.report_file.as_deref(),
) {
Ok(msg) => msg,
Err(e) => format!("need_help error: {e:#}"),
}
}
}
#[tool_handler]
impl ServerHandler for SubagentSignalMcp {}
/// Path the subagent-facing signal surface is served at, and the tail of the
/// URL [`crate::session::State`] hands to every subagent it spawns. Kept
/// here, next to the route that answers it, so the two can't drift.
pub const SIGNAL_PATH: &str = "/signal/mcp";
/// Run the MCP server over HTTP (rmcp streamable-http transport) on `addr`.
/// Loopback-only bind, one long-lived session — same shape as the bash and
/// matrix daemons' own `serve_http`.
///
/// Two routes off one listener: `/mcp` for the parent's four tools, and
/// [`SIGNAL_PATH`] for the subagent's two. Separate session managers because
/// they're separate MCP servers to separate clients — the parent's harness
/// on one, each subagent's own claude on the other.
///
/// # Errors
///
/// Returns an error if the listener cannot bind `addr` or the HTTP server
/// exits with a fatal error.
pub async fn serve_http(addr: std::net::SocketAddr, state: Arc<State>) -> anyhow::Result<()> {
use rmcp::transport::streamable_http_server::{
StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
};
// A subagent turn can run considerably longer than a bash command —
// same 24h keep-alive rationale as the bash/matrix daemons.
let manager = || {
let mut session_manager = LocalSessionManager::default();
session_manager.session_config.keep_alive = Some(std::time::Duration::from_hours(24));
std::sync::Arc::new(session_manager)
};
let parent_state = Arc::clone(&state);
let service = StreamableHttpService::new(
move || {
Ok(SubagentMcp {
state: Arc::clone(&parent_state),
})
},
manager(),
StreamableHttpServerConfig::default(),
);
let signal_service = StreamableHttpService::new(
move || {
Ok(SubagentSignalMcp {
state: Arc::clone(&state),
})
},
manager(),
StreamableHttpServerConfig::default(),
);
let app = axum::Router::new()
.nest_service("/mcp", service)
.nest_service(SIGNAL_PATH, signal_service);
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
%addr,
signal = SIGNAL_PATH,
"serving hive-subagent MCP over streamable-http at /mcp"
);
axum::serve(listener, app).await?;
Ok(())
}