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
This commit is contained in:
parent
6e2de33f26
commit
b18348bc9a
10 changed files with 1758 additions and 253 deletions
|
|
@ -1,35 +1,61 @@
|
|||
//! Builds a subagent's own filtered `--mcp-config`: only
|
||||
//! `hyperhive.extraMcpServers` entries with `availableToSubagents = true`
|
||||
//! (see `hive_agent_sock::extra_mcp::ExtraMcpServer::available_to_subagents`) ever reach
|
||||
//! a subagent's claude invocation. Everything else — the built-in hyperhive
|
||||
//! a subagent's claude invocation, plus this daemon's own two-tool signal
|
||||
//! surface. Everything else — the built-in hyperhive
|
||||
//! surface (todos/messaging), the automatically injected `bash` and `subagent`
|
||||
//! entries — stays unreachable by construction: none of those default to
|
||||
//! opted in, and the built-in surface isn't an `extraMcpServers` entry at
|
||||
//! all, so there's no name for an operator to opt it in under even if they
|
||||
//! wanted to.
|
||||
//!
|
||||
//! The signal surface is the one server a subagent always gets. It is a
|
||||
//! *different route* on this daemon's listener from the one the parent
|
||||
//! uses, serving `goal_reached` and `need_help` and nothing else — so
|
||||
//! "a subagent can say it is done or stuck" never widens into "a subagent
|
||||
//! can spawn subagents", which is what handing it the parent's route would
|
||||
//! have meant.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Filename the rendered config lives at, under [`crate::paths::harness_dir`].
|
||||
const CONFIG_FILE: &str = "subagent-mcp-config.json";
|
||||
|
||||
/// Render the subagent-eligible extra-MCP servers to a `--mcp-config` file,
|
||||
/// returning its path — or `None` when no entry opts in (or the render/write
|
||||
/// fails), so [`hive_claude::Config::mcp_config`] stays unset and the
|
||||
/// subagent gets literally zero MCP servers, the same default as before this
|
||||
/// toggle existed. Re-rendered on every call (cheap: a filter plus a small
|
||||
/// Name the signal surface appears under in a subagent's own MCP config,
|
||||
/// and therefore the prefix its tools are called by
|
||||
/// (`mcp__subagent_control__goal_reached`).
|
||||
const SIGNAL_SERVER: &str = "subagent_control";
|
||||
|
||||
/// Render a subagent's `--mcp-config` file, returning its path — or `None`
|
||||
/// when there is nothing to put in it (or the render/write fails), so
|
||||
/// [`hive_claude::Config::mcp_config`] stays unset and the subagent gets
|
||||
/// literally zero MCP servers.
|
||||
///
|
||||
/// `signal_url` is this daemon's own `goal_reached`/`need_help` route; it is
|
||||
/// always included when given, since a subagent that can't say it's done or
|
||||
/// stuck is exactly the one the turn cap has to stop on its behalf. `None`
|
||||
/// reproduces the pre-continuation shape — only the opted-in extras, and no
|
||||
/// file at all when none opt in.
|
||||
///
|
||||
/// Re-rendered on every call (cheap: a filter plus a small
|
||||
/// file write) rather than cached once at daemon startup, so a config change
|
||||
/// takes effect on this subagent's next `start`/`continue` without needing
|
||||
/// the daemon itself restarted.
|
||||
#[must_use]
|
||||
pub fn build() -> Option<PathBuf> {
|
||||
pub fn build(signal_url: Option<&str>) -> Option<PathBuf> {
|
||||
let state_dir = crate::paths::state_dir();
|
||||
let servers: serde_json::Map<String, serde_json::Value> =
|
||||
let mut servers: serde_json::Map<String, serde_json::Value> =
|
||||
hive_agent_sock::extra_mcp::load_extra_mcp()
|
||||
.into_iter()
|
||||
.filter(|(_, spec)| spec.available_to_subagents())
|
||||
.map(|(name, spec)| (name, spec.to_json_entry(&state_dir)))
|
||||
.collect();
|
||||
if let Some(url) = signal_url {
|
||||
servers.insert(
|
||||
SIGNAL_SERVER.to_owned(),
|
||||
serde_json::json!({ "type": "http", "url": url }),
|
||||
);
|
||||
}
|
||||
if servers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue