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
174 lines
5.6 KiB
Rust
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"
|
|
));
|
|
}
|
|
}
|