Skip to content
Draft
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
45 changes: 43 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent
(send_message, etc.)
```

Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)).
Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)), and **letta** (via [letta-acp](https://github.com/letta-ai/letta-acp)).

## Prerequisites

Expand Down Expand Up @@ -98,6 +98,47 @@ 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 Letta

[letta-acp](https://github.com/letta-ai/letta-acp) exposes a [Letta](https://docs.letta.com)
agent over ACP. Letta agents are stateful: memory persists across sessions, so
the same agent carries context between channels and across harness restarts.

```bash
# Install the adapter (npm package — no Rust build required)
npm install -g @letta-ai/letta-acp

# Run
export LETTA_AGENT_ID="agent-..." # optional: pin one persistent agent
export BUZZ_ACP_AGENT_COMMAND="letta-acp"

buzz-acp
```

Without `LETTA_AGENT_ID` the adapter creates an agent on first use and logs its
id to stderr; set the variable to that value to keep every run on the same
agent. Each channel session becomes its own conversation on that one agent.

### Letta backends

`LETTA_ACP_BACKEND` selects where the agent lives and where its tools run.
Since the agent answers Buzz by shelling out to the `buzz` CLI, the CLI and
the `BUZZ_*` environment have to be wherever the tools run:

| `LETTA_ACP_BACKEND` | Agent state | Tools run | Extra config |
|---|---|---|---|
| `local` (default) | this machine | this machine | `letta login`, or `letta --backend local connect anthropic --api-key ...` for model access |
| `cloud-oauth` | Letta Cloud | this machine | `letta login` once; no key in the harness env |
| `remote` | your app server | the app server | `LETTA_APP_SERVER_URL`, `LETTA_APP_SERVER_TOKEN`; install the `buzz` CLI there |
| `cloud` | Letta Cloud | Letta's sandbox | not usable from Buzz — the sandbox has no `buzz` CLI |

`local` and `cloud-oauth` need nothing beyond the harness environment they
already inherit; `cloud-oauth` is the way to run a cloud-hosted agent whose
tools still execute here. Plain `cloud` puts tool execution in Letta's
sandbox, out of reach of the `buzz` CLI, and MCP servers passed through
`BUZZ_ACP_MCP_COMMAND` do not close the gap: the sandbox has no executor for
adapter-side tools.

## Configuration

All configuration is via environment variables (or CLI flags — every env var has a matching flag).
Expand Down Expand Up @@ -269,7 +310,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru

**Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden.

**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead.
**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Letta, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead.

> **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment.

Expand Down
12 changes: 11 additions & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -689,7 +689,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_agent_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
| "claudecode" | "buzz-agent" => Some(Vec::new()),
| "claudecode" | "buzz-agent" | "letta-acp" => Some(Vec::new()),
_ => None,
}
}
Expand Down Expand Up @@ -1542,6 +1542,16 @@ mod tests {
normalize_agent_args("claude-agent-acp", vec!["acp".into()]),
Vec::<String>::new()
);
// letta-acp speaks ACP on stdin with no subcommand, so the legacy
// Goose-shaped "acp" default must not reach it as a positional.
assert_eq!(
normalize_agent_args("letta-acp", vec!["acp".into()]),
Vec::<String>::new()
);
assert_eq!(
normalize_agent_args("letta-acp", Vec::new()),
Vec::<String>::new()
);
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions desktop/public/harness-logos/CREDITS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Monochrome marks inlined as `currentColor` paths in
|---|---|---|---|---|---|
| Goose | [block/goose](https://github.com/block/goose) | `305849b71709b95b86ed9f11bd3bc939899c0aab` | Apache-2.0 © Block, Inc. | `documentation/static/img/goose.svg` | `fill="#101010"` → `currentColor`; dropped the redundant clipPath wrapper |
| Cursor | [simple-icons](https://github.com/simple-icons/simple-icons) | `16.27.1` (slug `cursor`) | CC0-1.0 (path data); nominative use of the Cursor mark to identify Cursor's harness | `icons/cursor.svg` | `fill` → `currentColor` |
| Letta | [letta-ai/letta-evals](https://github.com/letta-ai/letta-evals) | `2daaf0c598780b6fa2edc26de52348de127f88a5` | Apache-2.0 © Letta | `leaderboard_site/src/letta-logo.svg` | Dropped the per-path `fill` attributes (the root `fill="currentColor"` already carries them) and the fixed `width`/`height` |

Codex deliberately has **no** bundled mark: the OpenAI blossom was removed
from simple-icons in v16 at the vendor's request, so we do not ship it —
Expand Down
148 changes: 3 additions & 145 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ use crate::managed_agents::{
HarnessSource,
};

mod presets;
mod runtime_metadata;

pub(crate) use presets::{preset_harness_definitions, preset_harness_ids};
use presets::{PresetHarness, PRESET_HARNESSES};
pub(crate) use runtime_metadata::KnownAcpRuntime;

const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
Expand Down Expand Up @@ -1436,33 +1439,6 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option<AcpA
.map(|partial| partial.entry.availability)
}

// ── Tier-2 preset harnesses ────────────────────────────────────────────────
//
// Static data for well-known ACP harnesses that have bundled logos and
// verified command/args. PATH-probed at discovery time (Detected badge);
// not editable or deletable by users. Logos are bundled assets referenced
// by id in the frontend `RUNTIME_LOGOS` map.

struct PresetHarness {
id: &'static str,
label: &'static str,
command: &'static str,
args: &'static [&'static str],
install_instructions_url: &'static str,
install_hint: &'static str,
/// Vendor CLI the ACP command wraps, when the preset is an adapter
/// (e.g. Amp's `amp-acp` wraps the separately-installed `amp` CLI).
/// Consulted only when the adapter is absent, so `AdapterMissing`
/// replaces the misleading `NotInstalled` when the CLI is present but
/// the adapter is not. Deliberately NOT fed through the builtins'
/// full `classify_runtime` predicate: that would flip
/// adapter-present/CLI-absent from today's `Available` to `CliMissing`
/// (unselectable), and presets carry a single flat `install_hint`, so
/// the `CliMissing` copy would tell the user to install the adapter
/// they already have. `None` when the command IS the vendor CLI.
underlying_cli: Option<&'static str>,
}

/// Build the catalog entry for one preset harness through an injectable
/// resolver — the seam the preset loop consumes and tests bind.
///
Expand Down Expand Up @@ -1537,124 +1513,6 @@ fn preset_catalog_entry(
}
}

const PRESET_HARNESSES: &[PresetHarness] = &[
PresetHarness {
id: "cursor",
label: "Cursor",
command: "cursor-agent",
args: &["acp"],
install_instructions_url: "https://cursor.com/downloads",
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
underlying_cli: None,
},
PresetHarness {
id: "omp",
label: "Oh My Pi",
command: "omp",
args: &["acp"],
install_instructions_url: "https://github.com/can1357/oh-my-pi",
install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).",
underlying_cli: None,
},
PresetHarness {
id: "grok",
label: "Grok Build",
command: "grok",
args: &["agent", "--always-approve", "stdio"],
install_instructions_url: "https://build.x.ai/docs",
install_hint: "Buzz talks to Grok Build through its CLI's agent stdio mode.",
underlying_cli: None,
},
PresetHarness {
id: "opencode",
label: "OpenCode",
command: "opencode",
args: &["acp"],
install_instructions_url: "https://opencode.ai/docs",
install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).",
underlying_cli: None,
},
PresetHarness {
id: "kimi",
label: "Kimi Code",
command: "kimi",
args: &["acp"],
install_instructions_url: "https://kimi.ai/download",
install_hint: "Buzz talks to Kimi Code through its CLI's ACP mode (kimi acp).",
underlying_cli: None,
},
PresetHarness {
id: "amp",
label: "Amp",
command: "amp-acp",
args: &[],
install_instructions_url: "https://github.com/tao12345666333/amp-acp",
install_hint: "Buzz talks to the Amp CLI through the amp-acp adapter. Follow the setup guide to install the adapter so the amp-acp command is on your PATH.",
underlying_cli: Some("amp"),
},
PresetHarness {
id: "hermes",
label: "Hermes Agent",
command: "hermes-acp",
args: &[],
install_instructions_url: "https://hermes-agent.nousresearch.com",
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
underlying_cli: None,
},
PresetHarness {
id: "openclaw",
label: "OpenClaw",
command: "openclaw",
args: &["acp"],
install_instructions_url: "https://docs.openclaw.ai/start/getting-started",
install_hint: "Buzz talks to OpenClaw through its ACP mode (openclaw acp), which relies on the OpenClaw Gateway daemon. Follow the setup guide to install both.\n\n\
⚠️ Execution-locus note: `openclaw acp` runs tools inside the \
OpenClaw Gateway daemon, not in the Desktop process. \
Desktop-injected BUZZ_* env vars are visible to the `openclaw` \
harness process itself, but do NOT automatically reach the \
Gateway's execution environment. If your tools or agent logic \
needs BUZZ_* credentials at execution time, set them on the \
Gateway's own environment separately.",
underlying_cli: None,
},
];

/// Return the static preset harness definitions as `HarnessDefinition` values.
///
/// Used by `warm_harness_registry_from_dir` to seed the loaded-harness registry
/// at startup before the frontend triggers a full discovery run.
pub(crate) fn preset_harness_definitions(
) -> Vec<crate::managed_agents::custom_harnesses::HarnessDefinition> {
PRESET_HARNESSES
.iter()
.map(
|p| crate::managed_agents::custom_harnesses::HarnessDefinition {
id: p.id.to_string(),
label: p.label.to_string(),
command: p.command.to_string(),
args: p.args.iter().map(|s| s.to_string()).collect(),
env: std::collections::BTreeMap::new(),
install_instructions_url: p.install_instructions_url.to_string(),
install_hint: p.install_hint.to_string(),
},
)
.collect()
}

/// Return the static slice of preset harness IDs.
///
/// Used by `check_id_collision` in `custom_harnesses` to derive the reserved-ID
/// set from the single source of truth (`PRESET_HARNESSES`) rather than a
/// hand-maintained copy. Adding a preset automatically reserves its ID.
pub(crate) fn preset_harness_ids() -> &'static [&'static str] {
// `PRESET_HARNESSES` is `'static`; we project its `id` fields.
// Computed once via OnceLock to avoid repeated allocations on hot paths.
use std::sync::OnceLock;
static IDS: OnceLock<Vec<&'static str>> = OnceLock::new();
IDS.get_or_init(|| PRESET_HARNESSES.iter().map(|p| p.id).collect())
.as_slice()
}

/// Discover all ACP runtimes, optionally merging user-defined custom harnesses
/// from `custom_harnesses_dir`.
///
Expand Down
Loading