Skip to content

refactor(buzz-agent): swap the agent loop onto the goose library - #3262

Draft
michaelneale wants to merge 48 commits into
mainfrom
micn/buzz-agent-goose-core
Draft

refactor(buzz-agent): swap the agent loop onto the goose library#3262
michaelneale wants to merge 48 commits into
mainfrom
micn/buzz-agent-goose-core

Conversation

@michaelneale

@michaelneale michaelneale commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Swaps buzz-agent's internals onto the goose crate, used as a Rust library. buzz-agent still owns the agent loop — goose supplies the components it used to hand-roll.

buzz-agent keeps its name, its binary, and its ACP contract. Nothing changes for buzz-acp, packaging, or the harness catalog.

Why

buzz-agent carried ~8.5k lines of provider code for a handful of providers. goose has ~60 provider modules, plus MCP client, compaction, skills and prompt assembly. Maintaining a parallel implementation of all that was the cost.

Shape

buzz drives the rounds (src/loop_drive.rs) and calls goose at five points:

concern goose API replaced
model stream provider.stream(...) llm.rs (8,523)
tool surface agent.list_tools(...) mcp.rs (1,204)
tool dispatch agent.dispatch_tool_call(...) mcp.rs
system prompt PromptManager::build_system_prompt(...) inline in agent.rs
compaction context_mgmt::{check_if_compaction_needed, compact_messages} handoff.rs (650)
skills goose::skills platform extension builtin.rs + hints.rs

Turn-shape decisions stay buzz's, expressed as goose Operations driven through StateMachine::step: max-rounds, the _Stop veto, the reply guard, steer, and compaction. [Reflect] on failed tool results stays in tools.rs — no StateEffect edits tool-result content, so as an operation it would arrive as a separate message after the failure instead of inside it.

Inference is called by the loop rather than as Step::Inference, because it streams chunks to the ACP wire, emits keepalives, and accumulates usage_update for kind-44200 metrics.

No session state is written to sqlite. TurnState holds the conversation in memory and buzz carries it across prompts. A conversation of any length writes 0 message rows and 0 usage rows. One sessions row is still created per session: Agent::add_extension_inner loads the session to resolve each MCP extension's working directory, and without it the agent gets no tools. Removing it needs an upstream goose change.

Size

