Files
buzz/crates/buzz-agent
18eef633d8 feat(git): use agent display name as git author name (#3040)
Agent commits were authored by a raw 63-character npub, which makes `git
log`, `git blame`, and GitHub's author column effectively unreadable.
This uses the agent's display name for `user.name` instead, while
leaving the pubkey where it does real work.

## What changes

`build_git_env` in `crates/buzz-dev-mcp/src/shim.rs` now reads
`BUZZ_ACP_DISPLAY_NAME`, sanitizes it, and uses the result as
`user.name`. When the variable is absent or unusable it falls back to
`info.npub` — byte-identical to today's behavior.

`user.email`, `user.signingkey`, and the whole credential/signing block
are untouched. The pubkey is what NIP-98 auth, NIP-GS signing, and
contributor matching key on, and it stays in the email verbatim.

`crates/buzz-acp/src/lib.rs` forwards the variable into the dev-mcp
server's declared env, mirroring the existing `BUZZ_AUTH_TAG` block. It
reads `std::env::var` directly rather than going through `Config`, so
the variable is picked up whenever the process has it.
`crates/buzz-agent/src/mcp.rs` adds one `PASSTHROUGH_ENV` entry so ACP
clients that spawn `buzz-agent` without declaring the variable on the
wire still propagate it.

## Why a dedicated variable

`BUZZ_ACP_DISPLAY_NAME` is its own contract rather than a reuse of the
ACP session title. Commits outlive sessions: a session title is
per-session UI chrome and may be composed downstream into `Agent ·
#channel`, and if that composed form ever reached the env var, git
attribution would change silently with no test able to catch it. Git
identity gets a variable whose contract is "bare agent display name,
never channel-qualified."

Nothing writes it yet — a one-line Desktop write lands as a follow-up.
Until then `std::env::var` returns `Err`, the npub fallback fires, and
behavior is byte-for-byte current `main`.

## Sanitizing

Strip control characters, Unicode format characters, and angle brackets;
collapse whitespace runs, trim, cap at 80 characters (by `chars()`, so a
multi-byte name is never split mid-UTF-8).

Angle brackets go because git drops them silently rather than erroring:
`Duncan <evil@x.com>` renders as `Duncan evil@x.com <hex@relay>`. It
forges nothing, but it reads as though it might.

The empty result also has to cover more than literal emptiness. git's
`ident.c` treats a set of characters as "crud" — stripped from both
ends, and fatal when a name is *nothing but* those characters:

```
$ git -c user.name=';;' commit -m t
fatal: name consists only of disallowed characters: ;;
```

Verified against git 2.54.0 by committing with each ASCII byte 32..=126
as the entire `user.name`: exactly space, `"`, `'`, `,`, `:`, `;`, `<`,
`>`, `\` abort, plus all control characters (the predicate is `c <=
32`). `.` is not crud in this version, despite older lore. Names that
merely *contain* crud are fine — `O'Brien` and `Smith, Jr.` both commit
cleanly — so the check is "at least one non-crud character survives,"
not "no crud present." Without it, a display name of `;;` or `""` would
abort every commit that agent makes.

## Unicode format characters

`char::is_control` covers only category `Cc`. Category `Cf` — zero-width
spaces and joiners, bidi embedding and override marks, invisible math
operators, tag characters — is neither control, nor whitespace, nor git
crud, so those characters survived every one of the checks above. A
display name of nothing but U+200B ZERO WIDTH SPACE therefore satisfied
"at least one non-crud character survives" and git accepted the commit
with a visually blank author:

```
# pre-fix, BUZZ_ACP_DISPLAY_NAME set to two U+200B
$ git log -1 --format='%an' | xxd -p
e2808be2808b0a
```

Embedded marks were the other half: a trailing U+202E RIGHT-TO-LEFT
OVERRIDE reorders everything after it, so a stored author line renders
as something other than what it stores — the same confusion class the
angle-bracket filtering exists to prevent.

`is_unicode_format` rejects the whole `Cf` category rather than the
known-bad marks, because the boundary that matters is "invisible or
reorders text", not "the codepoint someone thought of". The 21 ranges
come from the UCD's `DerivedGeneralCategory.txt` (17.0.0), cross-checked
against Python's `unicodedata` (16.0.0); both yield exactly the same
set. They are inlined as a `matches!` rather than pulling in a
Unicode-tables crate for one predicate, and a test asserts both
endpoints of every range plus the codepoints immediately outside them —
including U+2065, which sits inside the U+2060 block but is unassigned
rather than `Cf`.

Filtering happens inside the existing per-word filter, so a format-only
name collapses to empty and falls out through the same `None` → npub
path as a crud-only name. No new fallback logic. And because filtering
precedes truncation, invisible padding cannot eat the 80-character
budget.

## NUL is handled one layer up

An interior NUL is a sibling constraint that cannot be fixed here: it
makes `Command::env` fail the entire spawn before this code runs, so it
has to die at the writer. #3028 establishes that pattern for the session
title in `resolve_session_title` via `filter(|c| !c.is_control())`, and
the Desktop follow-up that writes `BUZZ_ACP_DISPLAY_NAME` inherits it.
The shim sanitizer is a second line of defense for values that arrive
from somewhere other than Desktop.

## Verified end to end

Driving the real `buzz-dev-mcp` binary over stdio MCP and committing
inside its shimmed environment:

```
# BUZZ_ACP_DISPLAY_NAME="Duncan Idaho"
Duncan Idaho <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME unset
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME=";;"  (crud-only; would otherwise be fatal)
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME=U+200B U+200B  (format-only; would otherwise be blank)
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME="Duncan" + U+202E  (bidi override stripped)
Duncan <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0

# BUZZ_ACP_DISPLAY_NAME="Dun" + U+200B + "can"  (zero-width removed, word not split)
Duncan <dcfd242e...0f95@buzz.block.builderlab.xyz>
verify_exit=0
```

Signature verification passes in every case — the signing identity is
unchanged.

`Related: #3028` — it establishes the Desktop-side env plumbing this
builds beside; the one-line Desktop follow-up that writes
`BUZZ_ACP_DISPLAY_NAME` alongside the session title ships after it
merges. Not a dependency: with the variable absent, `std::env::var`
returns `Err` and the npub fallback keeps current behavior exactly.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-07-27 12:28:32 -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
                                   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 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 LLMtests/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 servertests/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.