openclaw - ✅(Solved) Fix [Bug]: Heartbeat dispatch delivers free text block alongside message-tool call (chatty non-Codex providers, v2026.5.18) [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#84217Fetched 2026-05-20 03:42:32
View on GitHub
Comments
1
Participants
2
Timeline
9
Reactions
1
Timeline (top)
labeled ×6cross-referenced ×2commented ×1

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.

Root Cause

  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 #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.

Fix Action

Fix / Workaround

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.

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 #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.

PR fix notes

PR #84273: Suppress heartbeat fallback after message-tool delivery

Description (problem / solution / changelog)

Summary

Fixes #84217.

  • Carries internal message-tool delivery evidence on reply payload metadata after a real message tool send succeeds in message-tool-only mode.
  • Makes the heartbeat runner suppress its fallback text when that delivery evidence is present, instead of sending duplicate narration through the heartbeat channel.
  • Adds a heartbeat regression covering the issue shape: message-tool delivery evidence plus stray fallback text.

Root cause

Heartbeat dispatch only understood the heartbeat_respond tool channel data. When non-Codex providers produced a generic message tool call and also returned visible fallback text, runHeartbeatOnce treated the fallback text as a normal heartbeat reply and sent it to Telegram.

Real behavior proof

Behavior or issue addressed: Heartbeat message-tool-only dispatch no longer sends fallback text when a visible generic message tool send has already succeeded.

Real environment tested: Local OpenClaw source checkout with production runHeartbeatOnce, production reply payload metadata helpers, and a real temporary session store on macOS. The proof used an in-memory Telegram sender so the dispatch path could be observed without contacting Telegram.

Exact steps or command run after this patch: PATH=/Users/andy/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin:$PATH node --import tsx --input-type=module --eval '<script importing runHeartbeatOnce, seeding a temp session store, returning markReplyPayloadForMessageToolDelivery({ text: "fallback narration that would duplicate the message tool" }) from getReplyFromConfig, and recording Telegram send calls>'

Evidence after fix:

{
  "result": {
    "status": "ran"
  },
  "replyCalls": [
    {
      "sourceReplyDeliveryMode": "message_tool_only",
      "enableHeartbeatTool": true,
      "forceHeartbeatTool": true
    }
  ],
  "telegramSendCallCount": 0,
  "heartbeatEvent": {
    "status": "sent",
    "preview": "fallback narration that would duplicate the message tool",
    "channel": "telegram"
  }
}

Observed result after fix: The heartbeat run still uses message-tool-only mode, records a successful heartbeat event, and does not call the Telegram fallback sender for the duplicate narration.

What was not tested: No live Telegram API call was made; the proof observes the production heartbeat dispatch decision before network delivery.

Validation

  • node scripts/run-vitest.mjs src/infra/heartbeat-runner.tool-response.test.ts
  • node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner-payloads.test.ts src/auto-reply/reply/dispatch-from-config.test.ts
  • git diff --check

Attribution

If maintainers squash or rework this PR, please preserve author attribution or include:

Co-authored-by: Andy Ye <[email protected]>

Changed files

  • src/auto-reply/heartbeat-reply-payload.ts (modified, +13/-0)
  • src/auto-reply/reply-payload.ts (modified, +12/-0)
  • src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts (modified, +33/-0)
  • src/auto-reply/reply/agent-runner.ts (modified, +26/-0)
  • src/auto-reply/types.ts (modified, +1/-0)
  • src/infra/heartbeat-runner.tool-response.test.ts (modified, +66/-1)
  • src/infra/heartbeat-runner.ts (modified, +26/-1)

Code Example

{
  "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..."}}
  ]
}

---

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)
RAW_BUFFERClick to expand / collapse

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 #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

  • #54138 (closed 2026-05-02 as completed) — fix targets legacy [TOOL_CALL] bracketed text. This bug is about a structured toolCall block coexisting with a free text block — different shape, not touched by the bracket-stripper.
  • #17646 (closed 2026-02-16) — HEARTBEAT_OK token suppression. Unrelated.
  • #18870 (closed 2026-02-17 via #18956) — fixes streamMode: "partial" interactive duplicates. Heartbeat runner uses a different dispatch path (sendDurableMessageBatch with explicit payload array).
  • #72071 (closed 2026-04-26) — Ollama-only fix. opencode-go provider is a separate stream adapter.
  • #78066 (closed 2026-05-18 via #83498) — fixes doctor --fix materializing visibleReplies: message_tool and adjusts automatic default. Doesn't touch heartbeat dispatch.

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.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING