Skip to content

feat(agent): optional reply guard reminds a silent turn to publish - #3763

Open
tlongwell-block wants to merge 3 commits into
mainfrom
dawn/reply-guard
Open

feat(agent): optional reply guard reminds a silent turn to publish#3763
tlongwell-block wants to merge 3 commits into
mainfrom
dawn/reply-guard

Conversation

@tlongwell-block

Copy link
Copy Markdown
Collaborator

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

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 9e0ae1f; 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.

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.

With BUZZ_AGENT_REQUIRE_REPLY=1 (default off, per-agent opt-in), a turn
about to end without a 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 its 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.

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

Recognition is deliberately coarse: a registered non-hook tool whose
qualified name ends in `__shell`, whose `command` argument contains
`messages send` or `reactions add`. Given the registry checks, that
suffix is exactly equivalent to a bare name of `shell` — registration
forbids `__` in server and bare names, so a trailing `__shell` could
only straddle the separator if the bare name began with `_`, which
`is_hook` excludes. It reads the structured `command` field rather than
serialized arguments, so a `description` that quotes a send cannot
disarm the guard. It recognizes an *attempt*, not a successful publish:
a failed send already returns non-zero exit and error JSON, which is
louder than this reminder. Detection runs after the per-turn tool-call
cap, since a discarded call never ran.

Adds FAKE_MCP_SHELL_TOOL=1 to the test MCP server: it previously exposed
no tool with a bare name of `shell`, so the satisfied-guard path was
untestable.

Plan reviewed to 9.5/10 with Wren; each guard behavior is pinned by a
mutation that breaks a specific named test.

Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
@tlongwell-block
tlongwell-block requested a review from a team as a code owner July 30, 2026 16:38
tlongwell-block and others added 2 commits July 30, 2026 14:30
Mesh agents run on small local models, which are the ones most likely to
do real work and then end the turn without publishing it -- the failure
BUZZ_AGENT_REQUIRE_REPLY exists to catch. Default it to 1 when the
provider resolves to relay-mesh; everywhere else it stays opt-in.

`apply_relay_mesh_env` is the single definition of "this is a mesh agent"
and already has `insert_default_if_unset` for exactly this "mesh default,
user may override" shape, so the default costs one line there.

The copy-forward entry in `relay_mesh_process_env` is the load-bearing
half. That map starts empty and is written onto the command *after* the
layered user env, so without it `apply_relay_mesh_env` re-defaults the key
and an explicit BUZZ_AGENT_REQUIRE_REPLY=0 is silently overridden back to
1 at spawn while readiness still reports 0. Same reason
BUZZ_AGENT_MAX_OUTPUT_TOKENS is already in that list.

5 tests: mesh defaults on, explicit 0 survives both the readiness and the
spawn path, non-mesh providers get nothing. Each verified to fail against
a targeted mutant (drop the default; make the insert unconditional; drop
the copy-forward).

Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
…nts"

This reverts commit 48f3068.

Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant