You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Configure an agent with a non-Codex primary chat model (zai/glm-5.1, xiaomi/mimo-v2.5, etc.).
Set agents.list[<id>].heartbeat = { every: "30m", target: "telegram", to: "<chatId>", accountId: "default", directPolicy: "allow" }.
Optionally set messages.visibleReplies: "message_tool" (doesn't help, see below).
Provide a HEARTBEAT.md workspace file with active-content prompts (e.g., morning recap instructions).
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):
src/agents/pi-tools.ts:557-564 — the 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.
Model picks generic message tool (familiar from regular chat) AND emits a free text block alongside.
src/infra/heartbeat-runner.ts:1740 — resolveHeartbeatToolResponseFromReplyResult(replyResult) returns undefined because no payload carries channelData[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY] (see src/auto-reply/heartbeat-tool-response.ts:104,109-123).
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.
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.
src/infra/heartbeat-runner.ts:1979-1998 — sendDurableMessageBatch dispatches normalized.text to Telegram. This path does NOT consult sourceReplyDeliveryMode. So the message_tool_only mode is effectively unenforced here.
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.
Summary
On OpenClaw
2026.5.18, when an agent's heartbeat tick runs on a non-Codex provider (verified onzai/glm-5.1viaopencode-goroute andxiaomi/mimo-v2.5), and the model emits both a freetextblock AND a structuredtoolCall: messagein 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
zai/glm-5.1,xiaomi/mimo-v2.5, etc.).agents.list[<id>].heartbeat = { every: "30m", target: "telegram", to: "<chatId>", accountId: "default", directPolicy: "allow" }.messages.visibleReplies: "message_tool"(doesn't help, see below).Result: model emits a
textblock describing what it's about to do + amessage(action=send, channel=telegram, target=<chatId>, message="...")toolCall in the same turn. Telegram receives 2-3 messages (thetextblock delivered separately, plus the polished message tool content).Concrete observed evidence (this repro happened in production)
Session JSONL (
agent:main:mainat timestamp2026-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:
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.18sourceAfter cloning the v2026.5.18 tag, the dispatch path is:
src/infra/heartbeat-runner.ts:1451-1456— callsshouldUseHeartbeatResponseToolPrompt({cfg, ...}).src/infra/heartbeat-runner.ts:470-477— returnstruewhenmessages.visibleReplies === "message_tool".src/infra/heartbeat-runner.ts:1727-1730— setsenableHeartbeatTool: true, forceHeartbeatTool: true, sourceReplyDeliveryMode: "message_tool_only".src/agents/pi-tools.ts:557-564— the design hole — whensourceReplyDeliveryMode === "message_tool_only"ORforceHeartbeatToolis set, the runtime exposes BOTH the genericmessagetool ANDheartbeat_respondto the model. The model is free to call either.messagetool (familiar from regular chat) AND emits a freetextblock alongside.src/infra/heartbeat-runner.ts:1740—resolveHeartbeatToolResponseFromReplyResult(replyResult)returnsundefinedbecause no payload carrieschannelData[HEARTBEAT_RESPONSE_CHANNEL_DATA_KEY](seesrc/auto-reply/heartbeat-tool-response.ts:104,109-123).src/infra/heartbeat-runner.ts:1778— empty-reply short-circuit doesn't fire becausehasOutboundReplyContent(replyPayload)istrue(the leakedtextblock exists).hasOutboundReplyContentatsrc/plugin-sdk/reply-payload.ts:164only checks for ANY text/media presence — it doesn't notice that a structuredtoolCallwas also present.src/infra/heartbeat-runner.ts:1806-1810— falls intonormalizeHeartbeatReply(replyPayload, ...)which strips only theHEARTBEAT_OKtoken (heartbeat-runner.ts:771-795). The narrationtextsurvives.src/infra/heartbeat-runner.ts:1979-1998—sendDurableMessageBatchdispatchesnormalized.textto Telegram. This path does NOT consultsourceReplyDeliveryMode. So themessage_tool_onlymode is effectively unenforced here.src/auto-reply/reply/agent-runner-payloads.ts:120-134—sanitizeHeartbeatPayload(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 structuredtoolCallblock is present in the same turn.Why related closed issues don't cover this
[TOOL_CALL]bracketed text. This bug is about a structuredtoolCallblock coexisting with a freetextblock — different shape, not touched by the bracket-stripper.HEARTBEAT_OKtoken suppression. Unrelated.streamMode: "partial"interactive duplicates. Heartbeat runner uses a different dispatch path (sendDurableMessageBatchwith explicit payload array).opencode-goprovider is a separate stream adapter.doctor --fixmaterializingvisibleReplies: message_tooland adjustsautomaticdefault. Doesn't touch heartbeat dispatch.Suggested fix
In
src/infra/heartbeat-runner.tsnear line 1740, afterresolveHeartbeatToolResponseFromReplyResultreturnsundefined, check whether the assistant message contained atoolCallto the genericmessagetool (or toheartbeat_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 whenmessages.visibleReplies: "message_tool"is set, and aligns with the "tool-reliable models" framing in the 2026.5.16+ release notes.Environment
2026.5.18(commit50a2481), installed vianpm install -g [email protected]v25.6.0messages.visibleReplies: "message_tool"was tested and reverted — symptom occurs whether the setting is present or notv2026.5.18tag locally and tracing the code path; reproduction in production sessionsHappy to share full session JSONL traces and additional gateway log excerpts if useful.