feat(buzz-agent-core): drive buzz-agent's ACP surface on the goose library

Feasibility spike: keep buzz-agent's ACP wire contract exactly as-is, but
replace the hand-written agent loop with goose used as a Rust library.

13,259 -> 1,562 src LOC (88% cut), plus ~5,800 test LOC that covered the
deleted loop.

Deleted, now goose's:
  llm.rs 3846      -> goose::providers (superset of the 4 providers)
  mcp.rs 1139      -> goose::agents::extension_manager
  auth.rs 845      -> goose::providers (incl. Databricks OAuth)
  hints.rs 726     -> prompt_manager::with_hints
  catalog.rs 631   -> goose::providers::init
  builtin.rs 575   -> goose skills platform extension
  handoff.rs 430   -> goose::context_mgmt (auto-compaction)

Kept deliberately (the contract buzz-acp depends on):
  wire.rs 293 verbatim; agentInfo.name = "buzz-agent" (kind-44200 harness
  attribution); 6-method surface; activeRunId with _meta nested inside
  update; usage_update before the session/prompt response; keepalive
  ticker; error -> JSON-RPC code mapping; single-flight; size caps.

This is NOT "goose as the harness". Picking Goose from the harness gallery
still shells out to a user-installed goose CLI. This is buzz-agent's own
identity with a goose-powered loop.

Why a separate crate excluded from the workspace: crates/buzz-agent is a
library linked into sprig AND desktop/src-tauri, so goose's ~700-crate graph
would land in the Tauri build and the workspace lockfile. Own Cargo.lock,
same isolation trick as PR #1526.

Notable: driving the library is what makes the persona work at all. Goose's
own ACP server never reads systemPrompt (zero hits in goose/crates/goose/src)
and both PRs that would have wired it -- buzz#1290, goose#9971 -- are closed
unmerged. tests/stdio_turn.rs asserts Fizz's prompt reaches the provider.

Also: GOOSE_MODE is left at goose's default rather than forced to "auto"
(auto-approve every tool call), which is what the desktop catalog ships for
the external goose runtime.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
Michael Neale
2026-07-27 15:03:43 +10:00
parent 63c62fcf3e
commit 2527efac3f
11 changed files with 8250 additions and 1 deletions

View File

@@ -28,7 +28,7 @@ members = [
"crates/buzz-dev-mcp",
"examples/countdown-bot",
]
exclude = ["desktop/src-tauri"]
exclude = ["desktop/src-tauri", "buzz-agent-core"]
resolver = "2"
[workspace.package]

1
buzz-agent-core/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
target/

