Files
buzz/docs/MCP_DRIVEN_HOOKS.md
081f805d5e feat(agent): optional reply guard reminds a silent turn to publish (#3763)
## Why

A Buzz agent's assistant text and reasoning are never shown to anyone —
only what it posts through the CLI. A turn that runs fifteen tool calls
and never publishes is a silent failure: the requester waits on a result
that was produced and thrown away.

This adds an optional reminder at the end-of-turn gate, off by default.

Tyler asked for it in buzz-mesh; plan iterated to **9.5/10 with @Wren**
(Minimalness 9.7, Elegance 9.5, Correctness 9.3).

## What

`BUZZ_AGENT_REQUIRE_REPLY=1` (default off, per-agent opt-in). A turn
about to end with no recognized attempt to post gets a reminder and is
rerolled. **At most two, then the turn ends regardless** — the guard
catches accidental omission, it does not compel speech. The reminder
text explicitly licenses silence so it cannot fight the base prompt's
"silence is usually correct."

**This is not a new MCP hook.** `RunCtx::run` *is* the turn, so the two
per-turn locals need no plumbing, and every tool call already passes
through it with arguments visible. The objection is appended at the
existing `_Stop` gate and rides `push_hook_outputs_as_tool_results`, so
the model receives it as a lower-trust tool result with `{hook, server,
text}` attribution. No new trust path, no new lifecycle event, no
dev-mcp or CLI protocol change.

Earlier revisions of this plan needed four crates (a `_UserPromptSubmit`
hook, a marker file, a `buzz-cli` change, dev-mcp state). Tyler pointed
out the agent already knows both facts; that deleted all of it. Net
runtime change is ~35 lines in `agent.rs` + ~4 in `config.rs`.

### Recognition contract

A registered non-hook tool whose qualified name ends in `__shell`, whose
`command` argument contains `messages send` or `reactions add`.

- **The `__` separator is exact, not approximate.** Given `has()` +
`!is_hook()`, `ends_with("__shell")` is *provably equivalent* to a bare
name of `shell`: registration forbids `__` in server and bare names
(`mcp.rs:227,268`) and qnames are `{server}__{bare}`, so a trailing
`__shell` could only straddle the separator if the bare name began with
`_` — which `is_hook` excludes. Without the separator, `powershell` and
`noshell` would match.
- **Reads the structured `command` field**, not serialized arguments, so
a `description` that quotes a send cannot disarm the guard, and a
non-string `command` is rejected rather than coerced.
- **Detects an attempt, not a successful publish.** A failed send
already returns non-zero exit and error JSON — louder than this
reminder. The variable is named `buzz_reply_call_seen` so the code can't
pretend otherwise.
- **Checked after the per-turn tool-call cap**, since a discarded call
never ran.
- `messages send` also covers `messages send-diff`. Reactions count
because the base prompt directs agents to react rather than post a bare
acknowledgement.

**Known limits, both deliberate and documented:** a command assembled at
runtime (`$CMD`) or hidden in a wrapper script is missed; text that
merely quotes a send (`echo "buzz messages send"`) matches. Missing a
real post is the expensive direction and substring matching is the
forgiving one there. Neither edge is pinned by a test, so the matcher
stays free to improve.

### Budget

Reminders share `BUZZ_AGENT_STOP_MAX_REJECTIONS`, the existing outer cap
on every end-turn objection. Default 3 fits both; at 1 only one fits; at
0 the guard is off with the hooks. A round carrying both a hook
objection and a reminder costs one rejection and delivers both texts. An
independent budget would either violate that bound or need a second
arbitration rule.

## Prior art

- **#3467** (closed) built the same detector one layer up in `buzz-acp`
for a different remedy. None of its symbols are on main — this borrows
its permission to be coarse, but reads structured data that ACP didn't
have.
- **#3648** (open) detects turns with *no output at all*; a turn with
fifteen tool calls and no post counts as output there, so it does not
cover this case.
- **#3741** (merged) is mesh-only.

## Testing

**14 new tests.** 4 unit tests on the matcher; 10 integration tests
through the ACP wire harness: off by default, `=0` still off, opted-in
silent → exactly 2 reminders then `end_turn`, registered `fake__shell`
send → 0 reminders, hallucinated `fake__shell` → still reminded, publish
call truncated past the 64-call cap → still reminded, budget 1 → 1
reminder, budget 0 → off, combined `_Stop` hook objection + reminder →
one round both texts and after 2 reminders the hook objection continues
alone, unparseable `=true` → startup error naming the key.

**10 mutation checks, each breaking a specific named test** — neutralize
the nag cap, stop sharing the budget, neutralize `buzz_reply_call_seen`,
drop `has`/`is_hook`, ignore the flag, drop the `__`, drop `reactions
add`, read serialized args, move detection before truncation.

`tests/bin/fake_mcp.rs` gains `FAKE_MCP_SHELL_TOOL=1`: it previously
exposed no tool with a bare name of `shell`, so the satisfied-guard path
was untestable.

Full `cargo test -p buzz-agent` green at 9e0ae1f04; clippy `-D warnings`
and `cargo fmt --check` clean.

**Unrelated flake found:**
`cancelled_turn_with_usage_emits_notification_before_response`
(`tests/fake_llm.rs`) is timing-sensitive. Under 10 loaded cores it
fails **2/20 on this branch and 1/20 at unmodified
`origin/main@02be413b8`** — pre-existing, not caused by this change
(which is inert without the env var). Flagging so it isn't misattributed
to the next PR that's open when CI hits it.

## Docs

`crates/buzz-agent/README.md` is the primary home (env var, recognition
contract, limits, budget interaction). `docs/MCP_DRIVEN_HOOKS.md` gets a
short cross-reference explaining this is *not* a hook — otherwise
readers hunt for a `_ReplyGuard` tool that doesn't exist.

---------

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
2026-07-31 07:02:48 -04:00

4.7 KiB

MCP-Driven Lifecycle Hooks

Overview

Buzz-agent supports lifecycle hooks — MCP tools that the agent calls at defined points in its execution loop. Any MCP server can participate by exposing tools with the _ prefix. Hooks are invisible to the LLM, advisory to the agent, and operator-configured.

This convention requires zero MCP protocol changes. Hooks are regular tools discovered via tools/list and invoked via tools/call.

Convention

  • Tools whose bare name starts with _ are lifecycle hooks
  • Hooks are filtered from the tool list sent to the LLM
  • Hooks are rejected if the LLM attempts to call them directly
  • Hooks are called by the agent at defined lifecycle points
  • Hook responses are injected as tool-result messages (lower trust than system)
  • Hook output is JSON-encoded for prompt-injection safety

Defined Hooks

_Stop

When: The LLM signals end_turn, before the agent honors it.

Input: {}

Output: Non-empty text = objection (agent continues). Empty = no objection (agent stops).

Use case: Todo enforcement — object when open tasks remain.

_PostCompact

When: After context compaction/handoff, before the next LLM prompt.

Input: {}

Output: Non-empty text = injected into fresh context. Empty = nothing injected.

Use case: Re-inject todo list state after history is summarized and reset.

Agent Sovereignty

Hooks are advisory, not authoritative. The agent enforces:

Constraint Behavior
Timeout (2.5s default) Treated as no objection. Server killed only on second consecutive timeout (tolerates one-off slowness)
Rejection budget (3/prompt) After exhaustion, agent stops regardless; the budget resets on the next prompt

These constraints ensure a buggy or malicious hook cannot trap the agent.

Configuration

Env Var Default Description
MCP_HOOK_SERVERS (unset = no hooks) Allowlist: * for all servers, or comma-separated names
BUZZ_AGENT_HOOK_TIMEOUT_MS 2500 Per-hook call timeout in milliseconds
BUZZ_AGENT_STOP_MAX_REJECTIONS 3 Per-prompt _Stop budget (0 = disable)

Hooks are off by default. The operator must explicitly opt in via MCP_HOOK_SERVERS.

Not a hook: the reply guard

buzz-agent has one in-process objection at the _Stop gate that is not an MCP hook and exposes no hook tool: the reply guard (BUZZ_AGENT_REQUIRE_REPLY=1), which reminds the model to publish when a turn is about to end with nothing posted to Buzz. There is no _ReplyGuard tool to implement and no server to allowlist — the env var and the recognition contract are documented in crates/buzz-agent/README.md.

It is mentioned here only because it shares this lifecycle point and this budget: its reminders count against BUZZ_AGENT_STOP_MAX_REJECTIONS like any hook objection, and a round carrying both a hook objection and a reminder costs one rejection and delivers both texts. Setting the budget to 0 disables both. That the gate can carry in-process objections alongside hook output is deliberate; hooks see no difference.

Implementing a Hook

Any MCP server can expose hooks. Example: a test-runner server that blocks end_turn while tests are failing:

{
  "name": "_Stop",
  "description": "Returns failing test summary if suite is red.",
  "inputSchema": { "type": "object" }
}

The server returns non-empty text to object, empty string to allow stopping.

Compatibility

Hook naming is aligned with the Open Plugin Spec event conventions. _Stop corresponds to the Stop event; _PostCompact corresponds to PostCompact.

MCP_HOOK_SERVERS is a standard env var name intended for cross-agent adoption.

Future Work

Additional hook points may be added to support the fuller Open Plugin Spec event set:

Open Plugin Event Potential Hook Status
Stop _Stop Implemented
PostCompact _PostCompact Implemented
PreToolUse _PreToolUse Deferred (overlaps with MCP Interceptors SEP-2624)
PostToolUse _PostToolUse Deferred (overlaps with MCP Interceptors SEP-2624)
SessionStart _SessionStart Candidate for future revision
SessionEnd _SessionEnd Candidate for future revision
UserPromptSubmit _UserPromptSubmit Candidate for future revision
SubagentStart _SubagentStart Candidate for future revision

Pre/post tool-call hooks are deferred pending coordination with the MCP Interceptors working group (SEP-2624), which addresses similar concerns at the protocol layer. The remaining events will be added as use cases emerge.