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
15 changes: 14 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/buzz-acp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ clap = { version = "4", features = ["derive", "env"] }
# Config file
toml = "1.0"

# Durable ACP session binding store location
dirs = "6"
# Cross-process flock for shared session bindings
fs2 = "0.4"

# Filter expressions
evalexpr = { workspace = true }

Expand All @@ -78,4 +83,5 @@ nix = { version = "0.31", default-features = false, features = ["signal"] }

[dev-dependencies]
tokio = { workspace = true, features = ["test-util"] }
tempfile = "3"
httparse = "1"
36 changes: 36 additions & 0 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,42 @@ buzz-acp
Older installs that still expose `claude-code-acp` are also supported. `buzz-acp`
treats both Claude ACP command names as the same zero-arg runtime.

## Running with Hermes Agent

[Hermes Agent](https://github.com/NousResearch/hermes-agent) includes a native ACP
server that uses the same personal configuration, credentials, memory, skills, and
tools as the Hermes CLI.

```bash
# Install Hermes, configure the personal agent, and add its optional ACP extra.
# See https://hermes-agent.nousresearch.com/docs/user-guide/features/acp/
# hermes acp --check

export BUZZ_ACP_AGENT_COMMAND="hermes"
export BUZZ_ACP_AGENT_ARGS="acp" # empty also normalises to acp
export HERMES_HOME="$HOME/.hermes" # process home; does not namespace the durable store
export BUZZ_ACP_NO_MEMORY="true" # must be the string true (not 1)
# Keep a single worker per profile home (Desktop: parallelism=1).
# For a second Hermes profile, use unique args (e.g. BUZZ_ACP_AGENT_ARGS="-p chad acp")
# so session bindings do not collide. HERMES_HOME alone is not enough.

buzz-acp
```

If `hermes-acp` is on `PATH`, it can be launched directly with empty agent
arguments instead. Desktop discovers both entrypoints as the first-class
**Hermes Agent** runtime.

For full profile tools over ACP (not the coding-only default toolset), set on
the Hermes home:

```yaml
acp:
tool_policy: profile
```

Owner-only Rocky attachment notes: [docs/hermes-agent-rocky.md](../../docs/hermes-agent-rocky.md).

## Configuration

All configuration is via environment variables (or CLI flags — every env var has a matching flag).
Expand Down
41 changes: 41 additions & 0 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,47 @@ impl AcpClient {
.session_id)
}

/// Send `session/load` for an existing ACP session id.
///
/// Used after harness restart when a durable channel→session binding is
/// known and the agent advertised `agentCapabilities.loadSession`.
/// History-replay `session/update` notifications are consumed by the
/// request loop and logged only — they are not re-published to Buzz.
pub async fn session_load_full(
&mut self,
cwd: &str,
session_id: &str,
mcp_servers: Vec<McpServer>,
) -> Result<SessionNewResponse, AcpError> {
let params = serde_json::json!({
"cwd": cwd,
"sessionId": session_id,
"mcpServers": mcp_servers,
});
let result = self.send_request("session/load", params).await?;
// Spec-compliant agents may omit sessionId on load (it is implied).
// Prefer the request id so callers always have a concrete binding.
let resolved_id = result
.get("sessionId")
.and_then(|v| v.as_str())
.unwrap_or(session_id)
.to_owned();
tracing::info!(target: "acp::session", "session loaded: {resolved_id}");
Ok(SessionNewResponse {
session_id: resolved_id,
raw: result,
})
}

