mirror of
https://github.com/block/buzz.git
synced 2026-11-01 15:01:12 +00:00
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:
@@ -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
1
buzz-agent-core/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
target/
|
||||
6229
buzz-agent-core/Cargo.lock
generated
Normal file
6229
buzz-agent-core/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
71
buzz-agent-core/Cargo.toml
Normal file
71
buzz-agent-core/Cargo.toml
Normal 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
|
||||
357
buzz-agent-core/src/agent.rs
Normal file
357
buzz-agent-core/src/agent.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
154
buzz-agent-core/src/config.rs
Normal file
154
buzz-agent-core/src/config.rs
Normal 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
688
buzz-agent-core/src/lib.rs
Normal 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(())
|
||||
}
|
||||
3
buzz-agent-core/src/main.rs
Normal file
3
buzz-agent-core/src/main.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
buzz_agent_core::run()
|
||||
}
|
||||
107
buzz-agent-core/src/types.rs
Normal file
107
buzz-agent-core/src/types.rs
Normal 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
293
buzz-agent-core/src/wire.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
346
buzz-agent-core/tests/stdio_turn.rs
Normal file
346
buzz-agent-core/tests/stdio_turn.rs
Normal 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 = ¬ifications[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);
|
||||
}
|
||||
Reference in New Issue
Block a user