Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-runtime/src/spec.rs
atlas b0e26e7e44 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
2026-09-29 22:29:36 +02:00

174 lines
5.6 KiB
Rust

//! Which runtime an agent runs, as read from its environment.
use std::collections::BTreeMap;
/// Selects the runtime: `claude` (also when unset) or `acp`.
pub const RUNTIME_ENV: &str = "HIVE_RUNTIME";
/// The ACP agent's program, spawned as-is (a path or a name on `PATH`).
pub const ACP_COMMAND_ENV: &str = "HIVE_ACP_COMMAND";
/// The ACP agent's arguments, as a JSON array of strings. Optional.
pub const ACP_ARGS_ENV: &str = "HIVE_ACP_ARGS";
/// Extra environment for the ACP agent only, as a JSON object of strings.
/// Optional. The agent also inherits the harness's own environment.
pub const ACP_ENV_ENV: &str = "HIVE_ACP_ENV";
/// The runtime an agent is configured with.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeSpec {
Claude,
Acp(AcpCommand),
}
/// How to spawn an ACP agent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcpCommand {
pub command: String,
pub args: Vec<String>,
pub env: BTreeMap<String, String>,
}
/// A runtime configuration that cannot be acted on.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SpecError {
#[error("{RUNTIME_ENV}={0:?} names no runtime (expected \"claude\" or \"acp\")")]
UnknownRuntime(String),
#[error("{RUNTIME_ENV}=acp but {ACP_COMMAND_ENV} is unset or empty")]
MissingCommand,
#[error("{var} is not valid JSON of the expected shape: {source}")]
Malformed {
var: &'static str,
#[source]
source: serde_json::Error,
},
}
impl RuntimeSpec {
/// Read the spec from the process environment.
pub fn from_env() -> Result<Self, SpecError> {
Self::parse(|var| std::env::var(var).ok())
}
/// Build the spec from `lookup`, which returns an env var's value.
pub fn parse(lookup: impl Fn(&str) -> Option<String>) -> Result<Self, SpecError> {
let runtime = lookup(RUNTIME_ENV).unwrap_or_default();
match runtime.trim() {
"" | "claude" => Ok(Self::Claude),
"acp" => {
let command = lookup(ACP_COMMAND_ENV).unwrap_or_default();
if command.trim().is_empty() {
return Err(SpecError::MissingCommand);
}
Ok(Self::Acp(AcpCommand {
command,
args: json_or_default(&lookup, ACP_ARGS_ENV)?,
env: json_or_default(&lookup, ACP_ENV_ENV)?,
}))
}
other => Err(SpecError::UnknownRuntime(other.to_owned())),
}
}
}
fn json_or_default<T: Default + serde::de::DeserializeOwned>(
lookup: &impl Fn(&str) -> Option<String>,
var: &'static str,
) -> Result<T, SpecError> {
match lookup(var) {
Some(raw) if !raw.trim().is_empty() => {
serde_json::from_str(&raw).map_err(|source| SpecError::Malformed { var, source })
}
_ => Ok(T::default()),
}
}
#[cfg(test)]
mod tests {
use super::{AcpCommand, RuntimeSpec, SpecError};
use std::collections::HashMap;
fn parse(vars: &[(&str, &str)]) -> Result<RuntimeSpec, SpecError> {
let vars: HashMap<String, String> = vars
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
RuntimeSpec::parse(|k| vars.get(k).cloned())
}
#[test]
fn unset_or_claude_is_the_claude_runtime() {
assert_eq!(parse(&[]).unwrap(), RuntimeSpec::Claude);
assert_eq!(
parse(&[("HIVE_RUNTIME", "claude")]).unwrap(),
RuntimeSpec::Claude
);
// ACP settings alone do not switch the runtime.
assert_eq!(
parse(&[("HIVE_ACP_COMMAND", "/bin/agent")]).unwrap(),
RuntimeSpec::Claude
);
}
#[test]
fn acp_resolves_command_args_and_env() {
let spec = parse(&[
("HIVE_RUNTIME", "acp"),
("HIVE_ACP_COMMAND", "/nix/store/x/bin/agent"),
("HIVE_ACP_ARGS", r#"["acp","--flag"]"#),
(
"HIVE_ACP_ENV",
r#"{"AGENT_CONFIG":"/nix/store/y/config.json"}"#,
),
])
.unwrap();
assert_eq!(
spec,
RuntimeSpec::Acp(AcpCommand {
command: "/nix/store/x/bin/agent".into(),
args: vec!["acp".into(), "--flag".into()],
env: [("AGENT_CONFIG".into(), "/nix/store/y/config.json".into())].into(),
})
);
}
#[test]
fn acp_args_and_env_are_optional() {
let spec = parse(&[("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", "agent")]).unwrap();
let RuntimeSpec::Acp(cmd) = spec else {
panic!("expected acp, got {spec:?}");
};
assert!(cmd.args.is_empty());
assert!(cmd.env.is_empty());
}
#[test]
fn acp_without_a_command_is_refused() {
assert!(matches!(
parse(&[("HIVE_RUNTIME", "acp")]),
Err(SpecError::MissingCommand)
));
assert!(matches!(
parse(&[("HIVE_RUNTIME", "acp"), ("HIVE_ACP_COMMAND", " ")]),
Err(SpecError::MissingCommand)
));
}
#[test]
fn malformed_json_names_the_variable() {
let err = parse(&[
("HIVE_RUNTIME", "acp"),
("HIVE_ACP_COMMAND", "agent"),
("HIVE_ACP_ARGS", r#"{"not":"a list"}"#),
])
.unwrap_err();
assert!(err.to_string().starts_with("HIVE_ACP_ARGS "), "{err}");
}
#[test]
fn an_unknown_runtime_is_refused_rather_than_defaulted() {
assert!(matches!(
parse(&[("HIVE_RUNTIME", "bogus")]),
Err(SpecError::UnknownRuntime(r)) if r == "bogus"
));
}
}