/// Returns true when an initialize result advertises `loadSession`.
pub fn agent_supports_load_session(init_result: &serde_json::Value) -> bool {
init_result
.get("agentCapabilities")
.and_then(|caps| caps.get("loadSession"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
}

/// Send Goose's custom system-prompt request after `session/new`.
pub async fn session_set_goose_system_prompt(
&mut self,
Expand Down
29 changes: 28 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,10 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String {

fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_agent_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
// Official Hermes entrypoint takes a subcommand: `hermes acp`.
"goose" | "hermes" => Some(vec!["acp".to_string()]),
// Direct console script already is the ACP server.
"hermes-acp" | "hermes_acp" => Some(Vec::new()),
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
| "claudecode" | "buzz-agent" => Some(Vec::new()),
_ => None,
Expand Down Expand Up @@ -1492,6 +1495,30 @@ mod tests {
);
}

#[test]
fn normalizes_hermes_entrypoints() {
assert_eq!(
normalize_agent_args("hermes", Vec::new()),
vec!["acp".to_string()]
);
assert_eq!(
normalize_agent_args("hermes-acp", Vec::new()),
Vec::<String>::new()
);
assert_eq!(
normalize_agent_args("hermes-acp", vec!["acp".into()]),
Vec::<String>::new()
);
// Explicit profile selection must be preserved for multi-profile hosts.
assert_eq!(
normalize_agent_args(
"hermes",
vec!["-p".into(), "default".into(), "acp".into()]
),
vec!["-p".to_string(), "default".to_string(), "acp".to_string()]
);
}

#[test]
fn normalize_agent_command_identity_variants() {
assert_eq!(normalize_agent_command_identity("goose"), "goose");
Expand Down
27 changes: 21 additions & 6 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod pool;
mod pool_lifecycle;
mod queue;
mod relay;
mod session_store;
mod setup_mode;
mod usage;

Expand Down Expand Up @@ -1143,7 +1144,7 @@ struct RespawnResult {
/// Tuple: (initialized client, protocol version, supports_goose_steer).
/// The third element is always `true` — the supervisor uses
/// try-and-tolerate for the steer extension.
result: Result<(AcpClient, u32, String)>,
result: Result<(AcpClient, u32, String, bool)>,
}

/// Outcome of a non-cancelling steer attempt, forwarded from a per-attempt
Expand Down Expand Up @@ -1187,7 +1188,7 @@ impl RespawnGuard {
/// Send the result and disarm the guard. Uses `try_send` (sync) so there
/// is no await boundary between marking `sent` and actually enqueueing —
/// cancellation cannot slip between the two.
fn send(mut self, result: Result<(AcpClient, u32, String)>) {
fn send(mut self, result: Result<(AcpClient, u32, String, bool)>) {
// Invariant: try_send succeeds because the channel capacity equals the
// slot count, and respawn_in_flight guarantees at most one outstanding
// result per slot. If this ever fails, the channel sizing or the
Expand Down Expand Up @@ -1560,6 +1561,14 @@ async fn tokio_main() -> Result<()> {
memory_enabled: config.memory_enabled,
harness_name: crate::config::normalize_agent_command_identity(&config.agent_command),
relay_url: config.relay_url.clone(),
agent_command: config.agent_command.clone(),
agent_args: config.agent_args.clone(),
session_store: std::sync::Arc::new(crate::session_store::SessionStore::open(
crate::session_store::SessionStore::default_path(
&config.agent_command,
&config.agent_args,
),
)),
});

if !config.memory_enabled {
Expand Down Expand Up @@ -1785,7 +1794,7 @@ async fn tokio_main() -> Result<()> {
while let Ok(rr) = respawn_rx.try_recv() {
crash_history[rr.index].respawn_in_flight = false;
match rr.result {
Ok((acp, protocol_version, agent_name)) => {
Ok((acp, protocol_version, agent_name, supports_load_session)) => {
let agent = OwnedAgent {
index: rr.index,
acp,
Expand All @@ -1796,6 +1805,7 @@ async fn tokio_main() -> Result<()> {
agent_name,
goose_system_prompt_supported: None,
protocol_version,
supports_load_session,
};
pool.return_agent(agent);
tracing::info!(agent = rr.index, "respawn complete");
Expand Down Expand Up @@ -2665,7 +2675,7 @@ async fn tokio_main() -> Result<()> {
// Drain any respawn results that completed before the abort. Explicitly
// shut down returned agents instead of relying on AcpClient::Drop.
while let Ok(rr) = respawn_rx.try_recv() {
if let Ok((mut acp, _, _)) = rr.result {
if let Ok((mut acp, _, _, _)) = rr.result {
acp.shutdown().await;
tracing::debug!(agent = rr.index, "reaped respawned agent on shutdown");
}
Expand Down Expand Up @@ -3748,6 +3758,8 @@ async fn initialize_agent_pool(
}),
);
let agent_name = normalized_agent_name(&init_result);
let supports_load_session =
AcpClient::agent_supports_load_session(&init_result);
agent_slots.push(Some(OwnedAgent {
index: i,
acp,
Expand All @@ -3758,6 +3770,7 @@ async fn initialize_agent_pool(
agent_name,
goose_system_prompt_supported: None,
protocol_version,
supports_load_session,
}));
}
Ok(Err(e)) => {
Expand Down Expand Up @@ -3808,7 +3821,7 @@ async fn spawn_and_init(
has_generated_codex_config: bool,
agent_index: usize,
observer: Option<observer::ObserverHandle>,
) -> Result<(AcpClient, u32, String)> {
) -> Result<(AcpClient, u32, String, bool)> {
let mut acp = AcpClient::spawn(command, args, extra_env, has_generated_codex_config)
.await
.map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?;
Expand All @@ -3818,6 +3831,7 @@ async fn spawn_and_init(
Ok(init_result) => {
tracing::info!("agent initialized: {init_result}");
let protocol_version = init_result["protocolVersion"].as_u64().unwrap_or(1) as u32;
let supports_load_session = AcpClient::agent_supports_load_session(&init_result);
acp.observe(
"agent_initialized",
serde_json::json!({
Expand All @@ -3826,7 +3840,7 @@ async fn spawn_and_init(
}),
);
let agent_name = normalized_agent_name(&init_result);
Ok((acp, protocol_version, agent_name))
Ok((acp, protocol_version, agent_name, supports_load_session))
}
Err(e) => {
// Explicitly shut down the spawned child to prevent zombie/leak.
Expand Down Expand Up @@ -5143,6 +5157,7 @@ mod error_outcome_emission_tests {
// Error branches under test never read this; 1 is the legacy
// non-systemPrompt path, the simplest valid value.
protocol_version: 1,
supports_load_session: false,
}
}

Expand Down
Loading