Skip to content

[Bug]: Heartbeat dispatch delivers free text block alongside message-tool call (chatty non-Codex providers, v2026.5.18) #84217

Description

@fanispoulinakisai-boop

Summary

On OpenClaw 2026.5.18, when an agent's heartbeat tick runs on a non-Codex provider (verified on zai/glm-5.1 via opencode-go route and xiaomi/mimo-v2.5), and the model emits both a free text block AND a structured toolCall: message in the same assistant turn, OpenClaw delivers BOTH to the channel as separate Telegram messages instead of just the structured tool call's content. The result is 3-4 Telegram messages per heartbeat tick.

Setting messages.visibleReplies: "message_tool" at the top level does NOT prevent this — that setting only affects prompt shaping and tool allowlisting (pi-tools.ts:557-564), it does not suppress free-text delivery from the heartbeat-runner's dispatch path.

Minimum reproduction

  1. Configure an agent with a non-Codex primary chat model (zai/glm-5.1, xiaomi/mimo-v2.5, etc.).
  2. Set agents.list[<id>].heartbeat = { every: "30m", target: "telegram", to: "<chatId>", accountId: "default", directPolicy: "allow" }.
  3. Optionally set messages.visibleReplies: "message_tool" (doesn't help, see below).
  4. Provide a HEARTBEAT.md workspace file with active-content prompts (e.g., morning recap instructions).
  5. Wait for a heartbeat tick during an active check-in window where the model decides to send content.

Result: model emits a text block describing what it's about to do + a message(action=send, channel=telegram, target=<chatId>, message="...") toolCall in the same turn. Telegram receives 2-3 messages (the text block delivered separately, plus the polished message tool content).

Concrete observed evidence (this repro happened in production)

Session JSONL (agent:main:main at timestamp 2026-05-19T07:27:42.746Z):

{
  "role": "assistant",
  "provider": "opencode-go",
  "model": "glm-5.1",
  "content": [
    {"type": "text", "text": "It's 10:26 AM Athens — morning check-in window. Fanis hasn't messaged today yet... Cron health shows all clear. ... Nothing urgent. Sending morningcheck-in."},
    {"type": "toolCall", "name": "message", "arguments": {"action": "send", "channel": "telegram", "target": "...", "message": "Morning. Tuesday schedule:\n• 11:00 — Scout builds Daily Intel\n..."}}
  ]
}

Gateway log:

10:27:43.270 [telegram] outbound send ok messageId=8698 operation=sendMessage  ← polished message tool content
10:27:59.509 [telegram] outbound send ok messageId=8699 operation=sendMessage  ← leaked text block (the "It's 10:26 AM Athens..." narration)

Two Telegram messages from a single heartbeat tick, only one of which was the model's actual delivery intent.

Code-path trace against v2026.5.18 source

After cloning the v2026.5.18 tag, the dispatch path is:

  1. src/infra/heartbeat-runner.ts:1451-1456 — calls shouldUseHeartbeatResponseToolPrompt({cfg, ...}).
  2. src/infra/heartbeat-runner.ts:470-477 — returns true when messages.visibleReplies === "message_tool".
  3. src/infra/heartbeat-runner.ts:1727-1730 — sets enableHeartbeatTool: true, forceHeartbeatTool: true, sourceReplyDeliveryMode: "message_tool_only".
  4. src/agents/pi-tools.ts:557-564the design hole — when sourceReplyDeliveryMode === "message_tool_only" OR forceHeartbeatTool is set, the runtime exposes BOTH the generic message tool AND heartbeat_respond to the model. The model is free to call either.
  5. Model picks generic message tool (familiar from regular chat) AND emits a free text block alongside.
  6. src/infra/heartbeat-runner.ts:1740resolveHeartbeatToolResponseFromReplyResult(replyResult) returns undefined because no payload carries channelData[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY] (see src/auto-reply/heartbeat-tool-response.ts:104,109-123).
  7. src/infra/heartbeat-runner.ts:1778 — empty-reply short-circuit doesn't fire because hasOutboundReplyContent(replyPayload) is true (the leaked text block exists). hasOutboundReplyContent at src/plugin-sdk/reply-payload.ts:164 only checks for ANY text/media presence — it doesn't notice that a structured toolCall was also present.
  8. src/infra/heartbeat-runner.ts:1806-1810 — falls into normalizeHeartbeatReply(replyPayload, ...) which strips only the HEARTBEAT_OK token (heartbeat-runner.ts:771-795). The narration text survives.
  9. src/infra/heartbeat-runner.ts:1979-1998sendDurableMessageBatch dispatches normalized.text to Telegram. This path does NOT consult sourceReplyDeliveryMode. So the message_tool_only mode is effectively unenforced here.
  10. src/auto-reply/reply/agent-runner-payloads.ts:120-134sanitizeHeartbeatPayload (the [Bug]: Heartbeat mode leaks [TOOL_CALL] bracket-format blocks into user messages #54138 fix) only strips legacy [TOOL_CALL]...[/TOOL_CALL] text-formatted blocks. It does NOT drop visible text blocks when a structured toolCall block is present in the same turn.

Why related closed issues don't cover this

Suggested fix

In src/infra/heartbeat-runner.ts near line 1740, after resolveHeartbeatToolResponseFromReplyResult returns undefined, check whether the assistant message contained a toolCall to the generic message tool (or to heartbeat_respond). If yes, treat it equivalently to a heartbeat tool response — suppress the free-text dispatch and let only the explicit tool-call content go through. This matches the user's intent when messages.visibleReplies: "message_tool" is set, and aligns with the "tool-reliable models" framing in the 2026.5.16+ release notes.

Environment

  • OpenClaw 2026.5.18 (commit 50a2481), installed via npm install -g [email protected]
  • macOS 26.5, Node v25.6.0
  • Shared gateway, Telegram channel
  • Configuration: messages.visibleReplies: "message_tool" was tested and reverted — symptom occurs whether the setting is present or not
  • Verified by cloning v2026.5.18 tag locally and tracing the code path; reproduction in production sessions

Happy to share full session JSONL traces and additional gateway log excerpts if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:message-lossChannel message delivery can be lost, duplicated, or misrouted.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions