Skip to content

Re-wrap nudge re-runs the model and duplicates replies when the agent already replied via send_message #3026

Description

@fjnoyp

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=0hasUnwrapped → nudge query.pushthe 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions