Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions crates/buzz-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,24 +164,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro
| `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. |
| `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. |
| `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. |
| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. |
| `BUZZ_AGENT_REQUIRE_REPLY` | `1` | `0` disables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. |


## Reply Guard

Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop
sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is
about to end without any recognized attempt to post to Buzz gets a reminder that
its assistant text is invisible to humans, and is rerolled.
On by default. A turn that is about to end without any recognized attempt to
post to Buzz gets a reminder that its assistant text is invisible to humans,
and is rerolled.

This exists because a Buzz agent's reasoning and tool output are not shown to
anyone. A turn that does real work and never posts is a silent failure — the
requester waits on a result that was produced and thrown away.

Mesh agents get it by default because they run on small local models, which are
the ones most likely to do the work and then end the turn without publishing it.
Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a
mesh agent back out; the default never overrides an explicit value.
Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts
out. Buzz shared-compute (mesh) agents also always get it via Desktop's
`insert_default_if_unset`, which never overrides an explicit value — so an
agent, persona, or global `BUZZ_AGENT_REQUIRE_REPLY=0` still opts a mesh agent
out even though the binary's own default now agrees with it.

**Advisory, never a trap.** At most two reminders, then the turn ends whether or
not anything was published. The guard catches accidental omission; it does not
Expand Down
6 changes: 3 additions & 3 deletions crates/buzz-agent/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -818,8 +818,8 @@ pub struct Config {
/// disable `_Stop` hooks entirely (agent always honors end_turn).
pub stop_max_rejections: u32,
/// Remind the model to publish when a turn is about to end without any
/// recognized attempt to post to Buzz. Default off; opt in per agent with
/// `BUZZ_AGENT_REQUIRE_REPLY=1`.
/// recognized attempt to post to Buzz. Default on; opt out per agent with
/// `BUZZ_AGENT_REQUIRE_REPLY=0`.
///
/// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`),
/// then the turn ends regardless. Bounded by the same
Expand Down Expand Up @@ -958,7 +958,7 @@ impl Config {
max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?,
hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?),
stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?,
require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0,
require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 1u8)? != 0,
hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"),
hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0,
thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?,
Expand Down
22 changes: 13 additions & 9 deletions crates/buzz-agent/tests/regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1918,23 +1918,27 @@ async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value {
}
}

/// Default off: a silent turn ends on the first end_turn with no extra round.
/// This is the invariant that keeps the feature free for everyone who hasn't
/// opted in.
/// Default ON: a turn that ends without publishing is reminded once before it
/// is allowed to end. This is the inverse of the invariant this test asserted
/// while the guard was opt-in, and it is the contract that makes the guard
/// reach runs Desktop's mesh launcher never touches — manual runs, dev runs,
/// and non-Desktop harnesses.
///
/// The reminder is bounded (`MAX_REPLY_NAGS`) and explicitly licenses silence,
/// so this costs a silent turn one extra round, not a forced reply.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn reply_guard_off_by_default() {
let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await;
async fn reply_guard_on_by_default() {
let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("still done")]).await;
let mut h = Harness::spawn(&llm.url).await;
let sid = init_session(&mut h, json!([])).await;

let r = prompt_to_completion(&mut h, &sid).await;
assert_eq!(r["result"]["stopReason"], "end_turn");

let captured = llm.captured.lock().await;
assert_eq!(
captured.len(),
1,
"guard must be inert when unset, got {} LLM calls",
assert!(
captured.len() > 1,
"guard must nag an unpublished turn when unset, got {} LLM call(s)",
captured.len()
);
h.shutdown().await;
Expand Down
9 changes: 6 additions & 3 deletions desktop/src-tauri/src/managed_agents/relay_mesh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,12 @@ pub fn apply_relay_mesh_env(
// may deliberately choose a smaller cap or a different effort. This function
// runs after those layers during readiness, so never clobber their values.
insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096");
// Mesh agents run on small local models, which are the ones most likely to
// do the work and then end the turn without publishing it — the failure the
// reply guard exists to catch. Everywhere else it stays opt-in and unset.
// Redundant with the buzz-agent binary's own default, which is now on, and
// kept deliberately. Mesh agents run on small local models — the ones most
// likely to do the work and then end the turn without publishing it, which
// is the failure the reply guard exists to catch — so this line states the
// requirement at the layer that knows about it, rather than inheriting it
// from a default another crate could reasonably revisit.
// A default, not policy: an explicit `0` from the agent/persona/global env
// survives (see `insert_default_if_unset`, and the copy-forward list in
// `relay_mesh_process_env` that preserves it through the spawn path).
Expand Down
6 changes: 3 additions & 3 deletions docs/MCP_DRIVEN_HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ Hooks are **off by default**. The operator must explicitly opt in via
### 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
MCP hook and exposes no hook tool: the reply guard, which reminds the model to
publish when a turn is about to end with nothing posted to Buzz. It is on by
default; `BUZZ_AGENT_REQUIRE_REPLY=0` opts out. 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](../crates/buzz-agent/README.md#reply-guard).
Expand Down