feat(agent): optional reply guard reminds a silent turn to publish - #3763
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>
…ents" This reverts commit d288723, restoring the mesh auto-enable. The intent is narrower than either previous state: mesh agents get BUZZ_AGENT_REQUIRE_REPLY=1 injected by Desktop, and every non-mesh agent stays unset and opt-in. The revert read "never fires by default" as a global prohibition and turned the mesh default off as well. Both halves of the default come back, because either alone is wrong. `relay_mesh_process_env` builds a fresh empty map that the spawn path writes onto the command *after* the layered user env, so without the copy-forward entry an explicit `BUZZ_AGENT_REQUIRE_REPLY=0` is silently re-defaulted to `1` at spawn while readiness still reports `0`. Drops the revert's `reply_guard_is_never_enabled_implicitly` rather than narrowing it to non-mesh providers: narrowed, its surviving assertion duplicates `non_mesh_provider_leaves_reply_guard_unset`, which already pins that a non-mesh provider receives no mesh env at all. The opt-out invariant it protected is unchanged and still covered by both `preserves_explicit_reply_guard_opt_out` tests -- an explicit `0` is never overridden, mesh included. 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>
|
testing this now |
|
@tlongwell-block sort of works, but really isn't great for latency - as if it retries a lot, hits the model hard (KV should be warm so that is good at least) but isn't a great experience when it fails (seems to go from seconds to a minute some times if things go bad). Testing more still... ie a real prompt with agent can be 1.2s reply on a good day, blows out to 30s/minute if it fails to reply, the downside is those retries become a DOS on models depending on where they are hosted, so not sure this is a good approach for either mesh/local or even (espeially) hosted where pay for token. Will keep digging as there is something good in here tho as it does work a bit. Is the idea to avoid an additional tool for all models as well? (ie use the shell one - which will argually get better over time so I agree with it) |
|
@tlongwell-block this I think is ok - KV does help hopefully, and this seems to be net better, so yeah, lets |
…cache * origin/main: feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
…evert-fix * origin/main: fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…chive * origin/main: (25 commits) feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) feat(desktop): delete a message by clearing its edit to empty (#3813) feat(relay): raise hosted community limit to five (#3829) feat(desktop): locally stored NIP-49 encrypted key backup (#2937) fix(catalog): update Amp tagline (#3806) fix(desktop): channel topic and membership metadata cleanup (#3642) fix(desktop): align data deletion labels (#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) fix(desktop): allow linux-only media items as dead code off-linux (#3811) fix(desktop): report authenticated relay recovery (#3812) fix(desktop): don't gate hover affordances on the hover media query (#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) test(desktop): click visible thread collapse guide (#3800) feat(desktop): raise the install ceiling and make installs observable (#3368) ... Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com> # Conflicts: # desktop/src/testing/e2eBridge.ts # desktop/tests/helpers/bridge.ts
…-style * origin/main: (22 commits) feat(desktop): import local Pocket voices (block#3259) fix(desktop): open profiles from avatars (block#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands block#2467 + block#3208) (block#3910) docs: add VISION_REMOTE_AGENTS.md (block#3924) feat(desktop): auto-enable huddle transcription for agents (block#3180) feat(agent): optional reply guard reminds a silent turn to publish (block#3763) feat(desktop): upgrade Pocket TTS model (block#3266) feat(desktop): delete a message by clearing its edit to empty (block#3813) feat(relay): raise hosted community limit to five (block#3829) feat(desktop): locally stored NIP-49 encrypted key backup (block#2937) fix(catalog): update Amp tagline (block#3806) fix(desktop): channel topic and membership metadata cleanup (block#3642) fix(desktop): align data deletion labels (block#2230) fix(relay): align NIP-11 max_limit with REQ ceiling (block#3635) fix(desktop): allow linux-only media items as dead code off-linux (block#3811) fix(desktop): report authenticated relay recovery (block#3812) fix(desktop): don't gate hover affordances on the hover media query (block#3657) feat(relay): gate kind 30178 team-catalog reads behind the shared tag (block#3358) test(desktop): click visible thread collapse guide (block#3800) feat(desktop): raise the install ceiling and make installs observable (block#3368) ... Amp-Thread-ID: https://ampcode.com/threads/T-019fb8e1-6ece-72a7-8808-9b12e0f7e833 Co-authored-by: Amp <amp@ampcode.com> Signed-off-by: Joah Gerstenberg <joah@squareup.com> # Conflicts: # desktop/src-tauri/src/linux_media.rs # desktop/src/app/AppShell.tsx
…-setting * origin/main: feat(desktop): import local Pocket voices (#3259) fix(desktop): open profiles from avatars (#3751) refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) (#3910) docs: add VISION_REMOTE_AGENTS.md (#3924) feat(desktop): auto-enable huddle transcription for agents (#3180) feat(agent): optional reply guard reminds a silent turn to publish (#3763) feat(desktop): upgrade Pocket TTS model (#3266) Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
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.