Skip to content
Merged
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
3 changes: 2 additions & 1 deletion crates/agent-controller/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ mod create;
mod credentials;
mod import;
mod ownership;
pub mod pi;
mod process;
mod runtime;
mod secret;
Expand All @@ -17,7 +18,7 @@ pub use config::{AgentEdit, AgentView, ControlSnapshot, HarnessEdit, ProcessStat
pub use create::{CreationProfile, NewAgent};
pub use credentials::PlatformCredentials;
pub use import::{CredentialedImport, ImportPreview, Imports, LegacySource, PreparedImport};
pub use runtime::{Action, Controller, GooseModelContext, ModelContext};
pub use runtime::{installed, Action, Controller, GooseModelContext, ModelContext};
pub use secret::{Credentials, Secret};
pub use store::Store;
type Result<T> = std::result::Result<T, String>;
180 changes: 180 additions & 0 deletions crates/agent-controller/src/pi.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
//! Pi's supported CLI boundary, shared by discovery and ACP launch.
use crate::{
runtime::{executable, installed},
HarnessEdit, Result,
};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};

/// Native only: contains local environment values, never serialized over IPC.
pub struct PiContext {
pub command: PathBuf,
pub workspace: PathBuf,
pub args: Vec<String>,
pub environment: BTreeMap<String, String>,
pub path: std::ffi::OsString,
}
impl PiContext {
pub(crate) fn new(
harness: &HarnessEdit,
workspace: &str,
environment: &BTreeMap<String, String>,
) -> Result<Self> {
let adapter = Path::new(&harness.command);
if !adapter.is_absolute()
|| adapter.file_name().and_then(|n| n.to_str()) != Some("buzz-pi-acp")
{
return Err("Pi requires an absolute buzz-pi-acp executable path".into());
}
executable(adapter)?;
if !Path::new(workspace).is_absolute() || !Path::new(workspace).is_dir() {
return Err("Choose an existing absolute workspace before browsing Pi models".into());
}
crate::config::validate_environment(environment)?;
// The adapter owns its supported runtime flags. Discovery applies its
// narrower, prompt-free contract separately.
let args = &harness.args;
if !args.is_empty() && args.first().map(String::as_str) != Some("--") {
return Err("Pi arguments must follow --".into());
}
if args.len() > 128
|| args
.iter()
.any(|a| a.is_empty() || a.len() > 8192 || a.contains([',', '\0']))
{
return Err("Invalid Pi arguments".into());
}
let resolve = |name| {
let sibling = adapter.parent().unwrap().join(name);
if executable(&sibling).is_ok() {
Some(sibling)
} else {
installed(name)
}
};
let command = resolve("pi").ok_or("Install Pi and reopen the desktop app")?;
let node = resolve("node").ok_or("Install Node.js for the Pi ACP adapter")?;
let path = std::env::join_paths([
node.parent().unwrap(),
command.parent().unwrap(),
Path::new("/usr/bin"),
Path::new("/bin"),
Path::new("/usr/sbin"),
Path::new("/sbin"),
])
.map_err(|_| "Invalid Pi tools path")?;
let mut environment = environment.clone();
environment.insert(
"PI_ACP_PI_COMMAND".into(),
command.to_string_lossy().into_owned(),
);
Ok(Self {
command,
workspace: workspace.into(),
args: args.iter().skip(1).cloned().collect(),
environment,
path,
})
}

pub fn catalog_args(&self) -> Result<Vec<String>> {
let mut result = Vec::new();
let mut args = self.args.iter();
while let Some(arg) = args.next() {
// Match Pi's separate-token CLI syntax exactly. Do not normalize
// options differently from runtime or rebind --api-key to a default
// provider after removing selection for this catalog-only process.
let flag = arg.as_str();
let selection = matches!(flag, "--provider" | "--model");
let takes_value = selection
|| matches!(
flag,
"--extension"
| "-e"
| "--skill"
| "--prompt-template"
| "--theme"
| "--thinking"
| "--tools"
| "-t"
| "--exclude-tools"
| "-xt"
| "--models"
);
if takes_value {
let value = args
.next()
.map(String::as_str)
.filter(|v| !v.is_empty() && !v.starts_with('-'))
.ok_or("Pi option is missing a value; check Advanced arguments")?;
if !selection {
result.extend([flag.to_owned(), value.to_owned()]);
}
} else if matches!(
flag,
"--no-extensions"
| "-ne"
| "--no-skills"
| "-ns"
| "--no-prompt-templates"
| "-np"
| "--no-themes"
| "--no-tools"
| "-nt"
| "--no-builtin-tools"
| "-nbt"
| "--no-context-files"
| "-nc"
| "--offline"
| "--approve"
| "-a"
| "--no-approve"
| "-na"
) {
result.push(arg.clone());
} else {
return Err("Pi model browsing does not support these Advanced arguments. Runtime arguments are preserved; use a custom model ID or adjust the arguments to browse".into());
}
}
Ok(result)
}

pub(crate) fn adapter_args(&self, harness: &HarnessEdit) -> Result<Vec<String>> {
validate_selection(&harness.provider, &harness.model)?;
let mut args = vec!["--".into()];
args.extend(self.args.clone());
if !harness.provider.is_empty() {
args.extend(["--provider".into(), harness.provider.clone()]);
}
if !harness.model.is_empty() {
args.extend(["--model".into(), harness.model.clone()]);
}
Ok(args)
}
}