6229
buzz-agent-core/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,71 @@
# buzz-agent-core — buzz-agent's ACP wire layer on top of the Goose Rust
# library (used as an SDK, not as a subprocess).
#
# WHY A SEPARATE CRATE, EXCLUDED FROM THE WORKSPACE
#
# `crates/buzz-agent` is a *library* linked into both `crates/sprig` and
# `desktop/src-tauri` (which uses only `catalog`/`config` for the Databricks
# model picker). Adding goose to that crate would pull goose's ~700-crate
# dependency graph into the Tauri app and into the workspace lockfile.
#
# So this crate is excluded from the workspace (see the root Cargo.toml
# `exclude` list) and carries its own Cargo.lock, exactly as PR #1526 did for
# `goose-acp`. Goose's ACP/MCP dependency versions move independently.
#
# Build: cargo build --release --manifest-path buzz-agent-core/Cargo.toml
#
# THIS IS NOT "GOOSE AS THE HARNESS". The binary keeps buzz-agent's identity
# (`agentInfo.name = "buzz-agent"`), its 6-method ACP surface, its
# `activeRunId` steering contract and its `usage_update` ordering. Only the
# agent loop underneath is Goose's instead of hand-written. Picking "Goose"
# from the harness gallery remains a separate thing: that shells out to a
# user-installed `goose` CLI.
[package]
name = "buzz-agent-core"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
name = "buzz_agent_core"
path = "src/lib.rs"
[[bin]]
name = "buzz-agent-core"
path = "src/main.rs"
[dependencies]
# Pinned to a release commit deliberately: goose's public `Agent` surface is
# not semver-stable (many fields are `pub(super)`) and its MSRV moves quickly.
# 305849b71 == goose 1.44.0.
#
# default-features = false drops: telemetry, otel, aws-providers,
# local-inference (candle/whisper), code-mode (pctx/V8), system-keyring, nostr.
# We name only rustls-tls, which is mandatory — `goose` pulls reqwest/sqlx/rmcp
# with default-features = false, so a TLS backend only arrives via this feature
# (and `rustls-tls` + `native-tls` are mutually exclusive: see the
# compile_error! at goose/src/lib.rs:3-4).
goose = { path = "../../goose/crates/goose", default-features = false, features = ["rustls-tls"] }
goose-provider-types = { path = "../../goose/crates/goose-provider-types" }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "time"] }
tokio-util = { version = "0.7", features = ["rt"] }
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
tempfile = "3"
# Size-optimized. PR #1526 measured 32.5 MiB (macOS arm64) for a comparable
# goose-linked binary; re-measure rather than trusting that number.
[profile.release]
opt-level = "z"
lto = "fat"
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -0,0 +1,357 @@
//! The turn driver: Goose's agent loop, translated onto buzz-agent's ACP wire.
//!
//! This replaces roughly 8,000 lines of `crates/buzz-agent`:
//!
//! | buzz-agent file | lines | who owns it now |
//! |---|---:|---|
//! | `llm.rs` | 3846 | `goose::providers` (superset of the 4 providers) |
//! | `mcp.rs` | 1139 | `goose::agents::extension_manager` |
//! | `auth.rs` | 845 | `goose::providers` (incl. Databricks OAuth) |
//! | `agent.rs` | 746 | `goose::agents::Agent::reply` |
//! | `hints.rs` | 726 | `goose`'s hint loader (`prompt_manager::with_hints`) |
//! | `catalog.rs` | 631 | `goose::providers::init` model discovery |
//! | `builtin.rs` | 575 | `goose`'s `skills` platform extension |
//! | `handoff.rs` | 430 | `goose::context_mgmt` (auto-compaction) |
//!
//! What this file keeps is the part Goose does *not* know about: the mapping
//! from `AgentEvent` onto the exact `session/update` notifications `buzz-acp`
//! consumes (`acp.rs:1528-1627`), and the `keepalive` ticker that resets the
//! harness idle clock.
use std::sync::Arc;
use futures::StreamExt;
use serde_json::{json, Value};
use tokio_util::sync::CancellationToken;
use goose::agents::{Agent, AgentEvent, SessionConfig};
use goose_provider_types::conversation::message::{Message, MessageContent};
use crate::types::{AgentError, ContentBlock, StopReason};
use crate::wire::{self, WireSender};
/// How often to emit `keepalive` while waiting on the provider.
///
/// NOT part of ACP. `buzz-acp` runs an idle-timeout clock that is reset on
/// every line of valid JSON (`acp.rs:1197-1199`); a silent agent is killed.
/// buzz-agent emitted this from inside its provider `select!`
/// (`agent.rs:122-127`) and `buzz-acp` treats it as a no-op that only resets
/// the clock (`acp.rs:1623`).
///
/// Goose streams, so during generation the chunks themselves keep the clock
/// alive — but there is no traffic during a long *pre-first-token* wait (big
/// prompt processing, provider queueing, reasoning models). So the ticker
/// stays.
const KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
/// Per-turn token accounting, mirroring buzz-agent's contract.
#[derive(Debug, Default, Clone, Copy)]
pub struct TurnTokens {
pub input: Option<u64>,
pub output: Option<u64>,
}
impl TurnTokens {
pub fn observed(&self) -> bool {
self.input.is_some() || self.output.is_some()
}
}
/// Flatten ACP prompt blocks into the text Goose's `Message` carries.
pub fn prompt_to_text(blocks: &[ContentBlock]) -> String {
let mut out = String::new();
for b in blocks {
match b {
ContentBlock::Text { text } => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(text);
}
ContentBlock::ResourceLink { uri } => {
if !out.is_empty() {
out.push('\n');
}
out.push_str(uri);
}
ContentBlock::Unsupported => {}
}
}
out
}
/// Drive one `session/prompt` turn to completion.
///
/// Returns the ACP stop reason plus the turn's token counts. The caller is
/// responsible for emitting `usage_update` *before* the `session/prompt`
/// response — that ordering is load-bearing for kind-44200 metrics
/// (`buzz-agent/src/lib.rs:701-706`).
#[allow(clippy::too_many_arguments)]
pub async fn run_turn(
agent: Arc<Agent>,
session_id: &str,
prompt: Vec<ContentBlock>,
max_rounds: Option<u32>,
wire_tx: &WireSender,
cancel: CancellationToken,
) -> (Result<StopReason, AgentError>, TurnTokens) {
let text = prompt_to_text(&prompt);
let mut tokens = TurnTokens::default();
let session_config = SessionConfig {
id: session_id.to_string(),
schedule_id: None,
max_turns: max_rounds,
retry_config: None,
};
let mut stream = match agent
.reply(
Message::user().with_text(text),
session_config,
Some(cancel.clone()),
)
.await
{
Ok(s) => s,
Err(e) => return (Err(AgentError::Llm(e.to_string())), tokens),
};
let mut keepalive = tokio::time::interval(KEEPALIVE_INTERVAL);
keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
keepalive.tick().await; // first tick is immediate; discard it
let mut stop = StopReason::EndTurn;
loop {
tokio::select! {
// Keep the harness idle clock alive during provider silence.
_ = keepalive.tick() => {
wire::send(
wire_tx,
wire::session_update(session_id, json!({ "sessionUpdate": "keepalive" })),
)
.await;
}
_ = cancel.cancelled() => {
stop = StopReason::Cancelled;
break;
}
next = stream.next() => {
let Some(event) = next else { break };
match event {
Ok(ev) => {
if let Some(reason) = handle_event(ev, session_id, wire_tx, &mut tokens).await {
stop = reason;
break;
}
}
Err(e) => {
let msg = e.to_string();
// Preserve buzz-agent's error taxonomy so the harness's
// JSON-RPC code mapping stays meaningful.
let err = if msg.contains("auth") || msg.contains("401") {
AgentError::LlmAuth(msg)
} else if msg.contains("model") && msg.contains("not found") {
AgentError::LlmModelNotFound(msg)
} else {
AgentError::Llm(msg)
};
return (Err(err), tokens);
}
}
}
}
}
if cancel.is_cancelled() {
stop = StopReason::Cancelled;
}
(Ok(stop), tokens)
}
/// Translate one `AgentEvent` into ACP notifications.
///
/// Returns `Some(StopReason)` if the event terminates the turn.
async fn handle_event(
event: AgentEvent,
session_id: &str,
wire_tx: &WireSender,
tokens: &mut TurnTokens,
) -> Option<StopReason> {
match event {
AgentEvent::Message(msg) => {
for content in &msg.content {
emit_content(content, session_id, wire_tx).await;
}
None
}
// Usage arrives per provider chunk, already cost-enriched. Accumulate
// rather than overwrite: `MessageUsage` is suppressed when the last
// assistant message has no user-visible content (goose
// `agent.rs:299`), so it is not a reliable sole source.
AgentEvent::Usage(usage) => {
let u = &usage.usage;
if let Some(i) = u.input_tokens {
tokens.input = Some(tokens.input.unwrap_or(0) + i.max(0) as u64);
}
if let Some(o) = u.output_tokens {
tokens.output = Some(tokens.output.unwrap_or(0) + o.max(0) as u64);
}
None
}
// Compaction happened. buzz-agent surfaced this as a `[Context
// Handoff]` history rewrite; here Goose has already done it and we
// only need to note it.
AgentEvent::HistoryReplaced(_) => {
tracing::info!(target: "buzz_agent::compaction", "history compacted");
None
}
_ => None,
}
}
/// Map message content onto the nine `sessionUpdate` variants `buzz-acp`
/// recognises (`acp.rs:1528-1627`). Anything else it debug-logs and drops.
async fn emit_content(content: &MessageContent, session_id: &str, wire_tx: &WireSender) {
match content {
MessageContent::Text(t) if !t.text.is_empty() => {
wire::send(
wire_tx,
wire::session_update(
session_id,
json!({
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": t.text },
}),
),
)
.await;
}
MessageContent::Thinking(t) if !t.thinking.is_empty() => {
wire::send(
wire_tx,
wire::session_update(
session_id,
json!({
"sessionUpdate": "agent_thought_chunk",
"content": { "type": "text", "text": t.thinking },
}),
),
)
.await;
}
// Tool lifecycle. buzz-agent guaranteed every announced tool reached a
// terminal state, or the desktop UI shows a stuck spinner
// (`agent.rs:470-477`). Goose emits request and response as separate
// messages, so the pairing is by tool-call id.
MessageContent::ToolRequest(req) => {
let (name, raw) = match &req.tool_call {
Ok(call) => (
call.name.to_string(),
serde_json::to_value(&call.arguments).unwrap_or(Value::Null),
),
Err(e) => (String::from("unknown"), json!({ "error": e.to_string() })),
};
wire::send(
wire_tx,
wire::session_update(
session_id,
json!({
"sessionUpdate": "tool_call",
"toolCallId": req.id,
"title": name,
"kind": "other",
"status": "in_progress",
"rawInput": raw,
}),
),
)
.await;
}
MessageContent::ToolResponse(resp) => {
let failed = match &resp.tool_result {
Ok(r) => r.is_error.unwrap_or(false),
Err(_) => true,
};
wire::send(
wire_tx,
wire::session_update(
session_id,
json!({
"sessionUpdate": "tool_call_update",
"toolCallId": resp.id,
"status": if failed { "failed" } else { "completed" },
}),
),
)
.await;
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prompt_flattens_text_blocks() {
let blocks = vec![
ContentBlock::Text {
text: "hello".into(),
},
ContentBlock::Text {
text: "world".into(),
},
];
assert_eq!(prompt_to_text(&blocks), "hello\nworld");
}
#[test]
fn prompt_includes_resource_links() {
let blocks = vec![
ContentBlock::Text { text: "see".into() },
ContentBlock::ResourceLink {
uri: "file:///a".into(),
},
];
assert_eq!(prompt_to_text(&blocks), "see\nfile:///a");
}
#[test]
fn prompt_skips_unsupported() {
let blocks = vec![
ContentBlock::Unsupported,
ContentBlock::Text {
text: "only".into(),
},
];
assert_eq!(prompt_to_text(&blocks), "only");
}
#[test]
fn turn_tokens_observed_requires_a_count() {
assert!(!TurnTokens::default().observed());
assert!(TurnTokens {
input: Some(1),
output: None
}
.observed());
assert!(TurnTokens {
input: None,
output: Some(1)
}
.observed());
}
}

View File

@@ -0,0 +1,154 @@
//! Environment configuration, translated into Goose's native vocabulary.
//!
//! This replaces `crates/buzz-agent/src/config.rs` (2,709 lines). Most of that
//! file existed to *implement* provider configuration: the `Provider` enum,
//! per-provider base URLs, model-name normalization, Databricks host parsing,
//! OpenAI auto-upgrade rules, and the resolution order between them.
//!
//! Goose already owns all of that (`goose::config`, `goose::providers`). So
//! what remains here is a translation table: read the `BUZZ_AGENT_*` variables
//! `buzz-acp` injects (see `desktop/src-tauri/src/managed_agents/runtime.rs`),
//! and set the `GOOSE_*` / provider variables Goose reads.
//!
//! The mapping is applied to the *process* environment before the Goose
//! `Config` singleton is first touched, because `Config::global()` reads env at
//! initialization.
/// ACP protocol version. Buzz squats on v2 ahead of the upstream RFD; see the
/// note in `lib.rs::initialize`.
pub const PROTOCOL_VERSION: u32 = 2;
/// Hard caps preserved from buzz-agent's wire contract. These are protocol
/// limits (rejections are `invalid_params`), not loop tuning, so they stay.
pub const MAX_PROMPT_BYTES: usize = 1024 * 1024;
pub const MAX_SYSTEM_PROMPT_BYTES: usize = 512 * 1024;
pub const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct Config {
/// Model id, if pinned by the harness. `None` lets Goose resolve its own
/// default from `GOOSE_MODEL` / its config file.
pub model: Option<String>,
/// Max provider round-trips per turn. Maps to `SessionConfig.max_turns`.
pub max_rounds: Option<u32>,
/// Concurrent sessions this process will hold.
pub max_sessions: usize,
/// Default system prompt, used only when `session/new` omits one.
pub system_prompt: Option<String>,
/// Per-turn wall-clock budget for a single provider request.
pub llm_timeout_secs: u64,
}
fn env_str(key: &str) -> Option<String> {
std::env::var(key).ok().filter(|s| !s.trim().is_empty())
}
fn env_parse<T: std::str::FromStr>(key: &str) -> Option<T> {
env_str(key).and_then(|s| s.parse().ok())
}
impl Config {
/// Read `BUZZ_AGENT_*` from the environment and, as a side effect, project
/// the provider-shaped ones onto the `GOOSE_*` names Goose reads.
pub fn from_env() -> Self {
Self::project_goose_env();
let system_prompt = env_str("BUZZ_AGENT_SYSTEM_PROMPT").or_else(|| {
env_str("BUZZ_AGENT_SYSTEM_PROMPT_FILE")
.and_then(|p| std::fs::read_to_string(p).ok())
.filter(|s| !s.trim().is_empty())
});
Self {
model: env_str("BUZZ_AGENT_MODEL"),
max_rounds: env_parse::<u32>("BUZZ_AGENT_MAX_ROUNDS").filter(|n| *n > 0),
max_sessions: env_parse("BUZZ_AGENT_MAX_SESSIONS").unwrap_or(8),
system_prompt,
llm_timeout_secs: env_parse("BUZZ_AGENT_LLM_TIMEOUT_SECS").unwrap_or(600),
}
}
/// Translate Buzz's provider configuration into Goose's environment.
///
/// Mirrors `goose_env.rs` from PR #1526, with one deliberate difference:
/// **`GOOSE_MODE` is not forced to `auto` here.** The desktop catalog
/// currently ships `default_env: &[("GOOSE_MODE", "auto")]`
/// (`discovery.rs:89`), i.e. auto-approve every tool call
/// (`goose_mode.rs:22-31`). Because this binary drives the agent in-process
/// we can gate tool calls ourselves later without inheriting that default;
/// leaving it unset means Goose falls back to its own default rather than
/// Buzz silently widening it. Callers that genuinely want auto-approve can
/// still set `GOOSE_MODE` explicitly.
fn project_goose_env() {
// Provider: Buzz's `openai-compat` and `relay-mesh` are both
// OpenAI-wire-compatible, and Goose knows them as plain `openai`.
if let Some(provider) = env_str("BUZZ_AGENT_PROVIDER") {
let goose_provider = match provider.as_str() {
"openai-compat" | "openai_compat" | "relay-mesh" | "relay_mesh" => "openai",
other => other,
};
set_if_absent("GOOSE_PROVIDER", goose_provider);
}
if let Some(model) = env_str("BUZZ_AGENT_MODEL") {
set_if_absent("GOOSE_MODEL", &model);
}
// Key/base-url aliasing: native Goose names win if already present.
if let Some(key) = env_str("OPENAI_COMPAT_API_KEY") {
set_if_absent("OPENAI_API_KEY", &key);
}
if let Some(base) = env_str("OPENAI_COMPAT_BASE_URL") {
set_if_absent("OPENAI_BASE_URL", &base);
}
if let Some(effort) = env_str("BUZZ_AGENT_THINKING_EFFORT") {
set_if_absent("GOOSE_THINKING_EFFORT", &effort);
}
if let Some(max_tokens) = env_str("BUZZ_AGENT_MAX_OUTPUT_TOKENS") {
set_if_absent("GOOSE_MAX_TOKENS", &max_tokens);
}
if let Some(ctx) = env_str("BUZZ_AGENT_MAX_CONTEXT_TOKENS") {
set_if_absent("GOOSE_CONTEXT_LIMIT", &ctx);
}
}
}
fn set_if_absent(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
std::env::set_var(key, value);
}
}
#[cfg(test)]
mod tests {
use super::*;
// Env is process-global; these tests set disjoint keys and assert only on
// the pure mapping helpers where possible.
#[test]
fn set_if_absent_does_not_clobber() {
std::env::set_var("BUZZ_TEST_EXISTING", "native");
set_if_absent("BUZZ_TEST_EXISTING", "translated");
assert_eq!(std::env::var("BUZZ_TEST_EXISTING").unwrap(), "native");
std::env::remove_var("BUZZ_TEST_EXISTING");
}
#[test]
fn set_if_absent_fills_missing() {
std::env::remove_var("BUZZ_TEST_MISSING");
set_if_absent("BUZZ_TEST_MISSING", "translated");
assert_eq!(std::env::var("BUZZ_TEST_MISSING").unwrap(), "translated");
std::env::remove_var("BUZZ_TEST_MISSING");
}
#[test]
fn stop_reason_wire_strings_are_stable() {
use crate::types::StopReason;
// buzz-acp parses these; drift breaks turn completion.
assert_eq!(StopReason::EndTurn.as_wire(), "end_turn");
assert_eq!(StopReason::Cancelled.as_wire(), "cancelled");
assert_eq!(StopReason::MaxTurnRequests.as_wire(), "max_turn_requests");
}
}

688
buzz-agent-core/src/lib.rs Normal file
View File

@@ -0,0 +1,688 @@
//! buzz-agent's ACP server, with Goose as the agent loop.
//!
//! This is the layer `buzz-acp` actually talks to, and it is deliberately
//! unchanged in contract from `crates/buzz-agent/src/lib.rs` (960 lines):
//! the same six JSON-RPC methods, the same `agentInfo.name = "buzz-agent"`,
//! the same `activeRunId` steering handshake, the same `usage_update`
//! ordering. Only the loop underneath is Goose's.
//!
//! Not standard ACP, and preserved here on purpose (each of these is
//! something `buzz-acp` or the desktop UI depends on):
//!
//! * `_goose/unstable/session/steer` with `expectedRunId` optimistic
//! concurrency, and `activeRunId` advertised via
//! `params.update._meta.goose.activeRunId` — note the `_meta` nests *inside*
//! `update` (`buzz-acp/src/acp.rs:1607-1613`). Get the depth wrong and the
//! harness silently falls back to cancel+re-prompt forever.
//! * `usage_update` on the `_goose/unstable/session/update` channel, emitted
//! *before* the `session/prompt` response, suppressed when no tokens were
//! seen (`buzz-agent/src/lib.rs:701-712`).
//! * `keepalive` — see `agent.rs`.
pub mod agent;
pub mod config;
pub mod types;
pub mod wire;
use std::collections::HashMap;
use std::sync::Arc;
use serde_json::{json, Value};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use goose::agents::{Agent, AgentConfig, ExtensionConfig, GoosePlatform};
use goose::config::PermissionManager;
use goose::session::session_manager::{SessionManager, SessionType};
use goose_provider_types::goose_mode::GooseMode;
use config::{Config, MAX_PROMPT_BYTES, MAX_SYSTEM_PROMPT_BYTES, PROTOCOL_VERSION};
use types::{AgentError, McpServerStdio};
use wire::{
classify, goose_session_update, Inbound, InitializeParams, SessionCancelParams,
SessionNewParams, SessionPromptParams, SessionSetModelParams, SessionSteerParams, WireSender,
INVALID_PARAMS, METHOD_NOT_FOUND,
};
/// One live ACP session, wrapping a Goose `Agent`.
struct Session {
agent: Arc<Agent>,
/// Goose's own session id (the SQLite-backed conversation).
goose_session_id: String,
busy: bool,
/// Set for the duration of a turn; advertised to steer-capable clients.
active_run_id: Option<String>,
cancel: Option<CancellationToken>,
model_override: Option<String>,
accumulated_input_tokens: u64,
accumulated_output_tokens: u64,
}
pub struct App {
cfg: Config,
sessions: Mutex<HashMap<String, Session>>,
}
/// Build a Goose agent for one ACP session.
///
/// Note `Agent::with_config` loads **zero** extensions — a tool-free agent is
/// the default, not something we switch off (goose `agent.rs:362-420`). Tools
/// arrive only via the `mcpServers` the harness declares in `session/new`,
/// which is how `buzz-dev-mcp` (shell/read_file/str_replace/todo + the
/// `_Stop`/`_PostCompact` hooks + the `buzz`/`rg`/`tree` PATH shim) is wired.
async fn build_agent(
cfg: &Config,
cwd: &str,
system_prompt: Option<&str>,
mcp_servers: &[McpServerStdio],
) -> Result<(Arc<Agent>, String), AgentError> {
let session_manager = Arc::new(SessionManager::instance());
let permission_manager = PermissionManager::instance();
let agent = Agent::with_config(AgentConfig::new(
session_manager.clone(),
permission_manager,
None,
// Deliberately Goose's own default rather than forcing `Auto`. The
// desktop catalog ships GOOSE_MODE=auto for the *external* goose
// runtime (`discovery.rs:89`), which auto-approves every tool call;
// driving the agent in-process means we don't have to inherit that.
GooseMode::default(),
// Session naming is a Goose UX affordance; the harness names sessions.
true,
GoosePlatform::GooseCli,
));
let session = session_manager
.create_session(
std::path::PathBuf::from(cwd),
"buzz-agent".to_string(),
SessionType::Acp,
GooseMode::default(),
)
.await
.map_err(|e| AgentError::Llm(format!("session create: {e}")))?;
// Provider. `Agent::with_config` starts with no provider set, so this is
// mandatory before the first `reply()` — otherwise the turn fails with
// "Provider not set".
//
// Provider/model names come from the `GOOSE_*` variables `Config::from_env`
// projected out of the `BUZZ_AGENT_*` ones; goose's registry owns base-url
// and credential resolution from there.
let provider_name = std::env::var("GOOSE_PROVIDER").unwrap_or_else(|_| "openai".to_string());
let model_name = cfg
.model
.clone()
.or_else(|| std::env::var("GOOSE_MODEL").ok())
.ok_or_else(|| AgentError::Llm("no model configured".into()))?;
let provider = goose::providers::create(&provider_name, Vec::new())
.await
.map_err(|e| map_provider_error(&e.to_string()))?;
let model_config =
goose::model_config::model_config_from_user_config(&provider_name, &model_name)
.map_err(|e| AgentError::LlmModelNotFound(e.to_string()))?;
agent
.update_provider(provider, model_config, &session.id)
.await
.map_err(|e| map_provider_error(&e.to_string()))?;
// Persona. This is the seam that does NOT work over plain ACP: goose's own
// ACP server never reads `systemPrompt` (rg finds zero hits in
// goose/crates/goose/src), and both PRs that would have wired it —
// buzz#1290 and goose#9971 — are closed unmerged. Driving the library
// directly is what makes Fizz (and `[Base]`) actually arrive.
if let Some(sp) = system_prompt.or(cfg.system_prompt.as_deref()) {
if !sp.trim().is_empty() {
agent.override_system_prompt(sp.to_string()).await;
}
}
for server in mcp_servers {
let envs: HashMap<String, String> = server
.env
.iter()
.map(|e| (e.name.clone(), e.value.clone()))
.collect();
let ext = ExtensionConfig::Stdio {
name: server.name.clone(),
description: String::new(),
cmd: server.command.clone(),
args: server.args.clone(),
envs: goose::agents::extension::Envs::new(envs),
env_keys: Vec::new(),
timeout: Some(300),
cwd: Some(cwd.to_string()),
bundled: None,
available_tools: Vec::new(),
};
if let Err(e) = agent.add_extension(ext, &session.id).await {
// Match buzz-agent: a failed MCP server is a hard session error,
// not a silently tool-less agent.
return Err(AgentError::Mcp(format!("{}: {e}", server.name)));
}
}
Ok((Arc::new(agent), session.id))
}
pub async fn serve(cfg: Config) -> anyhow::Result<()> {
let (wire_tx, wire_rx) = tokio::sync::mpsc::channel(256);
tokio::spawn(wire::writer_task(wire_rx));
let app = Arc::new(App {
cfg,
sessions: Mutex::new(HashMap::new()),
});
let mut stdin = tokio::io::BufReader::new(tokio::io::stdin());
loop {
match wire::read_bounded_line(&mut stdin, config::MAX_LINE_BYTES).await {
Ok(None) => break,
Ok(Some(line)) => {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<Value>(&line) {
Ok(msg) => dispatch(&app, msg, &wire_tx).await,
Err(e) => {
wire::send(
&wire_tx,
wire::err(
Value::Null,
wire::PARSE_ERROR,
&format!("jsonrpc: parse: {e}"),
),
)
.await;
}
}
}
Err(e) => {
tracing::error!("io: {e}");
break;
}
}
}
Ok(())
}
async fn dispatch(app: &Arc<App>, msg: Value, wire_tx: &WireSender) {
match classify(&msg) {
Inbound::Request { id, method, params } => {
handle_request(app, id, method, params, wire_tx).await
}
Inbound::Notification { method, params } => {
if method == "session/cancel" {
cancel_session(app, params).await;
}
}
Inbound::Ignored => {}
Inbound::Invalid { id, code, message } => {
wire::send(wire_tx, wire::err(id, code, &message)).await
}
}
}
async fn handle_request(
app: &Arc<App>,
id: Value,
method: String,
params: Value,
wire_tx: &WireSender,
) {
match method.as_str() {
"initialize" => initialize(id, params, wire_tx).await,
"session/new" => {
let app = app.clone();
let wire_tx = wire_tx.clone();
// Spawned so a slow MCP init can't block the read loop — otherwise
// `session/cancel` can't be received.
tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await });
}
"session/prompt" => {
let app = app.clone();
let wire_tx = wire_tx.clone();
tokio::spawn(async move { session_prompt(&app, id, params, &wire_tx).await });
}
"session/set_model" => set_model(app, id, params, wire_tx).await,
"session/cancel" => {
cancel_session(app, params).await;
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
}
"_goose/unstable/session/steer" => steer(app, id, params, wire_tx).await,
_ => {
wire::send(
wire_tx,
wire::err(
id,
METHOD_NOT_FOUND,
&format!("jsonrpc: method not found: {method}"),
),
)
.await
}
}
}
async fn initialize(id: Value, params: Value, wire_tx: &WireSender) {
let p: InitializeParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, &format!("initialize: {e}")),
)
.await
}
};
// Honest negotiation: min(client, ours). Buzz squats on v2 ahead of the
// upstream ACP RFD (#1237); see buzz-agent/src/lib.rs:279-283.
let negotiated = p.protocol_version.min(PROTOCOL_VERSION);
wire::send(
wire_tx,
wire::ok(
id,
json!({
"protocolVersion": negotiated,
"agentCapabilities": {
"loadSession": false,
"promptCapabilities": { "image": false, "audio": false, "embeddedContext": false },
"mcpCapabilities": { "http": false, "sse": false },
},
// Identity is unchanged on purpose: this is still buzz-agent.
// The `harness` field of the encrypted kind-44200 turn metric
// derives from this (`buzz-acp/src/pool.rs:3350`), so changing
// it would blank any dashboard filtering on it.
"agentInfo": { "name": "buzz-agent", "version": env!("CARGO_PKG_VERSION") },
}),
),
)
.await;
}
async fn session_new(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionNewParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, &format!("session/new: {e}")),
)
.await
}
};
if !std::path::Path::new(&p.cwd).is_absolute() {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/new: cwd must be absolute"),
)
.await;
}
if let Some(sp) = &p.system_prompt {
if sp.len() > MAX_SYSTEM_PROMPT_BYTES {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/new: systemPrompt too large"),
)
.await;
}
}
if app.sessions.lock().await.len() >= app.cfg.max_sessions {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/new: max sessions reached"),
)
.await;
}
let (agent, goose_session_id) =
match build_agent(&app.cfg, &p.cwd, p.system_prompt.as_deref(), &p.mcp_servers).await {
Ok(v) => v,
Err(e) => {
return wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await
}
};
let sid = format!("ses_{}", goose_session_id);
app.sessions.lock().await.insert(
sid.clone(),
Session {
agent,
goose_session_id,
busy: false,
active_run_id: None,
cancel: None,
model_override: None,
accumulated_input_tokens: 0,
accumulated_output_tokens: 0,
},
);
wire::send(wire_tx, wire::ok(id, json!({ "sessionId": sid }))).await;
}
async fn session_prompt(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionPromptParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, &format!("session/prompt: {e}")),
)
.await
}
};
let prompt_bytes: usize = p
.prompt
.iter()
.map(|b| match b {
types::ContentBlock::Text { text } => text.len(),
types::ContentBlock::ResourceLink { uri } => uri.len(),
types::ContentBlock::Unsupported => 0,
})
.sum();
if prompt_bytes > MAX_PROMPT_BYTES {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/prompt: prompt too large"),
)
.await;
}
let run_id = format!("run_{}", uuid_like());
let cancel = CancellationToken::new();
// Single-flight per session, and capture the agent handle.
let (agent, goose_session_id) = {
let mut sessions = app.sessions.lock().await;
let Some(s) = sessions.get_mut(&p.session_id) else {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/prompt: unknown session"),
)
.await;
};
if s.busy {
return wire::send(
wire_tx,
wire::err(
id,
INVALID_PARAMS,
"session/prompt: prompt already in flight",
),
)
.await;
}
s.busy = true;
s.active_run_id = Some(run_id.clone());
s.cancel = Some(cancel.clone());
(s.agent.clone(), s.goose_session_id.clone())
};
// Advertise the run id so steer-capable clients can target this turn.
// `_meta` nests INSIDE `update` — see the module docs.
wire::send(
wire_tx,
wire::session_update_with_goose_meta(
&p.session_id,
json!({ "sessionUpdate": "session_info_update" }),
json!({ "activeRunId": run_id }),
),
)
.await;
let (result, tokens) = agent::run_turn(
agent,
&goose_session_id,
p.prompt,
app.cfg.max_rounds,
wire_tx,
cancel,
)
.await;
// Clear run state so a late steer can't queue into a finished turn.
let accumulated = {
let mut sessions = app.sessions.lock().await;
sessions.get_mut(&p.session_id).map(|s| {
s.busy = false;
s.active_run_id = None;
s.cancel = None;
s.accumulated_input_tokens = s
.accumulated_input_tokens
.saturating_add(tokens.input.unwrap_or(0));
s.accumulated_output_tokens = s
.accumulated_output_tokens
.saturating_add(tokens.output.unwrap_or(0));
(s.accumulated_input_tokens, s.accumulated_output_tokens)
})
};
wire::send(
wire_tx,
wire::session_update_with_goose_meta(
&p.session_id,
json!({ "sessionUpdate": "session_info_update" }),
json!({ "activeRunId": Value::Null }),
),
)
.await;
// ORDERING IS LOAD-BEARING: emit usage BEFORE the prompt response.
// buzz-acp's UsageTracker processes this while the turn is still in
// flight; the response triggers take_turn_usage(). Reversing these
// produces zero kind-44200 events, silently.
if tokens.observed() {
if let Some((acc_in, acc_out)) = accumulated {
let model = {
let sessions = app.sessions.lock().await;
sessions
.get(&p.session_id)
.and_then(|s| s.model_override.clone())
.or_else(|| app.cfg.model.clone())
.unwrap_or_default()
};
wire::send(
wire_tx,
goose_session_update(
&p.session_id,
json!({
"sessionUpdate": "usage_update",
"used": acc_in.saturating_add(acc_out),
"contextLimit": 0u64,
"accumulatedInputTokens": acc_in,
"accumulatedOutputTokens": acc_out,
"model": model,
}),
),
)
.await;
}
}
match result {
Ok(stop) => {
wire::send(
wire_tx,
wire::ok(id, json!({ "stopReason": stop.as_wire() })),
)
.await
}
Err(e) => wire::send(wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await,
}
}
async fn set_model(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionSetModelParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, &format!("session/set_model: {e}")),
)
.await
}
};
if p.model_id.trim().is_empty() {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/set_model: empty modelId"),
)
.await;
}
let mut sessions = app.sessions.lock().await;
match sessions.get_mut(&p.session_id) {
Some(s) => {
s.model_override = Some(p.model_id);
drop(sessions);
wire::send(wire_tx, wire::ok(id, Value::Null)).await;
}
None => {
drop(sessions);
wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "session/set_model: unknown session"),
)
.await
}
}
}
/// Non-cancelling mid-turn steering.
///
/// Goose's own `steer()` has exactly buzz-agent's semantics: the message is
/// queued and drained at the *round boundary* (goose `agent.rs:1951-1974`),
/// the turn is not restarted, and a pending steer even prevents the turn from
/// ending (`agent.rs:2876-2878`).
async fn steer(app: &Arc<App>, id: Value, params: Value, wire_tx: &WireSender) {
let p: SessionSteerParams = match serde_json::from_value(params) {
Ok(p) => p,
Err(e) => {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, &format!("steer: {e}")),
)
.await
}
};
let (agent, goose_session_id) = {
let sessions = app.sessions.lock().await;
let Some(s) = sessions.get(&p.session_id) else {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "steer: unknown session"),
)
.await;
};
// Optimistic concurrency: the harness distinguishes "no active run"
// from "run id mismatch" to decide whether to fire its cancel+merge
// fallback (`buzz-acp/src/pool.rs:329-366`).
let Some(active) = s.active_run_id.as_deref() else {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "steer: no active run"),
)
.await;
};
if active != p.expected_run_id {
return wire::send(
wire_tx,
wire::err(id, INVALID_PARAMS, "steer: run id mismatch"),
)
.await;
}
(s.agent.clone(), s.goose_session_id.clone())
};
let text = agent::prompt_to_text(&p.prompt);
if text.trim().is_empty() {
// Best-effort by contract: a whitespace-only steer is dropped, never
// fatal to the turn (buzz-agent `agent.rs:266-278`).
return wire::send(wire_tx, wire::ok(id, json!({ "runId": p.expected_run_id }))).await;
}
let message_id = format!("steer_{}", uuid_like());
agent
.steer(
&goose_session_id,
goose_provider_types::conversation::message::Message::user().with_text(text),
)
.await;
wire::send(
wire_tx,
wire::ok(
id,
json!({ "runId": p.expected_run_id, "messageId": message_id }),
),
)
.await;
}
async fn cancel_session(app: &Arc<App>, params: Value) {
let Ok(p) = serde_json::from_value::<SessionCancelParams>(params) else {
return;
};
let sessions = app.sessions.lock().await;
if let Some(s) = sessions.get(&p.session_id) {
if let Some(c) = &s.cancel {
// Goose plumbs this token down into MCP tool calls and sends
// `notifications/cancelled` per in-flight request
// (goose `mcp_client.rs:687-690`) — a cooperative drain rather
// than a hard abort, matching buzz-agent's contract.
c.cancel();
}
}
}
/// Preserve buzz-agent's error taxonomy across provider construction, so the
/// harness's JSON-RPC code mapping (-32001 auth, -32002 model-not-found) keeps
/// its meaning.
fn map_provider_error(msg: &str) -> AgentError {
let lower = msg.to_ascii_lowercase();
if lower.contains("auth") || lower.contains("401") || lower.contains("api key") {
AgentError::LlmAuth(msg.to_string())
} else if lower.contains("model") && lower.contains("not found") {
AgentError::LlmModelNotFound(msg.to_string())
} else {
AgentError::Llm(msg.to_string())
}
}
/// Short random id. Not a UUID; just needs to be unguessable enough that a
/// stale steer can't collide with a live run.
fn uuid_like() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{nanos:x}")
}
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.with_writer(std::io::stderr)
.init();
let cfg = Config::from_env();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(serve(cfg))?;
Ok(())
}

