diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd..030fc0f202 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -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 @@ -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). @@ -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. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..3570edbab3 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -689,7 +689,7 @@ fn default_agent_args(command: &str) -> Option> { 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, } } @@ -1542,6 +1542,16 @@ mod tests { normalize_agent_args("claude-agent-acp", vec!["acp".into()]), Vec::::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::::new() + ); + assert_eq!( + normalize_agent_args("letta-acp", Vec::new()), + Vec::::new() + ); } #[test] diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index de4e003c3f..347e4db0fd 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -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 — diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..b07ce46d43 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -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"; @@ -1436,33 +1439,6 @@ pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option, -} - /// Build the catalog entry for one preset harness through an injectable /// resolver — the seam the preset loop consumes and tests bind. /// @@ -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 { - 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> = 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`. /// diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs new file mode 100644 index 0000000000..76a7dd9b21 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -0,0 +1,162 @@ +//! Tier-2 preset harness definitions. +//! +//! Split out of `discovery.rs` so adding a preset touches a small data file +//! rather than growing a module that is already over the desktop file-size +//! limit. + +// ── 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. + +pub(super) struct PresetHarness { + pub(super) id: &'static str, + pub(super) label: &'static str, + pub(super) command: &'static str, + pub(super) args: &'static [&'static str], + pub(super) install_instructions_url: &'static str, + pub(super) 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. + pub(super) underlying_cli: Option<&'static str>, +} + +pub(super) 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: "letta", + label: "Letta", + command: "letta-acp", + args: &[], + install_instructions_url: "https://github.com/letta-ai/letta-acp", + install_hint: "Buzz talks to Letta through the letta-acp adapter \ + (npm install -g @letta-ai/letta-acp). Set LETTA_AGENT_ID in the \ + agent's env vars to pin one persistent Letta agent — the adapter \ + creates one and logs its id on first run otherwise.", + underlying_cli: None, + }, + 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 { + 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> = OnceLock::new(); + IDS.get_or_init(|| PRESET_HARNESSES.iter().map(|p| p.id).collect()) + .as_slice() +} diff --git a/desktop/src/features/onboarding/ui/HarnessMarks.tsx b/desktop/src/features/onboarding/ui/HarnessMarks.tsx index 48d2da4404..04e58c2ec4 100644 --- a/desktop/src/features/onboarding/ui/HarnessMarks.tsx +++ b/desktop/src/features/onboarding/ui/HarnessMarks.tsx @@ -39,6 +39,23 @@ function CursorMark({ className }: MarkProps) { ); } +/// Letta mark from Letta's own letta-evals repo (Apache-2.0). +function LettaMark({ className }: MarkProps) { + return ( + + ); +} + /// Theme-adaptive inline marks, keyed by runtime/preset id. Consulted before /// the bitmap logo maps in `RuntimeIcon`. Codex deliberately has no entry: /// the OpenAI blossom was removed from simple-icons v16 at the vendor's @@ -46,4 +63,5 @@ function CursorMark({ className }: MarkProps) { export const RUNTIME_MARKS: Record> = { cursor: CursorMark, goose: GooseMark, + letta: LettaMark, }; diff --git a/desktop/src/features/onboarding/ui/presetLogos.test.mjs b/desktop/src/features/onboarding/ui/presetLogos.test.mjs index 0ff2193155..7a5df620da 100644 --- a/desktop/src/features/onboarding/ui/presetLogos.test.mjs +++ b/desktop/src/features/onboarding/ui/presetLogos.test.mjs @@ -24,15 +24,15 @@ const desktopRoot = path.resolve( "../../../..", ); -const discoveryRs = readFileSync( - path.join(desktopRoot, "src-tauri/src/managed_agents/discovery.rs"), +const presetsRs = readFileSync( + path.join(desktopRoot, "src-tauri/src/managed_agents/discovery/presets.rs"), "utf8", ); -const presetBlock = discoveryRs.match( +const presetBlock = presetsRs.match( /const PRESET_HARNESSES: &\[PresetHarness\] = &\[([\s\S]*?)\n\];/, ); -assert.ok(presetBlock, "could not locate PRESET_HARNESSES in discovery.rs"); +assert.ok(presetBlock, "could not locate PRESET_HARNESSES in presets.rs"); const presetIds = [...presetBlock[1].matchAll(/^\s{8}id: "([^"]+)",$/gm)].map( (match) => match[1], diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 79c55f0148..9c67dd2389 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -37,6 +37,9 @@ const HARNESS_DESCRIPTIONS: Record = { kimi: "A terminal coding agent for software development and command-line tasks.", // Sources: https://ampcode.com, https://ampcode.com/manual amp: "A coding agent from Sourcegraph.", + // Sources: https://github.com/letta-ai/letta-acp, + // https://docs.letta.com/overview + letta: "A stateful agent with memory that learns and evolves.", // Sources: https://github.com/NousResearch/hermes-agent, // https://hermes-agent.nousresearch.com/docs/ hermes: "A general-purpose AI agent from Nous Research.",