feat(agent): optional reply guard reminds a silent turn to publish - #3763
Open
tlongwell-block wants to merge 3 commits into
Open
feat(agent): optional reply guard reminds a silent turn to publish#3763tlongwell-block wants to merge 3 commits into
tlongwell-block wants to merge 3 commits into
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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::runis 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_Stopgate and ridespush_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
_UserPromptSubmithook, a marker file, abuzz-clichange, dev-mcp state). Tyler pointed out the agent already knows both facts; that deleted all of it. Net runtime change is ~35 lines inagent.rs+ ~4 inconfig.rs.Recognition contract
A registered non-hook tool whose qualified name ends in
__shell, whosecommandargument containsmessages sendorreactions add.__separator is exact, not approximate. Givenhas()+!is_hook(),ends_with("__shell")is provably equivalent to a bare name ofshell: registration forbids__in server and bare names (mcp.rs:227,268) and qnames are{server}__{bare}, so a trailing__shellcould only straddle the separator if the bare name began with_— whichis_hookexcludes. Without the separator,powershellandnoshellwould match.commandfield, not serialized arguments, so adescriptionthat quotes a send cannot disarm the guard, and a non-stringcommandis rejected rather than coerced.buzz_reply_call_seenso the code can't pretend otherwise.messages sendalso coversmessages 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
buzz-acpfor 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.Testing
14 new tests. 4 unit tests on the matcher; 10 integration tests through the ACP wire harness: off by default,
=0still off, opted-in silent → exactly 2 reminders thenend_turn, registeredfake__shellsend → 0 reminders, hallucinatedfake__shell→ still reminded, publish call truncated past the 64-call cap → still reminded, budget 1 → 1 reminder, budget 0 → off, combined_Stophook 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, drophas/is_hook, ignore the flag, drop the__, dropreactions add, read serialized args, move detection before truncation.tests/bin/fake_mcp.rsgainsFAKE_MCP_SHELL_TOOL=1: it previously exposed no tool with a bare name ofshell, so the satisfied-guard path was untestable.Full
cargo test -p buzz-agentgreen at 9e0ae1f; clippy-D warningsandcargo fmt --checkclean.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 unmodifiedorigin/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.mdis the primary home (env var, recognition contract, limits, budget interaction).docs/MCP_DRIVEN_HOOKS.mdgets a short cross-reference explaining this is not a hook — otherwise readers hunt for a_ReplyGuardtool that doesn't exist.