Commit Graph

7 Commits

Author SHA1 Message Date
Michael Neale
b631552fa4 fix(buzz-agent-core): drain on cancel instead of dropping the stream
The new cancel test caught two real bugs, both from breaking out of the select
loop the moment the token fired.

Dropping `stream` drops the futures goose is awaiting, so
`mcp_client.rs:688` never reaches its `cancel_token.cancelled()` arm and never
sends `notifications/cancelled`. Consequences: the MCP child keeps running its
tool after the turn is over, and any announced `tool_call` never reaches a
terminal state -- the desktop renders that as a spinner forever, which is the
invariant buzz-agent held at agent.rs:470-477.

Cancellation is cooperative, so treat it that way: keep polling the stream and
let goose unwind (emit tool responses, send the MCP cancellations, end the
stream), bounded by CANCEL_DRAIN_TIMEOUT = 5s. Track announced-minus-resolved
tool call ids and synthesise terminal updates for any stragglers if the drain
times out -- a wrong status beats a stuck spinner.

3 new tests: cancel mid-tool-call returns stopReason=cancelled with every tool
call resolved and activeRunId cleared; notifications/cancelled actually reaches
the MCP server (asserted via FAKE_MCP_CANCEL_LOG); cancel for an unknown
session doesn't kill the process.

28 tests green, fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 16:16:30 +10:00
Michael Neale
737bde4254 test(buzz-agent-core): validate against the real buzz-dev-mcp
Every other test in this crate drives `fake-mcp`, which answers whatever the
test tells it to. That proves our plumbing, not that it matches the server buzz
actually ships. These two drive the real binary: real tool names, real schemas,
real in-process todo state, real _Stop/_PostCompact semantics.

real_dev_mcp_stop_hook_blocks_end_of_turn scripts the exact scenario the veto
exists for -- the model records an open todo item via the real `todo` tool, then
tries to stop. Asserts the turn is extended and that buzz-dev-mcp's own
objection text ("open todo items") reaches the model.

real_dev_mcp_advertises_its_tools pins the shipped tool surface (shell,
read_file, str_replace, todo) and locks in the KNOWN DEVIATION: _Stop stays
visible to the model, with system-prompt guidance not to call it.

Both skip cleanly if buzz-dev-mcp isn't built.

25 tests green, fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 16:12:29 +10:00
Michael Neale
22f5e70cf2 feat(buzz-agent-core): inject [Reflect] on failed tool results
Last known behavioural gap. buzz-agent appended a reflection nudge to every
failed tool result (agent.rs:21-22, :364) so the model diagnoses the failure
instead of blindly retrying.

Goose gives no interception point for that: PostToolUseFailure is
fire-and-forget and its output is discarded (agent.rs:589-620). So we deliver
the same text via steer(), which goose drains at the round boundary
(agent.rs:1951-1974) -- exactly when the model would next act on the failed
result. Agent-visible, user-invisible. Capped at 8 per turn so a tool failing
in a loop cannot flood the conversation.

Tested against the real provider wire rather than trusting that steer() was
called: the fake provider records every chat-completions body, and the test
asserts [Reflect] is absent from the first generation and present in a later
one. Negative test confirms a successful tool call injects nothing.

Adds FAKE_MCP_TOOL_ERROR to the fake MCP server. 23 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 16:09:57 +10:00
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
Michael Neale
46124528ae fix(buzz-agent-core): advertise the model catalog on session/new
session/new returned only `sessionId`, so the desktop ModelPicker degraded to
"current model only" and buzz-acp could not resolve session/set_model targets
(resolve_model_switch_method, buzz-acp/src/acp.rs:1876).

This was a regression I introduced by deleting buzz-agent's catalog.rs without
replacing what it fed -- not a limitation of driving goose as a library. The
picker is the same UI and the same buzz-acp code path for every agent; goose's
CLI fills it via build_model_state, buzz-agent filled it via Databricks
discovery, and this crate filled it with nothing.

Cannot reuse goose's builder: build_model_state is pub(super)
(acp/response_builder.rs:130), invisible outside goose::acp. The underlying
data is public, so discover_models() rebuilds the same shape from
Provider::fetch_supported_models (goose-provider-types/base.rs:425) via
Agent::provider(), including goose's rule that the current model is prepended
when the provider's list omits it.

Absent catalog stays degraded UX, never a session failure -- matching
buzz-agent's Databricks fallback (catalog.rs:52-80).

New stdio test asserts the shape buzz-acp actually parses: currentModelId plus
availableModels entries keyed by `modelId`. 16 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 15:20:24 +10:00
Michael Neale
63efbb24b6 feat(buzz-agent-core): pin goose by git rev, wire session/set_model
Makes the spike reproducible off this machine and closes the model-switch
stub.

- Depend on aaif-goose/goose @ 305849b71 (v1.44.0, ancestor of origin/main)
  instead of a local path. Verified: cargo fetch + full test run from a clean
  git checkout of the dep.
- session/set_model now takes effect. The id is staged in `pending_model` and
  consumed by the next session/prompt, matching buzz-agent's "applies from the
  next prompt" contract (lib.rs:494-502) so an in-flight turn is never mutated.
  Rebuilds the provider and hot-swaps it via update_provider; SharedProvider is
  an Arc<Mutex<Option<..>>> for exactly this.
- New stdio test covers unknown-session, empty-modelId, and a real switch
  followed by a completing turn.

15 tests green. Measured release binary (macOS arm64):

  new  32.5 MiB raw / 9.9 MiB gzip -9
  old   9.8 MiB raw / 3.9 MiB gzip -9
  delta +22.7 MiB raw / +6.0 MiB gzip

Corroborates PR #1526's +22.9 MiB raw / +6.2 MiB gzip.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-07-27 15:12:23 +10:00
Michael Neale
2527efac3f 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>
2026-07-27 15:03:43 +10:00