refactor(hive-ag3nt): split hive bin into hive-agent / hive-agent-mcp / hive-agent-wake
This commit is contained in:
parent
2486251b32
commit
5b062dca55
14 changed files with 182 additions and 144 deletions
43
hive-ag3nt/src/bin/hive-agent-mcp.rs
Normal file
43
hive-ag3nt/src/bin/hive-agent-mcp.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//! MCP-server binary. Default is stdio — spawned by `claude` via
|
||||
//! `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back into
|
||||
//! the hyperhive broker. Pass `--http <addr>` to instead run a long-lived
|
||||
//! streamable-http listener (persistent daemon, the `hive-mcp-http`
|
||||
//! systemd unit) that claude reconnects to each turn, avoiding the
|
||||
//! per-turn stdio re-registration race. Sibling of `hive-agent` (the
|
||||
//! serve loop that renders the `--mcp-config` blob pointing here) and
|
||||
//! `hive-agent-wake`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, mcp};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-agent-mcp", about = "hyperhive MCP server")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
/// Serve over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: Option<std::net::SocketAddr>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
match cli.http {
|
||||
Some(addr) => mcp::serve_http(cli.socket, addr).await,
|
||||
None => mcp::serve_agent_stdio(cli.socket).await,
|
||||
}
|
||||
}
|
||||
60
hive-ag3nt/src/bin/hive-agent-wake.rs
Normal file
60
hive-ag3nt/src/bin/hive-agent-wake.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Wake CLI: inject a wake-up event into this container's harness inbox
|
||||
//! so the next turn fires with the given body. Intended for extra MCP
|
||||
//! servers / helpers (scraper, webhook listener, etc.) that need to
|
||||
//! nudge claude on external events; the built-in daemons (matrix, bash)
|
||||
//! talk to the socket directly instead. Sibling of `hive-agent` and
|
||||
//! `hive-agent-mcp`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, client};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive-agent-wake", about = "hyperhive harness wake signal")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
|
||||
/// Body of the wake message. Pass `-` to read from stdin.
|
||||
#[arg(long)]
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let body = if cli.body == "-" {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
buf
|
||||
} else {
|
||||
cli.body
|
||||
};
|
||||
let resp: AgentResponse = client::request(
|
||||
&cli.socket,
|
||||
&AgentRequest::Wake {
|
||||
from: cli.from,
|
||||
body,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
match resp {
|
||||
AgentResponse::Ok => Ok(()),
|
||||
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
||||
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
//! Unified hyperhive harness binary. Dispatches one of three subcommands
|
||||
//! (`serve` / `mcp` / `wake`). There is one role: agent. The `Surface`
|
||||
//! Harness serve-loop binary. Long-polls the broker inbox and drives one
|
||||
//! claude turn per message. There is one role: agent. The `Surface`
|
||||
//! trait + `AgentSurface` zero-sized type tag keeps the turn loop
|
||||
//! generic and testable. Architecture lives in
|
||||
//! generic and testable. Siblings: `hive-agent-mcp` (the MCP server this
|
||||
//! loop points claude at) and `hive-agent-wake` (external wake CLI).
|
||||
//! Architecture lives in
|
||||
//! [`docs/turn-loop.md::Harness binary shape`](../../../docs/turn-loop.md).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
|
@ -9,58 +11,23 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use clap::Parser;
|
||||
use hive_ag3nt::events::{Bus, LiveEvent, TurnState};
|
||||
use hive_ag3nt::login::{self, LoginState};
|
||||
use hive_ag3nt::turn_stats::TurnStats;
|
||||
use hive_ag3nt::{
|
||||
DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, mcp, plugins, serve_common, turn, web_ui,
|
||||
};
|
||||
use hive_ag3nt::{DEFAULT_SOCKET, DEFAULT_WEB_PORT, client, plugins, serve_common, turn, web_ui};
|
||||
use hive_sh4re::{AgentRequest, AgentResponse, HelperEvent, SYSTEM_SENDER};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "hive", about = "hyperhive harness")]
|
||||
#[command(name = "hive-agent", about = "hyperhive harness serve loop")]
|
||||
struct Cli {
|
||||
/// Path to the per-agent MCP socket (bind-mounted from the host).
|
||||
#[arg(long, global = true, default_value = DEFAULT_SOCKET)]
|
||||
#[arg(long, default_value = DEFAULT_SOCKET)]
|
||||
socket: PathBuf,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Run the long-lived harness loop. Polls inbox; replies via
|
||||
/// `claude --print` when available.
|
||||
Serve {
|
||||
/// Inbox poll interval in milliseconds.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
},
|
||||
/// Run the MCP server. Default is stdio — spawned by `claude` via
|
||||
/// `--mcp-config`; tools dispatch through `/run/hive/mcp.sock` back
|
||||
/// into the hyperhive broker. Pass `--http <addr>` to instead run a
|
||||
/// long-lived streamable-http listener (persistent daemon) that
|
||||
/// claude reconnects to each turn, avoiding the per-turn stdio
|
||||
/// re-registration race.
|
||||
Mcp {
|
||||
/// Serve over streamable-http on this address (e.g.
|
||||
/// `127.0.0.1:8790`) instead of stdio. Bind loopback only.
|
||||
#[arg(long)]
|
||||
http: Option<std::net::SocketAddr>,
|
||||
},
|
||||
/// Inject a wake-up event into this harness's inbox so the next
|
||||
/// turn fires with the given body. Intended for extra MCP servers
|
||||
/// / helpers (matrix bridge, scraper, webhook listener, etc.) that
|
||||
/// need to nudge claude on external events.
|
||||
Wake {
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Body of the wake message. Pass `-` to read from stdin.
|
||||
#[arg(long)]
|
||||
body: String,
|
||||
},
|
||||
/// Inbox poll interval in milliseconds.
|
||||
#[arg(long, default_value_t = 1000)]
|
||||
poll_ms: u64,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -73,15 +40,7 @@ async fn main() -> Result<()> {
|
|||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.cmd {
|
||||
Cmd::Serve { poll_ms } => serve_main::<AgentSurface>(&cli.socket, poll_ms).await,
|
||||
Cmd::Mcp { http } => match http {
|
||||
Some(addr) => mcp::serve_http(cli.socket, addr).await,
|
||||
None => mcp::serve_agent_stdio(cli.socket).await,
|
||||
},
|
||||
Cmd::Wake { from, body } => wake::<AgentSurface>(&cli.socket, from, body).await,
|
||||
}
|
||||
serve_main::<AgentSurface>(&cli.socket, cli.poll_ms).await
|
||||
}
|
||||
|
||||
// ---------- shared turn helpers ----------
|
||||
|
|
@ -261,13 +220,6 @@ trait Surface {
|
|||
/// generic `serve_loop` doesn't need the per-role Response enum
|
||||
/// at all.
|
||||
fn recv_next(socket: &Path) -> impl Future<Output = RecvOutcome>;
|
||||
|
||||
/// External `wake` subcommand (the `hive wake` CLI command, used
|
||||
/// by co-process daemons like matrix to push events into the
|
||||
/// harness inbox). Errors out via `anyhow::bail!` so the calling
|
||||
/// binary surfaces them on stderr.
|
||||
fn wake_external(socket: &Path, from: String, body: String)
|
||||
-> impl Future<Output = Result<()>>;
|
||||
}
|
||||
|
||||
// ---------- AgentSurface ----------
|
||||
|
|
@ -383,16 +335,6 @@ impl Surface for AgentSurface {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wake_external(socket: &Path, from: String, body: String) -> Result<()> {
|
||||
let resp: AgentResponse =
|
||||
client::request(socket, &AgentRequest::Wake { from, body }).await?;
|
||||
match resp {
|
||||
AgentResponse::Ok => Ok(()),
|
||||
AgentResponse::Err { message } => anyhow::bail!("wake: {message}"),
|
||||
other => anyhow::bail!("wake: unexpected response {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- generic turn loop ----------
|
||||
|
|
@ -673,20 +615,6 @@ async fn handle_turn<S: Surface>(
|
|||
}
|
||||
}
|
||||
|
||||
/// External `hive wake` subcommand — push a message into our own
|
||||
/// inbox so the next turn fires with the given body. Reads the body
|
||||
/// from stdin when `body == "-"`.
|
||||
async fn wake<S: Surface>(socket: &Path, from: String, body: String) -> Result<()> {
|
||||
let body = if body == "-" {
|
||||
let mut buf = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf)?;
|
||||
buf
|
||||
} else {
|
||||
body
|
||||
};
|
||||
S::wake_external(socket, from, body).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod continue_tests {
|
||||
use super::{TurnControl, should_self_continue, synthetic_continue};
|
||||
|
|
@ -52,7 +52,7 @@ const TOKEN_RETRY_MAX: u32 = 20;
|
|||
/// claim it's "new" — see docs/forge.md, "new vs activity on".
|
||||
const NEW_ITEM_TOLERANCE_SECS: i64 = 120;
|
||||
|
||||
/// Spawn point: called once from `hive serve`. Returns immediately if the forge is not
|
||||
/// Spawn point: called once from the `hive-agent` serve loop. Returns immediately if the forge is not
|
||||
/// configured. Otherwise loops forever, polling every
|
||||
/// `POLL_INTERVAL_SECS` seconds. Errors are never fatal.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Shared in-container harness code for the single `hive` binary that
|
||||
//! serves every agent role (the manager is just an agent role, not a
|
||||
//! separate binary).
|
||||
//! Shared in-container harness code for the sibling `hive-agent` /
|
||||
//! `hive-agent-mcp` / `hive-agent-wake` binaries, which serve every
|
||||
//! agent role (the manager is just an agent role, not a separate set
|
||||
//! of binaries).
|
||||
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
|
|
|
|||
|
|
@ -330,13 +330,13 @@ fn load_extra_mcp() -> std::collections::BTreeMap<String, ExtraMcpServer> {
|
|||
}
|
||||
|
||||
/// Render the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// `agent_binary` is the path (or PATH-resolvable name) of the `hive-ag3nt`
|
||||
/// executable; `socket` is the hyperhive per-agent socket bind-mounted into
|
||||
/// the container (forwarded to the child as `--socket <path>`). Merges in
|
||||
/// any extra MCP servers declared via `hyperhive.extraMcpServers` in the
|
||||
/// agent's NixOS config.
|
||||
/// `mcp_binary` is the path (or PATH-resolvable name) of the
|
||||
/// `hive-agent-mcp` bridge executable; `socket` is the hyperhive per-agent
|
||||
/// socket bind-mounted into the container (forwarded to the child as
|
||||
/// `--socket <path>`). Merges in any extra MCP servers declared via
|
||||
/// `hyperhive.extraMcpServers` in the agent's NixOS config.
|
||||
#[must_use]
|
||||
pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> String {
|
||||
pub fn render_claude_config(mcp_binary: &str, socket: &std::path::Path) -> String {
|
||||
let mut servers = serde_json::Map::new();
|
||||
// When the harness is configured to run the built-in server as a
|
||||
// persistent streamable-http daemon (loopback port in
|
||||
|
|
@ -353,8 +353,8 @@ pub fn render_claude_config(agent_binary: &str, socket: &std::path::Path) -> Str
|
|||
"url": format!("http://127.0.0.1:{port}/mcp"),
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"command": agent_binary,
|
||||
"args": ["--socket", socket.display().to_string(), "mcp"],
|
||||
"command": mcp_binary,
|
||||
"args": ["--socket", socket.display().to_string()],
|
||||
"env": {}
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//! Pure helpers factored out of the `hive` serve loop (`bin/hive.rs`).
|
||||
//! Pure helpers factored out of the harness serve loop
|
||||
//! (`bin/hive-agent.rs`).
|
||||
//! Only functions with no wire-type dependency live here;
|
||||
//! request/response-flavored helpers (`requeue_inflight`, `ack_turn`, etc.)
|
||||
//! stay in the binary because they touch the request enum variants directly.
|
||||
|
|
@ -60,7 +61,7 @@ pub struct TurnRowArgs<'a> {
|
|||
}
|
||||
|
||||
/// Assemble a `TurnStatRow` from the harness's per-turn state. Lives here
|
||||
/// (rather than inline in the `hive` serve loop) so it stays wire-type-free
|
||||
/// (rather than inline in the serve loop) so it stays wire-type-free
|
||||
/// and unit-testable; the binary just feeds it the post-turn counts.
|
||||
#[must_use]
|
||||
pub fn build_row(args: TurnRowArgs<'_>) -> TurnStatRow {
|
||||
|
|
|
|||
|
|
@ -108,8 +108,9 @@ impl TurnFiles {
|
|||
|
||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||
/// as `--socket <path>`). The MCP subcommand is always `mcp` on the single
|
||||
/// `hive` binary resolved from `/proc/self/exe`.
|
||||
/// as `--socket <path>`). The MCP server is the `hive-agent-mcp` binary
|
||||
/// installed next to the running `hive-agent` (resolved as a sibling of
|
||||
/// `/proc/self/exe`; PATH-resolvable name as the fallback).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
|
@ -120,7 +121,8 @@ pub async fn write_mcp_config(socket: &Path) -> Result<PathBuf> {
|
|||
let path = parent.join("claude-mcp-config.json");
|
||||
let exe = std::env::current_exe()
|
||||
.ok()
|
||||
.map_or_else(|| "hive".into(), |p| p.display().to_string());
|
||||
.and_then(|p| Some(p.parent()?.join("hive-agent-mcp")))
|
||||
.map_or_else(|| "hive-agent-mcp".into(), |p| p.display().to_string());
|
||||
let body = mcp_config::render_claude_config(&exe, socket);
|
||||
tokio::fs::write(&path, body).await?;
|
||||
tracing::info!(path = %path.display(), "wrote claude MCP config");
|
||||
|
|
@ -146,7 +148,7 @@ pub async fn write_system_prompt(socket: &Path, label: &str) -> Result<PathBuf>
|
|||
/// `result_kind = "compacted"` in turn stats so the stats page can distinguish
|
||||
/// those turns. Both `Ok(true)` and `Ok(false)` are ack'd; the error cases
|
||||
/// each map to a distinct serve-loop action (see [`emit_turn_end`] and the
|
||||
/// `hive` serve loop).
|
||||
/// `hive-agent` serve loop).
|
||||
pub type TurnOutcome = std::result::Result<bool, TurnError>;
|
||||
|
||||
/// The ways a turn can end without a usable result. Each is deliberately *not*
|
||||
|
|
@ -287,7 +289,7 @@ pub fn make_session(bus: &Bus) -> AgentSession {
|
|||
/// the whole turn is retried a single time before bubbling `AuthFailed` to
|
||||
/// the serve loop (which parks for re-login).
|
||||
///
|
||||
/// Called once per turn by the `hive` serve loop, which owns the shared
|
||||
/// Called once per turn by the `hive-agent` serve loop, which owns the shared
|
||||
/// `session` ([`make_session`]) and threads it in.
|
||||
pub async fn drive_turn(
|
||||
prompt: &str,
|
||||
|
|
|
|||
Loading…
Reference in a new issue