hive-runtime: shared runtime crate with claude and acp backends
A `Runtime` trait (run / compact / archive) with two backends: - claude: a pass-through to hive_claude's InfiniteSession and SessionStore, so a claude turn is the same spawn, session handling and errors as before. - acp: a generic Agent Client Protocol client. It spawns the command, args and env from RuntimeSpec (HIVE_RUNTIME / HIVE_ACP_COMMAND / HIVE_ACP_ARGS / HIVE_ACP_ENV), refuses an agent whose mcpCapabilities.http is not true, passes the claude --mcp-config servers as ACP mcpServers, keeps one session id in a file (session/load after a restart, session/new otherwise), and maps session/update into claude stream-json events plus usage_update into Telemetry. Permission requests are answered by a caller-supplied policy on the ACP tool kind. compact returns Unsupported for now. The crate depends on no hyperhive binary crate, so the subagent daemon can move onto it without pulling in hive-agent. Refs #4391
This commit is contained in:
parent
d98d407bf1
commit
b0e26e7e44
10 changed files with 1583 additions and 0 deletions
365
hive-runtime/src/acp/mod.rs
Normal file
365
hive-runtime/src/acp/mod.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
//! The ACP backend: one long-lived agent process per runtime, spawned on the
|
||||
//! first turn, holding one durable session whose id is kept in a file.
|
||||
|
||||
mod rpc;
|
||||
mod stream;
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use hive_claude::{Config, Progress, Sink};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use self::rpc::{Connection, Incoming};
|
||||
use self::stream::StreamMapper;
|
||||
use crate::spec::AcpCommand;
|
||||
use crate::{Error, Result, Runtime};
|
||||
|
||||
/// ACP protocol version this client speaks.
|
||||
const PROTOCOL_VERSION: u64 = 1;
|
||||
|
||||
/// Stderr lines kept for the error when the agent exits mid-turn.
|
||||
const STDERR_TAIL: usize = 20;
|
||||
|
||||
/// After a turn's reply, how long the agent must stay quiet before the turn
|
||||
/// is taken to be over, and the most that wait may take in all.
|
||||
const SETTLE_QUIET: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
const SETTLE_MAX: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// Decides the agent's `session/request_permission` requests from the tool
|
||||
/// call's ACP `kind` (`read`, `edit`, `execute`, `fetch`, …; `other` when the
|
||||
/// agent gives none). `true` allows the call once, `false` rejects it.
|
||||
pub type PermissionPolicy = Arc<dyn Fn(&str) -> bool + Send + Sync>;
|
||||
|
||||
/// Why an ACP operation failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum AcpError {
|
||||
#[error("failed to spawn ACP agent `{program}`: {source}")]
|
||||
Spawn {
|
||||
program: String,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("ACP agent closed its output (exited?)")]
|
||||
Closed,
|
||||
#[error("ACP agent exited mid-turn; last stderr:\n{stderr_tail}")]
|
||||
Exited { stderr_tail: String },
|
||||
#[error("ACP agent i/o failed: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("ACP `{method}` failed: {message} (code {code}) {data}")]
|
||||
Rpc {
|
||||
method: &'static str,
|
||||
code: i64,
|
||||
message: String,
|
||||
data: String,
|
||||
},
|
||||
#[error("ACP `{method}` response has no {field}")]
|
||||
Malformed {
|
||||
method: &'static str,
|
||||
field: &'static str,
|
||||
},
|
||||
#[error(
|
||||
"the ACP agent does not accept MCP servers over HTTP (`mcpCapabilities.http` is not true), \
|
||||
and the hyperhive tools are only served over HTTP"
|
||||
)]
|
||||
NoHttpMcp,
|
||||
#[error("reading the MCP config {path}: {detail}")]
|
||||
McpConfig { path: PathBuf, detail: String },
|
||||
}
|
||||
|
||||
/// Turns on an ACP agent's durable session.
|
||||
///
|
||||
/// From the [`Config`] it reads `cwd` (the agent's working directory),
|
||||
/// `mcp_config` (converted to the session's `mcpServers`) and
|
||||
/// `system_prompt_file` (prepended to the first prompt of each new session,
|
||||
/// since ACP has no system prompt). Everything else in it is claude's and is
|
||||
/// ignored, including `idle_timeout`.
|
||||
pub struct AcpRuntime {
|
||||
command: AcpCommand,
|
||||
session_file: PathBuf,
|
||||
permit: PermissionPolicy,
|
||||
live: Mutex<Option<Live>>,
|
||||
}
|
||||
|
||||
/// The running agent process and the session loaded into it.
|
||||
struct Live {
|
||||
conn: Connection,
|
||||
load_session: bool,
|
||||
session: Option<String>,
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
impl AcpRuntime {
|
||||
/// A runtime spawning `command`, keeping its session id in
|
||||
/// `session_file`, and answering its permission requests with `permit`.
|
||||
#[must_use]
|
||||
pub fn new(command: AcpCommand, session_file: PathBuf, permit: PermissionPolicy) -> Self {
|
||||
Self {
|
||||
command,
|
||||
session_file,
|
||||
permit,
|
||||
live: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn start(&self, cwd: &Path) -> Result<Live> {
|
||||
let conn = Connection::spawn(&self.command, cwd, self.permit.clone())?;
|
||||
let init = conn
|
||||
.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"clientCapabilities": {
|
||||
"fs": { "readTextFile": false, "writeTextFile": false },
|
||||
"terminal": false,
|
||||
},
|
||||
"clientInfo": { "name": "hyperhive", "version": env!("CARGO_PKG_VERSION") },
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let caps = &init["agentCapabilities"];
|
||||
if caps["mcpCapabilities"]["http"] != Value::Bool(true) {
|
||||
return Err(AcpError::NoHttpMcp.into());
|
||||
}
|
||||
tracing::info!(agent = %init["agentInfo"], "ACP agent initialized");
|
||||
Ok(Live {
|
||||
conn,
|
||||
load_session: caps["loadSession"] == Value::Bool(true),
|
||||
session: None,
|
||||
model: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Attach the durable session: the one already loaded, else the one named
|
||||
/// by the session file, else a new one. Returns whether it is new.
|
||||
async fn attach(&self, live: &mut Live, cwd: &Path, servers: &[Value]) -> Result<bool> {
|
||||
let persisted = std::fs::read_to_string(&self.session_file)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_owned())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(id) = persisted {
|
||||
if live.session.as_deref() == Some(id.as_str()) {
|
||||
return Ok(false);
|
||||
}
|
||||
if live.load_session {
|
||||
let params = json!({ "sessionId": id, "cwd": cwd, "mcpServers": servers });
|
||||
match live.conn.request("session/load", params).await {
|
||||
Ok(response) => {
|
||||
discard_stale(&mut live.conn)?;
|
||||
live.model = stream::session_model(&response);
|
||||
live.session = Some(id);
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e @ AcpError::Rpc { .. }) => {
|
||||
tracing::warn!(error = %e, "ACP session/load failed; starting a new session");
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
let params = json!({ "cwd": cwd, "mcpServers": servers });
|
||||
let response = live.conn.request("session/new", params).await?;
|
||||
let id = response["sessionId"]
|
||||
.as_str()
|
||||
.ok_or(AcpError::Malformed {
|
||||
method: "session/new",
|
||||
field: "sessionId",
|
||||
})?
|
||||
.to_owned();
|
||||
if let Some(dir) = self.session_file.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(AcpError::Io)?;
|
||||
}
|
||||
std::fs::write(&self.session_file, &id).map_err(AcpError::Io)?;
|
||||
live.model = stream::session_model(&response);
|
||||
live.session = Some(id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn turn(
|
||||
&self,
|
||||
live: &mut Live,
|
||||
config: &Config,
|
||||
prompt: &str,
|
||||
sink: &impl Sink,
|
||||
) -> Result<Progress> {
|
||||
let cwd = session_cwd(config);
|
||||
let (servers, names) = mcp_servers(config)?;
|
||||
let created = self.attach(live, &cwd, &servers).await?;
|
||||
let session = live.session.clone().unwrap_or_default();
|
||||
let text = match (created, &config.system_prompt_file) {
|
||||
(true, Some(path)) => match std::fs::read_to_string(path) {
|
||||
Ok(system) => format!("{system}\n\n{prompt}"),
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %path.display(), error = %e, "system prompt unreadable");
|
||||
prompt.to_owned()
|
||||
}
|
||||
},
|
||||
_ => prompt.to_owned(),
|
||||
};
|
||||
let params = json!({ "sessionId": session, "prompt": [{ "type": "text", "text": text }] });
|
||||
discard_stale(&mut live.conn)?;
|
||||
let mut reply = live.conn.send("session/prompt", params).await?;
|
||||
let mut mapper = StreamMapper::new(names);
|
||||
let mut stderr_tail = VecDeque::new();
|
||||
let response = loop {
|
||||
tokio::select! {
|
||||
// Notifications first: the ones sent before the reply belong
|
||||
// to this turn and must reach the sink before it ends.
|
||||
biased;
|
||||
incoming = live.conn.incoming.recv() => {
|
||||
let incoming = incoming.unwrap_or(Incoming::Closed);
|
||||
if !deliver(incoming, &session, &mut mapper, &mut stderr_tail, sink) {
|
||||
let stderr_tail = Vec::from(stderr_tail).join("\n");
|
||||
return Err(AcpError::Exited { stderr_tail }.into());
|
||||
}
|
||||
}
|
||||
reply = &mut reply => break reply.unwrap_or(Err(AcpError::Closed))?,
|
||||
}
|
||||
};
|
||||
// Updates may still arrive after the reply: an agent can forward its
|
||||
// stream asynchronously. Take them until the agent goes quiet.
|
||||
let settled = tokio::time::Instant::now() + SETTLE_MAX;
|
||||
while let Ok(Some(incoming)) = tokio::time::timeout_at(
|
||||
settled.min(tokio::time::Instant::now() + SETTLE_QUIET),
|
||||
live.conn.incoming.recv(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
if !deliver(incoming, &session, &mut mapper, &mut stderr_tail, sink) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for event in mapper.finish() {
|
||||
sink.on_event(&event);
|
||||
}
|
||||
match response["stopReason"].as_str() {
|
||||
Some("end_turn") => {}
|
||||
reason => {
|
||||
let reason = reason.unwrap_or("none");
|
||||
tracing::warn!(reason, "ACP turn stopped early");
|
||||
sink.on_stderr_line(&format!("ACP turn stopped: {reason}"));
|
||||
}
|
||||
}
|
||||
let mut progress = Progress::default();
|
||||
progress.created = created;
|
||||
progress.telemetry = mapper.telemetry(&response, live.model.as_deref());
|
||||
Ok(progress)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runtime for AcpRuntime {
|
||||
async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> {
|
||||
let mut guard = self.live.lock().await;
|
||||
if let Some(live) = guard.as_mut()
|
||||
&& live.conn.exited()
|
||||
{
|
||||
tracing::warn!("ACP agent has exited; respawning");
|
||||
*guard = None;
|
||||
}
|
||||
let live = match guard.as_mut() {
|
||||
Some(live) => live,
|
||||
None => guard.insert(self.start(&session_cwd(config)).await?),
|
||||
};
|
||||
let result = self.turn(live, config, prompt, sink).await;
|
||||
if let Err(Error::Acp(
|
||||
AcpError::Closed
|
||||
| AcpError::Exited { .. }
|
||||
| AcpError::Io(_)
|
||||
| AcpError::Malformed { .. },
|
||||
)) = &result
|
||||
{
|
||||
*guard = None;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn compact(&self, _config: &Config, _sink: &impl Sink) -> Result<()> {
|
||||
Err(Error::Unsupported("compact"))
|
||||
}
|
||||
|
||||
/// Moves the session file aside; the agent keeps its own copy of the
|
||||
/// session, so nothing is lost.
|
||||
fn archive(&self) -> Result<Option<PathBuf>> {
|
||||
if !self.session_file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_secs());
|
||||
let mut archived = self.session_file.clone().into_os_string();
|
||||
archived.push(format!(".{stamp}.archived"));
|
||||
let archived = PathBuf::from(archived);
|
||||
std::fs::rename(&self.session_file, &archived).map_err(AcpError::Io)?;
|
||||
Ok(Some(archived))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand one incoming item to the sink. Returns `false` once the agent has
|
||||
/// closed its output.
|
||||
fn deliver(
|
||||
incoming: Incoming,
|
||||
session: &str,
|
||||
mapper: &mut StreamMapper,
|
||||
stderr_tail: &mut VecDeque<String>,
|
||||
sink: &impl Sink,
|
||||
) -> bool {
|
||||
match incoming {
|
||||
Incoming::Update(params) => {
|
||||
if params["sessionId"].as_str() == Some(session) {
|
||||
for event in mapper.push(¶ms["update"]) {
|
||||
sink.on_event(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
Incoming::Stdout(line) => sink.on_stdout_line(&line),
|
||||
Incoming::Stderr(line) => {
|
||||
sink.on_stderr_line(&line);
|
||||
if stderr_tail.len() == STDERR_TAIL {
|
||||
stderr_tail.pop_front();
|
||||
}
|
||||
stderr_tail.push_back(line);
|
||||
}
|
||||
Incoming::Closed => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop what the agent sent outside a turn: the history a `session/load`
|
||||
/// replays (the caller already has it), and anything that arrived after the
|
||||
/// previous turn settled, which must not be shown as part of the next one.
|
||||
fn discard_stale(conn: &mut Connection) -> std::result::Result<(), AcpError> {
|
||||
while let Ok(incoming) = conn.incoming.try_recv() {
|
||||
match incoming {
|
||||
Incoming::Update(_) => {}
|
||||
Incoming::Stdout(line) | Incoming::Stderr(line) => {
|
||||
tracing::info!(line = %line, "ACP agent output between turns");
|
||||
}
|
||||
Incoming::Closed => return Err(AcpError::Closed),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn session_cwd(config: &Config) -> PathBuf {
|
||||
config
|
||||
.cwd
|
||||
.clone()
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn mcp_servers(config: &Config) -> Result<(Vec<Value>, Vec<String>)> {
|
||||
let Some(path) = &config.mcp_config else {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
};
|
||||
let fail = |detail: String| AcpError::McpConfig {
|
||||
path: path.clone(),
|
||||
detail,
|
||||
};
|
||||
let raw = std::fs::read_to_string(path).map_err(|e| fail(e.to_string()))?;
|
||||
let parsed: Value = serde_json::from_str(&raw).map_err(|e| fail(e.to_string()))?;
|
||||
Ok(stream::mcp_servers(&parsed))
|
||||
}
|
||||
Loading…
Reference in a new issue