fix(acp): deliver plain-text replies when the model skips the send tool - #2681
fix(acp): deliver plain-text replies when the model skips the send tool#2681micspiral wants to merge 2 commits into
Conversation
buzz-agent's output is its tool calls; streamed assistant content is observability-only and normally never posted. Capable models reliably call `buzz messages send`, but weaker local models (e.g. via Buzz shared compute) often answer a conversational prompt in plain content and never call the send tool, silently dropping the reply. Add a content-delivery fallback in buzz-acp: track per-turn whether the model published its own message and buffer streamed content; on a normal turn end with content but no publish tool call, post that content as a threaded kind-9 reply (reusing buzz_sdk::build_message, signed with the agent keys, best-effort, mirroring post_failure_notice). Skips bare acknowledgements the base prompt forbids publishing. Live-validated against a small Gemma on a real community: the fallback fires when the model answers in prose and stays dormant when it calls the send tool. Unit tests cover send-tool detection (incl. read-vs-send discrimination) and bare-ack filtering. (cherry picked from commit 1fe074b)
Review findings from #2448 (Wren + Eva): the content-delivery fallback inferred delivery from the send tool call's INPUT, which was wrong in both directions: - False negative (silent drop): turn_sent_message was set the moment a tool_call's rawInput matched 'messages send' — intent, not delivery. A send that subsequently failed (relay/network/auth/CLI error) still suppressed the fallback, dropping the exact reply the feature exists to save. - False positive (double post): the substring match missed argv-style publishes (subprocess.run(['buzz','messages','send',...])), so a capable model that narrates between tool calls and publishes via argv would get its whole turn narration re-posted by the fallback. Fix: registering a publish is now two-phase. - tool_call input matching (normalized so shell strings AND argv forms match) only registers a CANDIDATE toolCallId. - Delivery is confirmed solely from the candidate's terminal tool_call_update outcome: failed/cancelled → not delivered (fallback stays armed); completed → inspect rawOutput.isError, the CLI's {"accepted":...} response envelope, and any reported exit_code; a bare completed with no inspectable output keeps the status-quo direction (suppress, i.e. today's behavior). Tests: outcome classifier matrix (pending/failed/cancelled/completed x envelope/isError/exit_code) plus handler-level lifecycle tests through handle_session_update + take_undelivered_turn_message covering success, failure, cancel, failed-then-retry, and argv-style publish. Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> (cherry picked from commit e992ae7)
|
Additional live validation: I tested this PR unchanged with OpenClaw 2026.6.1 acting as a plain-text ACP server behind Path: Buzz mention → OpenClaw emitted standard This confirms the fallback covers an ACP bridge where giving the downstream agent the Buzz signing key or a general-purpose shell/MCP solely for reply delivery would be undesirable. |
|
I am going to close this as we have had a few runs at other ways of this - but if this comes back, we will bring it back |
What
Adds a content-delivery fallback to
buzz-acp: when a managed agent answers aconversational prompt in plain assistant text and never calls the publish tool
(
buzz messages send), the harness posts that streamed content as a threadedkind-9 reply — instead of silently dropping it.
Capable hosted models reliably publish via the send tool, but weaker local /
shared-compute models (e.g. a small Gemma/Qwen) often just answer in prose and
never call the tool, so the reply is lost. This closes that gap.
How
Delivery is confirmed from the send tool call's terminal outcome, not its
input text — intent is not delivery:
tool_callwhose input matches the publish signature (normalized so bothshell strings and argv forms like
subprocess.run(['buzz','messages','send',…])match) only registers a candidate
toolCallId.tool_call_update:failed/cancelled→ not delivered (fallback stays armed).completed→ inspectrawOutput.isError, the CLI's{"accepted":…}response envelope, and any reported
exit_code. A barecompletedwith noinspectable output keeps the status-quo direction (suppress).
EndTurnwith buffered content but no confirmed publish, post thecontent as a threaded reply (
buzz_sdk::build_message, signed with the agentkeys, best-effort — mirrors
post_failure_notice). Bare acknowledgements thebase prompt forbids publishing are skipped.
Fires only on
EndTurn(notMaxTokens/MaxTurnRequests, which aretruncated/aborted turns) and only when a triggering channel event was present
(not heartbeats).
Testing
publishes; the outcome classifier matrix
(pending/failed/cancelled/completed × envelope/isError/exit_code); bare-ack
filtering.
handle_session_update+take_undelivered_turn_message: success, failure, cancel, failed-then-retry,and argv-style publish.
community: fires on prose answers, stays dormant when the model calls the
send tool.
Notes
Split out of #2448 so this reply-delivery fix stands on its own, independent of
that PR's shared-compute usability changes.
+710 / crates/buzz-acponly — nodesktop / mesh / toggle changes.