Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 124 additions & 3 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,62 @@ fn build_client_capabilities() -> serde_json::Value {
})
}

/// Hermes performs substantially more Python/module initialization than the
/// lightweight ACP adapters: live probes on this machine completed in roughly
/// 13–21 seconds. Keep the fast fail for every other harness while giving
/// Hermes enough cold-start headroom to return its native ACP model catalog.
pub(crate) fn model_probe_timeout_for_agent(agent_command: &str) -> std::time::Duration {
match crate::config::normalize_agent_command_identity(agent_command).as_str() {
"hermes" | "hermes-agent" => std::time::Duration::from_secs(45),
_ => crate::MODELS_TIMEOUT,
}
}

/// Environment overrides required when Buzz owns a Hermes ACP session.
///
/// Hermes normally starts every configured MCP server before entering its ACP
/// JSON-RPC loop. Buzz passes the session's MCP servers explicitly through
/// `session/new` (an empty list when none are configured), so unrelated global
/// Hermes MCP startup must not block either discovery or a managed session.
/// The marker is Hermes-specific; all other ACP runtimes are unchanged.
pub(crate) fn acp_env_for_agent(agent_command: &str) -> Vec<(String, String)> {
match crate::config::normalize_agent_command_identity(agent_command).as_str() {
"hermes" | "hermes-agent" => vec![(
"HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(),
"1".to_string(),
)],
_ => Vec::new(),
}
}

/// Build the OS command that hosts an ACP runtime.
///
/// Hermes's installer exposes a Bash launcher that `exec`s its Python entry
/// point. On Unix/macOS that entry point stops servicing stdio when the launcher
/// itself is made the process-group leader. Keep a non-execing shell supervisor
/// as the group leader instead; Hermes remains a child in the same group, so
/// `killpg` still cleans up Hermes and every tool/MCP descendant.
fn build_agent_spawn_command(command: &str, args: &[String]) -> std::process::Command {
#[cfg(unix)]
if matches!(
crate::config::normalize_agent_command_identity(command).as_str(),
"hermes" | "hermes-agent"
) {
let mut supervised = std::process::Command::new("/bin/sh");
supervised
.arg("-c")
.arg("\"$@\"; status=$?; exit \"$status\"")
.arg("buzz-acp-hermes-supervisor")
.arg(command)
.args(args);
return supervised;
}

let mut direct = std::process::Command::new(command);
direct.args(args);
direct
}

impl AcpClient {
/// Kill the agent subprocess and wait for it to exit (no zombies).
///
Expand Down Expand Up @@ -413,9 +469,8 @@ impl AcpClient {
) -> Result<Self, AcpError> {
use std::process::Stdio;

let mut cmd = tokio::process::Command::new(command);
cmd.args(args)
.stdin(Stdio::piped())
let mut cmd = tokio::process::Command::from(build_agent_spawn_command(command, args));
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
// Inherit stderr so agent logs are visible in the harness terminal.
.stderr(Stdio::inherit())
Expand Down Expand Up @@ -460,6 +515,16 @@ impl AcpClient {
cmd.env("CODEX_CONFIG", merged);
}

// Runtime-specific ACP host environment. Applied to every spawn path
// (probes and managed sessions) so runtime isolation cannot silently
// depend on which caller created the client. Operator precedence still
// wins: an explicitly exported value is never overwritten.
for (key, value) in acp_env_for_agent(command) {
if std::env::var(&key).is_err() {
cmd.env(&key, &value);
}
}

// Spawn the agent in its own process group so SIGKILL doesn't propagate
// to the harness's own process group on Unix.
// tokio::process::Command::process_group is a stable tokio API (no extra imports needed).
Expand Down Expand Up @@ -2008,6 +2073,62 @@ fn configure_no_window(cmd: &mut tokio::process::Command) {
mod tests {
use super::*;

#[cfg(unix)]
#[test]
fn hermes_spawn_command_keeps_a_supervisor_as_process_group_leader() {
let args = vec!["acp".to_string()];
let command = build_agent_spawn_command("/Users/test/.local/bin/hermes", &args);
let actual_args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();

assert_eq!(command.get_program(), "/bin/sh");
assert_eq!(
actual_args,
vec![
"-c",
"\"$@\"; status=$?; exit \"$status\"",
"buzz-acp-hermes-supervisor",
"/Users/test/.local/bin/hermes",
"acp",
]
);
}

#[test]
fn hermes_runtime_gets_the_configured_mcp_skip_marker() {
let env = acp_env_for_agent("/Users/test/.local/bin/hermes");
assert_eq!(
env,
vec![(
"HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(),
"1".to_string()
)]
);
}

#[test]
fn non_hermes_runtimes_get_no_extra_acp_env() {
assert!(acp_env_for_agent("codex-acp").is_empty());
assert!(acp_env_for_agent("/opt/bin/goose").is_empty());
}

#[test]
fn hermes_probe_budget_exceeds_the_default_and_others_keep_it() {
let hermes = model_probe_timeout_for_agent("hermes");
let other = model_probe_timeout_for_agent("codex-acp");

// Contract: Hermes needs a strictly larger cold-start budget than the
// shared default, and every other runtime keeps the fast-fail default.
assert!(hermes > other);
assert_eq!(other, crate::MODELS_TIMEOUT);
assert_eq!(
model_probe_timeout_for_agent("/Users/test/.local/bin/hermes-agent"),
hermes
);
}

#[test]
fn stop_reason_parses_all_known_values() {
assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn));
Expand Down
25 changes: 25 additions & 0 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
"goose" => Some(vec!["acp".to_string()]),
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
| "claudecode" | "buzz-agent" => Some(Vec::new()),
"hermes" | "hermes-agent" => Some(vec!["acp".to_string()]),
_ => None,
}
}
Expand Down Expand Up @@ -1530,6 +1531,30 @@ mod tests {
);
}

