Summary
The unwrapped-output nudge (#2414, container/agent-runner/src/poll-loop.ts) re-runs the model whenever a turn's final text has no <message to="..."> blocks. But dispatchResultText only counts <message> blocks in the final text — it can't see replies the agent already made via the send_message MCP tool, which runs in the separate MCP server process and writes messages_out directly. So a turn that replies via send_message and ends with bare text reads as sent=0 → hasUnwrapped → nudge query.push → the model re-runs → calls send_message again → the user gets the same reply twice.
Impact observed
- On a tool-heavy deployment (agents frequently reply via
send_message, then end with a bare self-summary), ~7% of turns double-delivered — measured by counting per-message completions over a week of logs. The second run lands seconds after the first and is cache-cheap, so it's easy to miss anecdotally.
- With providers whose models habitually ignore the wrapping protocol (e.g. GLM via an OpenCode-style provider), effectively every such turn duplicates.
Deterministic repro (no model calls)
Drive processQuery with a mock provider whose response factory (1) calls writeMessageOut({kind:'chat', ...}) mid-turn — simulating the send_message subprocess — and (2) returns bare text. Result: the nudge fires, the factory runs a second time, and messages_out ends with duplicate reply rows for one inbound message.
Fix we applied on our fork (happy to upstream a PR)
Delete the nudge re-run entirely and make end-of-turn delivery single-shot:
<message> blocks → dispatch as today;
- else, if nothing was delivered this round → deliver the cleaned bare text once (strip
<internal>, unwrap stray <message> tags) — this also fixes the silent-drop of a genuinely undelivered bare reply;
- else the bare text is scratchpad.
"Delivered this round" = any kind='chat' reply row (text or files; excluding edit/reaction op-rows and channel_type='agent' a2a sends) written to the batch's origin channel_type+platform_id after a messages_out max-seq snapshot taken at round start, re-snapshotted at each round's end. Net-negative LOC; the model is never re-run for wrapping. If you'd prefer keeping the nudge, the minimal fix is gating it on the same delivered-this-round check.
Summary
The unwrapped-output nudge (#2414,
container/agent-runner/src/poll-loop.ts) re-runs the model whenever a turn's final text has no<message to="...">blocks. ButdispatchResultTextonly counts<message>blocks in the final text — it can't see replies the agent already made via thesend_messageMCP tool, which runs in the separate MCP server process and writesmessages_outdirectly. So a turn that replies viasend_messageand ends with bare text reads assent=0→hasUnwrapped→ nudgequery.push→ the model re-runs → callssend_messageagain → the user gets the same reply twice.Impact observed
send_message, then end with a bare self-summary), ~7% of turns double-delivered — measured by counting per-message completions over a week of logs. The second run lands seconds after the first and is cache-cheap, so it's easy to miss anecdotally.Deterministic repro (no model calls)
Drive
processQuerywith a mock provider whose response factory (1) callswriteMessageOut({kind:'chat', ...})mid-turn — simulating thesend_messagesubprocess — and (2) returns bare text. Result: the nudge fires, the factory runs a second time, andmessages_outends with duplicate reply rows for one inbound message.Fix we applied on our fork (happy to upstream a PR)
Delete the nudge re-run entirely and make end-of-turn delivery single-shot:
<message>blocks → dispatch as today;<internal>, unwrap stray<message>tags) — this also fixes the silent-drop of a genuinely undelivered bare reply;"Delivered this round" = any
kind='chat'reply row (text or files; excludingedit/reactionop-rows andchannel_type='agent'a2a sends) written to the batch's originchannel_type+platform_idafter amessages_outmax-seqsnapshot taken at round start, re-snapshotted at each round's end. Net-negative LOC; the model is never re-run for wrapping. If you'd prefer keeping the nudge, the minimal fix is gating it on the same delivered-this-round check.