/// Selection constraints shared by catalog discovery and ACP launch.
/// Empty selectors retain Pi defaults; model IDs may contain namespace slashes.
pub fn validate_selection(provider: &str, model: &str) -> Result<()> {
if !provider.is_empty() && model.is_empty() {
return Err(
"Choose a Pi model for the selected provider, or clear both fields to use Pi defaults"
.into(),
);
}
if provider.contains('/') {
return Err("Invalid Pi provider or model ID".into());
}
for (value, limit) in [(provider, 128), (model, 512)] {
if value.len() > limit
|| value.starts_with('-')
|| value.contains(',')
|| value.chars().any(char::is_control)
{
return Err("Invalid Pi provider or model ID".into());
}
}
Ok(())
}
120 changes: 86 additions & 34 deletions crates/agent-controller/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,37 @@ impl RuntimeBundle {
command.env(name, value);
}
}
let path = std::env::join_paths([
self.directory.as_path(),
Path::new("/usr/bin"),
Path::new("/bin"),
Path::new("/usr/sbin"),
Path::new("/sbin"),
])
let pi = (worker.file_name().and_then(|n| n.to_str()) == Some("buzz-pi-acp"))
.then(|| {
crate::pi::PiContext::new(&agent.harness, &agent.workspace, &agent.environment)
})
.transpose()?;
let (args, environment, tools_path) = if let Some(pi) = &pi {
(
pi.adapter_args(&agent.harness)?,
&pi.environment,
pi.path.clone(),
)
} else {
(
agent.harness.args.clone(),
&agent.environment,
"/usr/bin:/bin:/usr/sbin:/sbin".into(),
)
};
let path = std::env::join_paths(
std::iter::once(self.directory.clone()).chain(std::env::split_paths(&tools_path)),
)
.map_err(|_| "Invalid runtime tools path")?;
command.envs(&agent.environment);
command.env("PATH", path);
command.envs(environment).env("PATH", path);
let key_hex = key.hex();
command
.env("BUZZ_PRIVATE_KEY", &*key_hex)
.env("NOSTR_PRIVATE_KEY", &*key_hex)
.env("BUZZ_RELAY_URL", &agent.relay_url)
.env("BUZZ_AUTH_TAG", agent.auth_tag.as_deref().unwrap_or(""))
.env("BUZZ_ACP_AGENT_COMMAND", worker)
.env("BUZZ_ACP_AGENT_ARGS", agent.harness.args.join(","))
.env("BUZZ_ACP_AGENT_ARGS", args.join(","))
.env("BUZZ_ACP_SYSTEM_PROMPT", &agent.system_prompt)
.env("BUZZ_ACP_DISPLAY_NAME", &agent.name)
.env("BUZZ_ACP_LAZY_POOL", "true")
Expand All @@ -111,6 +124,7 @@ impl RuntimeBundle {
let mapping = match worker_name {
"buzz-agent" => Some(("BUZZ_AGENT_MODEL", "BUZZ_AGENT_PROVIDER")),
"goose" => Some(("GOOSE_MODEL", "GOOSE_PROVIDER")),
"buzz-pi-acp" => None,
_ if !agent.harness.provider.is_empty() => return Err("Set provider configuration through this external harness's environment; a provider selector mapping is not available".into()),
_ => None,
};
Expand All @@ -136,6 +150,11 @@ impl RuntimeBundle {
}
}
if let Some(value) = model {
let value = if pi.is_some() && !agent.harness.provider.is_empty() {
format!("{}/{value}", agent.harness.provider)
} else {
value.to_owned()
};
command.env("BUZZ_ACP_MODEL", value);
}
if respond_to == "allowlist" {
Expand Down Expand Up @@ -211,6 +230,24 @@ fn effective_databricks(agent: &Agent) -> Result<Option<crate::connection::Datab
settings.validate()?;
Ok(Some(settings))
}
pub fn installed(name: &str) -> Option<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = std::env::var_os("HOME") {
dirs.push(PathBuf::from(home).join(".local/bin"));
}
dirs.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
dirs.extend([
PathBuf::from("/opt/homebrew/bin"),
PathBuf::from("/usr/local/bin"),
]);
dirs.into_iter()
.filter(|p| p.is_absolute())
.map(|p| p.join(name))
.find(|p| executable(p).is_ok())
}

pub(crate) fn executable(path: &Path) -> Result<()> {
let metadata = path
.metadata()
Expand Down Expand Up @@ -321,20 +358,7 @@ impl Controller {
/// Native-only catalog configuration. Never serialize environment values or
/// lend runtime credentials to model discovery. Resolve an unsaved edit on a
/// clone using the same validation and precedence as Save/runtime.
pub fn model_context(&self, id: &str, revision: u64, edit: AgentEdit) -> Result<ModelContext> {
let agent = self.edited_model_agent(id, revision, edit)?;
model_context(&agent.harness, &agent.environment)
}
pub fn goose_model_context(
&self,
id: &str,
revision: u64,
edit: AgentEdit,
) -> Result<GooseModelContext> {
let agent = self.edited_model_agent(id, revision, edit)?;
goose_model_context(&agent.harness, &agent.environment)
}
fn edited_model_agent(&self, id: &str, revision: u64, edit: AgentEdit) -> Result<Agent> {
fn edited_agent(&self, id: &str, revision: u64, edit: AgentEdit) -> Result<Agent> {
let mut agent = self
.store
.agents()?
Expand All @@ -349,20 +373,41 @@ impl Controller {
agent.apply(edit)?;
Ok(agent)
}
pub fn model_context(&self, id: &str, revision: u64, edit: AgentEdit) -> Result<ModelContext> {
let agent = self.edited_agent(id, revision, edit)?;
model_context(&agent.harness, &agent.environment)
}
pub fn goose_model_context(
&self,
id: &str,
revision: u64,
edit: AgentEdit,
) -> Result<GooseModelContext> {
let agent = self.edited_agent(id, revision, edit)?;
goose_model_context(&agent.harness, &agent.environment)
}
pub fn pi_model_context(
&self,
id: &str,
revision: u64,
edit: AgentEdit,
) -> Result<crate::pi::PiContext> {
let agent = self.edited_agent(id, revision, edit)?;
crate::pi::PiContext::new(&agent.harness, &agent.workspace, &agent.environment)
}
pub fn draft_pi_model_context(edit: AgentEdit) -> Result<crate::pi::PiContext> {
crate::pi::PiContext::new(
&edit.harness,
&edit.workspace,
&draft_environment(edit.environment),
)
}
pub fn draft_goose_model_context(edit: AgentEdit) -> Result<GooseModelContext> {
let environment = edit
.environment
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect();
let environment = draft_environment(edit.environment);
goose_model_context(&edit.harness, &environment)
}
pub fn draft_model_context(edit: AgentEdit) -> Result<ModelContext> {
let environment = edit
.environment
.into_iter()
.filter_map(|(key, value)| value.map(|value| (key, value)))
.collect();
let environment = draft_environment(edit.environment);
model_context(&edit.harness, &environment)
}
pub fn requires_legacy_handover(&self, id: &str) -> Result<bool> {
Expand Down Expand Up @@ -605,6 +650,13 @@ impl Drop for Controller {
#[cfg(test)]
mod tests;

fn draft_environment(patch: BTreeMap<String, Option<String>>) -> BTreeMap<String, String> {
patch
.into_iter()
.filter_map(|(key, value)| value.map(|v| (key, v)))
.collect()
}

fn model_context(
harness: &crate::HarnessEdit,
environment: &BTreeMap<String, String>,
Expand Down
Loading
Loading