From 0ecbd6e04b1d3f6b808d53eec5da8b9202f72cee Mon Sep 17 00:00:00 2001 From: amanning3390 Date: Thu, 23 Jul 2026 19:16:38 -0500 Subject: [PATCH 1/2] feat(agents): add native Hermes ACP runtime and model selection Co-authored-by: amanning3390 Signed-off-by: amanning3390 --- crates/buzz-acp/src/acp.rs | 56 ++++++- crates/buzz-acp/src/config.rs | 25 +++ crates/buzz-acp/src/lib.rs | 60 +++++++ desktop/public/runtime-icons/hermes.png | Bin 0 -> 28150 bytes .../managed_agents/config_bridge/hermes.rs | 157 ++++++++++++++++++ .../src/managed_agents/config_bridge/mod.rs | 1 + .../managed_agents/config_bridge/reader.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 37 ++++- .../src/managed_agents/discovery/tests.rs | 47 +++++- .../src-tauri/src/managed_agents/readiness.rs | 34 ++++ .../features/onboarding/ui/RuntimeIcon.tsx | 1 + .../src/features/onboarding/ui/SetupStep.tsx | 13 +- .../ui/onboardingRuntimeSelection.ts | 2 +- .../settings/ui/DoctorSettingsPanel.tsx | 5 +- .../e2e/onboarding-agent-defaults.spec.ts | 17 +- 15 files changed, 443 insertions(+), 16 deletions(-) create mode 100644 desktop/public/runtime-icons/hermes.png create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/hermes.rs diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718..224b8d9bbe 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -367,6 +367,34 @@ fn build_client_capabilities() -> serde_json::Value { }) } +/// 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). /// @@ -413,9 +441,8 @@ impl AcpClient { ) -> Result { 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()) @@ -2008,6 +2035,29 @@ 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::>(); + + 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 stop_reason_parses_all_known_values() { assert_eq!(StopReason::from_str("end_turn"), Some(StopReason::EndTurn)); diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..3f0f335b96 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -619,6 +619,7 @@ fn default_agent_args(command: &str) -> Option> { "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, } } @@ -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}}"; diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 03b75a4211..807e684e34 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -66,6 +66,66 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// 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. +#[allow(dead_code)] +fn model_probe_timeout_for_agent(agent_command: &str) -> Duration { + match config::normalize_agent_command_identity(agent_command).as_str() { + "hermes" | "hermes-agent" => Duration::from_secs(45), + _ => 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. +#[allow(dead_code)] +fn acp_env_for_agent(agent_command: &str) -> Vec<(String, String)> { + match 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(), + } +} + +#[cfg(test)] +mod model_probe_timeout_tests { + use super::*; + + #[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 diff --git a/desktop/public/runtime-icons/hermes.png b/desktop/public/runtime-icons/hermes.png new file mode 100644 index 0000000000000000000000000000000000000000..c5da175f8eb397b579c00678b7687bfd930cdc78 GIT binary patch literal 28150 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 Option { + let path = hermes_config_path()?; + read_config_from_path(&path) +} + +fn read_config_from_path(path: &std::path::Path) -> Option { + let raw = std::fs::read_to_string(path).ok()?; + parse_hermes_config(&raw) +} + +fn parse_hermes_config(yaml_str: &str) -> Option { + let root: serde_yaml::Value = serde_yaml::from_str(yaml_str).ok()?; + let map = root.as_mapping()?; + + let model_value = mapping_value(map, "model"); + let model = match model_value { + Some(serde_yaml::Value::String(value)) => nonempty(value), + Some(serde_yaml::Value::Mapping(model_map)) => mapping_string(model_map, "default"), + _ => None, + }; + let provider = model_value + .and_then(serde_yaml::Value::as_mapping) + .and_then(|model_map| mapping_string(model_map, "provider")); + let thinking_effort = mapping_value(map, "agent") + .and_then(serde_yaml::Value::as_mapping) + .and_then(|agent_map| mapping_string(agent_map, "reasoning_effort")); + + let mut extra = std::collections::BTreeMap::new(); + if let Some(base_url) = model_value + .and_then(serde_yaml::Value::as_mapping) + .and_then(|model_map| mapping_string(model_map, "base_url")) + { + extra.insert("model.base_url".to_string(), base_url); + } + + let extensions = mapping_value(map, "mcp_servers") + .and_then(serde_yaml::Value::as_mapping) + .map(|servers| { + servers + .iter() + .filter_map(|(name, value)| { + let name = nonempty(name.as_str()?)?; + let server = value.as_mapping()?; + let kind = if mapping_string(server, "url").is_some() { + "http" + } else if mapping_string(server, "command").is_some() { + "stdio" + } else { + "unknown" + }; + let enabled = mapping_value(server, "enabled") + .and_then(serde_yaml::Value::as_bool) + .unwrap_or(true); + Some(super::types::ExtensionEntry { + name, + kind: kind.to_string(), + enabled, + }) + }) + .collect() + }) + .unwrap_or_default(); + + Some(RuntimeFileConfig { + model, + provider, + thinking_effort, + extensions, + extra, + ..RuntimeFileConfig::default() + }) +} + +fn mapping_value<'a>(map: &'a serde_yaml::Mapping, key: &str) -> Option<&'a serde_yaml::Value> { + map.get(serde_yaml::Value::String(key.to_string())) +} + +fn mapping_string(map: &serde_yaml::Mapping, key: &str) -> Option { + mapping_value(map, key) + .and_then(serde_yaml::Value::as_str) + .and_then(nonempty) +} + +fn nonempty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +pub(super) fn hermes_config_path() -> Option { + if let Ok(root) = std::env::var("HERMES_HOME") { + return Some(PathBuf::from(root).join("config.yaml")); + } + dirs::home_dir().map(|home| home.join(".hermes").join("config.yaml")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_model_provider_reasoning_and_mcp_servers() { + let yaml = r#" +model: + default: anthropic/claude-sonnet-4 + provider: openrouter + base_url: https://openrouter.ai/api/v1 +agent: + reasoning_effort: high +mcp_servers: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem"] + remote: + url: https://mcp.example.test + enabled: false +"#; + + let cfg = parse_hermes_config(yaml).expect("valid Hermes YAML should parse"); + assert_eq!(cfg.model.as_deref(), Some("anthropic/claude-sonnet-4")); + assert_eq!(cfg.provider.as_deref(), Some("openrouter")); + assert_eq!(cfg.thinking_effort.as_deref(), Some("high")); + assert_eq!( + cfg.extra.get("model.base_url").map(String::as_str), + Some("https://openrouter.ai/api/v1") + ); + assert!(cfg + .extensions + .iter() + .any(|entry| entry.name == "filesystem" && entry.kind == "stdio" && entry.enabled)); + assert!(cfg + .extensions + .iter() + .any(|entry| entry.name == "remote" && entry.kind == "http" && !entry.enabled)); + } + + #[test] + fn accepts_legacy_scalar_model_and_empty_yaml() { + let scalar = parse_hermes_config("model: glm-5").expect("scalar model should parse"); + assert_eq!(scalar.model.as_deref(), Some("glm-5")); + assert!(scalar.provider.is_none()); + + let empty = parse_hermes_config("{}").expect("empty config should parse"); + assert!(empty.model.is_none()); + assert!(empty.provider.is_none()); + assert!(empty.extensions.is_empty()); + } + + #[test] + fn invalid_yaml_returns_none() { + assert!(parse_hermes_config("{{{{not yaml").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..a68d27e439 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -2,6 +2,7 @@ mod buzz_agent; mod claude; mod codex; mod goose; +mod hermes; pub(crate) mod reader; mod schema_walker; pub(crate) mod types; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..46dd341b8b 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -20,6 +20,7 @@ pub(crate) fn read_config_surface( .map(|m| m.id) .and_then(|id| match id { "goose" => super::goose::read_config_file().map(|c| (c, true)), + "hermes" => super::hermes::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file().map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), @@ -218,6 +219,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "goose" => { super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } + "hermes" => { + super::hermes::hermes_config_path().map(|path| path.to_string_lossy().into_owned()) + } "claude" => Some(resolve_tilde("~/.claude.json")), "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..1cdae3fdbd 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -18,6 +18,8 @@ const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/e const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; +const HERMES_AVATAR_URL: &str = + "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/img/apple-touch-icon.png"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); @@ -157,6 +159,39 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes"], + aliases: &["hermes-agent"], + avatar_url: HERMES_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("hermes"), + cli_install_commands: &[ + "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", + ], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"iex (irm https://hermes-agent.nousresearch.com/install.ps1)\""], + adapter_install_commands: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com/docs/", + cli_install_hint: "Install Hermes Agent via the official install script.", + adapter_install_hint: "", + skill_dir: Some(".hermes/skills"), + supports_acp_model_switching: true, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.hermes/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `hermes model` to configure a provider and model."), + auth_probe_args: Some(&["hermes", "config", "get", "model.provider"]), + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +377,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "hermes" | "hermes-agent" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..b262d82dc5 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -5,10 +5,10 @@ use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, - is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + is_login_shell_path_uninit, is_safe_nvm_tag, known_acp_runtime_exact, + managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, + probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path, + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -96,6 +96,45 @@ fn normalizes_buzz_agent_args_to_empty() { ); } +#[test] +fn hermes_runtime_contract_is_acp_native() { + let runtime = known_acp_runtime_exact("hermes").expect("Hermes runtime must be registered"); + + assert_eq!( + normalize_agent_args("hermes", Vec::new()), + vec!["acp".to_string()], + "desktop launches must enter Hermes' ACP stdio mode" + ); + assert_eq!( + managed_agent_avatar_url("/Users/test/.local/bin/hermes"), + Some( + "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/website/static/img/apple-touch-icon.png" + .to_string() + ) + ); + assert_eq!( + runtime.cli_install_commands, + &["curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"] + ); + assert_eq!( + runtime.cli_install_commands_windows, + &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"iex (irm https://hermes-agent.nousresearch.com/install.ps1)\""] + ); + assert!(runtime.adapter_install_commands.is_empty()); + assert!(runtime.supports_acp_model_switching); + assert_eq!(runtime.model_env_var, None); + assert_eq!(runtime.provider_env_var, None); + assert!(runtime.required_normalized_fields.is_empty()); + assert_eq!( + runtime.auth_probe_args, + Some(&["hermes", "config", "get", "model.provider"][..]) + ); + assert_eq!( + runtime.login_hint, + Some("Run `hermes model` to configure a provider and model.") + ); +} + #[test] fn login_shell_lookup_treats_command_as_data() { let marker = diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..e03fea2f9a 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -289,6 +289,11 @@ fn collect_missing_requirements( rt, ), "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + "hermes" => cli_login::requirements( + &["hermes", "config", "get", "model.provider"], + "run `hermes model`", + rt, + ), _ => vec![], } } @@ -510,6 +515,35 @@ mod tests { .collect() } + #[cfg(unix)] + #[test] + fn hermes_unconfigured_provider_is_not_ready() { + use std::os::unix::fs::PermissionsExt; + + let _guard = crate::managed_agents::lock_path_mutex(); + let dir = tempfile::tempdir().expect("create temp dir"); + let bin = dir.path().join("hermes"); + std::fs::write(&bin, "#!/bin/sh\nexit 1\n").expect("write fake Hermes binary"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod fake Hermes binary"); + + let original_path = std::env::var("PATH").unwrap_or_default(); + std::env::set_var("PATH", format!("{}:{original_path}", dir.path().display())); + crate::managed_agents::clear_resolve_cache(); + + let result = agent_readiness(&make_env("hermes", BTreeMap::new())); + + std::env::set_var("PATH", &original_path); + crate::managed_agents::clear_resolve_cache(); + + assert!(!result.is_ready(), "unconfigured Hermes must not be ready"); + assert!(result.requirements().iter().any(|requirement| matches!( + requirement, + Requirement::CliLogin { setup_copy, .. } + if setup_copy == "run `hermes model`" + ))); + } + // ── buzz-agent tests ────────────────────────────────────────────────── #[test] diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index a410914377..13a8c03442 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -13,6 +13,7 @@ const RUNTIME_LOGOS: Record = { claude: claudeLogoUrl, codex: chatgptLogoUrl, goose: gooseLogoUrl, + hermes: "/runtime-icons/hermes.png", }; function isBuzzRuntime(runtime: AcpRuntimeCatalogEntry): boolean { diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 96e7322d14..6fc2ccc1f4 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -603,7 +603,14 @@ function RuntimeProvidersSection({
{orderedItems.length > 0 ? ( -
+
= 3 + ? "max-w-[896px] md:grid-cols-3" + : "max-w-[592px] md:grid-cols-2", + ) + > {orderedItems.map((runtime) => ( - No supported agent harnesses were detected yet. Install Claude Code - or Codex, then check again. + No supported agent harnesses were detected yet. Install Hermes + Agent, Claude Code, or Codex, then check again.

)} diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..1c6eed2efa 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,6 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = ["hermes", "claude", "codex"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index cee156ece7..a2cde1b919 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -39,6 +39,7 @@ const RUNTIME_LOGO_URLS: Record = { claude: "/runtime-icons/claude.png", codex: "/runtime-icons/codex.png", goose: "/runtime-icons/goose.svg", + hermes: "/runtime-icons/hermes.png", }; const RUNTIME_LOGO_SCALE: Record = { @@ -46,11 +47,13 @@ const RUNTIME_LOGO_SCALE: Record = { claude: "scale-110", codex: "scale-110", goose: "scale-125", + hermes: "scale-110", }; const RUNTIME_SORT_PRIORITY: Record = { "buzz-agent": 0, - goose: 1, + hermes: 1, + goose: 2, }; function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index f8e6322ed3..d60463a7e5 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -3,7 +3,7 @@ import { installMockBridge } from "../helpers/bridge"; import { passThroughBackupStep } from "../helpers/onboarding"; function runtime( - id: "buzz-agent" | "claude" | "codex" | "goose", + id: "buzz-agent" | "claude" | "codex" | "goose" | "hermes", availability: string, authStatus: Record, overrides: Record = {}, @@ -17,7 +17,9 @@ function runtime( ? "Claude Code" : id === "codex" ? "Codex" - : "Goose", + : id === "hermes" + ? "Hermes Agent" + : "Goose", avatar_url: "", availability, command: availability === "available" ? id : null, @@ -57,7 +59,7 @@ async function readSavedRuntime(page: Parameters[0]) { }); } -test("setup shows only Claude Code and Codex as detected harnesses", async ({ +test("setup shows Hermes, Claude Code, and Codex as detected harnesses", async ({ page, }) => { await installMockBridge( @@ -66,6 +68,7 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ acpRuntimesCatalog: [ runtime("buzz-agent", "available", { status: "not_applicable" }), runtime("goose", "available", { status: "not_applicable" }), + runtime("hermes", "available", { status: "logged_in" }), runtime("codex", "available", { status: "logged_in" }), runtime("claude", "available", { status: "logged_in" }), ], @@ -77,6 +80,14 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByTestId("onboarding-runtime-claude")).toBeVisible(); await expect(page.getByTestId("onboarding-runtime-codex")).toBeVisible(); + const hermes = page.getByTestId("onboarding-runtime-hermes"); + await expect(hermes).toBeVisible(); + await expect(hermes.getByRole("heading", { name: "Hermes Agent" })).toBeVisible(); + const hermesLogo = hermes.locator('img[src="/runtime-icons/hermes.png"]'); + await expect(hermesLogo).toBeVisible(); + await expect + .poll(() => hermesLogo.evaluate((image: HTMLImageElement) => image.naturalWidth)) + .toBeGreaterThan(0); await expect(page.getByTestId("onboarding-runtime-goose")).toHaveCount(0); await expect(page.getByTestId("onboarding-runtime-buzz-agent")).toHaveCount( 0, From bd96c2c4f76cde46d07cb872ed2b7ac557a1ac6b Mon Sep 17 00:00:00 2001 From: amanning3390 Date: Fri, 24 Jul 2026 16:28:05 -0500 Subject: [PATCH 2/2] fix(agents): apply Hermes ACP env and probe timeout at spawn The Hermes runtime helpers were defined but never reached a real code path: both carried #[allow(dead_code)] and were only exercised by their own unit tests. As a result Hermes could appear in onboarding and then fail model discovery on the shared 10s budget, and the paired Hermes change (NousResearch/hermes-agent#70405) stayed inert because Buzz never set the marker it reads. - Apply acp_env_for_agent() on the AcpClient::spawn command so every spawn path (probes and managed sessions) gets runtime-specific env. Operator precedence is preserved: an exported value is not overwritten. - Resolve the probe budget with model_probe_timeout_for_agent() at all three initialize/session sites, and report the budget actually used in the timeout messages instead of a hardcoded 10s. - Drop both #[allow(dead_code)] attributes so the compiler enforces the wiring. - Move the helpers next to the spawn they serve. Non-Hermes runtimes are unchanged: no extra env and the existing MODELS_TIMEOUT fast-fail. Co-authored-by: amanning3390 Signed-off-by: amanning3390 --- crates/buzz-acp/src/acp.rs | 71 ++++++++++++++++++++++++++++++++++++++ crates/buzz-acp/src/lib.rs | 48 ++++++-------------------- 2 files changed, 82 insertions(+), 37 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 224b8d9bbe..0d87022e30 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -367,6 +367,34 @@ 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 @@ -487,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). @@ -2058,6 +2096,39 @@ mod tests { ); } + #[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)); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 807e684e34..473daf49d1 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -60,45 +60,16 @@ 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); -/// 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. -#[allow(dead_code)] -fn model_probe_timeout_for_agent(agent_command: &str) -> Duration { - match config::normalize_agent_command_identity(agent_command).as_str() { - "hermes" | "hermes-agent" => Duration::from_secs(45), - _ => 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. -#[allow(dead_code)] -fn acp_env_for_agent(agent_command: &str) -> Vec<(String, String)> { - match 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(), - } -} - #[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() { @@ -3913,6 +3884,7 @@ fn extract_auth_methods(init_result: &serde_json::Value) -> Vec 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) => { @@ -3921,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; @@ -3930,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); } }; @@ -3961,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) => { @@ -3969,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; @@ -3978,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); } }; @@ -4021,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("/")) @@ -4040,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)) @@ -4056,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); } };