## 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>
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.