> On 8 tasks matched by name across the two runs, cost fell $8.36 → $1.77 (4.71×) and wall-clock 12,423 s → 1,085 s (11.45×). ## Summary Two independent, self-contained fixes to `buzz-agent`/`buzz-acp`, split out of the benchmark branch so they can land while the harness work continues: 1. **Request and surface Anthropic prompt caching.** buzz never sent a `cache_control` breakpoint, so on the Databricks Anthropic route `cache_read_input_tokens` was **structurally always 0** and the ~10× cache-read discount was never claimed. This teaches `anthropic_body()` to mark the cacheable prefix, and plumbs the cache split end-to-end so accounting can price it. 2. **Pass proxy + TLS-trust env into MCP tool subprocesses**, so agent tools on a proxy-only host stop reporting a live network as offline. ## Why the caching gap matters The Anthropic Messages API does **not** cache unless the request carries a `cache_control` breakpoint, and the Databricks AI Gateway — a third-party proxy in front of the model, in the same category as Bedrock/Vertex — does **not** auto-cache (only the first-party Anthropic API and Claude-on-AWS do zero-config caching). So every request was billed cold. Measured live against the Databricks gateway (`databricks-claude-opus-5`, 2026-07-28), the same call with and without a single `cache_control` marker: | Run | `input_tokens` | `cache_creation` | `cache_read` | latency | |---|---|---|---|---| | No `cache_control`, two byte-identical calls | 121,625 | 0 | **0** | ~9.3 s | | With one marker — cold (write) | 4 | 121,625 | 0 | 9.3 s | | With one marker — warm (read) | 4 | 0 | **121,625** | **4.5 s** | One marker moved 121,625 tokens from full-price input to a 0.1× cache read and roughly halved latency (a clean, isolated ~2.07× prefill speedup on this single-threaded microbenchmark). The gateway honours `cache_control`; buzz simply never sent it. At fleet scale this was a real budget item. Across matched Terminal-Bench solo sweeps (89 tasks, `-n 20`, before the fix), the two OpenAI-route models independently landed at ~86–87% cache reads — the expected shape for an agentic loop, where system + tools + append-only history repeat every turn — while the Anthropic route returned a hard 0% on every receipt: | Condition | Route | Input tokens | Cache reads | Cost | Cost if uncached | Discount | |---|---|---|---|---|---|---| | luna (`gpt-5-6`) | OpenAI | 20,320,818 | **17.7M (87.0%)** | $6.96 | $22.87 | **3.28×** | | sol (`gpt-5-6`) | OpenAI | 22,312,290 | **19.2M (85.9%)** | $37.07 | $123.35 | **3.33×** | | opus (`claude-opus-5`) | Anthropic | 12,459,822 | **0 (0.0%)** | $81.31 | $81.31 | **1.00×** | Applying luna's measured 87% read rate to the opus token counts at list prices (`input $5/M`, `cached_input $0.5/M`, `output $25/M`) puts the opus run at **~$32.53 vs the $81.31 actually paid — a ~60% overspend on those 49 trials (~$89 on a full sweep)**. That is an upper bound (it prices every cached token at the 0.1× read rate and ignores the 1.25× write premium), and the opus discount is structurally smaller than luna/sol's because opus emits ~3.5× more uncacheable output per trial, which sets a floor on what caching can recover. There is also a plausible **second-order effect**: Databricks appears to meter its per-minute rate limit on *uncached* input tokens, so the missing cache also cost rate-limit headroom — the opus endpoint lost 63% of its trials to fatal 429s while running alone at one-third of a GPT endpoint's raw throughput. This is a hypothesis, not a proven mechanism (the only zero-cache condition is also the only Anthropic endpoint), but it is the reading that explains the throttling with one rule instead of two. ## Post-fix results (provisional — first trials of an in-flight re-run) On 8 tasks matched by name across the two runs, cost fell **$8.36 → $1.77 (4.71×)** and wall-clock **12,423 s → 1,085 s (11.45×)**. | Metric | before (`4a955a858`) | after (`3bef1f6a`) | |---|---|---| | Cache reads as % of input | **0.0%** | **78.7%** (still climbing toward the ~86% steady state) | | `cost_usd_no_cache_discount / cost_usd` | **1.00×** | **2.18×** (tracking the projected ~2.5×) | | Trials with a fatal 429 (same `-n 20`) | **63%** | **15–19%** | To be clear about attribution: **~2× of that is the clean prefill saving from caching itself**; the rest is second-order — cached requests burn far less rate-limit budget, so they stall less and redo less destroyed work. The 11.45× is a system-level result specific to this throttled workspace, not a caching benchmark. Quality held (7/8 solved in each run). A controlled low-`-n` A/B (neither arm hitting a 429), which the `BUZZ_AGENT_PROMPT_CACHING` opt-out exists to enable, is still owed before this becomes a published claim. ## What changed ### 1. Request caching (`llm.rs`, `config.rs`) `anthropic_body()` emits ephemeral `cache_control` breakpoints, gated by `BUZZ_AGENT_PROMPT_CACHING` (**default on**, `=0` to opt out): - **Static prefix** — marker on the `system` block. Prefix order is `tools → system → messages`, so this single marker caches **tools + system** together. Byte-identical on every turn of a run, and survives a context handoff (system/tools come from cfg/mcp, not `self.history`). - **Rolling tail + leapfrog** — marker on the last block of the last **two** messages. The append-only history re-reads the prior turn's prefix from cache; marking two messages (not one) keeps consecutive breakpoints inside Anthropic's **20-block lookback window** even as tool parallelism rises, avoiding a silent full-price miss. An empty system prompt stays a bare string (Anthropic rejects empty text blocks), and below-threshold prefixes are silently not cached, so the flag is safe on by default. ### 2. Surface the cache split end-to-end — the plumbing (`types.rs`, `llm.rs`, `agent.rs`, `lib.rs`, `usage.rs`, `acp.rs`) This is the part that makes gaps like the one above **visible** instead of silent. A consumer that prices all of `input_tokens` at the full rate can't tell a route that's caching from one that isn't — the total looks right either way. So: - `LlmResponse` gains `cached_input_tokens` (a **subset** of `input_tokens`, never an addition); `parse_anthropic` / `parse_openai` / `parse_responses` each populate it. - A `usage_first()` helper reads the cache count wherever a provider hides it — flat `cache_read_input_tokens` (Anthropic), `prompt_tokens_details.cached_tokens` (OpenAI chat), `input_tokens_details.cached_tokens` (Responses) — taking the **first present value, never a sum**. Reading only flat keys is exactly why the OpenAI route's nested `cached_tokens` had *also* been going unclaimed: `prompt_tokens` is already inclusive, so the total looked correct while the discount silently went unreported. - The per-turn/per-session accumulators and the goose `usage_update` payload now carry `accumulatedCachedInputTokens`; `buzz-acp` deserializes it (`serde` default `0` for goose, which doesn't send it) and logs `cached=<n>`. ### 3. Fix a Databricks MLflow-route double-count (`llm.rs`) The Databricks MLflow route reports the flat Anthropic-spelled `cache_read_input_tokens` *alongside* an already-inclusive `prompt_tokens`, so the old code summed them and nearly doubled the count — inflating both the context-budget gate and cost. `openai_chat_input_tokens()` now reads `prompt_tokens` alone. Verified on a live `databricks-glm-5-2` response where `prompt_tokens + completion == total` proves inclusivity. (Anthropic's native route genuinely *excludes* the cache fields and is still summed — the two never collide, because `claude*` models route to the Anthropic path.) ### 4. Proxy + TLS-trust passthrough into MCP tools (`mcp.rs`) — independent fix `buzz-agent` `env_clear()`s each MCP child, and the allowlist carried no proxy/TLS vars. On a proxy-only host that doesn't degrade the tools, it **blinds** them: apt, curl, pip, git connect directly, the egress firewall resets the socket, and the agent reports "Connection reset by peer" — indistinguishable from a genuinely offline task. Adds both spellings of `HTTP(S)_PROXY`/`NO_PROXY`/`ALL_PROXY` (curl/git read lowercase; Go/Python read uppercase; libcurl ignores uppercase `HTTP_PROXY`) plus `SSL_CERT_FILE`/`SSL_CERT_DIR` for TLS-terminating proxies that present their own CA. ## Testing - `cargo fmt --all -- --check`, `cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent -p buzz-acp` — **all green** (632 + 299 lib tests plus integration suites, 0 failures). New tests cover: the three breakpoints and the disabled/empty-system/single-message edge cases; nested-vs-flat cache parsing for all three routes; the Databricks inclusive-`prompt_tokens` fix; wire deserialization of `accumulatedCachedInputTokens`; and the proxy/TLS passthrough allowlist. - Pre-push lefthook suite green (branch-skew, rust-tests, test, desktop-check/test/tauri). ## Relationship to the benchmark branch These are the non-`benchmarks/` changes from `benchmark/harness-accounting-and-solo`, lifted onto a clean base off `main` so they can merge independently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
or any OpenAI-compat
(vLLM, llama.cpp, OpenRouter,
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 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, 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, OpenRouter, Ollama, etc. |
OPENAI_COMPAT_API |
auto |
auto | chat | responses. auto picks Responses for *.openai.com, Chat Completions everywhere else. |
DATABRICKS_HOST |
— | Required when provider=databricks or provider=databricks_v2. |
DATABRICKS_MODEL |
— | Required when provider=databricks or provider=databricks_v2. |
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 |
32768 |
Per LLM call. Headroom for large tool-call inputs (e.g. file writes via heredoc); Sonnet 4 / Opus 4 cap at 64K. |
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 |
660 |
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. |
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 |
| OpenRouter | openai |
POST {base}/chat/completions |
anything they route |
| Block Gateway | openai |
POST {base}/chat/completions |
gpt-5, claude |
| 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/... |
databricks-gpt-5-5, databricks-claude-opus-4-7 |
If BUZZ_AGENT_PROVIDER=anthropic is selected without ANTHROPIC_API_KEY, or BUZZ_AGENT_PROVIDER=openai is selected without OPENAI_COMPAT_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 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 | 660 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 advertiseloadSession: 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.rsand the helpers intests/regressions.rsspin up a realtokio::net::TcpListeneron port 0, parseContent-Length, and return scripted JSON. No HTTP mocking library. - Fake MCP server —
tests/bin/fake_mcp.rsis 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]inregressions.rsis 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.