remove goose dep

Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
This commit is contained in:
Jack Amadeo
2026-09-01 21:04:01 -04:00
committed by Jimmy
parent c2564888ca
commit 6c3e52f21f
10 changed files with 556 additions and 1737 deletions

1562
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,25 +44,15 @@ path = "tests/bin/auth_worker.rs"
[dependencies]
# For the `buzz-agent auth <provider>` subcommand only. Provider auth for the
# agent loop itself is goose's; this is the interactive PKCE login that seeds
# the token cache that goose (and the desktop model picker) then read.
# agent loop itself is GDK-based; this is the interactive PKCE login that seeds
# the token cache used by inference and the desktop model picker.
buzz-model-catalog = { path = "../buzz-model-catalog" }
goose = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565", default-features = false, features = ["rustls-tls"] }
# Consume the GDK loop primitives from their owning crate. Keep this on the
# exact same Goose revision as `goose` so shared conversation types stay unified.
goose-agent = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565" }
goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565" }
goose-context-management = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565" }
goose-sdk-types = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565" }
# ModelConfig: goose re-exports it from goose-providers; goose::model_config
# keeps its own copy private.
goose-providers = { git = "https://github.com/aaif-goose/goose", rev = "0a9749b1cbf38d182080a53286c4942629f66565" }
# Resolution guard: goose pins icu_locale "=2.1.1" (needs icu_collections
# ~2.1.1) while url 2.5.x -> idna -> idna_adapter 1.2.2 pulls icu_normalizer
# 2.2.0 (needs icu_collections ~2.2.0). Only one 2.x icu_collections can be
# selected. 1.2.0 is the last idna_adapter on ICU4X 1.x, which keeps IDNA off
# the icu_collections 2.x line entirely and lets both resolve.
idna_adapter = "=1.2.0"
# Keep all GDK crates on the exact same Goose revision so shared types stay unified.
goose-agent = { git = "https://github.com/aaif-goose/goose", rev = "5a9a13b72aaf8f9aa17ce064bd4d4d761d656982" }
goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "5a9a13b72aaf8f9aa17ce064bd4d4d761d656982" }
goose-context-management = { git = "https://github.com/aaif-goose/goose", rev = "5a9a13b72aaf8f9aa17ce064bd4d4d761d656982" }
goose-sdk-types = { git = "https://github.com/aaif-goose/goose", rev = "5a9a13b72aaf8f9aa17ce064bd4d4d761d656982" }
goose-providers = { git = "https://github.com/aaif-goose/goose", rev = "5a9a13b72aaf8f9aa17ce064bd4d4d761d656982" }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
anyhow = "1"

View File

@@ -122,8 +122,8 @@ fn parse_approval(raw: Option<&str>) -> GooseMode {
/// Translate a Buzz provider id into the name goose's registry knows.
///
/// Unknown names pass through untouched — goose owns the registry, so
/// gatekeeping here would break every provider it gains that we don't list.
/// Unknown names pass through so provider construction can return the
/// authoritative supported-provider error.
fn goose_provider_name(provider: &str) -> &str {
match provider {
// Buzz's OpenAI-wire-compatible providers; goose calls them `openai`.
@@ -656,7 +656,7 @@ mod tests {
#[test]
fn unknown_providers_pass_through_untouched() {
// Goose owns the registry; we must not gatekeep names we don't know.
// Keep translation separate from the finite provider factory.
assert_eq!(goose_provider_name("anthropic"), "anthropic");
assert_eq!(
goose_provider_name("some_future_provider"),

View File

@@ -26,6 +26,7 @@ pub mod hooks;
pub mod loop_drive;
pub mod mcp;
pub mod model;
mod model_config;
pub mod ops;
pub mod permission;
pub mod prompt;
@@ -153,9 +154,8 @@ async fn build_agent(
.or_else(|| std::env::var("GOOSE_MODEL").ok())
.ok_or_else(|| AgentError::Llm("no model configured".into()))?;
let provider = build_provider(&provider_name).await?;
let model_config =
goose::model_config::model_config_from_user_config(&provider_name, &model_name)
.map_err(|error| AgentError::LlmModelNotFound(error.to_string()))?;
let model_config = crate::model_config::from_env(&provider_name, &model_name)
.map_err(|error| AgentError::LlmModelNotFound(error.to_string()))?;
let model = crate::model::SessionModel::new(provider, model_config, model_name);
let prompt = crate::prompt::SessionPrompt::new(cfg.goose_mode);
@@ -895,8 +895,8 @@ async fn cancel_session(app: &Arc<App>, params: Value) {
/// Swap the model for an existing session.
///
/// Rebuilds the provider (base-url/credential resolution lives in goose's
/// registry) and installs it with the new `ModelConfig`. `SharedProvider` is an
/// Rebuilds the configured Buzz provider and installs it with the new
/// `ModelConfig`. `SharedProvider` is an
/// `Arc<Mutex<Option<..>>>` precisely so this is hot-swappable
/// (goose `agents/types.rs:11-12`).
/// Construct the goose provider. See [`crate::provider`].
@@ -909,7 +909,7 @@ async fn build_provider(
async fn apply_model(model: &crate::model::SessionModel, model_id: &str) -> Result<(), AgentError> {
let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let provider = build_provider(&provider_name).await?;
let model_config = goose::model_config::model_config_from_user_config(&provider_name, model_id)
let model_config = crate::model_config::from_env(&provider_name, model_id)
.map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?;
// Swapped on buzz's own handle. `Agent::update_provider` would do the same
// thing plus a write to goose's session row; see [`crate::model`].

View File

@@ -518,7 +518,11 @@ async fn infer(
) -> Result<Option<Inference>, AgentError> {
let (provider, model_config, _model_id) = ctx.model.snapshot().await;
let system_prompt = ctx.prompt.build(&session.working_dir).await;
let system_prompt = ctx
.prompt
.build(&session.working_dir)
.await
.map_err(|error| AgentError::Llm(format!("system prompt: {error}")))?;
let tools = ctx.mcp.rmcp_tools();
let mut stream = provider

View File

@@ -35,6 +35,64 @@ pub struct ResultBudget {
pub text: usize,
}
fn discover_skills(
working_dir: &std::path::Path,
) -> Vec<goose_sdk_types::custom_requests::SourceEntry> {
use goose_agent::skills::{SkillDiscoveryOptions, SkillRoot, SkillScope};
let mut roots = Vec::new();
if let Some(home) = dirs::home_dir() {
for (precedence, path) in [
home.join(".agents/skills"),
home.join(".config/goose/skills"),
home.join(".claude/skills"),
]
.into_iter()
.enumerate()
{
roots.push(SkillRoot {
path,
scope: SkillScope::Global,
precedence: precedence as u32,
writable: false,
preserve_path: false,
});
}
}
for (offset, path) in [
working_dir.join(".agents/skills"),
working_dir.join(".goose/skills"),
working_dir.join(".claude/skills"),
]
.into_iter()
.enumerate()
{
roots.push(SkillRoot {
path,
scope: SkillScope::Project,
precedence: 100 + offset as u32,
writable: false,
preserve_path: false,
});
}
match goose_agent::skills::discover_skills(&SkillDiscoveryOptions {
roots,
working_dir: Some(working_dir.to_path_buf()),
}) {
Ok(discovery) => {
for diagnostic in discovery.diagnostics {
tracing::warn!(path = %diagnostic.path.display(), error = diagnostic.message, "skill ignored");
}
discovery.skills
}
Err(error) => {
tracing::warn!(%error, "skill discovery failed");
Vec::new()
}
}
}
const PASSTHROUGH_ENV: &[&str] = &[
// Core
"PATH",
@@ -216,12 +274,7 @@ impl McpRegistry {
hook_timeout: Duration::from_millis(
env_u64("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2_500).max(1),
),
skills: goose::skills::discover_skills(Some(std::path::Path::new(cwd)))
.into_iter()
.filter(|skill| {
skill.source_type != goose_sdk_types::custom_requests::SourceType::BuiltinSkill
})
.collect(),
skills: discover_skills(std::path::Path::new(cwd)),
};
let mut seen_names = HashSet::new();

View File

@@ -0,0 +1,76 @@
use anyhow::{bail, Result};
use goose_provider_types::thinking::ThinkingEffort;
use goose_providers::model::ModelConfig;
use std::collections::HashMap;
pub fn from_env(provider_name: &str, model_name: &str) -> Result<ModelConfig> {
let temperature = parse_optional::<f32>("GOOSE_TEMPERATURE")?;
if temperature.is_some_and(|value| value < 0.0) {
bail!("GOOSE_TEMPERATURE must be non-negative");
}
let max_tokens = parse_optional::<i32>("GOOSE_MAX_TOKENS")?;
if max_tokens.is_some_and(|value| value <= 0) {
bail!("GOOSE_MAX_TOKENS must be greater than zero");
}
let toolshim = optional_env("GOOSE_TOOLSHIM")
.map(|value| parse_bool("GOOSE_TOOLSHIM", &value))
.transpose()?
.unwrap_or(false);
let toolshim_model = optional_env("GOOSE_TOOLSHIM_OLLAMA_MODEL");
let mut model = ModelConfig::new(model_name)
.with_temperature(temperature)
.with_max_tokens(max_tokens)
.with_toolshim(toolshim)
.with_toolshim_model(toolshim_model);
if let Some(effort) = optional_env("GOOSE_THINKING_EFFORT") {
model = model.with_default_thinking_effort(Some(
effort
.parse::<ThinkingEffort>()
.map_err(anyhow::Error::msg)?,
));
}
if let Some(ttl) = optional_env("GOOSE_CACHE_TTL") {
let ttl = ttl.to_ascii_lowercase();
if !matches!(ttl.as_str(), "5m" | "1h") {
bail!("GOOSE_CACHE_TTL must be '5m' or '1h'");
}
model = model.with_cache_ttl(&ttl);
}
if provider_name == "openai" {
if let Some(store) = optional_env("OPENAI_STORE") {
model = model.with_merged_request_params(HashMap::from([(
"store".to_string(),
serde_json::Value::Bool(parse_bool("OPENAI_STORE", &store)?),
)]));
}
}
Ok(model.with_canonical_limits(provider_name))
}
fn optional_env(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn parse_optional<T: std::str::FromStr>(key: &str) -> Result<Option<T>>
where
T::Err: std::fmt::Display,
{
optional_env(key)
.map(|value| {
value
.parse()
.map_err(|error| anyhow::anyhow!("invalid {key} {value:?}: {error}"))
})
.transpose()
}
fn parse_bool(key: &str, value: &str) -> Result<bool> {
match value.to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => bail!("invalid {key} {value:?}"),
}
}

View File

@@ -1,81 +1,96 @@
//! buzz-agent's system prompt, assembled with goose's `PromptManager`.
//!
//! `Agent::prompt_manager` is `pub(super)` in goose, and `Agent::reply` is the
//! only thing that reads it. Since buzz-agent owns the loop it also owns the
//! prompt: we keep our own `PromptManager` and call it at the top of each
//! round, which is exactly what goose's inference step does internally.
//!
//! Owning it is not just a workaround for the visibility — it is the seam that
//! makes the persona work at all. goose's ACP server never reads
//! `systemPrompt`, so an agent driven over plain ACP silently loses Fizz and
//! the `[Base]` operating manual. Building the prompt here puts them in front
//! of the model.
//! Per-session system prompt composition using the Goose GDK.
use std::path::Path;
use std::sync::Arc;
use goose::agents::PromptManager;
use goose_agent::prompt::{
InstructionDiscovery, InstructionDiscoveryOptions, PromptComposer, PromptContext, PromptSource,
};
use goose_provider_types::goose_mode::GooseMode;
use tokio::sync::Mutex;
struct PromptState {
composer: PromptComposer,
instructions: InstructionDiscovery,
root_instructions_loaded: bool,
}
/// The system prompt for one session.
///
/// Cloneable so a turn can hold it without borrowing the session map.
#[derive(Clone)]
pub struct SessionPrompt {
manager: Arc<Mutex<PromptManager>>,
state: Arc<Mutex<PromptState>>,
mode: GooseMode,
}
impl SessionPrompt {
pub fn new(mode: GooseMode) -> Self {
Self {
manager: Arc::new(Mutex::new(PromptManager::new())),
state: Arc::new(Mutex::new(PromptState {
composer: PromptComposer::new(PromptSource::Literal(String::new())),
instructions: InstructionDiscovery::new(InstructionDiscoveryOptions::default()),
root_instructions_loaded: false,
})),
mode,
}
}
/// Replace goose's base template with the harness-supplied persona.
/// Replace the base prompt with the harness-supplied persona template.
pub async fn set_override(&self, template: String) {
self.manager
self.state
.lock()
.await
.set_system_prompt_override(template);
.composer
.set_base(PromptSource::Template(template));
}
/// Append a keyed section (AGENTS.md hints, the skill index, hook
/// guidance). Extras survive an override — goose appends them to whichever
/// base template is in force.
/// Append or replace a keyed prompt section.
pub async fn add_extra(&self, key: &str, instruction: String) {
self.manager
self.state
.lock()
.await
.add_system_prompt_extra(key.to_string(), instruction);
.composer
.add_extra(key.to_string(), instruction);
}
/// Render the prompt for one inference.
///
/// Called per round rather than once per session because
/// `build_system_prompt` also folds in subdirectory hints discovered from
/// the tool arguments seen so far — a prompt built once at session start
/// would never pick them up.
pub async fn build(&self, working_dir: &Path) -> String {
self.manager
.lock()
.await
.build_system_prompt(working_dir, Vec::new(), self.mode)
pub async fn build(&self, working_dir: &Path) -> anyhow::Result<String> {
let mut state = self.state.lock().await;
if !state.root_instructions_loaded {
for instruction in state.instructions.discover_root(working_dir)? {
state
.composer
.add_extra(instruction.key, instruction.content);
}
state.root_instructions_loaded = true;
}
for instruction in state
.instructions
.discover_new_subdirectory_instructions(working_dir)?
{
state
.composer
.add_extra(instruction.key, instruction.content);
}
state.composer.render(
&PromptContext {
current_date_time: String::new(),
goose_mode: self.mode,
variables: Default::default(),
},
[],
)
}
/// Feed tool arguments to the subdirectory-hint tracker, so entering a new
/// part of the tree pulls in that directory's AGENTS.md on the next round.
/// Feed tool arguments to the subdirectory-instruction tracker.
pub async fn record_tool_arguments(
&self,
arguments: &Option<serde_json::Map<String, serde_json::Value>>,
working_dir: &Path,
) {
self.manager
self.state
.lock()
.await
.instructions
.record_tool_arguments(arguments, working_dir);
}
}
@@ -88,21 +103,31 @@ mod tests {
async fn override_replaces_the_base_template() {
let prompt = SessionPrompt::new(GooseMode::Auto);
prompt.set_override("You are Fizz.".to_string()).await;
let built = prompt.build(Path::new(".")).await;
let built = prompt.build(Path::new(".")).await.unwrap();
assert!(built.contains("You are Fizz."));
}
#[tokio::test]
async fn extras_survive_an_override() {
// The persona arrives as an override and the hints as an extra; losing
// either one is a silent failure the harness cannot detect.
let prompt = SessionPrompt::new(GooseMode::Auto);
prompt.set_override("You are Fizz.".to_string()).await;
prompt
.add_extra("buzz_hints", "Always ship the sprocket first.".to_string())
.await;
let built = prompt.build(Path::new(".")).await;
let built = prompt.build(Path::new(".")).await.unwrap();
assert!(built.contains("You are Fizz."));
assert!(built.contains("Always ship the sprocket first."));
}
#[tokio::test]
async fn instructions_are_loaded_once() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("AGENTS.md"), "Project instruction").unwrap();
let prompt = SessionPrompt::new(GooseMode::Auto);
prompt.set_override("Persona".to_string()).await;
let first = prompt.build(root.path()).await.unwrap();
let second = prompt.build(root.path()).await.unwrap();
assert_eq!(first.matches("Project instruction").count(), 1);
assert_eq!(second.matches("Project instruction").count(), 1);
}
}

View File

@@ -1,132 +1,228 @@
//! Provider construction.
//!
//! Almost all of this is goose's: `goose::providers::create` resolves base
//! URLs, credentials and model metadata from its own registry, and buzz does
//! not duplicate any of it.
//!
//! # Databricks is the exception, on purpose
//!
//! Both sides implement OAuth 2.0 Authorization Code + PKCE against the same
//! Databricks host, and before this module they did it with **two separate
//! token caches**:
//!
//! | | flow | cache |
//! |---|---|---|
//! | inference | goose `providers::oauth` | goose's config dir |
//! | model picker | [`buzz_model_catalog::auth`] | `~/.config/buzz-agent/oauth/databricks/` |
//!
//! A user could be authenticated for inference and not for the model picker,
//! or the reverse, and be sent through the browser twice for the same host.
//!
//! Buzz's engine is the hardened one — PR #5534 gave it `O_NOFOLLOW`, mode
//! `0600` from creation and unique temp names — and it is also the one the
//! desktop calls directly, since the desktop cannot link goose at all (see
//! `buzz-model-catalog`'s lib.rs). So buzz's engine wins and goose's provider
//! is constructed around it.
//!
//! goose supports this without a fork: `DatabricksV2Provider::new` takes a
//! `DatabricksOauthTokenProvider` closure, and `databricks_v2_def::from_env`
//! is simply the variant that passes goose's own. Buzz passes its own instead.
//! Everything else about the provider — retries, the session-id header,
//! endpoint resolution, the wire format — stays goose's.
//! Provider construction for Buzz's supported provider set.
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use goose_providers::anthropic::{AnthropicProviderBuilder, ANTHROPIC_API_VERSION};
use goose_providers::api_client::{ApiClient, AuthMethod};
use goose_providers::base::Provider;
use goose_providers::databricks::DatabricksProvider;
use goose_providers::databricks_auth::{
DatabricksAuth, DatabricksOauthTokenProvider, DatabricksRefreshHook,
};
use goose_providers::databricks_v2::DatabricksV2Provider;
use goose_providers::ollama::{
OllamaOptions, OllamaProviderBuilder, OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT,
OLLAMA_PROVIDER_NAME,
};
use goose_providers::openai::{
parse_custom_headers, parse_openai_base_url, OpenAiProviderBuilder, OPEN_AI_DEFAULT_BASE_PATH,
OPEN_AI_VERSIONLESS_BASE_PATH,
};
use goose_providers::openrouter::OpenRouterProvider;
use crate::types::AgentError;
/// goose's registry name for the v2 Databricks provider.
const DATABRICKS_V2: &str = "databricks_v2";
const DEFAULT_TIMEOUT_SECS: u64 = 600;
/// Construct the goose provider for `provider_name`.
///
/// Relay-mesh `auto` is resolved by the desktop before spawn
/// (`managed_agents/relay_mesh.rs:relay_mesh_wire_model`), so buzz-agent sends
/// whatever model it was given and does not know meshes exist (#5289).
pub async fn build(provider_name: &str) -> Result<Arc<dyn Provider>, AgentError> {
if provider_name == DATABRICKS_V2 {
if let Some(host) = databricks_host() {
// Only when buzz has a host to authenticate against. Without one
// there is nothing to improve on, and falling through to goose's
// registry keeps `DATABRICKS_TOKEN`-only setups working unchanged.
return databricks_v2_with_buzz_oauth(host);
let provider = match provider_name {
"anthropic" => anthropic(),
"openai" | "openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => openai(),
"openrouter" => openrouter(),
"ollama" => ollama(),
"databricks" => databricks(false),
"databricks_v2" | "databricks-v2" => databricks(true),
other => Err(anyhow::anyhow!(
"unsupported Buzz provider {other:?}; supported providers: anthropic, openai, openai-compat, openrouter, ollama, databricks, databricks_v2"
)),
};
provider.map_err(|error| crate::map_provider_error(&error.to_string()))
}
fn required_env(key: &str) -> anyhow::Result<String> {
env(key).ok_or_else(|| anyhow::anyhow!("{key} is not set"))
}
fn env(key: &str) -> Option<String> {
std::env::var(key)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
env(key).and_then(|value| value.parse().ok())
}
fn anthropic() -> anyhow::Result<Arc<dyn Provider>> {
let api_client = ApiClient::with_timeout_and_tls(
env("ANTHROPIC_HOST").unwrap_or_else(|| "https://api.anthropic.com".to_string()),
AuthMethod::ApiKey {
header_name: "x-api-key".to_string(),
key: required_env("ANTHROPIC_API_KEY")?,
},
Duration::from_secs(env_parse("ANTHROPIC_TIMEOUT").unwrap_or(DEFAULT_TIMEOUT_SECS)),
None,
)?
.with_header("anthropic-version", ANTHROPIC_API_VERSION)?;
Ok(Arc::new(AnthropicProviderBuilder::new(api_client).build()))
}
fn openai() -> anyhow::Result<Arc<dyn Provider>> {
let raw_base = env("OPENAI_BASE_URL")
.or_else(|| env("OPENAI_HOST"))
.unwrap_or_else(|| "https://api.openai.com".to_string());
let (host, query, has_v1) = parse_openai_base_url(&raw_base)?;
let direct_openai = url::Url::parse(&host)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.is_some_and(|host| host.eq_ignore_ascii_case("api.openai.com"));
let auth = env("OPENAI_API_KEY")
.map(AuthMethod::BearerToken)
.unwrap_or(AuthMethod::NoAuth);
let mut api_client = ApiClient::with_timeout_and_tls(
host,
auth,
Duration::from_secs(env_parse("OPENAI_TIMEOUT").unwrap_or(DEFAULT_TIMEOUT_SECS)),
None,
)?
.with_query(query);
let organization = env("OPENAI_ORGANIZATION");
let project = env("OPENAI_PROJECT");
if let Some(value) = &organization {
api_client = api_client.with_header("OpenAI-Organization", value)?;
}
if let Some(value) = &project {
api_client = api_client.with_header("OpenAI-Project", value)?;
}
let custom_headers = env("OPENAI_CUSTOM_HEADERS").map(parse_custom_headers);
if let Some(headers) = &custom_headers {
for (key, value) in headers {
api_client = api_client.with_header(key, value)?;
}
}
goose::providers::create(provider_name, Vec::new())
.await
.map_err(|e| crate::map_provider_error(&e.to_string()))
let base_path = env("OPENAI_BASE_PATH").unwrap_or_else(|| {
if has_v1 {
OPEN_AI_DEFAULT_BASE_PATH.to_string()
} else {
OPEN_AI_VERSIONLESS_BASE_PATH.to_string()
}
});
Ok(Arc::new(
OpenAiProviderBuilder::new(api_client)
.base_path(base_path)
.organization(organization)
.project(project)
.custom_headers(custom_headers)
.preserve_thinking_context(!direct_openai)
.build(),
))
}
/// The Databricks workspace host, from the environment.
///
/// Read from the environment rather than from goose's `Config` because
/// `Config::get_secret` consults the OS keyring, and an agent subprocess is
/// launched with the environment the desktop gives it.
fn databricks_host() -> Option<String> {
std::env::var("DATABRICKS_HOST")
.ok()
.map(|host| host.trim().to_string())
.filter(|host| !host.is_empty())
fn openrouter() -> anyhow::Result<Arc<dyn Provider>> {
let host = env("OPENROUTER_HOST").unwrap_or_else(|| "https://openrouter.ai".to_string());
let api_client = ApiClient::new_with_tls(
host,
AuthMethod::BearerToken(required_env("OPENROUTER_API_KEY")?),
None,
)?
.with_header("HTTP-Referer", "https://buzz.block.xyz")?
.with_header("X-Title", "buzz-agent")?
.with_header("X-OpenRouter-Categories", "cli-agent,productivity")?;
let parameters = env("OPENROUTER_PARAMETERS")
.map(|value| serde_json::from_str::<HashMap<String, serde_json::Value>>(&value))
.transpose()?;
Ok(Arc::new(OpenRouterProvider::new(
api_client, parameters, None,
)))
}
/// goose's `databricks_v2` provider, backed by buzz's PKCE token source.
fn databricks_v2_with_buzz_oauth(host: String) -> Result<Arc<dyn Provider>, AgentError> {
// A static token still wins, exactly as in goose's own `from_env`: a
// configured `DATABRICKS_TOKEN` is an explicit instruction not to run an
// interactive flow at all.
let auth = match std::env::var("DATABRICKS_TOKEN") {
Ok(token) if !token.trim().is_empty() => DatabricksAuth::token(token),
_ => DatabricksAuth::oauth(host.clone()),
fn ollama() -> anyhow::Result<Arc<dyn Provider>> {
let raw = env("OLLAMA_HOST").unwrap_or_else(|| "localhost".to_string());
let with_scheme = if raw.contains("://") {
raw
} else {
format!("http://{raw}")
};
let mut url = url::Url::parse(&with_scheme)?;
if url.port().is_none() && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
url.set_port(Some(OLLAMA_DEFAULT_PORT))
.map_err(|_| anyhow::anyhow!("invalid Ollama port"))?;
}
let timeout = env_parse("OLLAMA_TIMEOUT").unwrap_or(DEFAULT_TIMEOUT_SECS);
let options = OllamaOptions {
input_limit: env_parse::<usize>("GOOSE_INPUT_LIMIT").filter(|limit| *limit > 0),
stream_usage: env("OLLAMA_STREAM_USAGE")
.and_then(|value| value.parse().ok())
.unwrap_or(true),
chunk_timeout_secs: env_parse::<u64>("OLLAMA_STREAM_TIMEOUT")
.or_else(|| env_parse("GOOSE_STREAM_TIMEOUT"))
.or_else(|| env_parse("OLLAMA_TIMEOUT"))
.filter(|timeout| *timeout > 0)
.unwrap_or(OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS),
};
let api_client = ApiClient::with_timeout_and_tls(
url.to_string(),
AuthMethod::NoAuth,
Duration::from_secs(timeout),
None,
)?;
Ok(Arc::new(
OllamaProviderBuilder::new(api_client)
.name(OLLAMA_PROVIDER_NAME)
.options(options)
.build(),
))
}
fn databricks(v2: bool) -> anyhow::Result<Arc<dyn Provider>> {
let host = required_env("DATABRICKS_HOST")?;
let auth = match env("DATABRICKS_TOKEN") {
Some(token) => DatabricksAuth::token(token),
None => DatabricksAuth::oauth(host.clone()),
};
let oauth_token_source = match &auth {
DatabricksAuth::OAuth { .. } => Some(
buzz_model_catalog::auth::PkceOAuthTokenSource::new(
buzz_model_catalog::databricks_pkce_config(&host),
)
.map_err(|e| AgentError::Llm(format!("databricks oauth: {e}")))?,
),
DatabricksAuth::OAuth { .. } => Some(buzz_model_catalog::auth::PkceOAuthTokenSource::new(
buzz_model_catalog::databricks_pkce_config(&host),
)?),
DatabricksAuth::Token(_) => None,
};
let oauth_token_provider = oauth_token_source
.as_ref()
.map(|source| buzz_oauth_token_provider(Arc::clone(source)));
let refresh_hook = oauth_token_source.map(buzz_oauth_refresh_hook);
let retry_config = DatabricksV2Provider::load_retry_config(|key| std::env::var(key).ok());
let provider = DatabricksV2Provider::new(
host,
auth,
retry_config,
None,
oauth_token_provider,
Some(Arc::new(|| {
std::env::var("DATABRICKS_TOKEN")
.ok()
.filter(|token| !token.trim().is_empty())
})),
None,
refresh_hook,
)
.map_err(|e| AgentError::Llm(format!("databricks provider: {e}")))?;
Ok(Arc::new(provider))
let token_resolver = Some(Arc::new(|| env("DATABRICKS_TOKEN")) as _);
if v2 {
let retry = DatabricksV2Provider::load_retry_config(env);
Ok(Arc::new(DatabricksV2Provider::new(
host,
auth,
retry,
None,
oauth_token_provider,
token_resolver,
None,
refresh_hook,
)?))
} else {
let retry = DatabricksProvider::load_retry_config(env);
Ok(Arc::new(DatabricksProvider::new(
host,
auth,
retry,
None,
oauth_token_provider,
token_resolver,
None,
None,
refresh_hook,
None,
)?))
}
}
/// Adapts buzz's [`TokenSource`](buzz_model_catalog::auth::TokenSource) to the
/// closure shape goose's Databricks provider asks for.
///
/// `bearer_no_browser` rather than `bearer`: this runs inside an inference
/// request on a headless agent subprocess, where the browser step would hang
/// with nobody to see it. Interactive login stays where a human can answer it
/// — `buzz-agent auth databricks`, and the desktop model picker.
fn buzz_oauth_token_provider(
source: Arc<buzz_model_catalog::auth::PkceOAuthTokenSource>,
) -> DatabricksOauthTokenProvider {
@@ -137,14 +233,11 @@ fn buzz_oauth_token_provider(
source
.bearer_no_browser()
.await
.map_err(|e| anyhow::anyhow!("{e}"))
.map_err(|error| anyhow::anyhow!("{error}"))
})
})
}
/// Goose's refresh hook is synchronous, so mark the rejected access token in
/// shared state and let the next async token-provider call force-refresh it.
/// This keeps the refresh grant headless and avoids blocking a runtime worker.
fn buzz_oauth_refresh_hook(
source: Arc<buzz_model_catalog::auth::PkceOAuthTokenSource>,
) -> DatabricksRefreshHook {
@@ -155,84 +248,13 @@ fn buzz_oauth_refresh_hook(
mod tests {
use super::*;
/// Guards against the environment leaking between these tests and the rest
/// of the suite: they mutate process-global state.
fn with_env<T>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> T) -> T {
let previous: Vec<(String, Option<String>)> = vars
.iter()
.map(|(key, _)| (key.to_string(), std::env::var(key).ok()))
.collect();
for (key, value) in vars {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
let result = f();
for (key, value) in previous {
match value {
Some(value) => std::env::set_var(&key, value),
None => std::env::remove_var(&key),
}
}
result
}
/// One test, not three, because `std::env` is process-global and Rust
/// runs tests in this binary on parallel threads: separate tests touching
/// `DATABRICKS_HOST` would race each other and fail intermittently.
#[test]
fn the_host_is_read_and_trimmed_and_blank_means_absent() {
with_env(
&[("DATABRICKS_HOST", Some(" https://example.databricks.com "))],
|| {
assert_eq!(
databricks_host().as_deref(),
Some("https://example.databricks.com")
);
},
);
// An empty or whitespace host is what a desktop launch with an
// unconfigured workspace produces. Treating it as present would build
// a provider pointed at "", failing later with a confusing URL error
// instead of falling back to goose's registry cleanly.
for blank in ["", " "] {
with_env(&[("DATABRICKS_HOST", Some(blank))], || {
assert!(databricks_host().is_none(), "blank host {blank:?} accepted");
});
}
with_env(&[("DATABRICKS_HOST", None)], || {
assert!(databricks_host().is_none());
});
}
/// Also one test: same process-global environment, same race otherwise.
#[test]
fn the_provider_builds_with_a_token_and_without_one() {
let host = "https://example.databricks.com";
// A configured token is an explicit instruction not to run any
// interactive flow, and goose's own `from_env` honours it. buzz must
// not quietly change that.
with_env(
&[
("DATABRICKS_HOST", Some(host)),
("DATABRICKS_TOKEN", Some("dapi-secret")),
],
|| assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()),
);
// A blank token is not a token. Treating it as one would send
// `Authorization: Bearer ` and 401 every request, never trying the
// cached OAuth credential that would have worked.
with_env(
&[
("DATABRICKS_HOST", Some(host)),
("DATABRICKS_TOKEN", Some(" ")),
],
|| assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()),
);
with_env(
&[("DATABRICKS_HOST", Some(host)), ("DATABRICKS_TOKEN", None)],
|| assert!(databricks_v2_with_buzz_oauth(host.to_string()).is_ok()),
);
#[tokio::test]
async fn unsupported_provider_fails_clearly() {
let error = match build("not-a-provider").await {
Ok(_) => panic!("unsupported provider was accepted"),
Err(error) => error.to_string(),
};
assert!(error.contains("unsupported Buzz provider"));
assert!(error.contains("openrouter"));
}
}

View File

@@ -1,7 +1,7 @@
//! The skill index for the system prompt.
//!
//! Discovery, `load_skill`, argument templating, supporting-file loading and
//! the path-escape guard are all [`goose::skills`]. The only thing left here
//! the path-escape guard come from [`goose_agent::skills`]. The only thing left here
//! is rendering the index, and only because the piece of goose that renders it
//! (`ops_skills::skill_instructions`) is reachable through `SkillOperation`,
//! which is `pub(super)` — its concrete operations stay internal to goose
@@ -42,7 +42,7 @@ pub fn load_skill(
.unwrap_or("");
let args = arguments.get("args").and_then(serde_json::Value::as_str);
if let Some(skill) = skills.iter().find(|skill| skill.name == name) {
return match goose::skills::loaded_skill_context_with_args(skill, args) {
return match goose_agent::skills::loaded_skill_context_with_args(skill, args) {
Ok(rendered) => {
rmcp::model::CallToolResult::success(vec![rmcp::model::ContentBlock::text(
rendered,
@@ -160,44 +160,3 @@ mod tests {
assert!(alpha < zebra, "index must not reorder between rounds");
}
}
/// Nothing in this crate may *load* hint files.
///
/// buzz-agent used to walk `AGENTS.md` itself while goose's
/// `PromptManager::build_system_prompt` was already loading the same files, so
/// every hint landed in the system prompt twice. This guard outlived the
/// module it was written in (`hints.rs`, deleted along with the duplicate
/// skills code it shared a file with), so it moves here rather than being
/// dropped.
///
/// It looks for the *filename literals* a loader would need, not for the
/// names in prose -- the previous version banned the bare word and could only
/// live in a file that never explained itself. The literals are assembled at
/// runtime so the guard does not trip over its own source text.
#[cfg(test)]
mod hint_files_are_gooses_job {
const SOURCES: &[(&str, &str)] = &[
("lib.rs", include_str!("lib.rs")),
("prompt.rs", include_str!("prompt.rs")),
("loop_drive.rs", include_str!("loop_drive.rs")),
("skills.rs", include_str!("skills.rs")),
];
#[test]
fn no_module_loads_hint_files() {
let quote = '"';
// Assembled rather than written out, so this test's own source does
// not count as a violation of the rule it enforces.
for name in [format!("AGENTS{}md", '.'), format!(".goose{}", "hints")] {
let literal = format!("{quote}{name}{quote}");
for (file, source) in SOURCES {
assert!(
!source.contains(&literal),
"{file} has a {literal} string literal: hint-file loading \
belongs to goose's PromptManager, or hints render twice \
in the prompt"
);
}
}
}
}