Skip to content

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

Merged
tlongwell-block merged 4 commits into
mainfrom
dawn/reply-guard
Jul 31, 2026
Merged

feat(agent): optional reply guard reminds a silent turn to publish#3763
tlongwell-block merged 4 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 3 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>
…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>
@micspiral

Copy link
Copy Markdown
Collaborator

testing this now

@micspiral

micspiral commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@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)

@micspiral

Copy link
Copy Markdown
Collaborator

@tlongwell-block this I think is ok - KV does help hopefully, and this seems to be net better, so yeah, lets :shipit: :shipit: :shipit: :shipit: :shipit: :shipit: :shipit: :shipit: :shipit: :shipit:

@tlongwell-block
tlongwell-block merged commit 081f805 into main Jul 31, 2026
32 checks passed
@tlongwell-block
tlongwell-block deleted the dawn/reply-guard branch July 31, 2026 11:02
tlongwell-block added a commit that referenced this pull request Jul 31, 2026
…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>
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…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>
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…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
wpfleger96 pushed a commit that referenced this pull request Jul 31, 2026
…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
joahg added a commit to joahg/buzz-dev-mode that referenced this pull request Jul 31, 2026
…-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
tellaho added a commit that referenced this pull request Jul 31, 2026
…-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>
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.

3 participants