mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
remove goose dep
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
This commit is contained in:
1562
Cargo.lock
generated
1562
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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`].
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
76
crates/buzz-agent/src/model_config.rs
Normal file
76
crates/buzz-agent/src/model_config.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user