#[test]
fn hermes_defaults_to_acp_arg() {
assert_eq!(default_agent_args("hermes"), Some(vec!["acp".to_string()]));
assert_eq!(
default_agent_args("hermes-agent"),
Some(vec!["acp".to_string()])
);
// Path-qualified and case variants normalize correctly.
assert_eq!(
default_agent_args("/usr/local/bin/hermes"),
Some(vec!["acp".to_string()])
);
assert_eq!(default_agent_args("Hermes"), Some(vec!["acp".to_string()]));
}

#[test]
fn hermes_not_treated_as_codex_for_network_env() {
assert_eq!(codex_network_env("hermes", "ws://localhost:3000"), None);
assert_eq!(
codex_network_env("hermes-agent", "ws://localhost:3000"),
None
);
}

// --- codex_network_env tests ---

const CODEX_CONFIG_JSON: &str = "{\"sandbox_workspace_write\":{\"network_access\":true}}";
Expand Down
48 changes: 41 additions & 7 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,43 @@ fn is_subcommand(name: &str) -> bool {
}

/// Timeout for lightweight helper subcommands (spawn + initialize + model/method probes).
const MODELS_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) const MODELS_TIMEOUT: Duration = Duration::from_secs(10);

/// Timeout for `buzz-acp authenticate`. Browser-based vendor auth can require
/// human interaction, so it must not share the short probe timeout.
const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60);

#[cfg(test)]
mod model_probe_timeout_tests {
use super::*;
use crate::acp::{acp_env_for_agent, model_probe_timeout_for_agent};

#[test]
fn hermes_gets_a_cold_start_model_probe_budget() {
assert_eq!(
model_probe_timeout_for_agent("hermes"),
Duration::from_secs(45)
);
assert_eq!(
model_probe_timeout_for_agent("/Users/test/.local/bin/hermes-agent"),
Duration::from_secs(45)
);
assert_eq!(model_probe_timeout_for_agent("codex-acp"), MODELS_TIMEOUT);
}

#[test]
fn hermes_acp_sessions_skip_unrelated_configured_mcp_startup() {
assert_eq!(
acp_env_for_agent("/Users/test/.local/bin/hermes"),
vec![(
"HERMES_ACP_SKIP_CONFIGURED_MCP".to_string(),
"1".to_string()
)]
);
assert!(acp_env_for_agent("codex-acp").is_empty());
}
}

/// Publish a kind:20001 presence update event via the WebSocket connection.
///
/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence
Expand Down Expand Up @@ -3853,6 +3884,7 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec<serde_json::Valu

/// `buzz-acp auth-methods` — spawn an adapter, initialize it, print authMethods.
async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {
let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command);
let mut client = match spawn_auth_client(&args.agent).await {
Ok(c) => c,
Err(e) => {
Expand All @@ -3861,7 +3893,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {
}
};

let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await {
let init_result = match tokio::time::timeout(probe_timeout, client.initialize()).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => {
client.shutdown().await;
Expand All @@ -3870,7 +3902,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {
}
Err(_) => {
client.shutdown().await;
eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})");
eprintln!("error: agent timed out ({probe_timeout:?})");
std::process::exit(1);
}
};
Expand Down Expand Up @@ -3901,6 +3933,7 @@ async fn run_auth_methods(args: AuthMethodsArgs) -> Result<()> {

/// `buzz-acp authenticate` — invoke one adapter-owned auth method.
async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command);
let mut client = match spawn_auth_client(&args.agent).await {
Ok(c) => c,
Err(e) => {
Expand All @@ -3909,7 +3942,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
}
};

let init_result = match tokio::time::timeout(MODELS_TIMEOUT, client.initialize()).await {
let init_result = match tokio::time::timeout(probe_timeout, client.initialize()).await {
Ok(Ok(result)) => result,
Ok(Err(e)) => {
client.shutdown().await;
Expand All @@ -3918,7 +3951,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
}
Err(_) => {
client.shutdown().await;
eprintln!("error: agent initialize timed out ({MODELS_TIMEOUT:?})");
eprintln!("error: agent initialize timed out ({probe_timeout:?})");
std::process::exit(1);
}
};
Expand Down Expand Up @@ -3961,6 +3994,7 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> {
async fn run_models(args: ModelsArgs) -> Result<()> {
use acp::{extract_model_config_options, extract_model_state};

let probe_timeout = acp::model_probe_timeout_for_agent(&args.agent.agent_command);
let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args);
let cwd = std::env::current_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("/"))
Expand All @@ -3980,7 +4014,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> {

// Initialize + session/new under a timeout. Client is owned above,
// so shutdown() runs on all paths (success, error, timeout).
let protocol_result = tokio::time::timeout(MODELS_TIMEOUT, async {
let protocol_result = tokio::time::timeout(probe_timeout, async {
let init = client.initialize().await?;
let session = client.session_new_full(&cwd, vec![], None).await?;
Ok::<_, acp::AcpError>((init, session))
Expand All @@ -3996,7 +4030,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> {
}
Err(_) => {
client.shutdown().await;
eprintln!("error: agent timed out ({MODELS_TIMEOUT:?})");
eprintln!("error: agent timed out ({probe_timeout:?})");
std::process::exit(1);
}
};
Expand Down
Binary file added desktop/public/runtime-icons/hermes.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading