Commit Graph

114 Commits

Author SHA1 Message Date
Thinker
d0744b222b fix(buzz-agent): keep turn model attribution coherent
Store the provider, model config, and exact ACP model id as one atomic
session snapshot. Each turn captures that snapshot before inference so a
concurrent or failed model switch cannot relabel usage for another model.

Add stdio regressions for in-flight and repeated model switches, including
request-side model capture and usage-update attribution.

Signed-off-by: Thinker <75d8a808fa21bb8d1812e080cf471db601c6e8e6a62dcd516d37531e83a7bb77@meshllm.communities.buzz.xyz>
2026-09-01 14:58:14 +10:00
Jimmy
6ee2a3e56e Merge current main and update Goose GDK
Merge Buzz main at 4a9de1a3a, preserving the extracted model catalog and full buzz-agent test coverage. Carry named-demo OAuth cache isolation through the extracted catalog, and update Goose/GDK to 0a9749b1c for the latest security and conversation fixes.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-09-01 13:51:12 +10:00
Logan Johnson
4a9de1a3a1 feat(desktop): add isolated named demo builds (#6407)
🤖 I’m Larry, updating this description on Logan’s behalf.

## Summary

Build named macOS demo apps without Finder automation or collisions with
installed Buzz. `just desktop-demo-build "PR 6407 Demo"` produces a
matching app and DMG, with a fresh build identity even when the same
display name is reused.

- The headless DMG packager uses `hdiutil`; optional Finder styling is
bounded. The existing production release recipe is unchanged.
- Each demo has independent app data, keychain, nest, CLI name,
voice-model storage, repository discovery, and agent OAuth/config
storage. Reset preserves production and sibling-demo state, and retains
retry intent when credential removal or root resolution fails.
- Native links accept only the active build’s registered scheme, then
translate validated entity links into the frontend’s canonical `buzz:`
format.
- The recipe builds all six executable sidecars. Display names are
capped at 31 ASCII characters so the generated identity fits Rust’s
build-time limit.

**Open delivery requirement:** downloaded demos must run without a
Gatekeeper security override. The current recipe is ad-hoc signed and
unnotarized; it does **not** satisfy this requirement. Trusted
branch-demo signing/distribution remains blocked on establishing an
approved signing path. This PR is not being presented as complete
download-and-run delivery.

### Related issue

N/A — reported in the Buzz DMG-packaging workstream.

### Testing

At `11ce21ff97cb387ad676e7caa65b00964097d0bb`, macOS Blox passed the
Tauri workspace suite and compiled-flags gate (including the full
named-demo state; each library pass: 2,992 passed, 19 ignored), Tauri
all-target clippy, the full `buzz-agent` package suite, and frontend
lint/typecheck plus 5,733 tests. Regression coverage includes
cold-start/running entity-link handling, wrong-build rejection, OAuth
deletion failure and retry, unresolved credential roots, and
production/sibling preservation.

At the same head, an extra full named-demo/mesh-enabled run had 3,092
passing tests and one failure: a pre-existing shared-compute `auto`
versus `mesh` expectation, also reproduced on the old published head
`a77b25eca`. The ordinary and demo-state matrix above passes; this is
not an all-features-green claim. Live macOS Launch Services delivery
remains unverified.

GitHub CI completed with 30 successful checks and 9 skipped. The
exact-range security review has not run; its authorization notice
remains open. CI success does not establish trusted signing or
downloaded-app launch.

Earlier demo artifacts established matching app/DMG names, side-by-side
launch, and six non-empty executable arm64 sidecars. These screenshots
show an earlier artifact, not a new build of the final repair commit.
Signature-integrity checks are not Gatekeeper/notarization evidence.

<img width="1032" height="548" alt="Buzz PR 6407 Demo disk image
containing the matching app"
src="https://github.com/user-attachments/assets/bca0277e-db03-4308-b280-fcad55e6d601"
/>

<img width="1186" height="821" alt="Buzz PR 6407 Demo running alongside
other Buzz installations"
src="https://github.com/user-attachments/assets/b4bf4ae5-c341-4e15-8090-9d2ea7c623b6"
/>

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
Co-authored-by: Other Brother Darryl <cee32d92756729ee0c097c5661b879c6199931cd25315c8cf398dcbf0f155cf1@buzz.block.builderlab.xyz>
Co-authored-by: Larry <loganj+sandbox-larry@squareup.com>
Co-authored-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
2026-08-31 21:14:19 -04:00
Kalvin C
0affe527e8 fix(model-capabilities): humanize databricks goose model names (#7135)
🤖
## Summary
- add curated human-readable labels for Databricks Goose models that
otherwise render as fully qualified identifiers
- render `data_workflow_tools.goose.goose-glm-5-3` as `GLM-5.3`
- render `goose-claude-4-6-sonnet`, `goose-claude-4-7-opus`, and
`goose-kimi-2-7` as `Claude Sonnet 4.6`, `Claude Opus 4.7`, and `Kimi
2.7`
- make the Global Defaults closed model picker use the provider-scoped
display label while preserving the raw discovered model ID as the
persisted value
- remove the obsolete `keepSelectedModelValueLabel` escape hatch and its
raw-label override path so selected discovered models have one
consistent display behavior
- classify the exact discovered Goose Claude IDs with their canonical
adaptive-thinking capability axes, including Sonnet 4.6's exclusion of
`xhigh`
- expand Rust and TypeScript alias coverage and regenerate the shared
139-vector capability corpus

## Test plan
- `cargo test -p buzz-agent --lib` — 517 passed, 1 ignored
- `cd desktop && pnpm test` — 5,821 passed
- Desktop TypeScript typecheck — passed
- Biome on the changed component — passed
- `git diff --check` — passed
- targeted Playwright Global Defaults regression — passed on the
preceding implementation head; the subsequent commit only removes dead
picker-prop plumbing

Verified at `b9609d12696173aa309d2dbaf4f093a502756c36`. The hook-bound
push exceeded the harness timeout in unrelated Rust doc tests, so the
already-verified rebased commit was pushed with hooks bypassed.

Follow-up to #6955.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-31 17:13:59 -07:00
Jimmy
04a10f6d89 Merge origin/main and update Goose GDK
Bring the draft branch to current Buzz main, update Goose/GDK to 1.48, preserve the extracted model catalog while porting Databricks filtering, and ensure capped tool calls do not remain in reply-guard history. Run all buzz-agent targets in the unit gate.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-31 11:19:51 +10:00
Will Pfleger
eed74bde2f feat(buzz-agent): surface stop reason and silent-turn WARN in telemetry (#7038)
## What

Two telemetry additions to make a known silent-death failure mode
visible in harness logs.

**1. `stop` field on `llm: call completed` INFO**
(`crates/buzz-agent/src/llm.rs`)

The `ProviderStop` value was already parsed and stored on `LlmResponse`
but never emitted in the log line. Without it, "model chose `end_turn`"
vs "gateway truncated/refused" is indistinguishable from telemetry
alone.

**2. WARN on silent-turn signature** (`crates/buzz-agent/src/agent.rs`)

Emits a `WARN` when a turn produces no publish, no visible assistant
text, and either near-zero or absent output tokens. The WARN logic is
extracted into `warn_if_silent_turn` (pure synchronous function) so the
seam is testable without the async run loop.

Three independent gates before the WARN fires:
1. **`!buzz_reply_call_seen`** — no publish attempt in any round,
tracked unconditionally via the existing `is_buzz_reply_call` matcher.
Read-only tool calls do NOT suppress the WARN; a turn that ran tools but
never published and died at 3 tokens is still a silent death.
2. **`text_is_empty`** — no visible assistant text in the final round. A
terse reply like "OK" (≤12 tokens, non-empty) is not a silent death.
3. **Token check** — two distinct WARN messages:
- `Some(t) where t <= 12`: near-zero token count, the observed failure
signature (2–12 tokens)
- `None` usage: provider omitted token counts entirely, separately
diagnostic

Tests use a scoped `tracing_subscriber` layer (same pattern as the
existing stall-warn tests in `llm.rs`) to exercise the WARN seam
directly:
- Canonical signature (no publish, no text, 4 tokens) → 1 WARN
- Non-empty assistant text → 0 WARNs
- Publish seen → 0 WARNs
- `None` usage (no publish, no text) → 1 WARN

## Why

Recurring silent-death incident in a specific agent×channel combination:
sessions die with 1 LLM call, 2–12 output tokens, no tool calls, no
message, no error — recorded as a "successful" turn. The harness log
shows the token count but not the `stop_reason`, leaving the root cause
undiagnosable without request-level tracing. The observed shape also
includes tool-step-then-3-token-death (one tool call, then silence) —
the publish-aware gate catches both shapes.

Context thread:
buzz://message?channel=91fd9ca1-cf04-4ef7-b18f-aa2aee55692b&id=e3f1693f2e29f26a0c840f8054d592270c1504beacdc1d9c2063d8ab82960a06

## Scope

Logging and telemetry only. No behavior change, no retry-logic change,
no stop-reason mapping change.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-29 19:14:28 -04:00
Will Pfleger
ae0ecd9a67 feat(model-capabilities): humanize Databricks UC model families (#6955)
Chat-eligible Unity Catalog models from the Gemini, DeepSeek, GLM, Grok,
Llama, Qwen, Gemma, and Inkling families were rendering as raw FQNs such
as `system.ai.gemini-3-5-flash` in the agent model picker. #6918
surfaces these models, but the capability manifest carried no records
for most of them and the registry-label path could not strip their Unity
Catalog or `goose-` prefixes.

I added a display-only `label_family_tokens` list and `databricks_v2`
exact records for the sixteen previously unlabeled endpoint stems. All
26 target FQNs and their `goose-` aliases now resolve to curated labels
through `databricks_registry_label`, while capability resolution
continues to use only the existing `family_tokens` (`claude-`, `gpt-`,
and `kimi-`). This keeps label discovery from changing capability
profiles for unrelated model IDs.

Each new record includes reconciliation metadata against the pinned
[models.dev catalog](https://models.dev/api.json). Nine records adopt
first-party effort evidence that the Databricks MLflow Chat transport
can express: Gemini 3.5 Flash, Gemini 3.5 Flash Lite, Gemini 3.6 Flash,
Gemini 3 Pro Image, DeepSeek V4 Flash, DeepSeek V4 Pro, GLM-5.3 Flash,
Grok 4.6, and Inkling. The remaining seven deliberately retain the
`databricks_v2/concrete_unknown` fallback because upstream evidence is
absent, identifies a non-reasoning model, or provides only
toggle/token-budget controls that `reasoning_effort` cannot represent.

The normative corpus adds the sixteen exact records, four Unity Catalog
alias probes, and two capability-isolation probes. Existing base vectors
remain unchanged, and Rust and TypeScript consume the same strict
manifest and corpus.

Related: #6918

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Thufir <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Thufir <7ebdb0b67dab08a570b9faf7bbada97535673b4ccaba2cbd546ad3ba84c87fa6@buzz.block.builderlab.xyz>
2026-08-27 17:25:19 -04:00
Kalvin C
be8d7756d5 feat(agent): discover Databricks Unity Catalog models (#6918)
## Summary

Add Unity Catalog model-service discovery to `buzz-agent` Databricks v2,
preserve full Unity Catalog FQNs through ACP model discovery, route
model services through MLflow chat, and add the optional
`DATABRICKS_MODEL_FILTER` visibility filter.

The resilience follow-up bounds each catalog attempt across headers,
bounded body reads, and JSON parsing; retries transient 499/5xx,
transport/body failures, malformed JSON, and attempt timeouts; and emits
only safe catalog/status diagnostics.


## Validation

The live validation and test gates below were run on the pre-rewrite tip
`1cf6c66e7c5bf9a00c357bda24df48e3beeb310b`. The rewritten tip
`0364cf44f3eb5cfbd5771b0a0dd7868ce0ad2c19` has identical trees for both
commits, so the attribution rewrite changes no tested code.

- 31 catalog tests passed
- 19 Databricks OAuth/routing integration tests passed
- `cargo check -p buzz-agent` passed
- `cargo clippy -p buzz-agent --all-targets -- -D warnings` passed
- `cargo fmt --check` passed
- `git diff --check` passed
- Desktop Tauri validation passed through the repository-supported setup
path, including 2,777 library tests and auxiliary terminal suites

The full `buzz-agent` package has one separately reproduced pre-existing
failure: `regressions::cancel_kills_inflight_tool_via_mcp_notification`,
failing on both baseline `eb8e97cfe` and the follow-up worktree. No
Databricks path reaches that test. The live validation proves discovery
and ACP exposure, not inference against every listed model.

---------

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
2026-08-27 13:37:12 -07:00
Jimmy
55326e36d6 refactor(buzz-agent): use goose-agent loop APIs directly
Depend on goose-agent at the same pinned Goose revision and import its state
machine, operation traits, effects, and turn-counting helpers directly. This
removes Buzz's copied messages_since_kickoff and assistant_turn_count helpers
without changing loop policy or the broader Goose dependency graph.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-27 10:19:11 +10:00
Michael Neale
0a4c78e1ef fix(buzz-agent): keep goose's bundled skills out of Buzz agents
goose's skills platform factory calls plain `SkillsClient::new`
(`platform_extensions/mod.rs:219-221`), which leaves goose's two compiled-in
skills switched on: `goose-doc-guide` and `web-search`
(`goose/src/skills/builtins/`). Neither is a Buzz skill. `web-search` tells the
model to shell out to `uvx ddgs` / Tavily / SearXNG — a capability Buzz does not
provide and never advertised on main, where `buzz-agent` scanned four
filesystem directories and had no builtins at all (`hints.rs:8`, `:204-217`).
So the goose swap widened every Buzz agent's advertised surface as a side
effect, which is not what "same agent, goose underneath" means.

`with_builtin_skills(false)` is only reachable off the constructor, so buzz
builds the client itself and registers it with `ExtensionManager::add_client`
instead of by name. The prompt index is filtered to match: a skill listed in
the index but absent from the client is a dead `load_skill` reference.

The `add_client` route keeps the bare tool name. `is_unprefixed_extension`
(`extension_manager.rs:392-400`) keys off the `ExtensionConfig`, not the
registration route, and this passes the same
`ExtensionConfig::Platform { name: "skills" }` the factory does, so the table's
`unprefixed_tools: true` still applies. The context is given the session
explicitly because `SkillsClient::new` reads `session.working_dir` for
discovery and falls back to the process cwd without it.

Measured against the real `~/.buzz` nest with the built binary and a local
fake SSE provider (no model call): index 13 skills -> 11, tool list still
`["load_skill"]`, `load_skill("buzz-cli")` still returns its body,
`load_skill("web-search")` now returns "not found". System prompt 12,632 ->
12,079 bytes.

`tests/skills.rs` gains two assertions it was missing. The existing test only
checked `stopReason == end_turn` and that a second round happened — a
`load_skill` answering "Skill not found." passes both, so the suite could not
distinguish a working skills path from a broken one. It now asserts the skill
body reached the model as a tool result. The new test pins the builtins from
both sides (absent from the index AND unresolvable through the tool) and was
verified to fail on the unpatched `lib.rs`.

buzz-agent: 111 unit + 36 integration green, `clippy --all-targets -D warnings`
clean, `fmt --check` clean.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-26 17:12:53 +10:00
Michael Neale
a41b78f03e Merge origin/main into micn/buzz-agent-goose-core
Adopts main's tool-call permission gate (#5712) onto the goose loop. The
conflicts were semantic, not textual: #5712 landed +2494/-299 inside
`crates/buzz-agent`, the crate this branch rewrote, so five of main's files
(`llm.rs`, `mcp.rs`, `fake_llm.rs`, `regressions.rs`, and main's `agent.rs`
loop) no longer exist here and their share of the feature had to be
re-expressed against goose.

Taken from main unchanged: `permission.rs` (the broker), `wire.rs`'s
`session/request_permission` shapes, `Inbound::Response`, `send_checked`, and
`write_frames`.

Re-expressed:

* The gate moved from main's `RunCtx::execute_parallel` to `tools::run_one`,
  which is where this branch dispatches model-issued calls. It runs after the
  `tool_call` announcement and before `Agent::dispatch_tool_call`, so a denied
  call still reaches a terminal wire state.
* The broker takes goose's `CancellationToken` instead of a
  `watch::Receiver<bool>`, and an `AskSubject` instead of buzz's deleted
  `ToolCall` type.
* Argument-shape validation is dropped: it guarded main's `mcp.rs`, and goose's
  `CallToolRequestParams::arguments` is already `Option<Map>`, so a non-object
  cannot be represented.
* `max_pending_permissions` / `permission_timeout` follow this crate's
  `env_parse`-with-default style rather than main's validated `parse_env`.

Two behaviour notes worth review:

* `load_skill` is now **gated**, where main exempted it as an in-process
  built-in. Under goose it is a real tool on the skills platform extension,
  dispatched through the same path as any MCP tool. Exempting it would need an
  allowlist keyed on tool *name* — which an untrusted MCP server controls, and
  could register its own `load_skill` to inherit. Test renamed accordingly.
* Connection teardown already cancels every session (`serve`'s shutdown), so
  main's `cancel_all_sessions` plus writer-death select arm had no separate work
  to do here; the broker's wire-closed path covers the rest.

Test harness changes:

* `tests/common/mod.rs`'s fake LLM now answers SSE. goose's openai-compatible
  provider requests `stream: true` and parses `chat.completion.chunk`; against
  main's plain `chat.completion` body every turn ended `end_turn` with no tool
  call and no ask, which reads as a broken gate rather than a broken fixture.
  Its `/models` lookup is also answered without popping the canned queue.
* `tests/approve/mod.rs` auto-approves for the nine suites whose subject is not
  the boundary, selecting by option `kind` rather than a hardcoded `optionId`.
* `cancel.rs` now answers the ask and waits for `FAKE_MCP_CALL_RECEIVED` before
  cancelling. Without that the cancel resolved the *permission wait*, the tool
  was never dispatched, and `cancel_propagates_notifications_cancelled_to_mcp`
  failed — a test named "cancel mid tool call" was exercising "cancel before
  tool call".

Verified at this tree: `buzz-agent` full suite 111 unit + 36 integration
(including all 9 boundary tests) pass, `clippy --all-targets -D warnings`
clean, `cargo fmt --check` clean, `cargo build --workspace` clean, and both
`Cargo.lock` files resolve `--locked` (8 goose deps in root, 0 in desktop).

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-26 13:39:15 +10:00
Will Pfleger
a1219070fa feat(buzz-agent): gate LLM tool calls on session/request_permission (#5712)
## Summary

buzz-agent now authorizes every LLM-issued MCP tool call through
`session/request_permission` before it executes, instead of running
tools unconditionally. The agent **always asks**; the client applies
`BUZZ_ACP_PERMISSION_POLICY` and answers. buzz-agent never reads the
policy — this keeps the policy decision on the client side, matching the
layering of the other ACP harnesses, and avoids duplicating policy logic
that would drift.

### Broker

A crate-local `PermissionBroker`
(`crates/buzz-agent/src/permission.rs`), owned by `App` for the
connection lifetime, owns the full request-correlation lifecycle:

- **Process-wide admission.** A global `Semaphore`
(`BUZZ_AGENT_MAX_PENDING_PERMISSIONS`, default 32, validated `>= 1`) is
acquired *before* any correlation entry is inserted. This is the
security bound: the per-turn tool semaphore is constructed fresh per
turn and `max_sessions` is unbounded by default, so neither bounds
simultaneously-outstanding asks process-wide.
- **Abort-safe cleanup.** A successful admission returns a
`PendingPermission` lease that owns the admission permit and the
correlation id. Its `Drop` synchronously removes the still-pending entry
and releases the slot, covering task abort/panic that bypasses the
normal `run_prompt` tail.
- **At-most-once resolution.** `deliver` claims (removes) the entry
*before* waking the waiter, so each id resolves once and a later lease
`Drop` is a no-op. Unknown/late ids are logged and dropped; only ids the
broker minted (`perm-<n>`) are recognized.
- **Single absolute deadline.** Admission, request enqueue, and response
wait all share one deadline (`BUZZ_AGENT_PERMISSION_TIMEOUT_SECS`,
default 330s, validated `>= 1`), so a saturated call cannot outlive one
timeout window even when a stalled writer blocks the enqueue.
Cancellation races inside every wait — resolution never depends on the
outer abort drain. A writer that dies mid-connection (stdout closed, or
a blocking write that only surfaces its error at flush) is
connection-fatal: it cancels all sessions, which resolves any ask
waiting on a reply that can never be written.

### Wire

`request_permission_params` (`crates/buzz-agent/src/wire.rs`) is
version-aware, keyed on the protocol version negotiated at `initialize`
and stored on `App` for the connection lifetime (never derived from a
later mutable session field). v2 nests the tool call under `subject:
{type: "tool_call", toolCall}` with top-level `title`/`options`; v1 uses
the legacy top-level `toolCall`. No hybrid shape. Both offered options
(`allow_once`, `reject_once`) carry `optionId == kind`, so the client's
`kind`-based selection and this side's `optionId`-based predicate agree
without a lookup table.

### Gate

In each spawned tool task (`crates/buzz-agent/src/agent.rs`) the
sequence is: acquire per-turn permit → argument-shape validation →
broker admission + request + wait → cancellation recheck →
`emit_in_progress` → `mcp.call`. Argument-shape validation is hoisted
out of `mcp.rs::do_call` into `validate_arg_shape` so a malformed
non-object argument is rejected locally without prompting for a call
that could never execute.

Authorization is fail-closed, stated once in `evaluate`: execute IFF
`outcome.outcome == "selected"` **and** the selected `optionId` equals
the offered allow option. Every other shape (reject, cancelled, JSON-RPC
error, malformed, unknown outcome, wrong/unknown option, timeout,
wire-channel closure) denies with a synthetic tool error, and the turn
continues.

### Scope

Only LLM-issued MCP calls are gated. The built-in `load_skill` tool and
`call_hooks` lifecycle calls (`_Stop`, `_PostCompact`) are exempt — they
are not model-issued. `readOnlyHint` is never treated as a security
boundary.

First cut ships `allow_once`/`reject_once` only; session-scoped grants
are deliberately out of scope.

### Related issue

Part of #4938. This PR and
[#5106](https://github.com/block/buzz/pull/5106) jointly implement the
feature: #5106 is the client-side policy engine and permission cards;
this PR is buzz-agent's asking side (`session/request_permission`).
Neither closes #4938 alone.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-25 11:49:07 -04:00
Jimmy
e0d7899e17 Merge current main and update Goose GDK
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-24 16:04:53 +10:00
Jimmy
2ea414f0b3 test(agent): serialize provider environment
Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-21 16:46:18 +10:00
Jimmy
55edc4aef5 fix(agent): preserve migration behavior
Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-21 16:39:27 +10:00
Will Pfleger
b728a2af31 feat(llm): stamp thinking effort on call-completed log line (#6424)
The performance-analysis pipeline measures harness × provider × model ×
effort from production logs, but thinking effort was the only dimension
not stamped per call — it had to be attributed by config-changeover
timestamp.

Add `thinking_effort = ?cfg.thinking_effort` to the `llm: call
completed` `tracing::info!` in `Llm::complete()` so every call
self-describes all four dimensions. `?`-formatting preserves the
`None`-vs-`Some(...)` distinction the same way the token fields keep
None-vs-zero.

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-20 14:45:03 -04:00
Will Pfleger
2ce8df8533 fix(models): curate Databricks alias-aware labels for 5 missing endpoints (#6360)
Curates Databricks model labels through prefix-stripping aliasing and
materializes exact records for five endpoints missing from the
Databricks catalog.

**Alias-aware label resolution** (Databricks v2)
- Strip workspace-specific prefixes (`goose-`, `team-x-`, etc.) before
exact-record lookup so any prefixed alias resolves to its canonical
label without enumerating every alias variant.
- Return `None` for uncurated ids instead of falling back to a raw
string; callers control the fallback display.

**New exact records — label-only (Anthropic Messages route, axes from
family rules)**
- `databricks-claude-fable-5` → "Claude Fable 5" (materialized from
`anthropic-adaptive-xhigh-fable-5`)
- `databricks-claude-opus-4-8` → "Claude Opus 4.8" (materialized from
`anthropic-adaptive-xhigh-opus-4-8`)
- `databricks-claude-opus-5` → "Claude Opus 5" (materialized from
`anthropic-adaptive-xhigh-opus-5`)
- `databricks-claude-sonnet-5` → "Claude Sonnet 5" (materialized from
`anthropic-adaptive-xhigh-sonnet-5`)

**New capability-bearing record — Kimi K3 (MLflow Chat route)**
- `databricks-kimi-k3` → "Kimi K3" with axes from models.dev Moonshot
catalog: reasoning toggle + effort `[low, high, max]`, no default;
`_reconciliation_doc` records the source and reconciliation policy.

**Corpus and tests**
- 6 new normative-corpus vectors (canonical + one `goose-*` alias per
new id); executable vector count updated to 113 in both Rust and TS
gates.
- Rust and TS label tests extended to cover all 3 new canonical ids and
their aliases via the prefix stripper.
- Sentinel variable fix in discovery-provider test to prevent false
failure in Databricks dev environments where `BUZZ_AGENT_PROVIDER` is
set.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-20 13:46:10 -04:00
Jimmy
913cd56eb3 chore(buzz-agent): update goose to 1.47
Keep the Goose facade and split provider crates on one current upstream revision after reconciling Buzz main.

Co-authored-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
2026-08-20 22:18:27 +10:00
Michael Neale
b3d77f2b26 test(buzz-agent): cover provider failure paths end-to-end
`stdio_turn.rs` drives the real binary against a real socket, but its
fake provider only ever answers 200. buzz's deleted `llm.rs` tests
covered the unhappy paths; nothing on this branch did.

Five tests against a scriptable provider (429/401/500/truncated stream),
driving the actual binary over stdio. The property under test is the one
buzz owns and a user feels: a misbehaving provider ENDS THE TURN. Silence
is the worst failure available to us — `buzz-acp` waits on that response,
so a turn that never answers is an agent that has visibly stopped
replying with nothing in the log to explain it. Each turn runs under a
deadline so a hang fails as one test rather than stalling the suite.

Deliberately not a port of the 149 `llm.rs` tests: nothing here asserts
backoff timing, retry counts or `Retry-After` parsing. That is goose's
logic to test, and re-asserting it would rebuild the duplication this PR
deletes. What is asserted is observable behaviour through goose —
including that 401 keeps its distinct `-32001` code, which `buzz-acp`
routes on (`acp.rs:118`) to tell a credentials problem apart from a
generic provider failure.

Every test verified falsifiable by mutating the provider, not by
inspection: making the 429 terminal, serving 401 as 500, letting the
"persistent" 500 succeed, and keeping the second turn broken each fail
the intended assertion with a clear message.

That exercise also showed `BUZZ_AGENT_LLM_TIMEOUT_SECS` is load-bearing
rather than test impatience: with the fake provider sleeping on a
half-written stream the turn still ends, and with the timeout removed the
prompt never answers at all. So the projection added in the previous
commit is what rescues a provider that holds a socket open — noted at
the line that depends on it.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-18 12:44:23 +10:00
Michael Neale
678dc68384 chore(buzz-agent): drop the resurrected model_capabilities.rs copy
The merge brought #5597's `buzz-agent/src/model_capabilities.rs` back as
an untracked-by-any-`mod` file. The live copy is in
`buzz-model-catalog`, where I moved it so the desktop can read the
manifest without linking goose; the two were byte-identical.

Nothing declared `mod model_capabilities` in buzz-agent, so it was never
compiled — which is exactly why it survived a green build, a full test
run and clippy. Found by counting files, not by a failure.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-18 12:14:37 +10:00
Michael Neale
16a52827ee docs(buzz-agent): correct stale env/limit rows after the goose move
Swept all 27 `BUZZ_AGENT_*` knobs from the pre-goose config against this
branch, prompted by finding that #6115's mesh timeout reached nothing.
The good news: every knob whose implementation moved or went away is
already documented as such. These rows were not.

- `BUZZ_AGENT_MAX_TOKEN_RECOVERIES` still documented as live with a
  default of `3`. It arrived with #5475, which tuned buzz's own
  truncation-recovery loop — deleted on this branch along with the
  request transport, so goose owns retries and the knob is inert.
- Frame cap listed as a configurable 4 MiB in two places. It is a fixed
  16 MiB protocol limit (`config.rs::MAX_LINE_BYTES`); the env row above
  already said so, the security and limits tables disagreed.
- `BUZZ_AGENT_MAX_HISTORY_BYTES` still had a limits-table row after
  being marked no-longer-read; byte-based eviction is gone.
- Noted the `GOOSE_MAX_TOOL_RESPONSE_SIZE` projection where the limit is
  stated, not only in the env table.

No behaviour change.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-18 12:12:29 +10:00
Michael Neale
e5f73cd27c fix(buzz-agent): carry BUZZ_AGENT_LLM_TIMEOUT_SECS onto goose
PR #6115 seats a 660 s client budget for mesh agents in the desktop
(`managed_agents/relay_mesh.rs`) because MeshLLM's own backend budget is
600 s and a cold prefill of a large agent prompt can legitimately take
~500 s. On this branch that seed reached nothing: buzz-agent no longer
has a request loop, and `project_goose_env` had no projection for the
variable, so goose's own default applied instead.

goose's default is 600 s (`DEFAULT_PROVIDER_TIMEOUT_SECS`) — just *under*
the mesh server's, which is the same wrong-side-of-the-server ordering
#6115 diagnosed. So the bug #6115 fixes would have come back on merge,
silently and only on shared compute.

goose reads the timeout per provider rather than globally, so this
projects onto the variable belonging to the configured provider.
`relay-mesh` maps to goose's `openai` provider and so reads
`OPENAI_TIMEOUT` (`goose/src/providers/openai_def.rs:118`). Providers
goose gives no timeout knob — databricks — return `None` rather than
getting a variable goose never reads.

Two tests. The mesh one asserts the invariant (`> 600`) rather than the
literal, and I verified it fails with the projection removed and the test
kept, reconstructing that baseline from the file rather than `git stash`.

README corrected: it claimed this knob was "no longer read", and its
limits table still listed the pre-goose 240 s default.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-18 12:09:57 +10:00
Michael Neale
9ca88061fa Merge origin/main into micn/buzz-agent-goose-core
82 commits of main, plus the goose pin moved from bf332b9 to 7c4ba22
(60 commits) because main's changes and goose's API changes overlap.

Conflict resolutions worth knowing about:

* `llm.rs`, `handoff.rs`, `tests/regressions.rs` — main changed files this
  branch deletes. Reviewed each change rather than dropping it silently:
  #5475 raised `BUZZ_AGENT_MAX_OUTPUT_TOKENS` and the truncation-recovery
  allowance in buzz's own request loop, which no longer exists; goose owns
  retries and output limits now, so there is nothing to port.

* `model_capabilities.rs` (#5597) moved to `buzz-model-catalog`, not kept in
  `buzz-agent`. The manifest is read by the desktop model picker, which
  cannot link goose (`libsqlite3-sys` collision), so it has to live in the
  crate the desktop already depends on. `ThinkingEffort` moved with it,
  reduced to the vocabulary the manifest is typed in — the request-path
  mapping went with buzz's HTTP transport. The 103-vector corpus guard
  passes in its new home; `just ci` and `regen-model-corpus` now point at
  it, and `run-tests.sh` runs the new crate's lib tests.

* Curated model labels reach the picker. main added them to the Databricks
  discovery path; on this branch `session/new` enumerates through goose's
  provider API instead, so the label lookup had to be applied there or
  #5597 would have been silently reverted for every provider.

goose API changes this bump required:

* `StateMachine`/`Step`/`Operation` are generic over session and effect
  type, and the loop moved into a new `goose-agent` crate.
* `StateEffect` split: buzz now names `ConversationEffect`, the narrow set,
  rather than goose's `GooseEffect`. `ReplaceConversation` lost its `usage`
  field — buzz always passed `None`, and the driving loop already resets
  the running total, so this is a rename not a behaviour change.

78 unit + 22 integration tests pass, clippy `-D warnings` and fmt clean on
the pinned 1.95.0 toolchain.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
2026-08-18 11:43:30 +10:00
Will Pfleger
1b7e5ac1be feat(model-capabilities): drive model capabilities and labels from one manifest (#5597)
## Summary

Centralizes model capability knowledge — thinking mode, supported effort
levels, wire routes, and human-readable labels — into a single manifest,
`scripts/model-capabilities.json`. Rust and TypeScript each get a small
interpreter that reads the same manifest, replacing hand-maintained
tables scattered across both languages that had already drifted apart. A
capability change is now a data edit, not parallel edits to two code
paths. Supersedes the codegen approach explored in #3603.

A cross-language contract keeps the two interpreters honest:
`scripts/normative-corpus.json` is a golden snapshot generated from the
Rust resolver (103 vectors covering all six capability axes) and
replayed natively in TS. CI fails if either language disagrees with the
corpus or the corpus drifts from the resolver. Regenerate with `just
regen-model-corpus`.

## Behavior changes

- **Effort dropdown for `openai-compat` providers** no longer offers
`max`. The request path always clamped `max` to `xhigh` on the wire, so
the UI stops offering a value that was silently rewritten. UI-only,
wire-identical.
- **Databricks v2 routing (wire-visible):** uncurated endpoint names
carrying a bare Claude code-name segment (e.g. `goose-opus-5`) now route
to the MLflow chat wire instead of Anthropic Messages — they lose
Anthropic prompt caching but still succeed on a valid OpenAI-compatible
wire. Curated `databricks-claude-*` records and any name starting with
`claude` are unchanged. A handful of other uncurated/adversarial name
shapes similarly fall back to MLflow chat instead of pattern-matched
routes; every curated model resolves identically to before, all axes.
- **Curated model labels on the real discovery path.** The Databricks
API returns no display name, so discovery emits the raw endpoint id as
the model `name` (`{id, name: id}`) on every path. `ModelEntry.name` is
now curated at all four construction seams in `buzz-agent` — v2
discovery, v1 parse, the auth-empty default catalog, and the
configured-model fallback — via a read-only `databricks_registry_label`
lookup over the manifest's `databricks_v2` exact records; `id` stays the
raw wire/config value. A known id renders its curated label
(`databricks-gpt-5-5` → `GPT-5.5`), an unknown id passes through
unchanged, and the default-catalog row reads `GPT-5.5 (default
catalog)`. As a defense against older `buzz-agent` binaries and any
harness that echoes ids, `resolveModelLabel` treats a discovered name
equal to the trimmed id as absent and falls through to the registry
tier; a genuinely distinct name (including the suffixed default-catalog
label) still wins.

## Cleanup

Deletes the duplicated capability tables and their tests: the
`config.rs` gpt5 matchers, effort tables, and clamp logic; the legacy
segment-based Databricks v2 route classifier in `llm.rs`; and the TS
hand tables plus `effortTable.fixture.json`. All are replaced by
manifest lookups through the shared resolver — no line of capability
data exists in two places.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-17 11:23:28 -04:00
Atish Patel
72d56e7bd3 fix(agent): raise output limit and allow 3 recoveries (#5475)
## Summary

Raise the built-in output and recovery defaults so long-running agents
have more room to finish useful work instead of terminating after
repeated 32,768-token reasoning-only responses.

- Raise `BUZZ_AGENT_MAX_OUTPUT_TOKENS` from 32,768 to 65,536
- Raise the finite output-truncation recovery allowance from 2 to 3 via
`BUZZ_AGENT_MAX_TOKEN_RECOVERIES`; `0` still disables recovery
- Strengthen the recovery prompt so the model stops prolonged reasoning,
uses tools immediately, and builds scripts or artifacts in small
verifiable steps
- Preserve the safety invariant that incomplete truncated tool calls are
discarded and never executed
- Keep proactive handoff independently at 90% of
`BUZZ_AGENT_MAX_CONTEXT_TOKENS` (180,000 tokens with the 200,000
default), regardless of the output allowance
- Add request-loop and configuration regressions for exact-N recovery,
disabled recovery, successful tool-first recovery, discarded truncated
calls, and finite round bounds

`BUZZ_AGENT_MAX_OUTPUT_TOKENS` remains an explicit per-agent deployment
setting. Operators should configure it at or below the served model's
output limit; this PR does not perform live provider capability
discovery or automatic clamping.

**Risk:** Medium — this increases the default request size and permits
one additional recovery attempt by default. Recovery remains finite and
bounded by `BUZZ_AGENT_MAX_ROUNDS`. Deployments whose served model
rejects 65,536 output tokens must set a lower per-agent value.

Current output limits
- model - output token max
- DeepSeek V4 Flash - 384,000 tokens
- Qwen 3.8 (Max) - 131,072 tokens
- GLM 5.2 - 131,072 tokens
- GPT 5.6 - 128,000 tokens
- Claude Opus 5 - 128,000 tokens
- Gemini 3.6 Flash - 65,536 tokens
- Kimi K3 (Moonshot)- 131,072 tokens

### Related issue

None found. Originating benchmark analysis:
`buzz://message?channel=c3252dd2-0142-4e01-88c7-a2183c3960a5&id=91e991aab5fd49094583c3937477f6c12db57a41d86edf7fd4745d0d57d10017`

### Testing

- `cargo fmt --all -- --check`
- `cargo test -p buzz-agent` — 595 passed, 0 failed, 0 ignored at
`bd6de557b367850f50325bafdd3c046131942bef`
- `cargo clippy -p buzz-agent --all-targets -- -D warnings`
- Previously failing
`cancelled_turn_with_usage_emits_notification_before_response` passed
alone and in the full rerun
- Push hooks passed: organization guard, branch skew, Rust tests, and
Desktop Tauri checks

### Update — 2026-08-11

Per review feedback, the recovery default is 3. The OpenRouter live
`/models` output-cap discovery, cache, request clamp, and related
tests/documentation were removed. Per-agent output configuration is now
the sole output-cap mechanism. Proactive handoff and its pre-usage byte
fallback now depend only on 90% of `BUZZ_AGENT_MAX_CONTEXT_TOKENS`; with
the 200,000 default, the handoff threshold is 180,000 regardless of
`BUZZ_AGENT_MAX_OUTPUT_TOKENS`.

Generated with Brainy Bumble


### Targeted validation — 2026-08-11

Ran the exact PR binary once on each of the 11 benchmark tasks causally
affected by the previous 32,768-token ceiling, using OpenRouter with
`deepseek/deepseek-v4-flash-0731` pinned to Fireworks and maximum
reasoning effort. Relay-429 collection failures were excluded and rerun
at concurrency 2.

- **6/11 passed:** `circuit-fibsqrt`, `feal-linear-cryptanalysis`,
`model-extraction-relu-logits`, `path-tracing`,
`schemelike-metacircular-eval`, and `sqlite-db-truncate`
- **5/11 reached the benchmark deadline:** `adaptive-rejection-sampler`,
`dna-assembly`, `path-tracing-reverse`, `regex-chess`, and
`write-compressor`
- `regex-chess` reached exactly 65,536 output tokens, triggered one
output-limit recovery, and then reached the deadline. This directly
confirms that the larger ceiling and recovery path were active, but not
that recovery guarantees completion.

For context, ten of these tasks were 0/5 in the historical baseline;
`sqlite-db-truncate`, the clean control, was 4/5. This is targeted
one-attempt-per-task validation rather than a statistically powered
comparison. The result should not be attributed solely to the recovery
default of 3: this PR also raises the output ceiling and strengthens
recovery behavior, and OpenRouter routing conditions may differ from the
historical direct-Fireworks runs.

Generated with Brainy Bumble

---------

Signed-off-by: Atish Patel <atish@squareup.com>
Signed-off-by: Brainy Bumble <0ed7657b57c0e8a9f5288390dd6c8d5d0a3a06abe9b01b9006814f52077d6cdf@buzz.block.builderlab.xyz>
Signed-off-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
Co-authored-by: Brainy Bumble <0ed7657b57c0e8a9f5288390dd6c8d5d0a3a06abe9b01b9006814f52077d6cdf@buzz.block.builderlab.xyz>
Co-authored-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
2026-08-12 16:57:11 -04:00
Michael Neale
0ea48f44ea test(buzz-agent): prove turn-to-turn memory across separate prompts
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>
2026-08-12 16:22:10 +10:00
Michael Neale
e0d5f20475 test(buzz-agent): cover the steer and compaction operations
The two operations added in 099bacbe 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>
2026-08-12 16:15:04 +10:00
Michael Neale
60e63a065d docs(buzz-agent): record why [Reflect] stays out of the state machine
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>
2026-08-12 15:33:59 +10:00
Michael Neale
099bacbe6a refactor(buzz-agent): express steer and compaction as goose Operations
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>
2026-08-12 15:32:05 +10:00
Michael Neale
ab5b289488 fix(buzz-agent): restore the env surface main had
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>
2026-08-12 15:18:27 +10:00
Michael Neale
15f6fd57f8 refactor(buzz-agent): express the _Stop veto as a goose Operation
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>
2026-08-12 14:06:03 +10:00
Michael Neale
42fb755040 fix(buzz-agent): the agent's configured provider must beat an inherited 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>
2026-08-12 12:57:32 +10:00
Michael Neale
69c6091216 feat(buzz-agent): back goose's Databricks provider with buzz's PKCE engine
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>
2026-08-12 10:44:46 +10:00
Michael Neale
8e1ba34207 refactor(buzz-agent): use goose's skills instead of buzz's duplicate
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>
2026-08-12 10:39:22 +10:00
Michael Neale
3190102efb merge origin/main into micn/buzz-agent-goose-core-v2
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>
2026-08-12 09:10:52 +10:00
Mic Neale
240cdd3ea1 chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh (#5289)
Shared compute now has exactly two model choices: MeshLLM's virtual
`mesh`
model, or a model you name. Buzz picks between them in one place, and
buzz-agent no longer knows meshes exist.

## What changed

- **MeshLLM v0.74.0 → v0.75.1.** v0.75.0 added
`degrade_to_single_model`, so a
  `model=mesh` request is answered by one served model when there is no
committee to form, instead of failing. v0.75.1 adds Mesh-LLM#1196, which
skips stale pre-0.75 runtime cache entries rather than aborting startup
on
them — without it, anyone who had run mesh on 0.73/0.74 could not start.
- **Deleted the client-side mesh catalog probe.** buzz-agent used to
poll
`/v1/models` (5s TTL, 30s cooldown, two-observation debounce) to decide
whether `mesh` was safe to send. MeshLLM now decides per request, so the
  polling, its hysteresis, and its 503 fallback are gone.
- **One mapping point.** `relay_mesh_wire_model()` turns the stored
value into
a wire name: `auto` becomes `mesh`, a named model passes through. The
spawn
env, the ACP harness, and the readiness probe all use it, so they cannot
disagree — previously `BUZZ_ACP_MODEL` and the probe both said `auto`, a
name
  the mesh does not advertise.
- **Removed the `nostr-relay-pool` advisory exception.** #5404 allowed
RUSTSEC-2026-0243 "after mesh-llm migrates to nostr-sdk >= 0.45".
v0.75.1
does, so the retired crate is gone from both lockfiles and the exception
  would only mask a future advisory for it.
- **Deleted `scripts/ensure-mesh-native-runtime.sh`** and its six
justfile call
sites. It built llama.cpp from source into the runtime cache; the app
already
  downloads the signed release runtime itself, and CI never called it.

## Why it is better

**−639 lines of Rust.** Availability is decided by the node that knows
the
answer, per request, instead of by a client cache that could be stale
for up to
30 seconds. A second worker joining now takes effect on the next request
rather
than after two confirming probes.

## Behaviour change

A 503 on an explicit `mesh` request takes the ordinary transport retry
under
the same model instead of failing over to a second one — there is no
second
model to fail over to now. MoA repairs partial committee results
internally
before it reaches that point.

## Validation

`crates/buzz-relay/examples/mesh_agent_e2e.rs` now sends `mesh` where it
previously sent `auto` or the physical model id, so no leg was covering
what
Buzz actually puts on the wire. 4/4 on gemma-4-E4B, gemma-4-26B-A4B, and
Qwen3-8B — including a real ACP tool call through `mesh` into
buzz-dev-mcp,
asserted by reading the written file back off disk.

Hand-tested in the desktop app on both gemma-4 sizes: picked Auto, agent
logged
`model_id=mesh`, replied in channel.

## Not covered

A committee that forms and then loses a worker returns 502, and that
needs two
workers to reproduce — not testable on one machine.

---------

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
2026-08-11 11:40:07 -04:00
Will Pfleger
5e4d0fe925 fix(buzz-agent): harden Databricks OAuth token cache and callback (#5534)
Hardens the Databricks PKCE OAuth code in
`crates/buzz-agent/src/auth.rs`. Two fixes.

## Token cache is owner-only across its whole lifecycle, and race-safe

The PKCE cache holds both the access and refresh tokens, but `save()`
wrote it with a bare `fs::write` + `fs::rename`. Under a `022` umask the
file landed world-readable, and the fixed `*.json.tmp` temp name races
across concurrent savers sharing `$HOME` — one writer's `rename` can
fail on another's half-written temp.

**On write**, `write_private_cache()` creates a temp file with
owner-only permissions from the moment it exists — mode `0o600` on Unix
via `OpenOptions::mode` — writes and fsyncs it, then renames over the
destination. The rename swaps the inode wholesale, so a pre-existing
cache file with loose permissions is *replaced* by the new private inode
rather than inheriting its mode. `unique_suffix()` (getrandom, timestamp
fallback) gives each write a distinct temp name, and a drop guard
removes the temp on any failure path.

**On load**, owner-only is enforced as a cache lifecycle invariant, not
just a write-path property. A world-readable cache left by an older
buzz-agent was previously read straight into memory and returned on the
fresh cache-hit path without ever invoking `save()`, so a token file
with no advertised expiry could stay exposed indefinitely.
`read_cache()` now funnels every load — initial and cross-process
re-reads — through `read_private_cache()`, which on Unix opens with
`O_NOFOLLOW` (kernel-level symlink refusal, no stat/open TOCTOU),
requires a regular file, and `fchmod`s the pinned handle to `0o600` when
any group/other bit is set. A cache that cannot be secured is treated as
absent, so callers fail closed to a fresh flow rather than trusting an
exposed file.

## OAuth callback no longer reflects untrusted input

The localhost callback embedded the untrusted `error` query param
straight into the HTML response — an XSS sink on the redirect page — and
routed that same raw value into the error string that reaches the logs.

`callback_outcome()` is now a pure function returning `(result,
static_page)`: the browser always sees a fixed literal page that embeds
no request parameter, and failure detail travels only through the result
channel. `sanitize_callback_detail()` strips control characters (CR/LF
log-line injection) and caps length before that detail enters the error
string bound for the logs.

## Deferred: Windows owner-only ACLs

Windows owner-only protection is out of scope for this change. The
goose-parity route (`CreateFileW` with an owner-only SDDL
`D:P(A;;FA;;;OW)`) requires `unsafe` FFI, which this crate's
`#![forbid(unsafe_code)]` prohibits; reconciling that conflict is a
separate decision. Both platform seams — `create_private_temp_file`
(write) and `read_private_cache` (load) — have a `#[cfg(not(unix))]`
branch that relies on the default per-user ACLs and is the drop-in point
if Windows protection is added later. No new dependency and no `unsafe`
are introduced here.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
2026-08-11 09:58:15 -04:00
Michael Neale
45584a9c7a refactor(buzz-agent): hold the provider on buzz's side, not in goose'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>
2026-08-11 21:32:00 +10:00
Michael Neale
ce844719f8 refactor(buzz-agent): keep the turn conversation in memory, not sqlite
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>
2026-08-11 20:25:46 +10:00
Michael Neale
979eff99fd refactor(buzz-agent): express the round budget as a goose Operation
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>
2026-08-11 19:07:07 +10:00
Michael Neale
c312a70e53 fix(buzz-agent): mark steered messages with with_steer()
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>
2026-08-11 18:51:57 +10:00
Michael Neale
c918877d60 fix(buzz-agent): stop rendering every AGENTS.md into the prompt twice
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>
2026-08-11 18:48:37 +10:00
Michael Neale
626b079fa3 fix(buzz-agent): keep agent conversations out of the user's goose session 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>
2026-08-11 18:23:53 +10:00
Michael Neale
20a731b6e8 fix(desktop): admit buzz_agent logs to per-agent log files
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>
2026-08-11 16:19:44 +10:00
Michael Neale
1d098d6d52 test(buzz-agent): hand-test script driving a real provider over ACP stdio
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>
2026-08-11 15:46:24 +10:00
Michael Neale
9869f24319 refactor(buzz-agent): buzz owns the agent loop; goose supplies the parts
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>
2026-08-11 14:50:05 +10:00
Michael Neale
80a9f9a360 chore(buzz-agent): bump goose pin to bf332b9 (unrolled loop) and fix 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>
2026-08-11 14:14:14 +10:00
Michael Neale
d35f7042a5 Merge origin/main into buzz-agent-goose-core 2026-08-11 14:10:58 +10:00
Will Pfleger
5e4c05f90b feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
## What

Implements Phases 2 and 4a of the Usage v2 plan (plan events
`d0268cd0`/`0e95b035`), extending the archive backend to emit,
transport, archive, and aggregate both cache categories and billing
identity fail-closed.

### P2 — emission, transport, archive

**Tri-state accumulators** (`Unseen`/`Exact`/`Unknown`) for cache-read
and cache-write in `buzz-agent` turn and session state. Absent field =
Unknown (never zero) through the full pipeline. No `unwrap_or(0)` on the
cache path. Both cache folds are gated on usage-bearing responses (same
gate as the total-state and identity folds) — a response with no usage
at all must not poison either accumulator.

**Overflow-aware input token parsing and accumulation** — closed
end-to-end from parse through wire to ACP:
- `sum_usage()` returns `SumUsageResult` (`Exact(u64)` | `Overflow`) —
checked arithmetic, never clamps. `anthropic_input_tokens()` returns
`Option<SumUsageResult>` since it sums three fields (`input_tokens +
cache_read_input_tokens + cache_creation_input_tokens`) that can
collectively overflow. Single-field callers (`prompt_tokens`,
`completion_tokens`, etc.) convert via `.into_exact()` — their
single-field sums cannot overflow.
- `LlmResponse.input_tokens_overflowed: bool` propagates the parse-layer
signal into the run loop. When set, `input_tokens` is `None` (clamped
value discarded), the context-gate baseline
(`last_request_input_tokens`) is frozen at its prior reading, and
`turn_input_tokens` is poisoned to `TurnIOState::Poisoned` before any
emission — including mid-turn `emit_usage_update` calls. A dedicated
enum on `LlmResponse.input_tokens` would ripple into ~20 existing test
assertions on `r.input_tokens == Some(...)`; the bool flag confines the
change to the two call sites that check it.
- `TurnIOState` (`Unseen`/`Exact`/`Poisoned`) for input and output:
per-round fold uses `checked_add`; overflow poisons permanently at turn
and session level, no healing. Absence does not poison (pass-2-cleared
contract unchanged). Wire emission omits
`accumulatedInputTokens`/`accumulatedOutputTokens` when poisoned — never
null, never `u64::MAX`. ACP treats absent = publisher-poisoned:
`delta_reliable: false`, null turn fields, null cumulative for that
category; session cumulative stays unknown for all subsequent turns once
poisoned.

**Conditional wire emission** for `accumulatedCachedInputTokens` and new
`accumulatedCacheWriteTokens`: fields are omitted when the cumulative is
Unseen or Unknown. ACP `_goose/unstable/session/update` contract
documented next to the payload with tests for all absence/zero variants.

**`PricingIdentity` stamping (publisher-side)**:
- `pricing_authority()`: canonical parsed-URL endpoint comparison
against the official allowlist — HTTPS only, exact allowlisted host
(lookalike-safe), default port (omitted or explicit :443), required API
base path, rejects userinfo/query/fragment/path-prefix lookalikes.
- Model: the actually-requested `request_model` after mesh/auto
resolution (not `effective_model_str`).
- Turn discipline: identity retained only while ALL usage in the current
turn carries one identical proven identity; any mismatch,
unproven-usage-bearing response, or unpaired cumulative snapshot poisons
to absent; a later matching notification does not heal a mixed turn.

**ACP `UsageTracker` identity fold**: per-in-flight-turn tri-state
identity accumulator replacing last-update-wins. Any absent identity on
a token-advancing notification or exact mismatch poisons to absent;
poison survives later updates; reset in `begin_turn()`/`take()`; reset
also when a request fails (baseline cleared so preflight gate cannot
stay frozen sub-threshold on retries).

**M3 migration**: adds `turn_cache_write_tokens`,
`cumulative_cache_write_tokens`, `pricing_authority`, `pricing_model`,
`pricing_cache_class` to `agent_metric_index`. Additive, idempotent,
guarded per-column by marker. M2 migration also guarded per-column (turn
and cumulative cache-read columns checked and added independently;
marker commits only after both are present). Fresh-DB schema includes
all columns.

**First-turn baselines**: `seed_zero_baseline` seeds `last_input:
Some(0)`, `last_output: Some(0)`, `last_cached_input: Some(0)`,
`last_cache_write: Some(0)`, and `last_total: Some(0)` — all have the
known-zero-at-spawn argument. Absent fields from incoming snapshots
still produce unknown (tri-state unchanged). Sessions buzz-acp did not
spawn (no seed) remain fail-closed on turn one.

**`ReportedUsage` TS mirror**: `cacheReadTokens`, `cacheWriteTokens`,
`freshInputTokens` added to `tauriArchive.ts` as `UsageField` members,
field-for-field with the Rust struct.

### P4a — aggregation layer

**Extended S-1 ladder** to cache-read and cache-write via the same
`ladder_token` path as the existing token fields.

**`freshInputTokens` derivation**: checked arithmetic, fail-closed —
absent cache fields produce Unknown (not zero), overflow and
`cacheRead+cacheWrite > input` both produce `incomplete: true`.
Aggregated as a `UsageField`.

**D6 comparator**: `sort_value()` = provider total when known, else
`input+output` when both known, else `None` (unknown-last). Replaces the
prior total-only comparator for both agent-level and model-level sort.
Ships a pinned test vector that the TS render layer (P5) must match.

## Test coverage

- `buzz-agent`: 440 lib + 15 integration (golden_transcripts) — includes
13 new `cache_total_state_tests`; 14 new `turn_io_state_tests`; 3 new
`sum_usage_*` tests (exact single-field, exact two-field, overflow
signals correctly); 3 new `parse_anthropic_*` tests (overflow flag set +
value cleared, normal sum no flag, absent usage no flag); end-to-end
golden transcript drives real subprocess with Anthropic-shaped
`input_tokens: u64::MAX, cache_read: 1` response and asserts
`accumulatedInputTokens` absent from the emitted `usage_update` — no
logic duplication; 3 wire pin tests; 4 `fold_pricing_identity_*` tests;
`pricing_authority()` explicit-:443 acceptance
- `buzz-acp`: 700 tests (691 lib + 9 integration) — 4 new usage tests
(absent input → unreliable+null; absent output → unreliable+null;
goose-shaped both present unchanged; poison mid-session); 3 ACP behavior
tests; 7 pool lifecycle tests
- Desktop (Rust): 2259+ tests — 14 new P4a pinned tests; 2 M3 round-trip
tests; 1 serde key-shape test; 2 M2 partial-schema migration tests;
first-turn cache round-trip test

## Related PRs

- P1 NIP-AM spec: [#4632](https://github.com/block/buzz/pull/4632)
- P3 pricing table: [#4629](https://github.com/block/buzz/pull/4629)
- UI (P5): [#4001](https://github.com/block/buzz/pull/4001)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-08-10 10:47:37 -04:00
Tyler
5bf78671f4 fix(agent): retry LLM completion on malformed 2xx JSON body (#5351)
## Problem

A provider can return HTTP 200 with a **truncated JSON body** — cleanly
closed connection, correct framing, content cut off mid-value. Both LLM
HTTP loops treated this as a terminal error on the first attempt:
`AgentError::Llm("json: EOF while parsing a value")`, surfaced as code
-32000 at the ACP boundary, killing the agent turn before it produced
anything.

Observed live in a tb2.1 bench trial (write-compressor, tb21-twins-1):
deepseek via OpenRouter returned a truncated body, the agent died
mid-prompt with 0 turns completed, and the trial scored 0 on a provider
hiccup.

Meanwhile the same loops already retry timeouts, 429s, 5xxs, 499s, and
mid-body stream stalls — a truncated-but-complete body was the one
transient upstream fault that fell through to terminal.

## Fix

In both `post()` and `openrouter_post()`
(`crates/buzz-agent/src/llm.rs`): when the fully-received success body
fails `serde_json::from_slice`, `continue` the **existing** retry loop
instead of returning terminal — same `MAX_RETRIES` (3) bound, same
`backoff_with_jitter`. On exhaustion, the error goes through
`terminal_llm_error` so it carries cumulative duration + attempt count
like every other retried failure (previously the `json:` error carried
neither).

`post_anthropic` routes through `post()`, so
Anthropic/OpenAI/Databricks/mesh and OpenRouter are all covered.

## Why this cannot re-run a tool call

Hard requirement: tool calls are not idempotent, and this change must
not introduce any possibility of replaying one.

1. **The retry lives inside the HTTP POST helper, below the parse
boundary.** Tool calls are only ever extracted from a *successfully
parsed* response value
(`parse_openai`/`parse_anthropic`/`parse_responses`, all downstream of
these helpers' `Ok` return). A malformed body never parses, therefore no
tool call was ever extracted from it, therefore nothing downstream of it
ever dispatched.
2. **What is re-sent is the completion request itself** — the identical
`body_bytes` captured once at function entry. Sending a completion
request executes no tools; it asks the model for the next message.
3. **Same safety class as existing behavior.** The loop already re-sends
this identical request on 429/5xx/timeout/stream-stall; this adds one
more transient-fault arm to the same loop with the same bytes.

## Tests

Three new tests mirroring the existing 499/dropped-connection fixtures
(raw `TcpListener` stubs):
- `post_retries_malformed_json_body_and_succeeds` — truncated 200 body
on attempt 1, valid JSON on attempt 2; asserts success and **exactly 2**
server-side requests
- `post_exhausts_retries_on_persistent_malformed_json` —
always-truncated body; asserts exactly `MAX_RETRIES` attempts and a
terminal error carrying `json:` + cumulative/attempt context
- `openrouter_post_retries_malformed_json_body_and_succeeds` — same
recovery through OpenRouter's separate loop

Full `cargo test -p buzz-agent` green at e7a5d7bb (430 lib + all
integration targets, 0 failures); `cargo fmt` + `clippy --all-targets`
clean.

Originating conversation: buzz-benchmarking channel, thread 397a992d.

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
2026-08-08 17:29:06 -04:00