View File

@@ -0,0 +1,3 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
buzz_agent_core::run()
}

View File

@@ -0,0 +1,107 @@
//! Wire-facing types shared with `wire.rs`.
//!
//! This is a deliberate subset of `crates/buzz-agent/src/types.rs` (353 lines).
//! The original carried the hand-written loop's internal vocabulary —
//! `HistoryItem`, `ToolCall`, `ToolResult`, `LlmResponse`, `ProviderStop`,
//! `ToolDef`, plus byte-accounting helpers (`estimated_bytes`,
//! `context_pressure_bytes`) that fed the bespoke handoff heuristic.
//!
//! Goose owns all of that now: conversation state is `goose::conversation`,
//! tool plumbing is `rmcp`, and compaction is `goose::context_mgmt`. What
//! survives here is only what crosses the ACP wire.
use serde::Deserialize;
/// A stdio MCP server declaration from `session/new`.
#[derive(Debug, Deserialize, Clone)]
pub struct McpServerStdio {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: Vec<EnvVar>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct EnvVar {
pub name: String,
pub value: String,
}
/// A single block of an ACP `session/prompt` payload.
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentBlock {
Text {
text: String,
},
ResourceLink {
uri: String,
},
#[serde(other)]
Unsupported,
}
/// ACP stop reasons. Wire strings are load-bearing: `buzz-acp` parses
/// `stopReason` off the `session/prompt` response and errors without it
/// (`acp.rs:1757-1761`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopReason {
EndTurn,
Cancelled,
MaxTokens,
MaxTurnRequests,
Refusal,
}
impl StopReason {
pub fn as_wire(self) -> &'static str {
match self {
Self::EndTurn => "end_turn",
Self::Cancelled => "cancelled",
Self::MaxTokens => "max_tokens",
Self::MaxTurnRequests => "max_turn_requests",
Self::Refusal => "refusal",
}
}
}
/// Errors surfaced to the client as JSON-RPC error responses.
#[derive(Debug)]
pub enum AgentError {
InvalidParams(String),
Llm(String),
LlmAuth(String),
LlmModelNotFound(String),
Mcp(String),
Cancelled,
}
impl std::fmt::Display for AgentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidParams(s) => write!(f, "invalid params: {s}"),
Self::Llm(s) => write!(f, "llm: {s}"),
Self::LlmAuth(s) => write!(f, "llm auth: {s}"),
Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"),
Self::Mcp(s) => write!(f, "mcp: {s}"),
Self::Cancelled => write!(f, "cancelled"),
}
}
}
impl std::error::Error for AgentError {}
impl AgentError {
/// Preserved verbatim from buzz-agent: the harness's error taxonomy keys
/// off these codes.
pub fn json_rpc_code(&self) -> i32 {
match self {
Self::InvalidParams(_) => -32602,
Self::LlmAuth(_) => -32001,
Self::LlmModelNotFound(_) => -32002,
_ => -32000,
}
}
}

293
buzz-agent-core/src/wire.rs Normal file
View File

@@ -0,0 +1,293 @@
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt};
use tokio::sync::mpsc;
use crate::types::{ContentBlock, McpServerStdio};
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub enum WireMsg {
Notify(Value),
}
pub type WireSender = mpsc::Sender<WireMsg>;
#[derive(Debug)]
pub enum Inbound {
Request {
id: Value,
method: String,
params: Value,
},
Notification {
method: String,
params: Value,
},
Ignored,
Invalid {
id: Value,
code: i32,
message: String,
},
}
#[derive(Debug, Deserialize)]
pub struct InitializeParams {
#[serde(rename = "protocolVersion")]
pub protocol_version: u32,
#[serde(default, rename = "clientCapabilities")]
pub _client_capabilities: Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionNewParams {
pub cwd: String,
#[serde(default)]
pub mcp_servers: Vec<McpServerStdio>,
#[serde(default)]
pub system_prompt: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionPromptParams {
pub session_id: String,
pub prompt: Vec<ContentBlock>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionCancelParams {
pub session_id: String,
}
/// Params for goose's non-standard `_goose/unstable/session/steer` request:
/// inject user input into the *currently active* prompt without starting a new
/// one. `expected_run_id` must match the run id buzz-agent advertised via
/// `params.update._meta.goose.activeRunId` on a `session/update`, so a steer
/// can't race a turn that already ended or hasn't started.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSteerParams {
pub session_id: String,
#[serde(default)]
pub prompt: Vec<ContentBlock>,
pub expected_run_id: String,
}
/// Params for `session/set_model`: override the active model for an existing
/// session without respawning. Applied immediately; subsequent prompts on this
/// session use `model_id` instead of the configured `BUZZ_AGENT_MODEL`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionSetModelParams {
pub session_id: String,
pub model_id: String,
}
pub fn classify(msg: &Value) -> Inbound {
if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
return Inbound::Invalid {
id: msg.get("id").cloned().unwrap_or(Value::Null),
code: INVALID_REQUEST,
message: "jsonrpc: missing or invalid version".into(),
};
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).map(str::to_owned);
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match (method, id) {
(Some(m), Some(id)) => Inbound::Request {
id,
method: m,
params,
},
(Some(m), None) => Inbound::Notification { method: m, params },
// Bare responses (id present, no method) are unexpected — buzz-agent
// does not issue requests to the client. Ignore silently.
(None, Some(_)) => Inbound::Ignored,
(None, None) => Inbound::Invalid {
id: Value::Null,
code: INVALID_REQUEST,
message: "jsonrpc: missing method and id".into(),
},
}
}
pub fn ok(id: Value, result: Value) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "result": result })
}
pub fn err(id: Value, code: i32, message: &str) -> Value {
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
}
pub fn session_update(sid: &str, update: Value) -> Value {
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": { "sessionId": sid, "update": update },
})
}
/// A `_goose/unstable/session/update` notification — the separate top-level
/// method goose uses for custom usage and status events. Used by buzz-agent
/// to emit the `usage_update` payload so buzz-acp's `UsageTracker` can treat
/// buzz-agent and goose symmetrically.
pub fn goose_session_update(sid: &str, update: Value) -> Value {
json!({
"jsonrpc": "2.0",
"method": "_goose/unstable/session/update",
"params": { "sessionId": sid, "update": update },
})
}
/// A `session/update` notification carrying a `update._meta.goose.<key>` field.
/// Used to advertise `activeRunId` (so steer-capable clients can target the
/// in-flight run) and `queuedSteer` (so they can correlate an accepted steer
/// with the chunk that later picks it up) — matching goose's wire layout where
/// `_meta` is nested inside the `update` object (per the ACP `SessionInfoUpdate`
/// schema), not alongside it at the params level.
pub fn session_update_with_goose_meta(sid: &str, update: Value, goose_meta: Value) -> Value {
let mut update = update;
update["_meta"] = json!({ "goose": goose_meta });
json!({
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": sid,
"update": update,
},
})
}
pub async fn send(wire: &WireSender, msg: Value) {
let _ = wire.send(WireMsg::Notify(msg)).await;
}
pub async fn read_bounded_line<R: AsyncBufRead + Unpin>(
stdin: &mut R,
max: usize,
) -> std::io::Result<Option<String>> {
let mut buf: Vec<u8> = Vec::new();
loop {
let chunk = stdin.fill_buf().await?;
if chunk.is_empty() {
if !buf.is_empty() {
tracing::error!(
"io: unterminated frame at EOF ({} bytes dropped)",
buf.len()
);
}
return Ok(None);
}
let take = chunk
.iter()
.position(|b| *b == b'\n')
.map_or(chunk.len(), |i| i + 1);
if buf.len().saturating_add(take) > max {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("io: line exceeds max ({max} bytes)"),
));
}
buf.extend_from_slice(&chunk[..take]);
stdin.consume(take);
if buf.ends_with(b"\n") {
buf.pop();
if buf.ends_with(b"\r") {
buf.pop();
}
match String::from_utf8(buf) {
Ok(s) => return Ok(Some(s)),
Err(_) => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"io: frame contains invalid UTF-8",
))
}
}
}
}
}
pub async fn writer_task(mut rx: mpsc::Receiver<WireMsg>) {
let mut stdout = tokio::io::stdout();
while let Some(msg) = rx.recv().await {
let WireMsg::Notify(v) = msg;
let mut s = match serde_json::to_string(&v) {
Ok(s) => s,
Err(e) => {
tracing::error!("io: serialize: {e}");
continue;
}
};
s.push('\n');
if stdout.write_all(s.as_bytes()).await.is_err() {
return;
}
let _ = stdout.flush().await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn session_new_params_deserializes_system_prompt() {
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"systemPrompt": "You are a helpful agent."
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert_eq!(
params.system_prompt.as_deref(),
Some("You are a helpful agent.")
);
}
#[test]
fn session_new_params_system_prompt_defaults_to_none() {
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": []
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert!(params.system_prompt.is_none());
}
#[test]
fn session_new_params_ignores_unknown_fields() {
// Backward compat: old agents with new harness — unknown fields are ignored.
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"unknownField": "should be ignored"
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.cwd, "/tmp/test");
assert!(params.system_prompt.is_none());
}
#[test]
fn session_new_params_empty_string_system_prompt() {
// An explicit empty string is distinct from absent — deserializes to Some("").
let json = serde_json::json!({
"cwd": "/tmp/test",
"mcpServers": [],
"systemPrompt": ""
});
let params: SessionNewParams = serde_json::from_value(json).unwrap();
assert_eq!(params.system_prompt, Some(String::new()));
}
}

View File

@@ -0,0 +1,346 @@
//! End-to-end stdio test: drive the binary over ACP exactly as `buzz-acp`
//! does, against a fake OpenAI-compatible provider.
//!
//! This is the test that proves the integration, not just the compile:
//!
//! 1. `initialize` negotiates and self-identifies as `buzz-agent`.
//! 2. `session/new` accepts a `systemPrompt` (Fizz) and MCP servers.
//! 3. **The persona actually reaches the model** — the fake provider asserts
//! Fizz's text is present in the `system` field of the request body. This
//! is the whole reason for driving goose as a library: goose's own ACP
//! server never reads `systemPrompt` (zero hits in goose/crates/goose/src),
//! so over plain ACP the persona is silently dropped.
//! 4. `session/prompt` runs a turn and returns a `stopReason`.
//! 5. `usage_update` arrives on the `_goose/unstable/session/update` channel
//! BEFORE the `session/prompt` response — ordering that kind-44200 metrics
//! depend on (`buzz-acp/src/lib.rs:701-706`).
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
use serde_json::{json, Value};
const FIZZ_PROMPT: &str = "You are Fizz, an energetic maker who turns ideas into action. Be upbeat, practical, and decisive.";
/// Minimal OpenAI-compatible `/chat/completions` server.
///
/// Returns the captured `system` prompt of the first request over a channel so
/// the test can assert the persona survived the trip.
fn spawn_fake_provider() -> (String, mpsc::Receiver<String>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("addr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let tx = tx.clone();
let mut reader = BufReader::new(stream.try_clone().expect("clone"));
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() {
continue;
}
// Read headers, find content-length.
let mut content_length = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
break;
}
let trimmed = line.trim_end();
if trimmed.is_empty() {
break;
}
if let Some(v) = trimmed.strip_prefix("content-length: ") {
content_length = v.parse().unwrap_or(0);
} else if let Some(v) = trimmed.strip_prefix("Content-Length: ") {
content_length = v.parse().unwrap_or(0);
}
}
let mut body = vec![0u8; content_length];
use std::io::Read;
let _ = reader.read_exact(&mut body);
// Models endpoint: return a tiny catalog.
if request_line.contains("/models") {
let payload = json!({
"object": "list",
"data": [{ "id": "fake-model", "object": "model" }],
})
.to_string();
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
payload.len(),
payload
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
continue;
}
// Chat completions: capture the system prompt, then answer.
if let Ok(req) = serde_json::from_slice::<Value>(&body) {
let system = req["messages"]
.as_array()
.and_then(|msgs| {
msgs.iter()
.find(|m| m["role"] == "system")
.and_then(|m| m["content"].as_str())
.map(str::to_owned)
})
.unwrap_or_default();
let _ = tx.send(system);
}
// Goose sets `stream: true` on the OpenAI provider
// (goose-providers/src/openai.rs:650), so answer with SSE. A
// non-streaming JSON body parses as an empty response and the turn
// ends with zero tokens — which correctly suppresses
// `usage_update`, but proves nothing.
let chunk = |delta: Value, finish: Value, usage: Value| {
let mut c = json!({
"id": "chatcmpl-test",
"object": "chat.completion.chunk",
"created": 1,
"model": "fake-model",
"choices": [{ "index": 0, "delta": delta, "finish_reason": finish }],
});
if !usage.is_null() {
c["usage"] = usage;
}
format!("data: {c}\n\n")
};
let body = format!(
"{}{}{}data: [DONE]\n\n",
chunk(
json!({ "role": "assistant", "content": "Buzzing along!" }),
Value::Null,
Value::Null
),
chunk(json!({ "content": " Done. 🐝" }), Value::Null, Value::Null),
chunk(
json!({}),
json!("stop"),
json!({ "prompt_tokens": 42, "completion_tokens": 7, "total_tokens": 49 })
),
);
let resp = format!(
"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
let _ = stream.flush();
}
});
(format!("http://{addr}"), rx)
}
struct Harness {
child: Child,
stdin: std::process::ChildStdin,
stdout: BufReader<std::process::ChildStdout>,
next_id: i64,
}
impl Harness {
fn start(base_url: &str, home: &std::path::Path) -> Self {
let exe = env!("CARGO_BIN_EXE_buzz-agent-core");
let mut child = Command::new(exe)
// Buzz-shaped config; config.rs translates these onto GOOSE_*.
.env("BUZZ_AGENT_PROVIDER", "openai-compat")
.env("BUZZ_AGENT_MODEL", "fake-model")
.env("OPENAI_COMPAT_API_KEY", "test-key")
.env("OPENAI_COMPAT_BASE_URL", base_url)
.env("BUZZ_AGENT_MAX_ROUNDS", "2")
// Isolate goose's config/session state from the developer's real one.
.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join("config"))
.env("XDG_DATA_HOME", home.join("data"))
.env("GOOSE_DISABLE_KEYRING", "1")
.env("RUST_LOG", "warn")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.expect("spawn buzz-agent-core");
let stdin = child.stdin.take().expect("stdin");
let stdout = BufReader::new(child.stdout.take().expect("stdout"));
Self {
child,
stdin,
stdout,
next_id: 0,
}
}
fn request(&mut self, method: &str, params: Value) -> i64 {
self.next_id += 1;
let id = self.next_id;
let msg = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params });
writeln!(self.stdin, "{msg}").expect("write");
self.stdin.flush().expect("flush");
id
}
/// Read lines until the response with `id` arrives, collecting every
/// notification seen along the way (so ordering can be asserted).
fn await_response(&mut self, id: i64) -> (Value, Vec<Value>) {
let mut notifications = Vec::new();
loop {
let mut line = String::new();
let n = self.stdout.read_line(&mut line).expect("read");
assert_ne!(n, 0, "agent closed stdout before responding to id={id}");
let Ok(msg) = serde_json::from_str::<Value>(&line) else {
continue;
};
if msg.get("id").and_then(Value::as_i64) == Some(id) {
return (msg, notifications);
}
if msg.get("method").is_some() {
notifications.push(msg);
}
}
}
}
impl Drop for Harness {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn full_turn_over_stdio_carries_the_persona() {
let (base_url, system_rx) = spawn_fake_provider();
let home = tempfile::tempdir().expect("tempdir");
let cwd = tempfile::tempdir().expect("cwd");
let mut h = Harness::start(&base_url, home.path());
// 1. initialize
let id = h.request(
"initialize",
json!({
"protocolVersion": 2,
"clientCapabilities": { "_meta": { "goose": { "customNotifications": true } } },
}),
);
let (resp, _) = h.await_response(id);
let result = &resp["result"];
assert_eq!(result["protocolVersion"], 2, "negotiates min(client, 2)");
assert_eq!(
result["agentInfo"]["name"], "buzz-agent",
"identity must stay buzz-agent — kind-44200 `harness` attribution keys off this"
);
// 2. session/new carrying Fizz
let id = h.request(
"session/new",
json!({
"cwd": cwd.path().to_str().unwrap(),
"mcpServers": [],
"systemPrompt": FIZZ_PROMPT,
}),
);
let (resp, _) = h.await_response(id);
let session_id = resp["result"]["sessionId"]
.as_str()
.expect("sessionId in session/new result")
.to_string();
assert!(session_id.starts_with("ses_"));
// 3. session/prompt
let id = h.request(
"session/prompt",
json!({
"sessionId": session_id,
"prompt": [{ "type": "text", "text": "say hello" }],
}),
);
let (resp, notifications) = h.await_response(id);
assert!(
resp["result"]["stopReason"].is_string(),
"buzz-acp errors without stopReason: got {resp}"
);
// 4. THE point of the whole exercise: the persona reached the provider.
let captured = system_rx
.recv_timeout(Duration::from_secs(30))
.expect("provider never received a chat completion request");
assert!(
captured.contains("You are Fizz"),
"persona did not reach the model. system prompt was:\n{captured}"
);
// 5. activeRunId advertised with _meta nested INSIDE update.
let run_id_update = notifications.iter().find(|n| {
n["method"] == "session/update"
&& n["params"]["update"]["sessionUpdate"] == "session_info_update"
&& n["params"]["update"]["_meta"]["goose"]["activeRunId"].is_string()
});
assert!(
run_id_update.is_some(),
"no activeRunId advertised — steering silently degrades to cancel+re-prompt. saw: {:#?}",
notifications
.iter()
.map(|n| n["method"].as_str().unwrap_or("?"))
.collect::<Vec<_>>()
);
// 6. usage_update present, on the goose-namespaced channel.
let usage_idx = notifications.iter().position(|n| {
n["method"] == "_goose/unstable/session/update"
&& n["params"]["update"]["sessionUpdate"] == "usage_update"
});
let usage_idx =
usage_idx.expect("no usage_update — kind-44200 turn metrics would silently vanish");
let usage = &notifications[usage_idx]["params"]["update"];
assert!(
usage["accumulatedInputTokens"].as_u64().unwrap_or(0) > 0,
"expected non-zero accumulated input tokens, got {usage}"
);
// Ordering is implicitly proven: `notifications` only contains messages
// seen strictly BEFORE the session/prompt response was read.
}
#[test]
fn unknown_method_gets_method_not_found() {
let (base_url, _rx) = spawn_fake_provider();
let home = tempfile::tempdir().expect("tempdir");
let mut h = Harness::start(&base_url, home.path());
let id = h.request("does/not/exist", json!({}));
let (resp, _) = h.await_response(id);
assert_eq!(
resp["error"]["code"], -32601,
"silence would hang the harness waiting for a response"
);
}
#[test]
fn session_new_rejects_relative_cwd() {
let (base_url, _rx) = spawn_fake_provider();
let home = tempfile::tempdir().expect("tempdir");
let mut h = Harness::start(&base_url, home.path());
let id = h.request(
"session/new",
json!({ "cwd": "relative/path", "mcpServers": [] }),
);
let (resp, _) = h.await_response(id);
assert_eq!(resp["error"]["code"], -32602);
}