Files
buzz/crates/buzz-agent
Logan Johnson d5a73b9f35 fix(desktop): derive agent availability from relay presence (#7127)
🤖

## Requested rebase published — ef40744b

Rebased onto fetched main **47d068e2109d077414cbf2f4f1c927f6d051037a**,
published **ef40744b3aeb4baaf8c81416e1a644fb5b315f91** with the exact
expected-old `df7fad6a` force-with-lease. No merge.

Manual conflicts were additive: preserve main's exact-key identity
documentation alongside the availability contract, and retain both
Bestie props and the shared availability reader in
`UnifiedAgentsSection`. Range-diff confirms unchanged lifecycle policy:
exact-key action-time authority, Unknown versus Offline, rejected
shutdown retains record/memberships, and separate local/provider/owner
gates. Main's exact-key profile routing survives. Both test-only CI
synchronization repairs (natural toast expiry and bounded stderr wait)
are byte-identical to the prior head. All ten original
authors/messages/DCO/material coauthor trailers are preserved;
configured signing policy was not changed.

Fresh checks on the rebased candidate:
- TypeScript, Biome on 26 changed TypeScript files, differential
file-size gate, and diff whitespace: pass.
- Focused production-hook/card/profile units: **58/58**.
- Fresh E2E build, availability/deletion browser: **11/11**, no retries.
- Main exact-key profile cases plus failed-DM send/startup retries:
**6/6**, no retries.

Previously reviewed full Desktop/buzz-agent package and mutation
evidence is reused for unchanged behavior; no ceremonial full suite, new
native/provider test, or `just ci` pass is claimed. Local configs,
dependency links, and historical artifacts are preserved.

Hosted observation: **MERGEABLE**, **BLOCKED / REVIEW_REQUIRED**, no
new-head formal review. [CI
33699735990](https://github.com/block/buzz/actions/runs/33699735990) is
running (including Rust and Desktop lanes), not a completed success. DCO
and required Security aggregate passed at the observation; the separate
Codex advisory review was skipped. No completed failing check or new
inline feedback observed. Historical approvals are not new-head
approvals. No reviewer/security authorization or merge action was
performed.

---

## Feature summary and retained pre-rebase evidence

## Summary

In Buzz Desktop, an agent could look online just because it had been
started or deployed, even when there was no current sign it was
connected. Cards and profiles now show availability from the agent's
relay presence rather than a saved launch record, so you can distinguish
an online agent from one that was merely deployed.

Agents cards and profiles use presence reported through the shared
server (the relay). A successful presence read with no online agent
shows Offline; failed/disconnected evidence shows unknown, rather than
retaining a misleading cached Online state.

Lifecycle actions remain separate. An offline agent may still have a
Shutdown action because the deployment record exists. Shutdown reports a
**request**, not proof the process stopped. Offline does not imply that
starting a duplicate agent is safe, and Online does not promise a
response.

### Related issue

Independent base: `main`; no stack parent or child among the
replacements. Extracted from
[#7114](https://github.com/block/buzz/pull/7114), retained as historical
source (`98fe33ec`).

[Behavior
contract](f4bb2ed44e/docs/agent-availability.md).
Originating [Buzz
discussion](buzz://message?channel=f7a9536a-1738-4bad-a888-b3ea25010ef1&id=7aa1f0ab23dce514bd8a0221441cf005bf428914621171472b79747c50820848)
· channel `f7a9536a-1738-4bad-a888-b3ea25010ef1`.

### Testing

The same saved provider-backed agent, with only authored presence
changing. These are mock-browser states, not a before/after deployment
or live relay transport test; production UI is unchanged by the later
fixture repairs.

**No online presence:** gray dot, existing Shutdown control retained.

![Offline presence does not remove the deployment lifecycle
control](https://raw.githubusercontent.com/block/buzz/ed68b1b4597dde47118b7591b1e8ebf3702447ca/pr-7127--offline-deployment.png)

**Online presence:** green dot, same lifecycle control.

![Authored Online presence changes availability without changing the
deployment](https://raw.githubusercontent.com/block/buzz/ed68b1b4597dde47118b7591b1e8ebf3702447ca/pr-7127--online-deployment.png)

[Capture
details](https://github.com/block/buzz/pull/7127#issuecomment-5482264824).
To check manually, compare runtime-only transitions with presence
updates, then disconnect/fail the presence read and verify it does not
stay Online. A Shutdown request should not immediately claim confirmed
termination.

#### Historical pre-rebase evidence and limitations (df7fad6a)

Lifecycle production source remains
**`f4bb2ed44e5a989d93c5f51e93c0bbd2dca941be`**. Current published head
is **`df7fad6ae65dda78508317186a95522d1bb22ed9`**: the prior browser
synchronization at `b78d093e` plus an additive two-file Rust
test-harness synchronization described below. No production bytes,
dependency/configuration files, or prior commits were changed; no
rebase. Current live main `0dbd036f5bff33e7ade75e7639f3218d424a6e73` has
identical failing-test/toaster/send-flow source; the causal browser
comparison used latest successfully tested main
`04babf02655440b4dfd37f2e2df605ead0a030d8`.

**Lifecycle/deletion correction:** both Agents and actual profile
deletion now pass the shared exact-key availability reader, not raw
cached data. It reads the canonical query state and connection at action
time, including after awaited channel discovery.
Failed/disconnected/pending evidence and unqueried persona siblings are
unknown; successful missing means Offline only for a requested key.
Successful background refetch cache remains usable; settled failure
revokes it. No second cache or per-row polling was added.

Provider record + channel + Online/Away/**unknown** awaits shutdown
submission before local removal; rejection preserves record/membership
for retry. Established Offline preserves intentional no-request removal.
No route preserves warned local removal. Local agents retain native
stop-before-remove, independent of presence. Profile consent now
describes a shutdown **request**, not remote deletion or guaranteed
termination. Existing ownership and force gates are unchanged.

**Verified, reused exact-candidate validation:** the independently
approved eleven-file patch (SHA-256
`2f69fe12ef0420e62dea1fd8db28cfa22cde5eecaf8080e656310a3e60d0cf86`) was
committed without byte changes. Desktop **5,921 passed, 0
failed/skipped**, including **26 new mounted production hook/IPC
regressions**; rebuilt availability browser suite **11/11 passed, no
retries**, including four actual profile Delete journeys. Desktop check
(existing 4 warnings/5 infos), typecheck, production/protected-feature
artifact matrix, differential file-size/policy and diff checks passed.
No blanket rerun or new full-repository `just ci` is claimed for this
frontend correction.

Production regressions cover cached Online **and Offline**
failure/disconnection, genuine missing/Offline, pending, successful
inflight refetch versus settled error, retained reader, error during
awaited channel discovery, unqueried persona sibling, shutdown
rejection/order/cancel, no route and local authority. Browser fixtures
use safe mock IPC and a retained provider receipt, not a real
deployment. Three restored mutation controls fail: unknown → skip
shutdown (**15** regressions), Agents raw-cache reader (**6**), actual
profile raw-cache caller (**1 browser journey**, false removal on failed
cached Offline). Independent review approved the exact frozen bytes and
added **4/4 cached-empty failure/disconnection probes** across both
callers. This is local independent approval, not formal GitHub/A Team
clearance.

The prior native propagation/poll-count defects remain closed ([earlier
response](https://github.com/block/buzz/pull/7127#issuecomment-5512278755)).
The prior hover-popover correction at `b55423f6` remains covered by the
full 11-journey browser run: pending/failed/disconnected means no badge
or accessible status, genuine missing/Offline retains an Offline badge.
Its earlier fallback-restoration mutation failed as expected (badge
count 1 rather than 0); that historical witness is reused, not rerun.

**Reused unchanged native/system boundary:** local `just ci` at
`c59067d8` passed workspace/Tauri fmt/clippy, static/policy checks, Rust
unit recipe, native workspace **3,159 passed / 20 ignored**, Web build
and **2,019 mobile tests**. No native implementation changed in this
lifecycle correction. These are historical boundary results, not
new-head native/live certification. [Parent
CI](https://github.com/block/buzz/actions/runs/33650549130) passed with
**14 retry-recovered browser flakes**, not retry-free. Old-head
CI/reviews are not current-head clearance.

**Hosted gates:**
[CI33662151103](https://github.com/block/buzz/actions/runs/33662151103)
on `f4bb2ed4` **FAILED**: smoke shard1 had 322 pass, one failure, one
retry-recovered flaky, two skipped. The failed first-DM retry test timed
out on all three attempts because the error toast intercepted Send. That
failure is preserved, not waived; the scoped test repair below is
published as `b78d093e`.
[CI33668171165](https://github.com/block/buzz/actions/runs/33668171165)
on `b78d093e` subsequently **FAILED** the Rust unit budget regression
described below. Both original failures remain visible; neither was
retried to green. Exact `f4bb2ed4` and `b78d093e` APPROVED reviews cover
unchanged reviewed bytes, not formal approval of the new head. Fresh
exact-head CI and the established automated technical rereview are the
next gates for `df7fad6a`. Historical deletion responses remain
([5092381800](https://github.com/block/buzz/pull/7127#issuecomment-5513759316),
[5092391193](https://github.com/block/buzz/pull/7127#issuecomment-5513763497)).
No formal review dismissed. The [security
notice](https://github.com/block/buzz/pull/7127#issuecomment-5482231278)
and latest-push maintainer/codeowner policy remain separate actionable
gates: eligible Block organization members own current-range
authorization. No merge/security authority exercised.

**CI causal repair (`b78d093e`, test only):** the error `Message failed
to send: Mock first DM send failed.` is deliberately injected by the
existing fixture. CI screenshot and retry trace show the bottom-right
Sonner notification over the actual enabled Send button. `fill()` leaves
the pointer parked there; Sonner pauses its 4-second lifetime while
hovered. A fast run can click before animation settles (unchanged local
test passed in 2.7s; two actual tested-main CI cases passed first
attempt in 3.3s), which does not disprove the failure. Independent
controlled browser runs on `f4bb2ed4` and tested main `04babf` both
reproduced the same toast hit-test at Send `(1203,627,32,32)`,
persistent hover beyond 4s, and intercepted ordinary click with no
second send. Moving the real pointer to the editor allows natural expiry
and successful ordinary retry, preserving all original
DM-channel/recipient assertions. This same synchronization already
exists in the neighboring agent-startup-failure test.

The one-file correction keeps the visible error assertion, scopes its
toast locator, moves the pointer back to the editor and observes normal
toast removal (bounded 10s) before retry. No forced click, direct toast
dismissal, mocked clock, CSS override, skipped test, production behavior
change, or new backend mock. Six focused browser executions pass
(first-send/startup-failure, three repeats each, no retries); the
held-toast control fails on original bytes at the Send click while the
exact repaired test passes. Biome and diff checks pass. Reuse unchanged
5,921 Desktop / 11 availability browser / four independent probes above;
no semantic production change warrants repeating those suites. Original
failed CI attempt/retries, local fast pass, deliberate failing control
and all traces remain in `WORK_LOGS/AVAILABILITY_CI_B9210A40`. Browser
evidence is mock-IPC Chromium, not native/live-relay certification. The
UI still temporarily overlays Send while a notification is hovered; the
test exercises its real move-away/expiry recovery, not immediate
click-through.

**Rust CI causal repair (`df7fad6a`, test only):** [original Rust / Unit
Tests failure,
job100375291370](https://github.com/block/buzz/actions/runs/33668171165/job/100375291370)
tested GitHub merge `223dee91a396d8cb4ebf18b9b8559e5a54951235`.
`context_recovery_budget_exhaustion_surfaces_the_error` failed at
`regressions.rs:2756` in **0.091s** because its immediate stderr
snapshot lacked `context recovery budget spent`. ACP context-error
assertions had already passed. The captured prefix shows all three
budgets **32768 → 16384 → 8192 bytes**, above the 4096-byte floor, and
ends during the third attempt. This is **not evidence of floor
exhaustion**. The collector is an independent Tokio task; a stdout
response is not a stderr barrier. Recovery, harness and test blobs were
identical across the compared base/head/merge parents; no production
regression was implicated.

The shared test Harness now provides a bounded event/condition wait,
registering for collector notifications before reading the buffer to
avoid lost wakeups. The budget and adjacent terminal floor assertions
wait for their own diagnostic and retain the matching snapshot. The
budget test still requires the provider's ACP context error and exactly
three recovery rungs, now corroborated by **exactly four provider
calls** and no floor diagnostic. Timeout remains a real failure with
captured stderr. No fixed sleep, weaker assertion, skip,
provider-limit/logging change, dependency/config edit, or production
change.

**Deterministic causal control:** the same real agent/HTTP-provider/ACP
scenario holds only stderr collection behind a one-shot gate until after
stdout responds. The old immediate snapshot fails the original budget
assertion (intentional exit101); the repaired wait explicitly remains
Pending while held, then passes after release. No scheduler-speed
assumption or fixed sleep. This reproduces the observation race under
controlled delay, **not the exact historical CI schedule**. A
missing-diagnostic test proves the wait actually times out. Original
failure and deliberate failing-control logs/patch remain in
`WORK_LOGS/RUST_TRIAGE_06E32D2D` and `WORK_LOGS/RUST_SYNC_BC5758B9`.

**Final candidate validation:** focused recovery **9/9**, floor **1/1**,
absent-diagnostic timeout **1/1** pass. One full touched-package run,
`cargo test --locked -p buzz-agent`: **695 passed, 0 failed, 1 existing
ignored**, including all **54 regressions**. Local nextest was
unavailable, so this uses the repository-supported cargo-test fallback,
not a claim of nextest reproduction. `cargo fmt --check`, package-scoped
Clippy all-targets with `-D warnings`, differential file-size/policy and
diff checks pass. Previously reviewed availability production and the
Desktop/browser evidence above are unchanged and reused; no all-native
blanket rerun. The test-only delta was self-reviewed against collector
ordering, timeout and falsification evidence. Existing production
approval remains valid for those bytes; exact-new-head technical/CI
clearance is not assumed. The required **Security aggregate** is
distinct from optional Codex advisory feedback; no security
authorization, human review contact, or merge was requested.

**Remaining policy limits:** shutdown submission is not harness
acceptance or process termination; confirmed Offline/no-route local
removal may leave a remote process; route discovery is best effort,
membership cleanup uses `Promise.allSettled`, and multi-instance
deletion is sequential/non-atomic. No distributed singleton,
provider-health, tenant-switch cancellation, live relay TTL or packaged
WebView/VoiceOver certification is claimed. The pre-existing DM-header
raw-presence fallback (`ChannelScreenHeader`/`useActiveChannelHeader`)
remains outside this repair and uncertified. Screenshots above remain
historical mock-browser illustrations, not new deletion or native
transport evidence.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
2026-09-02 21:53:15 -04:00
..

buzz-agent

Minimal, unbreakable ACP-compliant LLM agent. Stdio in, tool calls out. Non-streaming. No persistence. No cleverness.

ACP is the Agent Client Protocol — JSON-RPC 2.0 over stdio between a client (Zed, JetBrains, buzz-acp, …) and an agent. MCP is how the agent talks to its tools.

buzz-agent is the agent.

What It Is

        +--------+   stdio (JSON-RPC 2.0)   +---------------+
        | client | <----------------------> |  buzz-agent |
        +--------+        ACP frames        +---------------+
                                              │            │
                                              │            │ rmcp (stdio)
                                              │            ▼
                                              │       MCP servers
                                              │       (your tools)
                                              ▼
                                            HTTPS
                                              │
                                              ▼
                                  Anthropic Messages API,
                                   OpenRouter, or any OpenAI-compat
                                  (vLLM, llama.cpp, Databricks,
                                   Block Gateway, Ollama, …)

A client sends session/prompt. The agent loops: call the LLM → get tool calls → run them via MCP → feed results back → repeat. The loop terminates when the LLM stops asking for tools, the round cap is hit, or the client cancels.

The agent's output is its tool calls. Generated text is forwarded to the client as agent_message_chunk updates, but the real work happens in the tools. The LLM call is non-streaming — one HTTP POST, one response.

Quick Start

# Build
cargo build --release -p buzz-agent

# Run against Anthropic
BUZZ_AGENT_PROVIDER=anthropic \
ANTHROPIC_API_KEY=sk-ant-... \
ANTHROPIC_MODEL=claude-sonnet-4-5 \
  ./target/release/buzz-agent

# Or any OpenAI-compatible endpoint
BUZZ_AGENT_PROVIDER=openai \
OPENAI_COMPAT_API_KEY=sk-... \
OPENAI_COMPAT_MODEL=gpt-5 \
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \
  ./target/release/buzz-agent

# Or OpenRouter
BUZZ_AGENT_PROVIDER=openrouter \
OPENROUTER_API_KEY=sk-or-v1-... \
OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \
  ./target/release/buzz-agent

# Or Databricks model serving via OAuth 2.0 PKCE
BUZZ_AGENT_PROVIDER=databricks \
DATABRICKS_HOST=https://dbc-...cloud.databricks.com \
DATABRICKS_MODEL=goose-claude-4-6-sonnet \
  ./target/release/buzz-agent

That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr.

ACP Transcript

A complete round-trip. Lines starting with → are client→agent (stdin); ← are agent→client (stdout). Each line is one newline-terminated JSON value. Comments are not part of the wire.

// 1. Handshake.
→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}}
← {"jsonrpc":"2.0","id":1,"result":{
    "protocolVersion":1,
    "agentCapabilities":{
      "loadSession":false,
      "promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},
      "mcpCapabilities":{"http":false,"sse":false}
    },
    "agentInfo":{"name":"buzz-agent","version":"0.1.0"}
  }}

// 2. Open a session. The client passes the MCP servers to spawn.
→ {"jsonrpc":"2.0","id":2,"method":"session/new","params":{
    "cwd":"/tmp",
    "mcpServers":[{"name":"echo","command":"/usr/local/bin/echo-mcp","args":[],"env":[]}]
  }}
← {"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_a1b2c3d4e5f6a7b8"}}

// 3. Prompt. The agent loops until the LLM stops calling tools.
→ {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{
    "sessionId":"ses_a1b2c3d4e5f6a7b8",
    "prompt":[{"type":"text","text":"echo hello"}]
  }}

// 4. Agent emits tool_call (status: pending) — visible to the UI.
← {"jsonrpc":"2.0","method":"session/update","params":{
    "sessionId":"ses_a1b2c3d4e5f6a7b8",
    "update":{
      "sessionUpdate":"tool_call",
      "toolCallId":"toolu_01XYZ",
      "title":"echo__say",
      "kind":"other",
      "status":"pending",
      "rawInput":{"text":"hello"}
    }
  }}

// 5. Agent moves the call to in_progress, runs the MCP tool, then completed.
← {"jsonrpc":"2.0","method":"session/update","params":{
    "sessionId":"ses_a1b2c3d4e5f6a7b8",
    "update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_01XYZ","status":"in_progress"}
  }}
← {"jsonrpc":"2.0","method":"session/update","params":{
    "sessionId":"ses_a1b2c3d4e5f6a7b8",
    "update":{
      "sessionUpdate":"tool_call_update",
      "toolCallId":"toolu_01XYZ",
      "status":"completed",
      "content":[{"type":"content","content":{"type":"text","text":"hello"}}]
    }
  }}

// 8. The model sees the result, decides it's done, and the prompt resolves.
← {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

That's ACP. Three request methods (initialize, session/new, session/prompt), one inbound notification (session/cancel), and three outbound update variants (agent_message_chunk, tool_call, tool_call_update). The full server is hand-rolled in main.rs.

Configuration

Everything is environment variables. No flags, no config files. (We are a subprocess; subprocess config is environment.)

Variable Default Notes
BUZZ_AGENT_PROVIDER — Required. anthropic, openai, openrouter, databricks, or databricks_v2. No implicit fallback — the agent errors at startup when this is unset.
ANTHROPIC_API_KEY — Required when provider=anthropic.
ANTHROPIC_MODEL — Required when provider=anthropic.
ANTHROPIC_BASE_URL https://api.anthropic.com
ANTHROPIC_API_VERSION 2023-06-01
OPENAI_COMPAT_API_KEY — Required when provider=openai.
OPENAI_COMPAT_MODEL — Required when provider=openai.
OPENAI_COMPAT_BASE_URL https://api.openai.com/v1 Point at vLLM, llama.cpp, Ollama, etc.
OPENAI_COMPAT_API auto auto | chat | responses. auto picks Responses for *.openai.com, Chat Completions everywhere else.
OPENROUTER_API_KEY — Required when provider=openrouter.
OPENROUTER_MODEL — Required when provider=openrouter. Use OpenRouter's vendor/model id, e.g. anthropic/claude-sonnet-4.5.
OPENROUTER_BASE_URL https://openrouter.ai/api/v1
DATABRICKS_HOST — Required when provider=databricks or provider=databricks_v2.
DATABRICKS_MODEL — Required when provider=databricks or provider=databricks_v2.
DATABRICKS_MODEL_FILTER — Optional discovery-only, comma-separated full-string */? patterns OR-matched against raw Databricks endpoint and Unity Catalog model-service IDs. Blank/unset shows all; this is visibility filtering, not an authorization boundary.
DATABRICKS_TOKEN — Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache.
BUZZ_AGENT_SYSTEM_PROMPT built-in Inline system prompt.
BUZZ_AGENT_SYSTEM_PROMPT_FILE — File path. Mutually exclusive with the above.
BUZZ_AGENT_MAX_ROUNDS 0 Tool-loop iteration cap. 0 = unlimited.
BUZZ_AGENT_MAX_OUTPUT_TOKENS 65536 Desired per-call ceiling. Set this at or below the served model's output limit for each agent deployment. Proactive handoff is independently based on 90% of BUZZ_AGENT_MAX_CONTEXT_TOKENS.
BUZZ_AGENT_MAX_TOKEN_RECOVERIES 3 Retries after a successful response is truncated at the output-token limit. 0 disables recovery; the finite value and BUZZ_AGENT_MAX_ROUNDS prevent infinite retries.
BUZZ_AGENT_MAX_CONTEXT_TOKENS 200000 Provider context window used by the handoff gate.
BUZZ_AGENT_MAX_HANDOFFS 10 Max context handoffs per session before falling back to truncation.
BUZZ_AGENT_LLM_TIMEOUT_SECS 240 Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock).
BUZZ_AGENT_TOOL_TIMEOUT_SECS 1260 Per-tool call timeout in seconds
BUZZ_AGENT_MAX_PARALLEL_TOOLS 8 Max concurrent tool calls per turn (1 = sequential)
BUZZ_AGENT_MAX_SESSIONS unlimited Max concurrent ACP sessions. Sessions are cheap; default has no cap.
BUZZ_AGENT_MAX_LINE_BYTES 4194304 4 MiB. Hard cap on inbound JSON-RPC frames.
BUZZ_AGENT_MAX_HISTORY_BYTES 1048576 1 MiB. Old turns are evicted past this.
BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES 51200 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt.
BUZZ_AGENT_REQUIRE_REPLY 0 (1 on mesh) 1 enables the reply guard — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to 1 for Buzz shared-compute agents.

Reply Guard

Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop sets BUZZ_AGENT_REQUIRE_REPLY=1 automatically. With it enabled, a turn that is about to end without any recognized attempt to post to Buzz gets a reminder that its assistant text is invisible to humans, and is rerolled.

This exists because a Buzz agent's reasoning and tool output are not shown to anyone. A turn that does real work and never posts is a silent failure — the requester waits on a result that was produced and thrown away.

Mesh agents get it by default because they run on small local models, which are the ones most likely to do the work and then end the turn without publishing it. Setting BUZZ_AGENT_REQUIRE_REPLY=0 on the agent, persona, or global env opts a mesh agent back out; the default never overrides an explicit value.

Advisory, never a trap. At most two reminders, then the turn ends whether or not anything was published. The guard catches accidental omission; it does not compel speech. The reminder text explicitly licenses silence, because the built-in system prompt says publishing is optional and silence is often the correct outcome.

Recognition contract. A turn counts as having replied when it issues a call that:

  • resolves to a registered, non-hook tool (a hallucinated tool name is rejected at preflight and never runs, so it must not disarm the guard),
  • whose qualified name ends in __shell — i.e. the bare tool name is exactly shell, which is buzz-dev-mcp's shell tool and any other server's, and
  • whose command argument contains messages send or reactions add.

messages send also covers messages send-diff. Reactions count because the built-in prompt directs agents to react rather than post a bare acknowledgement, so nagging an agent that reacted would punish documented behavior.

Detection is checked after the per-turn tool-call cap (MAX_TOOL_CALLS_PER_TURN) is applied: a publish-shaped call that was discarded never ran.

It recognizes an attempt, not a successful publish. Only the command text is inspected, never the exit status. A send that fails still satisfies the guard — which is fine, since a failed send already returns a non-zero exit and error JSON to the model, louder feedback than a reminder.

Known limits, both deliberate. A command assembled at runtime ($CMD) or buried in a wrapper script is missed, so that turn is reminded despite having posted. Text that merely quotes a send (echo "buzz messages send") matches, so that turn is not reminded. Missing a real post is the expensive direction, and substring matching is the forgiving one there. Neither edge is pinned by a test; the matcher is free to improve.

Budget. Reminders ride the existing _Stop gate and share BUZZ_AGENT_STOP_MAX_REJECTIONS — the outer cap on every end-turn objection. At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off along with the hooks. A round carrying both a _Stop hook objection and a reminder costs one rejection and delivers both texts. This is not a new lifecycle hook — see MCP_DRIVEN_HOOKS.md.

Providers

buzz-agent speaks a few HTTP dialects. Pick with BUZZ_AGENT_PROVIDER.

Provider BUZZ_AGENT_PROVIDER Endpoint (auto) Tested with
Anthropic anthropic POST {base}/v1/messages claude-sonnet-4-5, claude-opus-4
OpenAI openai POST {base}/responses gpt-5, gpt-5-mini, o4-mini, gpt-4o
vLLM openai POST {base}/chat/completions any tool-calling model
llama.cpp openai POST {base}/chat/completions any tool-calling GGUF
Ollama openai POST {base}/chat/completions llama3.1, qwen2.5-coder
Block Gateway openai POST {base}/chat/completions gpt-5, claude
OpenRouter openrouter POST {base}/chat/completions anything they route (extended-thinking replay, provider-agnostic tool calling)
Databricks databricks POST {host}/serving-endpoints/{model}/invocations goose-claude-4-6-sonnet
Databricks AI Gateway v2 databricks_v2 POST {host}/ai-gateway/{provider}/v1/... workspace endpoints and Unity Catalog model-service FQNs; UC FQNs use MLflow Chat Completions

The optional DATABRICKS_MODEL_FILTER applies only to model discovery. Each comma-separated entry is trimmed and matched against the complete raw ID with case-sensitive * (zero or more characters) and ? (one Unicode character) semantics; patterns are OR-ed. Unset or blank preserves the full authenticated catalog. A nonblank value containing no usable patterns is rejected. This controls picker visibility only; Databricks and Unity Catalog permissions remain the authorization boundary. A filtered-empty result is authoritative and does not restore the built-in fallback models.

If BUZZ_AGENT_PROVIDER=anthropic is selected without ANTHROPIC_API_KEY, BUZZ_AGENT_PROVIDER=openai is selected without OPENAI_COMPAT_API_KEY, or BUZZ_AGENT_PROVIDER=openrouter is selected without OPENROUTER_API_KEY, the agent returns an error — there is no implicit fallback to another provider.

provider=openai speaks two HTTP dialects: the Responses API (/v1/responses, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the Chat Completions API (/chat/completions, the broadly-supported OpenAI-compatible wire format).

By default (OPENAI_COMPAT_API=auto) the agent picks Responses when OPENAI_COMPAT_BASE_URL points at an *.openai.com host and Chat Completions everywhere else. Pin the choice explicitly with OPENAI_COMPAT_API=chat or OPENAI_COMPAT_API=responses for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway).

provider=openrouter is first-class, not routed through provider=openai: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top —

  • reasoning.effort is set on the request when reasoning effort is configured. The request deliberately carries no provider.require_parameters filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise reasoning, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead.
  • The response's reasoning_details array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought.
  • anthropic/* models get Anthropic-style cache_control breakpoints injected on the system message and the last two user messages.
  • Retryable statuses (429 and typed provider_overloaded 503) honor the documented Retry-After header (clamped to a small ceiling — see RETRY_AFTER_CAP_SECS in llm.rs — since the sleep happens outside BUZZ_AGENT_LLM_TIMEOUT_SECS); 502 and untyped 503 retry with jittered backoff instead. 401 is treated as an expired/invalid key and refreshed once, while 402 (no credits) and 403 (guardrail/moderation/permission) fail immediately without retry.

Provider is a Rust enum with one match in Llm::complete. There is no trait, no Box<dyn>, no async-trait. Adding a provider is a match arm and one body/parse pair in llm.rs.

MCP Servers

The client passes MCP server specs in session/new. The agent spawns each one as a stdio subprocess, calls tools/list, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as server__tool (double underscore separator). Bare tool names containing __ are rejected at registration.

Example: a single echo MCP server.

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "session/new",
  "params": {
    "cwd": "/work",
    "mcpServers": [
      {
        "name": "echo",
        "command": "/usr/local/bin/echo-mcp",
        "args": ["--mode", "stdio"],
        "env": [
          { "name": "ECHO_VERBOSE", "value": "1" }
        ]
      }
    ]
  }
}

Multiple servers: just add more entries. Tool calls fan out to the right server by namespace prefix.

Transport: stdio only. No HTTP, no SSE. We advertise this in agentCapabilities (mcpCapabilities.http: false, mcpCapabilities.sse: false); spec-compliant clients won't ask for what we don't have.

Security Model

The trust boundary is the operator who launched the agent. The harness, MCP server binaries, and API keys are all trusted. Untrusted input — model output, tool results, prompts — is bounded.

Boundary Mechanism
Stdout discipline Single-consumer mpsc channel feeding stdout. No two tasks can interleave bytes. All logs go to stderr.
MCP child env Whitelist (PATH, HOME, TERM, LANG, LC_ALL, TMPDIR) plus what the client explicitly passes. Your ANTHROPIC_API_KEY does not leak into MCP children.
MCP child lifetime Process group via setpgid(0,0) in pre_exec. On transport break or shutdown: killpg(SIGKILL). Grandchildren die too.
Server poisoning After a timeout or transport break, the offending server is marked dead. Future calls trigger a lazy restart with exponential backoff. Other servers keep working.
Frame size BUZZ_AGENT_MAX_LINE_BYTES (default 4 MiB). Oversize → connection killed.
LLM response size 16 MiB hard cap. Both Content-Length precheck and streaming-buffer cap.
Cancellation tokio::select! { biased; _ = cancel.changed() => ... } at every loop boundary. Cancel always wins the race.
Session isolation Unlimited concurrent sessions by default (configurable via BUZZ_AGENT_MAX_SESSIONS). One prompt per session at a time. Each session gets its own MCP servers.
tool_use ↔ tool_result pairing Encoded in the type system. Every ToolCall and ToolResult carries a provider_id: String (not Option).

Bounded Everything

Limit Default Where
Inbound JSON-RPC frame 4 MiB BUZZ_AGENT_MAX_LINE_BYTES
Single prompt 1 MiB MAX_PROMPT_BYTES
History window 1 MiB BUZZ_AGENT_MAX_HISTORY_BYTES
LLM response body 16 MiB MAX_LLM_RESPONSE_BYTES
LLM error body 4 KiB MAX_LLM_ERROR_BODY_BYTES
Tool result body (total, incl. images) 8 MiB MAX_TOOL_RESULT_BYTES
Tool result text 50 KiB BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES
MCP servers / session 16 MAX_MCP_SERVERS
Tools / session 128 MAX_TOOLS_PER_SESSION
Tool description bytes 1 KiB MAX_DESCRIPTION_BYTES
Tool schema bytes 4 KiB MAX_SCHEMA_BYTES (oversize → replaced with {})
Tool calls per turn 64 MAX_TOOL_CALLS_PER_TURN
Loop rounds 0 (unlimited) BUZZ_AGENT_MAX_ROUNDS
LLM read inactivity timeout 240 s BUZZ_AGENT_LLM_TIMEOUT_SECS
Tool call timeout 1260 s BUZZ_AGENT_TOOL_TIMEOUT_SECS

What This Is NOT

A short list, because the answer is mostly "no":

  • Not a framework. No plugins, no recipes, no slash commands, no modes. MCP servers can participate in agent lifecycle via hook tools (_Stop, _PostCompact), but these are advisory, fail-open, and budget-bounded — not a plugin system.
  • Not streaming. One non-streaming HTTP POST per round. The LLM's generated text is forwarded to the client as agent_message_chunk, but there is no token-level streaming.
  • Not persistent. Everything is in-memory, per-process. No SQLite. When context fills, the agent summarizes its own history and continues (context handoff). No external persistence.
  • Not an SDK. This is a binary. The protocol seam is stdin/stdout. Use it from any language.
  • Not a UI. No TUI, no web, no notifications. The client renders.
  • Not authenticated. API keys come from env. Use systemd, Docker secrets, or a wrapper.
  • Not networked MCP. Stdio transport only. No HTTP/SSE MCP transport.
  • Not load-able. No session/load. We advertise loadSession: false.
  • Not a router. No agent-to-agent, no fan-out, no orchestration. One model. One loop.

Concurrency model:

                  ┌──── reader task ──────────┐
                  │  (stdin → JSON-RPC → ...) │
                  │                           │
   stdin ─────────┤   dispatch                │
                  │     │                     │
                  │     ├── initialize        │  (sync reply)
                  │     ├── session/new       │  (sync reply)
                  │     ├── session/prompt ───┼─── spawn ──> prompt task
                  │     │                     │              │
                  │     ├── session/cancel ───┼─> watch::send│ (biased select wins)
                  │     │                     │              │
                  └───────────────────────────┘              │
                                                             │
                  ┌── writer task ────────────────┐          │
   stdout ────────┤  mpsc<WireMsg> consumer       │<─────────┘
                  │  (the only stdout writer)     │
                  └───────────────────────────────┘

One reader, one writer, up to 8 concurrent prompt tasks (one per session).

Building

cargo build --release -p buzz-agent

Testing

cargo test -p buzz-agent

Test strategy is real subprocess, no mocks:

  • Fake LLM — tests/fake_llm.rs and the helpers in tests/regressions.rs spin up a real tokio::net::TcpListener on port 0, parse Content-Length, and return scripted JSON. No HTTP mocking library.
  • Fake MCP server — tests/bin/fake_mcp.rs is a separate binary controlled by env vars: FAKE_MCP_HANG_INIT, FAKE_MCP_TOOL_DELAY, FAKE_MCP_SPAWN_GRANDCHILD, etc. Each fault path is a real process being abused.
  • Regression tests are the changelog. Each #[test] in regressions.rs is named for the bug it locks down: assistant_text_preserved_across_prompts, cancel_leaves_history_valid_for_next_prompt, mcp_init_timeout_kills_child, oversize_line_kills_connection. Read them in order to learn the protocol's failure modes.