Files
buzz/buzz-agent-core/Cargo.toml
Michael Neale e9aca45048 feat(buzz-agent-core): implement the _Stop veto and _PostCompact re-injection
Closes the last behavioural gap. buzz-agent's most load-bearing non-standard
behaviour -- the agent may not end its turn while its todo list has open items
-- now works, with no changes to goose.

Why not goose's hook system. Goose has a blocking Stop hook with exactly these
semantics (agent.rs:2891-2917), but it is unreachable: hook_manager is private
with a #[cfg(test)] setter, and hooks are otherwise discovered as subprocesses
from <root>/.goose/plugins/*/hooks/hooks.json. The subprocess route looked
viable until you notice where the answer lives -- buzz-dev-mcp's todo list is
in-process state (todo.rs:49, a Mutex<Vec<Item>>) and that process's stdio is
owned by goose. A hook binary goose spawns cannot see it, so a materialised
hooks.json would produce a hook that always answers "no objection": worse than
no hook, because it looks like it works.

Instead we own the outer loop, so we ask the tool ourselves.
Agent::dispatch_tool_call is public (agent.rs:1059). Between rounds we call
_Stop on the same extension the model uses; on objection we re-enter reply()
with the objection as an agent-visible/user-invisible message. Capped at 3
consecutive vetoes, mirroring goose's own stop_hook_block_cap.

_PostCompact is wired to AgentEvent::HistoryReplaced and re-injects via steer(),
which goose drains at the round boundary (agent.rs:1951-1974).

Extension name is discovered by "___Stop" suffix rather than hardcoded --
buzz-acp derives it from the MCP binary's file stem (buzz-acp/src/lib.rs:4145),
so it is not a fixed string.

KNOWN DEVIATION: buzz-agent hid _-prefixed tools from the model
(agent.rs:328-336) while still calling them itself. Goose's available_tools
allowlist gates advertising and dispatch through the same cache
(extension_manager.rs:1421, :1698), so hiding them would make them
undispatchable and break the veto. They stay visible; a system-prompt
extension tells the model not to call them.

4 new tests against the real fake-mcp binary (copied from crates/buzz-agent)
counting provider generations: 2 objections => 3 calls, permanent objection
capped at 4, no hook => 1 call, and discovery under a non-obvious extension
name. 21 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 16:07:02 +10:00

79 lines
3.1 KiB
TOML

# 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"
# Test-only fake MCP server, copied from crates/buzz-agent. Built
# unconditionally because cargo can't gate bins on `cfg(test)`.
[[bin]]
name = "fake-mcp"
path = "tests/bin/fake_mcp.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 = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab", default-features = false, features = ["rustls-tls"] }
goose-provider-types = { git = "https://github.com/aaif-goose/goose", rev = "305849b71709b95b86ed9f11bd3bc939899c0aab" }
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"] }
rmcp = { version = "1.8", default-features = false, features = ["schemars"] }
[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