mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(buzz-acp): update base prompt; add buzz context and skills to Pi agents (#7335)
## Summary - launch Pi through a private Buzz wrapper so managed Pi agents receive Buzz's base prompt plus the configured thread/channel session model via `--system-prompt` - load the Buzz workspace skill directory with Pi's repeatable `--skill` flag, making `~/.buzz/.agents/skills/buzz-cli` available alongside Pi's normal global skill discovery - report Pi and `pi-acp` installation states separately so setup guidance points to the missing component `pi-acp` does not currently consume the ACP `session/new` system prompt, but its normal new-session and restore paths do honor `PI_ACP_PI_COMMAND` as the executable used to launch Pi. Buzz reserves that variable and sets it to a private generated launcher. The launcher invokes `pi` from Buzz's effective `PATH`, adds `--system-prompt <file>` and `--skill <workspace>/.agents/skills`, and forwards `pi-acp`'s RPC and session arguments unchanged. This keeps the integration entirely in `block/buzz`, without changes to Pi or `pi-acp`. ### How PI_ACP_PI_COMMAND works in `pi-acp` `PI_ACP_PI_COMMAND` selects one executable; it does not accept arguments. Therefore: ```bash # Does not work PI_ACP_PI_COMMAND="pi --skill ~/.buzz/.agents/skills" ``` Buzz does not expose this variable as user configuration. It creates a private launcher and sets `PI_ACP_PI_COMMAND` only on the `pi-acp` child. Conceptually, that launcher executes: ```sh #!/bin/sh exec pi \ --system-prompt "$PRIVATE_SYSTEM_PROMPT" \ --skill "$HOME/.buzz/.agents/skills" \ "$@" ``` ### Related issue Follow-up to #7208. No duplicate issue or open PR found. ### Testing - `just ci` - `cargo test -p buzz-acp pi_launcher` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml managed_agents::env_vars::tests` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml managed_agents::discovery::presets::tests` - pre-push branch checks No screenshots: the UI change is state-dependent setup guidance only. --- **Update Sep 4, 13:35:** Made `PI_ACP_PI_COMMAND` entirely Buzz-owned. - Managed agent configuration now rejects the variable as a user override. - Buzz always launches `pi` from its effective `PATH` and rejects inherited values before creating its private launcher. - Removed the custom Pi executable discovery path and its internal environment alias. --- **Update Sep 4, 14:03:** Reject inherited `PI_ACP_PI_COMMAND` values instead of replacing them. - Pi startup now fails with an actionable message telling the user to unset the variable. - Removed the Pi-specific exception from the generic ACP environment injection path. Generated with Codex --------- Signed-off-by: Salman Mohammed <smohammed@squareup.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
You are operating inside the Buzz platform — a Nostr-based messaging platform for human-agent collaboration. The buzz-acp harness routes channel events to your session.
|
||||
You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration.
|
||||
Buzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work.
|
||||
|
||||
## Buzz CLI
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ mod config;
|
||||
mod engram_fetch;
|
||||
mod filter;
|
||||
mod observer;
|
||||
mod pi_launcher;
|
||||
mod pool;
|
||||
mod pool_lifecycle;
|
||||
mod prompt_framing;
|
||||
@@ -2510,6 +2511,49 @@ async fn tokio_main() -> Result<()> {
|
||||
|
||||
tracing::info!("buzz-acp starting: {}", config.summary());
|
||||
|
||||
let cwd = current_working_directory()?;
|
||||
let base_prompt_content = config.base_prompt_content.take();
|
||||
let base_prompt = if config.no_base_prompt {
|
||||
None
|
||||
} else {
|
||||
// Build standing context once under the configured policy, before any
|
||||
// agent process starts. Pi consumes this through its native
|
||||
// `--system-prompt`; other ACP agents consume the same bytes through
|
||||
// session/new or legacy first-turn framing.
|
||||
Some(
|
||||
config.session_policy.append_session_model(
|
||||
base_prompt_content
|
||||
.as_deref()
|
||||
.unwrap_or(include_str!("base_prompt.md")),
|
||||
),
|
||||
)
|
||||
};
|
||||
// PI_ACP_PI_COMMAND is Buzz-owned. Strip stale/user-provided copies from
|
||||
// every adapter before optionally installing Buzz's generated Pi launcher.
|
||||
config
|
||||
.persona_env_vars
|
||||
.retain(|(key, _)| !key.eq_ignore_ascii_case(pi_launcher::PI_ACP_PI_COMMAND_ENV));
|
||||
let managed_skills_dir = std::path::Path::new(&cwd).join(".agents/skills");
|
||||
let inherited_pi_command_is_set =
|
||||
std::env::var_os(pi_launcher::PI_ACP_PI_COMMAND_ENV).is_some();
|
||||
let (pi_launch_override, base_prompt) = pi_launcher::PiLaunchOverride::prepare(
|
||||
&config.agent_command,
|
||||
base_prompt,
|
||||
&managed_skills_dir,
|
||||
inherited_pi_command_is_set,
|
||||
)
|
||||
.context("failed to prepare Pi launch overrides")?;
|
||||
if let Some(prepared) = pi_launch_override.as_ref() {
|
||||
config.persona_env_vars.push((
|
||||
pi_launcher::PI_ACP_PI_COMMAND_ENV.to_string(),
|
||||
prepared.launcher_path().to_string_lossy().into_owned(),
|
||||
));
|
||||
tracing::info!(
|
||||
skills_dir = %managed_skills_dir.display(),
|
||||
"configured Pi to consume Buzz standing context and managed skills through native CLI flags"
|
||||
);
|
||||
}
|
||||
|
||||
let observer = config
|
||||
.relay_observer
|
||||
.then(observer::ObserverHandle::in_process);
|
||||
@@ -2758,8 +2802,6 @@ async fn tokio_main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let base_prompt_content = config.base_prompt_content.take();
|
||||
let cwd = current_working_directory()?;
|
||||
let ctx = Arc::new(PromptContext {
|
||||
mcp_servers: build_mcp_servers(&config),
|
||||
initial_message: config.initial_message.clone(),
|
||||
@@ -2770,20 +2812,7 @@ async fn tokio_main() -> Result<()> {
|
||||
system_prompt: config.system_prompt.clone(),
|
||||
session_title: config.session_title.clone(),
|
||||
team_instructions: config.team_instructions.clone(),
|
||||
base_prompt: if config.no_base_prompt {
|
||||
None
|
||||
} else {
|
||||
// Build standing context once under the configured policy, before
|
||||
// any session/new. Both modern ACP and legacy first-turn framing
|
||||
// consume this same assembled base (including custom base files).
|
||||
Some(
|
||||
config.session_policy.append_session_model(
|
||||
base_prompt_content
|
||||
.as_deref()
|
||||
.unwrap_or(include_str!("base_prompt.md")),
|
||||
),
|
||||
)
|
||||
},
|
||||
base_prompt,
|
||||
heartbeat_prompt: config.heartbeat_prompt.clone(),
|
||||
cwd,
|
||||
rest_client: relay.rest_client(),
|
||||
@@ -4116,6 +4145,10 @@ async fn tokio_main() -> Result<()> {
|
||||
// for the background task to finish, rather than aborting immediately (#40).
|
||||
relay.shutdown().await;
|
||||
|
||||
// Pi may restore subprocesses throughout the pool lifetime. Remove its
|
||||
// private prompt and launcher only after every adapter has shut down.
|
||||
drop(pi_launch_override);
|
||||
|
||||
tracing::info!("buzz-acp stopped");
|
||||
Ok(())
|
||||
}
|
||||
@@ -5208,6 +5241,9 @@ mod agent_draft_prompt_tests {
|
||||
#[test]
|
||||
fn shared_base_prompt_teaches_portable_agent_drafts() {
|
||||
let prompt = include_str!("base_prompt.md");
|
||||
assert!(prompt.starts_with(
|
||||
"You are an agent operating inside Buzz — a Nostr-based messaging platform for human-agent collaboration.\nBuzz is a desktop and mobile collaboration app organized around channels, conversations, and shared work."
|
||||
));
|
||||
assert!(prompt.contains("buzz agents draft-create"));
|
||||
assert!(prompt.contains("ask for at most two things"));
|
||||
assert!(prompt.contains("what it should do day-to-day"));
|
||||
|
||||
380
crates/buzz-acp/src/pi_launcher.rs
Normal file
380
crates/buzz-acp/src/pi_launcher.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
//! Pi-specific native launcher setup.
|
||||
//!
|
||||
//! `pi-acp` does not currently consume ACP `session/new.systemPrompt`, but it
|
||||
//! does let callers replace the `pi` executable through
|
||||
//! `PI_ACP_PI_COMMAND`. For Pi sessions, Buzz points that variable at a
|
||||
//! private launcher which adds `--system-prompt <file>` and the canonical Buzz
|
||||
//! `--skill <directory>` before forwarding the adapter's RPC/session arguments
|
||||
//! unchanged.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) const PI_ACP_PI_COMMAND_ENV: &str = "PI_ACP_PI_COMMAND";
|
||||
|
||||
/// Files backing the Pi launcher for one `buzz-acp` process.
|
||||
///
|
||||
/// The guard must live as long as the ACP pool because `pi-acp` may start or
|
||||
/// restore Pi subprocesses after its own initialization.
|
||||
pub(crate) struct PiLaunchOverride {
|
||||
directory: PathBuf,
|
||||
launcher: PathBuf,
|
||||
}
|
||||
|
||||
impl PiLaunchOverride {
|
||||
/// Prepare a Pi launcher when the configured ACP adapter is `pi-acp`.
|
||||
///
|
||||
/// Returns the prompt that still needs ordinary ACP delivery. For Pi, the
|
||||
/// base prompt moves into Pi's native system role and is therefore removed
|
||||
/// from first-turn user framing. Other adapters receive it unchanged.
|
||||
pub(crate) fn prepare(
|
||||
agent_command: &str,
|
||||
base_prompt: Option<String>,
|
||||
managed_skills_dir: &Path,
|
||||
inherited_pi_command_is_set: bool,
|
||||
) -> io::Result<(Option<Self>, Option<String>)> {
|
||||
if crate::config::normalize_agent_command_identity(agent_command) != "pi-acp" {
|
||||
return Ok((None, base_prompt));
|
||||
}
|
||||
|
||||
if inherited_pi_command_is_set {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
"PI_ACP_PI_COMMAND is managed by Buzz; unset it before starting a managed Pi agent",
|
||||
));
|
||||
}
|
||||
|
||||
// Buzz owns PI_ACP_PI_COMMAND and always uses it to point pi-acp at
|
||||
// this generated launcher. The launcher resolves the ordinary `pi`
|
||||
// command from Buzz's effective PATH.
|
||||
let prepared = Self::create("pi", base_prompt.as_deref(), managed_skills_dir)?;
|
||||
Ok((Some(prepared), None))
|
||||
}
|
||||
|
||||
pub(crate) fn launcher_path(&self) -> &Path {
|
||||
&self.launcher
|
||||
}
|
||||
|
||||
fn create(
|
||||
pi_command: &str,
|
||||
prompt: Option<&str>,
|
||||
managed_skills_dir: &Path,
|
||||
) -> io::Result<Self> {
|
||||
let directory = std::env::temp_dir().join(format!(
|
||||
"buzz-acp-pi-launcher-{}-{}",
|
||||
std::process::id(),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
create_private_directory(&directory)?;
|
||||
|
||||
let prompt_path = directory.join("SYSTEM.md");
|
||||
let launcher = directory.join(launcher_file_name());
|
||||
// Construct the cleanup guard before either file write. Any later `?`
|
||||
// drops it, so a partial setup cannot strand the private prompt file.
|
||||
let prepared = Self {
|
||||
directory,
|
||||
launcher,
|
||||
};
|
||||
|
||||
if let Some(prompt) = prompt {
|
||||
write_private_file(&prompt_path, prompt.as_bytes(), false)?;
|
||||
}
|
||||
|
||||
let script = launcher_script(
|
||||
pi_command,
|
||||
prompt.map(|_| prompt_path.as_path()),
|
||||
managed_skills_dir,
|
||||
)?;
|
||||
write_private_file(&prepared.launcher, script.as_bytes(), true)?;
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PiLaunchOverride {
|
||||
fn drop(&mut self) {
|
||||
if let Err(error) = fs::remove_dir_all(&self.directory) {
|
||||
if error.kind() != io::ErrorKind::NotFound {
|
||||
tracing::warn!(
|
||||
path = %self.directory.display(),
|
||||
%error,
|
||||
"failed to remove temporary Pi launcher"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn create_private_directory(path: &Path) -> io::Result<()> {
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.mode(0o700).create(path)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn create_private_directory(path: &Path) -> io::Result<()> {
|
||||
fs::create_dir(path)
|
||||
}
|
||||
|
||||
fn write_private_file(path: &Path, content: &[u8], executable: bool) -> io::Result<()> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(if executable { 0o700 } else { 0o600 });
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let _ = executable;
|
||||
|
||||
let mut file = options.open(path)?;
|
||||
file.write_all(content)?;
|
||||
file.sync_all()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn launcher_file_name() -> &'static str {
|
||||
"pi-with-buzz-context"
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launcher_file_name() -> &'static str {
|
||||
"pi-with-buzz-context.cmd"
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn launcher_file_name() -> &'static str {
|
||||
"pi-with-buzz-context"
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn launcher_script(
|
||||
pi_command: &str,
|
||||
prompt_path: Option<&Path>,
|
||||
managed_skills_dir: &Path,
|
||||
) -> io::Result<String> {
|
||||
let system_prompt_arg = match prompt_path {
|
||||
Some(prompt_path) => format!(" --system-prompt {}", shell_quote(prompt_path.as_os_str())?),
|
||||
None => String::new(),
|
||||
};
|
||||
Ok(format!(
|
||||
"#!/bin/sh\nexec {}{} --skill {} \"$@\"\n",
|
||||
shell_quote(OsStr::new(pi_command))?,
|
||||
system_prompt_arg,
|
||||
shell_quote(managed_skills_dir.as_os_str())?,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn shell_quote(value: &OsStr) -> io::Result<String> {
|
||||
let value = value.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Pi launcher paths must be valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
Ok(format!("'{}'", value.replace('\'', "'\"'\"'")))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launcher_script(
|
||||
pi_command: &str,
|
||||
prompt_path: Option<&Path>,
|
||||
managed_skills_dir: &Path,
|
||||
) -> io::Result<String> {
|
||||
let system_prompt_arg = match prompt_path {
|
||||
Some(prompt_path) => {
|
||||
let prompt_path = prompt_path.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Pi launcher paths must be valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
format!(" --system-prompt \"{}\"", batch_escape(prompt_path))
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
let managed_skills_dir = managed_skills_dir.to_str().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"Pi skill paths must be valid UTF-8",
|
||||
)
|
||||
})?;
|
||||
Ok(format!(
|
||||
"@echo off\r\n\"{}\"{} --skill \"{}\" %*\r\nexit /b %ERRORLEVEL%\r\n",
|
||||
batch_escape(pi_command),
|
||||
system_prompt_arg,
|
||||
batch_escape(managed_skills_dir),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn batch_escape(value: &str) -> String {
|
||||
value.replace('%', "%%").replace('"', "\"\"")
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn launcher_script(
|
||||
_pi_command: &str,
|
||||
_prompt_path: Option<&Path>,
|
||||
_managed_skills_dir: &Path,
|
||||
) -> io::Result<String> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"Pi launch overrides are unsupported on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_pi_adapter_keeps_base_prompt_for_acp_delivery() {
|
||||
let base = Some("Buzz base".to_string());
|
||||
let (prepared, remaining) =
|
||||
PiLaunchOverride::prepare("goose", base.clone(), Path::new("/unused/skills"), true)
|
||||
.expect("prepare");
|
||||
assert!(prepared.is_none());
|
||||
assert_eq!(remaining, base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_adapter_rejects_inherited_pi_command() {
|
||||
let error = PiLaunchOverride::prepare(
|
||||
"pi-acp",
|
||||
Some("Buzz base".to_string()),
|
||||
Path::new("/unused/skills"),
|
||||
true,
|
||||
)
|
||||
.err()
|
||||
.expect("inherited PI_ACP_PI_COMMAND must be rejected");
|
||||
|
||||
assert_eq!(error.kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(error.to_string().contains("managed by Buzz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_base_prompt_still_creates_pi_skills_launcher() {
|
||||
let (prepared, remaining) =
|
||||
PiLaunchOverride::prepare("pi-acp", None, Path::new("/unused/skills"), false)
|
||||
.expect("prepare");
|
||||
let prepared = prepared.expect("Pi skills launcher");
|
||||
assert!(remaining.is_none());
|
||||
assert!(!prepared.directory.join("SYSTEM.md").exists());
|
||||
|
||||
#[cfg(unix)]
|
||||
assert!(fs::read_to_string(prepared.launcher_path())
|
||||
.expect("read launcher")
|
||||
.contains("--skill '/unused/skills'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_adapter_moves_buzz_base_out_of_ordinary_acp_delivery() {
|
||||
let base = crate::scope::SessionPolicy::Thread
|
||||
.append_session_model(include_str!("base_prompt.md"));
|
||||
let (prepared, remaining) = PiLaunchOverride::prepare(
|
||||
"/opt/bin/pi-acp",
|
||||
Some(base.clone()),
|
||||
Path::new("/buzz/.agents/skills"),
|
||||
false,
|
||||
)
|
||||
.expect("prepare");
|
||||
let prepared = prepared.expect("Pi launcher");
|
||||
|
||||
assert!(remaining.is_none());
|
||||
assert_eq!(
|
||||
fs::read_to_string(prepared.directory.join("SYSTEM.md")).expect("read prompt"),
|
||||
base
|
||||
);
|
||||
assert!(base.contains("each thread gets its own"));
|
||||
|
||||
#[cfg(unix)]
|
||||
assert!(fs::read_to_string(prepared.launcher_path())
|
||||
.expect("read launcher")
|
||||
.contains("exec 'pi'"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn pi_launcher_replaces_system_prompt_and_forwards_adapter_args() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::process::Command;
|
||||
|
||||
let fixture_dir =
|
||||
std::env::temp_dir().join(format!("buzz-acp-pi-system-prompt-test-{}", Uuid::new_v4()));
|
||||
create_private_directory(&fixture_dir).expect("create fixture dir");
|
||||
let capture_path = fixture_dir.join("args.txt");
|
||||
let fake_pi = fixture_dir.join("fake-pi");
|
||||
let managed_skills_dir = fixture_dir.join("managed skills");
|
||||
let fake_script = format!(
|
||||
"#!/bin/sh\nprintf '%s\\n' \"$@\" > {}\n",
|
||||
shell_quote(capture_path.as_os_str()).expect("quote capture path")
|
||||
);
|
||||
write_private_file(&fake_pi, fake_script.as_bytes(), true).expect("write fake pi");
|
||||
|
||||
let prepared = PiLaunchOverride::create(
|
||||
fake_pi.to_str().expect("UTF-8 fake Pi path"),
|
||||
Some("Buzz base\n\n## Session Model\nThread scoped"),
|
||||
&managed_skills_dir,
|
||||
)
|
||||
.expect("prepare Pi launcher");
|
||||
let prompt_path = prepared.directory.join("SYSTEM.md");
|
||||
|
||||
let status = Command::new(prepared.launcher_path())
|
||||
.args(["--mode", "rpc", "--session", "/tmp/session.jsonl"])
|
||||
.status()
|
||||
.expect("run launcher");
|
||||
assert!(status.success());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&capture_path).expect("read captured args"),
|
||||
format!(
|
||||
"--system-prompt\n{}\n--skill\n{}\n--mode\nrpc\n--session\n/tmp/session.jsonl\n",
|
||||
prompt_path.display(),
|
||||
managed_skills_dir.display(),
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&prompt_path).expect("read system prompt"),
|
||||
"Buzz base\n\n## Session Model\nThread scoped"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&prompt_path)
|
||||
.expect("prompt metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(prepared.launcher_path())
|
||||
.expect("launcher metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
assert_eq!(
|
||||
fs::metadata(&prepared.directory)
|
||||
.expect("directory metadata")
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o700
|
||||
);
|
||||
|
||||
drop(prepared);
|
||||
assert!(!prompt_path.exists());
|
||||
fs::remove_dir_all(fixture_dir).expect("remove fixture dir");
|
||||
}
|
||||
}
|
||||
@@ -1130,7 +1130,6 @@ pub fn discover_acp_runtimes_from(
|
||||
// Track all ids seen so far (builtins) to prevent preset/custom collisions.
|
||||
let mut seen_ids: std::collections::HashSet<String> =
|
||||
entries.iter().map(|e| e.id.clone()).collect();
|
||||
|
||||
// Phase 2.5: insert static preset entries (PATH-probed, not editable/deletable).
|
||||
for def in PRESET_HARNESSES {
|
||||
if seen_ids.contains(def.id) {
|
||||
|
||||
@@ -16,11 +16,10 @@ pub(super) struct PresetHarness {
|
||||
install_instructions_url: &'static str,
|
||||
install_hint: &'static str,
|
||||
/// Vendor CLI the ACP command wraps, when the preset is an adapter.
|
||||
///
|
||||
/// Consulted only when the adapter is absent, so `AdapterMissing` replaces
|
||||
/// `NotInstalled` when the CLI is present but the adapter is not. `None`
|
||||
/// when the command is itself the vendor CLI.
|
||||
underlying_cli: Option<&'static str>,
|
||||
/// State-specific setup guidance for the wrapped vendor CLI.
|
||||
underlying_cli_install_hint: Option<&'static str>,
|
||||
underlying_cli_install_instructions_url: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// Build one preset catalog entry through an injectable command resolver.
|
||||
@@ -28,28 +27,44 @@ pub(super) fn preset_catalog_entry(
|
||||
def: &PresetHarness,
|
||||
resolve: impl Fn(&str) -> Option<PathBuf>,
|
||||
) -> AcpRuntimeCatalogEntry {
|
||||
let (availability, command, binary_path) = match resolve(def.command) {
|
||||
Some(path) => (
|
||||
AcpAvailabilityStatus::Available,
|
||||
Some(def.command.to_string()),
|
||||
Some(path.display().to_string()),
|
||||
),
|
||||
None => {
|
||||
let underlying_cli_found = def
|
||||
.underlying_cli
|
||||
.map(|cli| resolve(cli).is_some())
|
||||
.unwrap_or(false);
|
||||
if underlying_cli_found {
|
||||
(AcpAvailabilityStatus::AdapterMissing, None, None)
|
||||
} else {
|
||||
(AcpAvailabilityStatus::NotInstalled, None, None)
|
||||
}
|
||||
}
|
||||
};
|
||||
let underlying_cli_path = def
|
||||
.underlying_cli
|
||||
.and_then(resolve)
|
||||
.and_then(&resolve)
|
||||
.map(|path| path.display().to_string());
|
||||
let (availability, command, binary_path) = super::classify_runtime(
|
||||
resolve(def.command).map(|path| (def.command, path)),
|
||||
def.underlying_cli,
|
||||
underlying_cli_path.is_some(),
|
||||
);
|
||||
|
||||
let cli_install_hint = def.underlying_cli.map(|cli| {
|
||||
def.underlying_cli_install_hint
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| {
|
||||
format!(
|
||||
"Install the {} CLI and make sure {} is on your PATH.",
|
||||
def.label, cli
|
||||
)
|
||||
})
|
||||
});
|
||||
let install_hint = match availability {
|
||||
AcpAvailabilityStatus::Available if def.underlying_cli.is_some() => String::new(),
|
||||
AcpAvailabilityStatus::CliMissing => cli_install_hint.unwrap_or_default(),
|
||||
AcpAvailabilityStatus::NotInstalled if def.underlying_cli.is_some() => {
|
||||
format!(
|
||||
"{} {}",
|
||||
cli_install_hint.unwrap_or_default(),
|
||||
def.install_hint
|
||||
)
|
||||
}
|
||||
_ => def.install_hint.to_string(),
|
||||
};
|
||||
let install_instructions_url = match availability {
|
||||
AcpAvailabilityStatus::CliMissing | AcpAvailabilityStatus::NotInstalled => def
|
||||
.underlying_cli_install_instructions_url
|
||||
.unwrap_or(def.install_instructions_url),
|
||||
_ => def.install_instructions_url,
|
||||
};
|
||||
|
||||
AcpRuntimeCatalogEntry {
|
||||
id: def.id.to_string(),
|
||||
@@ -71,12 +86,10 @@ pub(super) fn preset_catalog_entry(
|
||||
max_tokens_env_var: None,
|
||||
context_limit_env_var: None,
|
||||
max_rounds_env_var: None,
|
||||
install_hint: def.install_hint.to_string(),
|
||||
install_instructions_url: def.install_instructions_url.to_string(),
|
||||
install_hint,
|
||||
install_instructions_url: install_instructions_url.to_string(),
|
||||
can_auto_install: false,
|
||||
// Presets carry one flat install hint, so builtin external-CLI copy
|
||||
// would name the wrong missing component for adapter presets.
|
||||
requires_external_cli: false,
|
||||
requires_external_cli: def.underlying_cli.is_some(),
|
||||
underlying_cli_path,
|
||||
node_required: false,
|
||||
auth_status: AuthStatus::NotApplicable,
|
||||
@@ -97,8 +110,14 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
command: "pi-acp",
|
||||
args: &[],
|
||||
install_instructions_url: "https://github.com/svkozak/pi-acp",
|
||||
install_hint: "Buzz talks to Pi through the pi-acp adapter. Install Pi with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, then install the adapter with `npm install -g pi-acp`.",
|
||||
install_hint: "Install the Pi ACP adapter with npm install -g pi-acp.",
|
||||
underlying_cli: Some("pi"),
|
||||
underlying_cli_install_hint: Some(
|
||||
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent.",
|
||||
),
|
||||
underlying_cli_install_instructions_url: Some(
|
||||
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent",
|
||||
),
|
||||
},
|
||||
PresetHarness {
|
||||
id: "devin",
|
||||
@@ -108,6 +127,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://docs.devin.ai/cli",
|
||||
install_hint: "Buzz talks to Devin through the official Devin CLI's ACP mode (devin acp).",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "cursor",
|
||||
@@ -117,6 +138,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://cursor.com/downloads",
|
||||
install_hint: "Buzz talks to Cursor through the cursor-agent CLI's ACP mode.",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "omp",
|
||||
@@ -126,6 +149,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://omp.sh/",
|
||||
install_hint: "Buzz talks to Oh My Pi through its CLI's ACP mode (omp acp).",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "grok",
|
||||
@@ -135,6 +160,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
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,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "opencode",
|
||||
@@ -144,6 +171,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://opencode.ai/docs",
|
||||
install_hint: "Buzz talks to OpenCode through its CLI's ACP mode (opencode acp).",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "kimi",
|
||||
@@ -153,6 +182,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
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,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "amp",
|
||||
@@ -162,6 +193,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
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"),
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "hermes",
|
||||
@@ -171,6 +204,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
install_instructions_url: "https://hermes-agent.nousresearch.com",
|
||||
install_hint: "Buzz talks to Hermes Agent through its hermes-acp command.",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
PresetHarness {
|
||||
id: "openclaw",
|
||||
@@ -187,6 +222,8 @@ pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[
|
||||
needs BUZZ_* credentials at execution time, set them on the \
|
||||
Gateway's own environment separately.",
|
||||
underlying_cli: None,
|
||||
underlying_cli_install_hint: None,
|
||||
underlying_cli_install_instructions_url: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -303,6 +340,8 @@ mod tests {
|
||||
install_instructions_url: "https://example.com/install",
|
||||
install_hint: "Install the amp-acp npm adapter.",
|
||||
underlying_cli: Some("amp"),
|
||||
underlying_cli_install_hint: Some("Install the Amp Test CLI."),
|
||||
underlying_cli_install_instructions_url: Some("https://example.com/amp"),
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -377,6 +416,8 @@ mod tests {
|
||||
assert_eq!(available.availability, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(available.command.as_deref(), Some("pi-acp"));
|
||||
assert!(available.default_args.is_empty());
|
||||
assert!(available.install_hint.is_empty());
|
||||
assert!(available.requires_external_cli);
|
||||
assert_eq!(
|
||||
available.underlying_cli_path.as_deref(),
|
||||
Some("/usr/local/bin/pi")
|
||||
@@ -391,12 +432,38 @@ mod tests {
|
||||
);
|
||||
assert!(adapter_missing.command.is_none());
|
||||
assert!(adapter_missing.default_args.is_empty());
|
||||
assert_eq!(
|
||||
adapter_missing.install_hint,
|
||||
"Install the Pi ACP adapter with npm install -g pi-acp."
|
||||
);
|
||||
assert_eq!(
|
||||
adapter_missing.install_instructions_url,
|
||||
"https://github.com/svkozak/pi-acp"
|
||||
);
|
||||
|
||||
let cli_missing = preset_catalog_entry(preset, |command| {
|
||||
(command == "pi-acp").then(|| PathBuf::from("/usr/local/bin/pi-acp"))
|
||||
});
|
||||
assert_eq!(cli_missing.availability, AcpAvailabilityStatus::CliMissing);
|
||||
assert_eq!(cli_missing.command.as_deref(), Some("pi-acp"));
|
||||
assert_eq!(
|
||||
cli_missing.install_hint,
|
||||
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent."
|
||||
);
|
||||
assert_eq!(
|
||||
cli_missing.install_instructions_url,
|
||||
"https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent"
|
||||
);
|
||||
|
||||
let not_installed = preset_catalog_entry(preset, |_| None);
|
||||
assert_eq!(
|
||||
not_installed.availability,
|
||||
AcpAvailabilityStatus::NotInstalled
|
||||
);
|
||||
assert_eq!(
|
||||
not_installed.install_hint,
|
||||
"Install Pi with npm install -g --ignore-scripts @earendil-works/pi-coding-agent. Install the Pi ACP adapter with npm install -g pi-acp."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -411,8 +478,12 @@ mod tests {
|
||||
entry.underlying_cli_path.as_deref(),
|
||||
Some("/usr/local/bin/amp")
|
||||
);
|
||||
assert!(!entry.requires_external_cli);
|
||||
assert!(entry.requires_external_cli);
|
||||
assert_eq!(entry.install_hint, "Install the amp-acp npm adapter.");
|
||||
assert_eq!(
|
||||
entry.install_instructions_url,
|
||||
"https://example.com/install"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -420,7 +491,12 @@ mod tests {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |_| None);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled);
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
assert!(!entry.requires_external_cli);
|
||||
assert!(entry.requires_external_cli);
|
||||
assert_eq!(
|
||||
entry.install_hint,
|
||||
"Install the Amp Test CLI. Install the amp-acp npm adapter."
|
||||
);
|
||||
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -437,17 +513,20 @@ mod tests {
|
||||
entry.underlying_cli_path.as_deref(),
|
||||
Some("/usr/local/bin/amp")
|
||||
);
|
||||
assert!(entry.install_hint.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapter_presence_is_enough_for_availability() {
|
||||
fn adapter_without_underlying_cli_reports_cli_missing() {
|
||||
let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| {
|
||||
(command == "amp-acp").then(|| PathBuf::from("/usr/local/bin/amp-acp"))
|
||||
});
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::Available);
|
||||
assert_eq!(entry.availability, AcpAvailabilityStatus::CliMissing);
|
||||
assert_eq!(entry.command.as_deref(), Some("amp-acp"));
|
||||
assert_eq!(entry.binary_path.as_deref(), Some("/usr/local/bin/amp-acp"));
|
||||
assert!(entry.underlying_cli_path.is_none());
|
||||
assert_eq!(entry.install_hint, "Install the Amp Test CLI.");
|
||||
assert_eq!(entry.install_instructions_url, "https://example.com/amp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -145,6 +145,14 @@ fn reserved_keys_include_agent_owner_for_legacy_records() {
|
||||
assert!(merged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_keys_include_pi_acp_command() {
|
||||
assert!(is_reserved_env_key("PI_ACP_PI_COMMAND"));
|
||||
let agent = map(&[("PI_ACP_PI_COMMAND", "/tmp/custom-pi")]);
|
||||
let merged = merged_user_env(&BTreeMap::new(), &agent);
|
||||
assert!(merged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_keys_include_respond_to_gate() {
|
||||
// Respond-to mode + allowlist control who the agent answers.
|
||||
|
||||
@@ -41,6 +41,9 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[
|
||||
"BUZZ_ACP_AGENT_COMMAND",
|
||||
"BUZZ_ACP_AGENT_ARGS",
|
||||
"BUZZ_ACP_MCP_COMMAND",
|
||||
// pi-acp's executable override is reserved for Buzz's generated launcher,
|
||||
// which injects the managed system prompt and skills.
|
||||
"PI_ACP_PI_COMMAND",
|
||||
// Control-plane parallelism: the Desktop resolves the effective
|
||||
// worker-pool size (applying any per-harness cap) and writes it into
|
||||
// launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the
|
||||
|
||||
Reference in New Issue
Block a user