crates/buzz-agent + buzz-model-catalog, production code only (excluding #[cfg(test)] modules and tests/):

10,326 → 5,624 lines, −45%.

Including tests the branch is +11,214 / −26,459 (of which Cargo.lock is +2,587).

deleted lines shrunk main → branch
llm.rs 8,523 config.rs 3,169 → 534
mcp.rs 1,204 agent.rs 1,601 → 231
builtin.rs + hints.rs ~1,200 types.rs 1,146 → 267
auth.rs (moved) 847
catalog.rs (moved) 668
handoff.rs 650

New: ops.rs 609 (the operations), loop_drive.rs 682 (with tests; ~570 production), tools.rs, hooks.rs, prompt.rs, steer.rs, turn_state.rs, skills.rs.

New crate: buzz-model-catalog

The desktop cannot link goose: goose pulls sqlx-sqlite → libsqlite3-sys 0.30, the desktop has rusqlite 0.37 → libsqlite3-sys 0.35, and both declare links = "sqlite3". Cargo refuses two packages linking the same native library. The desktop only needs Databricks discovery, config::Provider, and the Windows shell-env contract, so those moved to a leaf crate. Its PKCE engine also now backs goose's Databricks provider, so inference and model discovery share one token cache.

Test coverage

135 test functions: 100 in buzz-agent, 35 in buzz-model-catalog. Main had 591.

That drop is real and worth stating plainly: 264 of the missing tests belonged to modules this PR deletes — 184 in llm.rs alone (HTTP transport, retry, per-provider wire formats), plus hints.rs, builtin.rs, mcp.rs, catalog.rs. They tested code that no longer exists here; goose has its own coverage for the same ground. Most of the remainder were table-driven cases in config.rs and byte-accounting tests in types.rs for the handoff heuristic that compaction replaced.

What is covered now:

  • ops (16) — every operation's decision boundary: at/under budget, veto cap including 0-disables, veto not applying while tools are outstanding, a missing hook extension never trapping a turn, reply guard armed by shape not by name.
  • tests/memory.rs — two prompts on one session; asserts the provider sees turn 1's user message and assistant reply in request 2. This is the one that guards the in-memory history change.
  • config (14) — the agent's provider beats an inherited GOOSE_PROVIDER; every remapped BUZZ_AGENT_* knob still reaches goose with buzz's default, not goose's.
  • wire (10) — usage payloads: totals omitted when poisoned, pricing identity only when proven.
  • 21 integration tests spawn the real binary over stdio against a fake provider and a real MCP server. None of them changed during the operations migration.
  • scripts/handtest.py --all — 18/18 against a live provider.
  • Hand-tested in the desktop against a live relay: three agents on Anthropic, OpenAI and OpenRouter.

Behaviour parity

The BUZZ_AGENT_* environment surface is preserved. Knobs whose implementation moved to goose are remapped onto goose's equivalent rather than dropped (TOOL_TIMEOUT_SECSGOOSE_DEFAULT_EXTENSION_TIMEOUT, MAX_TOOL_RESULT_TEXT_BYTESGOOSE_MAX_TOOL_RESPONSE_SIZE, NO_HINTS, STOP_MAX_REJECTIONS), projecting buzz's defaults where they differ from goose's. Genuinely removed knobs are marked "No longer read" in the README rather than left documented as live.

Also fixed

  • Agents inherited the developer's shell config. A spawned agent picked up GOOSE_PROVIDER/GOOSE_MODEL and provider API keys from the desktop's environment, which could silently override the agent record's own provider — an OpenAI-configured agent sending OpenAI model names to Anthropic. Spawn now strips inherited agent config by prefix (GOOSE_, ANTHROPIC_, OPENAI_, OPENROUTER_, DATABRICKS_) before writing what it derives from the record.
  • desktop/src-tauri did not compile against this branch — it called authenticate_databricks, which moved. just ci never builds the desktop crate.
  • Agent logs never reached disk. buzz-acp inherits stderr to the agent, so the desktop's RUST_LOG gated the agent's output too, and it named only buzz_acp.
  • Eight nostr advisories cleared by a lockfile bump; no new deny.toml ignores.

Known gaps

  • Databricks inference is not hand-tested end-to-end.

  • Sessions land in buzz-agent's own store (session_store.rs), not the user's goose session DB — but the one row per session described above is still created.

  • Binary size: 10.4 MB -> 78.4 MB raw, 3.9 MB -> 23.4 MB gzipped (release, same toolchain). buzz-agent is a shipped desktop sidecar, so this lands in every download. cargo bloat: goose 10.5 MB of .text, rmcp 4.0 MB, against 481 KB for buzz-agent's own code. Dependencies 165 -> 458 crates.

    The heavy optional goose features are already off (default-features = false, only rustls-tls): no local-inference/candle, no aws-providers, no otel, no code-mode. What remains is goose's non-optional surface, some of which buzz never uses -- sqlx + libsqlite3-sys (we write zero message rows), arboard (a clipboard lib, for a CLI OAuth convenience unreachable in a headless subprocess), clap, jsonschema, minijinja. Reducing further needs upstream feature-gating in goose, not changes here.

…brary

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>
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>
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>
…njection

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>
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>
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>
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>
Steering injects a message into a live turn without cancelling it. buzz-acp
prefers it over cancel+re-prompt because the latter throws away the model's
in-progress work, and it guards the call with optimistic concurrency.

Four tests, all against the real stdio wire:

- steer_injects_without_cancelling_the_turn: asserts the turn still ends with
  end_turn AND that the steered text reached the provider. Goose's steer() is
  drained at the round boundary (agent.rs:1951-1974), same as buzz-agent's.
- steer_with_stale_run_id_is_rejected / steer_outside_a_turn_is_rejected: both
  must be errors so buzz-acp can fall back to cancel+merge
  (buzz-acp/src/pool.rs:329-366) rather than silently steering the wrong turn.
- active_run_id_is_cleared_when_the_turn_ends: asserts an explicit trailing
  null, then that a later steer is rejected.

await_active_run_id() reads params.update._meta.goose.activeRunId at exactly
the depth buzz-acp parses (acp.rs:1607-1613) -- a _meta one level too high
silently degrades steering to cancel+re-prompt forever, with no error anywhere.

All 6 ACP methods now have end-to-end coverage. 32 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
My own comment said GooseMode::default() was "deliberately not Auto" and
therefore did not auto-approve tool calls. That is wrong: GooseMode derives
Default with #[default] on Auto (goose_mode.rs:23-25), i.e. every tool call is
approved without asking. The comment documented a security posture the code did
not have -- the most dangerous kind of wrong comment.

Behaviour is unchanged and deliberately so: auto-approve is what buzz ships
today (buzz-acp/src/acp.rs:1671-1712 auto-approves every permission request,
and the desktop catalog sets GOOSE_MODE=auto for the external goose runtime,
discovery.rs:89). Flipping it here would silently change how every existing
agent behaves.

What changes is that it is now a knob instead of a hardcode.
BUZZ_AGENT_APPROVAL selects approve / smart_approve / chat / auto, threaded
through AgentConfig and create_session. Unknown values warn and fall back to
auto -- a typo must not take an agent off the air, and must not silently
tighten either.

Nothing in buzz drives this yet. Wiring it to a real human affordance is the
first step of the isolation work, and this is the seam that work will use.

4 new unit tests pin the mapping and the fallback. 35 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-acp derives harness identity from the command's BASENAME
(normalize_agent_command_identity, buzz-acp/src/config.rs:600-615) and already
has a "buzz-agent" arm meaning "no extra args" (default_agent_args, :617-624).

Naming the binary `buzz-agent` makes this a drop-in swap: point
BUZZ_ACP_AGENT_COMMAND at the built path and buzz-acp cannot tell the
difference -- same identity, same args, same ACP contract, goose underneath.
That is the whole premise, so the artifact should reflect it.

Crate stays buzz-agent-core (it is workspace-excluded and owns its lockfile);
only the emitted binary is renamed. 35 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Hand-testing this is a swap, not a new setup: `just agent-core` is `just goose`
with BUZZ_ACP_AGENT_COMMAND repointed at the built binary (and MCP_COMMAND at
buzz-dev-mcp). Because buzz-acp identifies a harness by command BASENAME and
the binary is emitted as `buzz-agent`, buzz-acp cannot tell it apart from the
old one -- so the old `just goose` still works for A/B against the same relay.

HANDTEST.md lists the seven things only a human can check, ordered by risk:
persona arrival, the _Stop veto, streaming feel (the most likely source of
"something feels off" -- goose streams token-by-token where buzz-agent emitted
one chunk per round), cancel-mid-tool leaving no stuck spinner, steering not
restarting the turn, the model picker, and whether the model calls the now-
visible _ tools it has been told to leave alone.

Also records what is NOT done: never run against a real provider (Databricks
OAuth is entirely goose's code path now and completely unexercised), never run
inside the desktop app, nothing wired into packaging or the catalog.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-agent keeps its name, its binary, its ACP contract and its place in the
workspace. Only the guts change: ~11k lines of hand-written agent loop are
replaced by the goose crate used as a Rust library.

Deleted, now goose's: llm.rs 3846 (providers), mcp.rs 1139 (extension manager),
hints.rs 726, builtin.rs 575 (skills), handoff.rs 430 (context_mgmt), plus most
of config.rs 2709. Kept: wire.rs verbatim, and the parts goose does not know
about -- the exact session/update shapes buzz-acp parses, the keepalive ticker,
usage_update ordering, activeRunId, and the error taxonomy.

Behaviour preserved, each with an end-to-end stdio test: the Fizz persona (the
reason for embedding -- goose's own ACP server never reads systemPrompt, so
this only works via the library API), the _Stop end-turn veto, _PostCompact
re-injection, [Reflect] on failed tool calls, the model catalog, set_model,
cancel and steer. Validated against the real buzz-dev-mcp, not just a fake.

Two dependency conflicts had to be solved to get goose into the workspace:

1. goose pins icu_locale "=2.1.1" (needs icu_collections ~2.1.1) while
   url 2.5.x -> idna -> idna_adapter 1.2.2 pulls icu_normalizer 2.2.0 (needs
   icu_collections ~2.2.0). Only one 2.x icu_collections can be selected.
   Fixed by pinning idna_adapter "=1.2.0", the last release on ICU4X 1.x,
   which keeps IDNA off that line entirely.

2. The desktop could no longer link buzz-agent at all: goose pulls
   sqlx-sqlite -> libsqlite3-sys 0.30, desktop has rusqlite 0.37 ->
   libsqlite3-sys 0.35, and both declare links = "sqlite3". Cargo forbids
   that and no pin resolves it. But the desktop only ever used Databricks
   model discovery and WINDOWS_SHELL_RESOLUTION_ENV, so those moved to a new
   buzz-model-catalog crate (no goose, no sqlite, same API). The desktop
   dependency is renamed in place, so no desktop source changes.

Also fixes a test that had been silently skipping: real_dev_mcp.rs located
buzz-dev-mcp by a hardcoded parent depth, so after the move it returned early
and reported 0.00s. It now searches upward and panics on a miss.

Workspace, sprig, and desktop all build. 41 tests green, fmt + clippy clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
The old version described a parallel `buzz-agent-core` crate and a `just
agent-core` recipe, neither of which exists any more -- the goose-backed loop
IS buzz-agent now.

There is nothing special to run: `just dev` and `just goose` already build and
use the swapped crate. If you see a difference, that is the bug.

Keeps the seven human-only checks (persona arrival, _Stop veto, streaming feel,
cancel leaving no stuck spinner, steering, model picker, hook-tool hygiene) and
the known gaps.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Audited every BUZZ_AGENT_* variable the desktop and buzz-acp inject against
what the swapped config.rs actually reads. Three gaps, two of them breaking.

1. `buzz-agent auth <provider>` was dropped in the rewrite. Goose owns provider
   auth for the agent loop, but nothing in goose does an *interactive*
   Databricks PKCE login -- and buzz-model-catalog/src/auth.rs:417 still tells
   users to run this exact command when the token cache is empty. Restored,
   now backed by buzz-model-catalog.

2. The desktop persists the provider as "databricks-v2" (agent_models.rs:757)
   but goose registers "databricks_v2" (goose-providers/src/databricks_v2.rs).
   An existing Databricks v2 agent would fail to start with "unknown provider".
   Added the alias; extracted the mapping into goose_provider_name() with tests
   including a pass-through case, since goose owns the registry and we must not
   gatekeep names we don't list.

3. BUZZ_AGENT_PREFER_MESH_FOR_AUTO is still injected (relay_mesh.rs:42) but is
   no longer honoured: it used to re-resolve the relay-mesh `auto` model against
   the /models catalog mid-run so a long-lived agent could join or leave MoA
   without restarting (old llm.rs:410-440). Goose resolves the model once at
   session start and has no equivalent hook. The agent still works, it just
   pins whatever `auto` resolved to at startup. Now warns loudly rather than
   ignoring it silently.

Verified: `buzz-agent auth` with no args and with a bogus provider both give
the same errors as before. 44 tests green, fmt + clippy clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Buzz owns the meaning of relay-mesh `auto`, and the swap had silently dropped
it. The desktop sets BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1 on every relay-mesh agent
(relay_mesh.rs:41-44); the old loop honoured it per request (old llm.rs:406-470)
by polling the router's /models catalog and sending mesh-llm's virtual
Mixture-of-Agents model instead of `auto` whenever the mesh could support it.

I previously described this as "pins whatever auto resolved to at startup".
That was wrong: `auto` is a router-side id, so nothing resolves it -- the agent
just sent `auto` forever and MoA never engaged at all. For mesh-llm lab work
that is the entire feature missing, not a degraded version of it.

mesh::MeshAutoProvider wraps goose's provider and rewrites
ModelConfig.model_name per call. Provider requires only get_name + stream, so
wrapping is cheap -- and this is precisely the kind of interception that is only
possible with goose as a library; an out-of-process ACP agent has no seam for it.

Hysteresis is identical to the old implementation, deliberately: 5s catalog TTL,
two consecutive positive observations to enable, immediate disable plus a 30s
cooldown on a negative one, and an unreachable/malformed catalog preserves the
last confirmed route rather than treating a failed probe as evidence the mesh
vanished. A mid-request contraction (503 "MoA requires >=2 models" or
error.type=moa_failure) cools down and retries once on `auto`, so the turn still
completes. Other 5xx must NOT be treated as contractions -- that would mask real
outages behind a silent retry -- and there is a test pinning that.

4 end-to-end tests against a fake mesh-llm router assert what actually goes on
the wire: two-turn confirmation before MoA engages, single-model mesh never
routes to MoA, contraction produces a mesh->auto retry pair without failing the
turn, and the policy is inert (no extra /models polls) when the flag is absent.

That last test initially failed on an absolute catalog-hit count -- my
assertion was wrong, not the code: session/new polls /models for the desktop
model picker and goose does its own lazy capability lookup. Rewritten as a
differential across the TTL boundary, which isolates the policy's own poll.

48 tests green, fmt + clippy clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
I claimed the restored relay-mesh policy was identical to the old loop. Checked
it properly: constants, catalog parsing, hysteresis and the gate all match, but
contraction detection does not, and the difference is forced.

The old loop read the raw HTTP body and accepted two shapes: a 503 whose
error.message is the MoA-unavailable string, or any 5xx whose error.type is
"moa_failure". A provider-level wrapper only sees what goose leaves behind, and
extract_message (goose-providers/src/http_status.rs:186-197) reduces the payload
to error.message when that field exists.

So "moa_failure" *alongside* a message is invisible to us and fails the turn
instead of retrying on auto. Verified by probe, not assumed. The message shape
-- which is what mesh-llm's under-provisioned path actually sends -- still works,
as does moa_failure with no message.

Documented on is_mesh_contraction and pinned with a test that fails loudly if
goose ever stops stripping the body, so the caveat cannot silently rot.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Yesterday I documented a "known blind spot" and left it. Checking mesh-llm's
actual source shows that was the wrong call: the gap covers the COMMON failure,
not a rare one.

mesh-llm has two failure paths:

  503, gateway level  — mesh too small to start MoA at all.
                        Plain message, no JSON.
                        (moa_gateway/mod.rs:56)
  502, MoA level      — workers or reducers died mid-turn.
                        Body carries error.message AND error.type=moa_failure.
                        (mesh-mixture-of-agents/src/lib.rs:1168)

Only the 503 was handled. The 502 is the one that actually fires in a running
lab — a worker dropping out mid-turn — and because goose reduces a payload to
error.message when that field exists (http_status.rs:186-197), the moa_failure
type never reached us. Those turns failed outright instead of retrying on auto.

Fixed by matching the messages themselves: MOA_FAILURE_MESSAGES lists every
error_response call site in mesh-llm. The JSON-shaped check stays as a fallback
for moa_failure with no message field.

Message matching is brittle if mesh-llm rewords these, but the alternative is
an HTTP-level seam goose does not expose, and silently losing the fallback is
worse than a string that needs updating.

49 tests green, fmt + clippy clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…-core

* origin/main: (48 commits)
  fix(buzz-acp): accept id-keyed config options when resolving model switch (#2795)
  fix(desktop): probe legacy Goose install dir on Windows (#3248)
  refactor(desktop): extract install command execution into install_exec (#3251)
  Polish composer activity layout and transitions (#3151)
  feat(invites): add use-limited invite links (#3141)
  fix(node): bump Buzz-supplied Node runtimes past OpenClaw's >=24.15.0 floor (#3218)
  fix(desktop): preserve thread anchor through layout reflow (#3212)
  feat(search): parse from:/in:/after:/before: and pass them in the filter (#2871)
  fix(desktop): fetch join policies through native networking (#2862)
  fix(desktop): republish agent identity records when a persona rename propagates (#2607)
  fix(desktop): keep project Inbox previews compact (#3193)
  Inbox refactor (#2045)
  Fix composer selection formatting and drop overlay (#3172)
  Refine pending message status (#3153)
  feat(admin): show reported message content in report detail (#3149)
  fix(desktop): recover full local storage on startup (#3182)
  Replace mobile reconnect banners with skeleton shimmer (#3143)
  fix(desktop): keep collapsed table separators out of spoilers (#3169)
  chore(deps): update plugin org.jetbrains.kotlin.android to v2.2.21 (#3058)
  resolve findings (#3150)
  ...

Signed-off-by: Michael Neale <michael.neale@gmail.com>

# Conflicts:
#	crates/buzz-agent/src/mcp.rs
An adversarial review of the swap found several real defects, most of them
comments asserting parity that did not survive checking. Fixed:

B3 serve() dropped in-flight work on stdin EOF. Dropping a CancellationToken
   does not cancel it, so goose never sent notifications/cancelled to its MCP
   children and they outlived us as orphans -- exactly the failure run_turn
   goes to lengths to avoid on session/cancel. The detached writer task was
   also never awaited, so frames still queued (including a response from a turn
   that finished on the same tick) were discarded. main did both; restored.

B4 max_sessions was TOCTOU-racy. session/new is dispatched on its own task and
   build_agent (MCP spawn + provider round-trip) sits between the check and the
   insert, so N concurrent calls all passed. main re-checked under the insert
   guard; that guard had been dropped. Restored.

B5 usage_update reported an empty model whenever the model came from
   GOOSE_MODEL rather than BUZZ_AGENT_MODEL -- a supported path everywhere else
   (build_agent and session/new both fall back to it). Blanked kind-44200
   attribution silently. Now uses the same resolution chain.

B6 BUZZ_AGENT_LLM_TIMEOUT_SECS was parsed, documented, and never read. Deleted
   rather than left as a knob that does nothing.

R2 A whitespace-only steer returned success instead of INVALID_PARAMS. buzz-acp
   maps success to SteerAck::Ok and treats the message as delivered, so it was
   swallowed and the cancel+merge fallback suppressed. Now rejected up front,
   before touching the session map, as main did.

R6 Restored #![forbid(unsafe_code)], lost in the rewrite.

B1/B2 are documentation corrections, and they matter more than the code fixes:
the module table claimed builtin.rs was replaced by goose's skills extension
and hints.rs by goose's hint loader. Neither holds. Agent::with_config loads
zero extensions and build_agent only adds the harness's declared mcpServers, so
the skills extension is never loaded and load_skill/SKILL.md discovery are
simply gone. Goose's hint loader keys off .goosehints while the old code walked
for AGENTS.md -- every repo here ships the latter and none the former, so no
hints load at all. Both now documented as losses instead of substitutions.

53 tests green, fmt + clippy -D warnings clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
… loop

The swap documented both as losses (B1/B2 in fa8166e). Restore them by
keeping the old buzz-agent modules and wiring them into goose, rather than
using goose's own equivalents, which don't fit:

* goose's hint loader keys off GOOSE_HINTS_FILENAME (.goosehints); our repos
  ship AGENTS.md. hints.rs (directory-chain walk + ~/AGENTS.md + skill
  discovery under .agents/skills, .goose/skills, .claude/skills) is kept and
  its output injected via extend_system_prompt("buzz_hints") at session
  build — system_prompt_extras survive override_system_prompt, so this works
  with the persona path too.

* goose's skills platform extension is never loaded (Agent::with_config
  loads zero extensions; build_agent only adds the harness's mcpServers).
  builtin.rs / load_skill is kept and registered as a goose *frontend*
  extension: goose advertises the tool, and we answer the calls in-process.

The frontend-tool wiring had a deadlock in the first cut: it listened for
MessageContent::ToolRequest, but goose strips frontend calls out of the
normal ToolRequest flow (reply_parts.rs categorize_tools) and yields a
dedicated FrontendToolRequest variant instead, then BLOCKS the reply stream
on tool_result_rx.recv() until handle_tool_result is called. The handler
never matched, so the first load_skill call hung the turn forever — this is
what the hung `cargo test --test skills` processes on this machine were.
Now:

* serve_frontend_tool matches FrontendToolRequest, answers every yielded
  request exactly once (unknown tool name gets an error result rather than
  silence — goose is already blocked on the id), and skips only the Err
  parse case, where goose does not block (tool_execution.rs:181 yields
  inside the Ok arm only).

* emit_content announces FrontendToolRequest to the desktop as a tool_call
  update; its result comes back as a plain ToolResponse, and an update for
  a never-announced id would break the announce→terminal pairing that keeps
  the UI spinner honest.

The skills integration test drives the full path over stdio against a fake
SSE provider: AGENTS.md content and the skill name/description index must
reach the system prompt, the skill BODY must not (that is the point of
load_skill), load_skill must be advertised, and the turn must complete —
a broken frontend-tool path fails by hanging, so the turn completing IS the
assertion.

53 buzz-agent tests green; fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
The Security job's cargo-deny licenses gate rejected MIT-0 (MIT No
Attribution — OSI approved, strictly more permissive than MIT), newly
pulled in via goose → jsonschema → referencing → fluent-uri →
borrow-or-share.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…t a frontend tool

Adopts the shape Maple uses for its in-process tools (MapleDeveloperClient,
SkillsClient): an `McpClientTrait` impl registered via
`extension_manager.add_client` with an `ExtensionConfig::Platform`, rather than
`ExtensionConfig::Frontend`.

Goose advertises frontend tools but refuses to dispatch them. It yields a
`FrontendToolRequest` and blocks the reply stream until the embedder calls
`handle_tool_result` -- strictly sequential, no timeout, and a single result
channel with no request-id correlation, so one missed or duplicated result
wedges the session for good and the cancel token will not free it. That is a
failure mode with no upside here. A platform client goes through goose's
ordinary tool path and gets concurrency, per-request timeouts and
`notifications/cancelled` for free.

Net effect is less code: `serve_frontend_tool` is gone, and with it the
`skills` parameter threaded through run_turn -> drive_stream -> handle_event
and the `Session.skills` field. The BuiltinClient owns the skill list.

Also fixes a real mismatch the swap exposed. Goose namespaces platform tools as
`{extension}__{tool}` (`extension_manager.rs:1415`), so the model sees
`buzz__load_skill`, but the skills section of the system prompt told it to call
`load_skill`. The prompt now derives the name from the same constants the
registration uses, so the two cannot drift.

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

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Nine conflicts. The deletions resolve trivially -- goose owns those files now,
so mcp.rs, llm.rs, handoff.rs and tests/fake_llm.rs stay deleted.

The four content conflicts (agent.rs, config.rs, lib.rs, types.rs) all resolve
to this branch's rewrite, but two of main's changes are wire-facing and buzz-acp
now depends on them, so they are ported onto the goose loop rather than
discarded:

* #3463 `accumulatedCachedInputTokens` -- buzz-acp/src/usage.rs reads it for
  pricing. Sourced from goose's `cache_read_input_tokens` +
  `cache_write_input_tokens`, which goose documents as subsets of
  `input_tokens` (`token_usage.rs:72-78`), so it stays a subset here too.
* #3593 `accumulatedTotalTokens` -- emitted only when exactly known. One turn
  without a provider total poisons the session cumulative to `None` and the
  field is omitted, because buzz-acp must not read a missing total as zero.

Cargo.lock was regenerated rather than resolved by hand: main moved mesh-llm to
v0.74.0, which requires rmcp ^1.8, and the stale lock still pinned 1.7.0.

89 tests green, fmt + clippy -D warnings clean workspace-wide; workspace,
sprig and desktop/src-tauri all build.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…rmcp 3.x breaks

Moves the goose git pin from 305849b to bf332b9, which is past ca52cce
(#9574, the unrolled agent loop). Consequences of the bump:

- goose now uses rmcp 3.x; buzz-agent's own rmcp dep moves 1 -> 3 so the
  two do not resolve to different crate versions of the same types.
- rmcp 3 renames Content -> ContentBlock and adds fields to
  ListToolsResult; builtin_client.rs updated to match.
- buzz-model-catalog needs an explicit dirs dep after the merge.
- TurnTotalState / PricingIdentity, added on main while this branch was
  away, ported back into types.rs for wire.rs's usage_update_payload.

cargo check -p buzz-agent passes.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Inverts the previous design. buzz-agent no longer calls
goose::agents::Agent::reply -- it drives its own round loop and calls
goose for the four heavy components:

  model call        Provider::stream (via Agent::provider)
  tool surface      Agent::list_tools
  tool execution    Agent::dispatch_tool_call
  system prompt     PromptManager (our own instance)
  compaction        context_mgmt::{check_if_compaction_needed, compact_messages}

Under Agent::reply every buzz-specific behaviour had to be smuggled in
around goose's turn policy: the _Stop veto needed an outer loop purely to
re-enter reply(), and [Reflect] had to be delivered as a *steer* because
the tool result itself was out of reach. Owning the loop removes the
smuggling -- the veto is a branch, and [Reflect] goes back on the tool
result where buzz-agent originally put it and where the model reads it in
context.

New modules:
  loop_drive.rs  the round loop: inference, tools, compaction, _Stop veto,
                 steer drain, cancellation, max-rounds bound
  tools.rs       parallel dispatch, announce->terminal wire invariant,
                 [Reflect] on failure
  prompt.rs      our PromptManager (goose's is pub(super) to Agent::reply)
  steer.rs       our steer queue (goose's drain is pub(crate) to reply)

agent.rs shrinks to what goose knows nothing about: ACP session/update
emission and the keepalive ticker. Tool lifecycle now emits from tools.rs,
which is the only place that knows when a call starts and ends -- emitting
from streamed content as well would double-announce every tool.

Behaviour preserved and covered by the existing suite: Fizz persona,
_Stop veto (cap 3), [Reflect] (cap 8), _PostCompact re-injection,
steering without cancellation, cancel leaving no unresolved tool call,
usage accounting, and the model catalog.

81 unit + 21 integration tests pass; clippy clean (the one hints.rs
warning predates this branch).

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
…tdio

Closes the biggest coverage gap on this branch: every automated test talks
to a fake SSE server, so nothing proved the loop works against a real
model. scripts/handtest.py starts the actual binary, speaks real ACP to
it, and asserts on the five behaviours that are easy to break and hard to
notice:

  basic      persona + AGENTS.md hints reach the model; catalog populated
  tools      a real MCP tool is dispatched and its output comes back
  stop-veto  _Stop blocks end-of-turn, and the cap still releases it
  cancel     cancel mid-tool leaves no tool call spinning
  steer      a mid-turn steer is absorbed without restarting the turn

All 18 checks pass against Anthropic claude-sonnet-4-6. Each mode gets a
fresh process so one mode's conversation cannot pollute the next.

HANDTEST.md updated: describes the script, and its 'never run against a
real provider' known gap is replaced with what is now actually covered.
Also corrects the opening paragraph, which still said goose owns the loop.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-acp inherits its stderr to the agent subprocess (acp.rs:463), so the
desktop's RUST_LOG filter for the harness also decides what the agent can
write to disk. It named only buzz_acp, so every buzz-agent diagnostic was
filtered out before reaching the per-agent log.

That is invisible until something goes wrong: an agent misbehaving in the
desktop left no trace of compaction, the _Stop veto, max-rounds, or
provider errors -- exactly the lines you need to tell whether a turn did
what it should. Found while trying to confirm from the logs alone that a
desktop agent was running the new loop; the logs could not answer it.

Adds buzz_agent=info to the default, appends only the missing directive
when an operator filter is present (so RUST_LOG=buzz_agent=debug is not
downgraded to info), and takes a fully-specified filter verbatim.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
…sion DB

goose's SessionManager::instance() resolves to Paths::data_dir(), so
buzz-agent was persisting every turn into the same sessions.db as the
user's own goose CLI history. On this machine that file is already 132 MB
and 892 sessions, with desktop agent turns from minutes ago interleaved
into it.

Not a correctness bug -- SQLite handles concurrent writers -- but agent
conversations do not belong in a human's personal session history, and
buzz-agent should not grow a shared file it does not own and cannot prune.

Resolves its own directory instead: BUZZ_AGENT_SESSION_DIR when absolute,
else <data-dir>/buzz-agent, else goose's singleton. A relative env value
is ignored rather than resolved against the agent's CWD, which is the
user's workspace.

Deliberately not GOOSE_PATH_ROOT: that would move goose's config and
keyring lookups too, and provider configuration should keep resolving
where goose puts it.

Resolved once behind a OnceLock -- SessionManager::new builds its own
SQLite pool, so a per-session call would open a pool per session against
the same file.

Verified: with BUZZ_AGENT_SESSION_DIR set, the turn's session lands in
that directory and the shared DB is untouched.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-agent scanned for hint files itself and added the result as a
system-prompt extra. But goose's PromptManager::build_system_prompt
already calls .with_hints() (prompt_manager.rs:255), which runs the same
walk. Every AGENTS.md therefore reached the model twice -- once under our
'## Project Hints', again under goose's '### Project Hints'.

Verified against a scratch repo with a canary string: 2 occurrences
before, 1 after.

The walk really is the same algorithm, which is why this went unnoticed:
goose's find_git_root + get_local_directories collect cwd->root and
reverse for root->cwd ordering, exactly as buzz's did. goose's loader is
a superset -- .goosehints as a second filename, CONTEXT_FILE_NAMES to
configure the list, gitignore-aware filtering, and @file expansion
bounded at the git root.

So hint-file loading is deleted here and left to goose. What stays is
skill discovery, because load_skill is served by our builtin_client and
needs the same SkillEntry list the tool dispatches against.

hints.rs 732 -> 479 lines. A guard test fails if hint-file loading is
reintroduced, since the duplicate rendered silently -- the prompt was
merely longer, never wrong-looking.

Verified end-to-end against Anthropic: handtest.py --all, 18/18, persona
and hints still reach the model and load_skill still resolves.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
goose's own steer path sets metadata.steer (agent.rs:540) and its ACP
surface republishes it as _meta.goose.steer. buzz-agent queued steers as
plain user messages, so nothing downstream could tell a mid-turn steer
apart from the user simply sending another message -- which is the one
distinction the flag exists to preserve.

buzz-acp does not read it today; this keeps the conversation record
honest for anything that later does.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
First step of moving buzz's loop decisions into goose's StateMachine, one
at a time (PLANS/BUZZ_OPERATIONS_MIGRATION.md). The remaining decisions
still live in loop_drive.rs; this adds the gate they will move into.

Two things the state-machine shape buys immediately:

- The budget now counts assistant turns since the kickoff message rather
  than loop iterations, so it survives a process restart. The loop-local
  counter did not.
- Precedence between the guards becomes list order rather than control
  flow, which is what makes the remaining conversions safe to do
  incrementally.

Deliberately not goose's MaxTurnsOperation: that appends an assistant
message ('Would you like me to continue?') before yielding. buzz-acp
publishes assistant messages into a channel, so a synthesised one would
be indistinguishable from the agent saying it. buzz signals the exhausted
budget through the max_turn_requests stop reason instead.

Two things found while building it, both now pinned by tests:

- goose's operation helpers (messages_since_kickoff, assistant_turn_count)
  are pub in a *private* module, so they are not reachable from outside
  the crate -- only the names re-exported from state_machine are.
  Reimplemented here against goose's semantics.
- A mid-turn steer is user-visible, so it becomes the new kickoff and
  restarts the budget. That is the behaviour we want, but it is emergent
  rather than chosen, so a test pins it.

StepResult cannot carry a stop reason, so operations record one in a
shared Outcome cell; first writer wins, since the machine stops at the
first operation that applies.

Verified: max_rounds=2 against a real provider returns max_turn_requests
with the gate firing from buzz_agent::ops; handtest.py --all 18/18;
79 unit + 21 integration green.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
The turn loop round-tripped every message through goose's SessionManager
-- a sqlite database -- once per round. That was never required. None of
the goose APIs buzz calls reads the store:

- Provider::stream takes &[Message].
- Agent::dispatch_tool_call takes &Session but reads only working_dir
  and id.
- context_mgmt::{check_if_compaction_needed, compact_messages} read
  usage.total_tokens and model_config, and carry session_id only into a
  task-local that becomes an agent-session-id HTTP header for provider
  request attribution (goose/src/session_context.rs).

So the turn now owns a Session value directly (crate::turn_state) and
buzz-agent keeps the conversation across turns itself. goose still gets
the &Session its signatures ask for; nothing backs it with a database.

This is the right shape for buzz regardless of dependencies: a Buzz
agent's durable record is the relay, not a local sqlite file. Agents are
chatty and long-lived, and every one of them was growing a database
nothing ever read back.

One row remains, created at session/new: update_provider persists the
provider/model config against the session id, and that config is what
goose reads back to resolve the context limit for compaction. Declining
it would make auto-compaction fire at the wrong point. Zero message rows
are written for a conversation of any length.

Verified: a two-turn conversation recalls the first turn's content with
0 rows in the messages table; handtest.py --all 18/18; 84 unit + 21
integration green.

Also fixes the Desktop Core CI failure this branch introduced: the log
filter added earlier pushed runtime.rs past the 1000-line ratchet, so it
moves to its own module with its tests.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
…s session row

I claimed the remaining session row was needed because update_provider
persists the model config there and goose reads it back for the context
limit. That was wrong, and the fix is straightforward: buzz builds the
provider and model config itself, so it can hold them.

New crate::model::SessionModel owns the pair. The turn reads the provider
and config from it instead of Agent::provider and
model_config_for_session, and session/set_model swaps it there rather
than calling update_provider. That removes the last write buzz makes to
goose's store.

The row itself still has to exist, but for a different reason than I
gave. Agent::add_extension_inner (goose agent.rs:1465) resolves each MCP
extension's working directory by loading the session, and fails setup if
the row is missing -- 'Failed to get session: Session not found'. I found
that by removing create_session and watching every tool-using test fail.
So: one row per session, created for extension setup, and zero writes
after it. Removing it needs goose to take the working directory as an
argument instead of reading it back.

Verified: a session with an MCP server, a tool call and two turns leaves
1 sessions row and 0 messages rows, still recalls turn 1 in turn 2, and
still advertises 9 catalog models. handtest.py --all 18/18; 87 unit + 21
integration green.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
I reported the Security CI failure as not fixable here and something for
main to add to deny.toml's ignore list. That was wrong: the advisories
carry a semver-compatible fix, and cargo-deny prints it.

  cargo update -p nostr              0.44.6 -> 0.44.8
  cargo update -p nostr-relay-pool   0.44.1 -> 0.44.3

That clears all eight: RUSTSEC-2026-0224/0225/0226/0231/0232 and the
three other nostr parsing advisories. cargo-deny check now reports
advisories ok, bans ok, licenses ok, sources ok. Lockfile-only -- no
manifest change, no new ignores, and nothing added to deny.toml.

RUSTSEC-2026-0243 (nostr-relay-pool unmaintained) stays ignored; that one
genuinely needs the nostr-sdk 0.45 migration.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Conflicts, and how each was resolved:

* crates/buzz-agent/src/mesh.rs + tests/mesh_auto.rs — DELETED. main's
  #5289 removed buzz-agent's client-side mesh catalog probe on purpose:
  MeshLLM v0.75 decides per request, the desktop resolves the wire model
  before spawn (relay_mesh_wire_model), and BUZZ_AGENT_PREFER_MESH_FOR_AUTO
  is no longer set by anything. Keeping my MeshAutoProvider would have
  resurrected policy main deliberately retired.
* crates/buzz-agent/src/llm.rs — stays deleted; goose owns the transport.
  main's changes to it were the Databricks OAuth hardening (#5534), which
  applies to auth.rs, now living in buzz-model-catalog.
* crates/buzz-agent/src/config.rs — ours; main's only change was dropping
  prefer_mesh_for_auto, which this branch had already dropped.
* buzz-model-catalog: picked up #5534's O_NOFOLLOW/0600 token-cache
  hardening, which needs the nix dep the extraction had left behind.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-agent grew its own skill discovery, prompt index and load_skill tool
before goose had one. goose now ships goose::skills plus a SkillsClient
registered in PLATFORM_EXTENSIONS, so buzz's version was a strictly
smaller duplicate: the same three project directories, but goose also
reads the global directories, plugin-installed skills and its own
builtins, and supports args templating buzz never had.

Delete builtin.rs, builtin_client.rs and hints.rs (1,204 lines) and
register goose's skills extension by name so its factory runs. goose
declares it unprefixed_tools, so the model now sees load_skill rather
than buzz__load_skill.

skills.rs keeps only the prompt index, because the goose code that
renders it lives on SkillOperation, which is pub(super). The hint-file
guard from hints.rs moves there rather than being dropped, and now
matches filename literals instead of the bare words so it can document
itself.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
…ngine

Both sides ran OAuth PKCE against the same Databricks host with separate
token caches: goose's providers::oauth for inference, buzz's
PkceOAuthTokenSource for the desktop model picker. A user could be
authenticated for one and not the other and face two browser flows for
one host.

Buzz's engine is the hardened one (#5534: O_NOFOLLOW, 0600 from
creation, unique temp names) and the only one the desktop can link, so
it wins. goose supports this without a fork: DatabricksV2Provider::new
takes an oauth token provider closure, and databricks_v2_def::from_env
is just the variant passing goose's own. Retries, the agent-session-id
header, endpoint resolution and the wire format all stay goose's.

Uses bearer_no_browser: inference runs in a headless subprocess where a
browser step would hang unseen. Interactive login stays in buzz-agent
auth databricks and the desktop picker. A static DATABRICKS_TOKEN still
takes precedence, as in goose's own from_env, and no DATABRICKS_HOST
falls through to goose's registry unchanged.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
…ed one

An agent configured for OpenAI sent its OpenAI model to Anthropic and
404'd every turn (llm model not found: gpt-5.6-sol at
api.anthropic.com). Found in live testing, not in a test.

project_goose_env used set_if_absent, so an inherited GOOSE_PROVIDER
won over the agent's own BUZZ_AGENT_PROVIDER. That looks defensive but
inverts the intended precedence: BUZZ_AGENT_PROVIDER/MODEL are not
ambient config, they are derived by the desktop from the agent record's
structured provider/model fields at spawn time, and the desktop
deliberately refuses to persist GOOSE_PROVIDER/GOOSE_MODEL in an
agent's env so they cannot shadow those fields
(env_vars.rs:DERIVED_PROVIDER_MODEL_ENV_KEYS). The subprocess still
inherits the desktop's environment, though, and anyone with goose
installed exports GOOSE_PROVIDER from their login shell.

The failure is silent in the worst way: the agent's settings read
correctly in the UI while its traffic goes to another provider.

Set both unconditionally when the agent has them, and likewise map
OPENAI_COMPAT_API_KEY over OPENAI_API_KEY: an inherited OPENAI_API_KEY
would otherwise authenticate and bill the agent as whoever that key
belongs to. With no BUZZ_AGENT_* value there is nothing to override
with, so an ambient GOOSE_* is still honoured.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Desktop already refuses to persist GOOSE_PROVIDER/GOOSE_MODEL in an
agent's env_vars so a stale override cannot shadow the record's
structured provider/model fields. That guard only covers env we
persist. The spawned child still inherits Desktop's environment, and
Desktop inherits the user's login shell — where anyone with goose
installed exports GOOSE_PROVIDER.

So the developer's shell became the agent's configuration: an agent
configured for OpenAI sent its OpenAI model to Anthropic and 404'd
every turn, while the UI still showed OpenAI. Two agents on different
providers made it look like one bot was broken rather than that the
setting was being ignored.

Clear DERIVED_PROVIDER_MODEL_ENV_KEYS at spawn before writing the ones
derived from the record, so the record is the only source and an agent
with no configured provider gets none rather than the developer's. The
agent-side precedence fix (buzz-agent config.rs) makes buzz-agent
robust to a dirty environment; this makes Desktop stop handing it one.

Test asserts no inherited value survives into the child, and fails
without the clearing step.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Step 2 of PLANS/BUZZ_OPERATIONS_MIGRATION.md. Behaviour is unchanged:
the existing stop_hook and real_dev_mcp integration tests pass without
modification, which is the point of this step.

BuzzStopVetoOperation replaces the inline stop_veto() branch. The loop
now runs the gate a second time when a round wants to end, which is
where the operation applies -- it declines unless the last message ends
the turn, so the top-of-round call is a no-op for it.

Two things move rather than change:

- The block cap was a loop-local u32 in run_turn; objections now carry
  a metadata note and the cap counts them since kickoff. run_turn is
  per session/prompt, so both are turn-scoped -- same three-strikes
  behaviour, but now a pure function of the conversation, which is why
  goose tags its own denials the same way.
- MAX_STOP_BLOCKS was declared in both loop_drive and hooks with the
  same value. Dropped the loop_drive copy.

Deliberately not goose's StopHookOperation: it drives goose's private
HookManager plugin system, while buzz's hook is an MCP tool that can
see buzz-dev-mcp's in-process todo state. It also emits a user-facing
notification per denial, which in buzz would post 'stop hook blocked
ending this turn' into a channel every time an agent had an open todo.
The objection reaches the model, not the room.

65 unit + 21 integration green, handtest --all 18/18.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
The inherited-provider fix pushed runtime.rs from 996 to 1008 lines,
past the 1000-line limit, failing Desktop Core.

Move the clearing step into agent_env.rs as
clear_inherited_provider_model_env, next to
build_buzz_agent_provider_defaults which it pairs with, and next to the
test that covers it. runtime.rs drops to 991; no behaviour change.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
The port dropped BUZZ_AGENT_* knobs whose implementation moved to goose.
That conflated two different things: goose owning the mechanism, and the
setting silently ceasing to work. Someone who set
BUZZ_AGENT_TOOL_TIMEOUT_SECS=1200 got no error and a 300s timeout.

Restored as mappings onto goose, defaults matching main:

- TOOL_TIMEOUT_SECS   -> GOOSE_DEFAULT_EXTENSION_TIMEOUT
- MAX_TOOL_RESULT_TEXT_BYTES -> GOOSE_MAX_TOOL_RESPONSE_SIZE
- NO_HINTS            -> CONTEXT_FILE_NAMES=[]
- STOP_MAX_REJECTIONS -> the veto cap, configurable again (0 disables)

Both timeout and truncation project buzz's *default* as well as an
explicit value, because goose's differ (300s vs 660s, 200KB vs 50KB) --
mapping only explicit values would still have changed behaviour for
everyone who never set them.

Two silent regressions fixed:

- MAX_SESSIONS defaulted to 8; main was unlimited. A busy agent would
  have started refusing sessions it used to accept.
- The reply guard was gone entirely. Desktop still enables it by default
  for shared-compute agents (relay_mesh.rs), so the flag was set and
  ignored. Restored as BuzzReplyGuardOperation with main's nag text,
  two-reminder budget, and publish-shaped-call latch.

README: every removed knob is now listed as 'No longer read' with what
replaced it, rather than documented as if still live.

74 unit + 21 integration green, handtest --all 18/18.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
The previous fix removed GOOSE_MODEL and GOOSE_PROVIDER: the two keys
that caused the observed bug. That is a denylist of length two. GOOSE_MODE,
GOOSE_CONTEXT_LIMIT, GOOSE_MAX_TOKENS, GOOSE_THINKING_EFFORT,
OPENAI_API_KEY and the rest still reached the agent from the developer's
login shell -- the same bug waiting on a different variable.

Strip by prefix instead: GOOSE_, ANTHROPIC_, OPENAI_, OPENROUTER_,
DATABRICKS_. That closes the class, including variables goose adds later.

Deliberately not a whole-environment allowlist. Agents run shell tools
that need the developer's PATH, HOME, SSH_AUTH_SOCK, proxy settings and
toolchain vars; an allowlist that missed one would break tools silently,
which is worse than the bug being fixed. Agent configuration is Buzz's
to own -- the rest of the shell is the user's.

The test now supplies inherited keys explicitly instead of reading the
real environment. As written before it passed only because my own shell
exports GOOSE_*; on a clean machine it asserted nothing.

Desktop suite 2401 green, clippy and fmt clean, ratchet passes.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Steps 4 and 5 of PLANS/BUZZ_OPERATIONS_MIGRATION.md. Behaviour is
unchanged: steer.rs, cancel.rs and the real-MCP integration tests all
pass without modification.

BuzzSteerOperation drains the steer queue; BuzzCompactionOperation wraps
goose's check_if_compaction_needed/compact_messages plus the _PostCompact
re-injection that buzz's old context handoff existed for.

These need a second StateMachine (round_start), not more steps on the
existing one: round_gate also runs when a turn wants to END, and draining
steers or compacting there would change when they happen. Since step()
stops at the first operation that applies, the start machine runs to
exhaustion -- a turn that both steers and compacts needs two passes.

The gate now carries StateEffect rather than Message, because compaction
replaces the conversation instead of appending to it. apply_effects
reports whether state actually changed, which is what stops a
no-op-applied operation from spinning the loop.

Not goose's CompactionOperation: it yields to the client and emits a
user-facing notification. In buzz a yield ends the turn, and the
notification would post 'compacting' into the channel -- so a long
conversation would stop mid-work and narrate its own housekeeping.

Nearly dropped the [PostCompact] prefix on the re-injected state while
moving it. Caught by re-reading the old function before deleting it; the
model uses that prefix to tell re-injected state from a user turn.

74 unit + 21 integration green, handtest --all 18/18.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Reflect was step 3 of the migration plan. It should not be an Operation,
and the reason is worth writing down rather than leaving as an omission.

StateEffect can append a message or patch a tool request's metadata, but
nothing edits tool result *content*. PatchToolRequestMeta is applied via
SessionManager -- the store this branch deliberately does not write to --
and patches metadata, not what the model reads.

So as an operation the reflection would arrive as a separate message
after the result rather than inside it. That is exactly the arrangement
this port moved away from when it stopped delivering reflections as
steers. Structure follows behaviour.

Also corrected reflect.rs's module comment, which still described the
steer-based delivery an intermediate commit used.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
The two operations added in 099bacb had no direct unit tests -- they
were covered only indirectly by the integration suite. Four tests, each
pinning a property where the failure would be silent:

- a steer drains rather than peeks (a peek repeats the instruction to
  the model every round)
- an empty queue is NotApplicable, not an empty Applied (the loop treats
  applied-but-unchanged as a reason to look again, so an always-applying
  operation spins it)
- only effects that change state count as progress, including effects
  this loop deliberately ignores
- compaction clears the running token total (a stale total describes the
  pre-compaction conversation and would re-trigger compaction at once)

Verified by mutation rather than assumed: removing the token reset fails
the compaction test, and making drain() peek fails the steer test.

78 unit + 21 integration green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Mic asked whether the tests really cover multi-turn recall. They did
not, and I had claimed the gap could only be closed by hand testing.
That was wrong on both counts.

Every existing integration test sends a single session/prompt. All of
them would still pass if history were dropped between prompts and each
turn started from nothing -- which is exactly the risk this branch
introduces, since conversation history now lives in TurnState rather
than goose's sqlite store.

memory.rs sends two prompts on one session and asserts on what the
PROVIDER receives in the second request: turn 1's user message and turn
1's assistant reply must both be present. Asserting on the second answer
instead would pass against a model that ignored history entirely.

Verified against the real regression: deleting the line that carries
the conversation forward (s.history = conversation) in session_prompt fails the test with 'history is not carried across
prompts'. Nothing else in the suite notices that deletion.

Also fixed a flaw in my own first draft: the fake provider varied its
reply depending on whether the request mentioned the secret word. Turn
1's own user message mentions it, so both turns got the same reply and
the assistant-reply assertion proved nothing. It now returns a fixed
distinctive string.

79 unit + 22 integration green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
@micspiral
micspiral force-pushed the micn/buzz-agent-goose-core branch from 44bc41b to 0ea48f4 Compare August 12, 2026 06:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant