From 7eb3766456ebfad82009a8ec56dff47bf7aaf850 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sat, 14 Mar 2026 09:31:59 -0500 Subject: [PATCH] fix(cron): prevent announce dedupe from counting disabled message-tool sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a cron job runs with `delivery.mode: "announce"`, the message tool is disabled so the runner can handle delivery. However, if the `didSendViaMessagingTool` flag carried stale state from a prior turn or subagent, the `skipMessagingToolDelivery` check could treat it as a completed delivery and skip announce — resulting in zero delivery. Fix: add a `messageToolDisabled` flag to the tool policy and exclude disabled-tool runs from the `skipMessagingToolDelivery` check. When the message tool was unavailable, any send-state is not a valid delivery signal. Also improve `appendCronDeliveryInstruction` to explicitly tell the agent the message tool is unavailable when announce delivery is active, so skills that reference the message tool do not confuse the agent into producing empty or error output. Closes #42244 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cron/isolated-agent/run.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 8a074338da7..149f4962dd6 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -192,11 +192,19 @@ async function resolveCronDeliveryContext(params: { function appendCronDeliveryInstruction(params: { commandBody: string; deliveryRequested: boolean; + messageToolDisabled: boolean; }) { if (!params.deliveryRequested) { return params.commandBody; } - return `${params.commandBody}\n\nReturn your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself.`.trim(); + // When the message tool is disabled (announce delivery handles it), be + // explicit so skills that reference the message tool do not confuse the + // agent into producing empty or error output. + // See: https://github.com/openclaw/openclaw/issues/42244 + const instruction = params.messageToolDisabled + ? "IMPORTANT: The message/send tool is not available for this run. Do NOT attempt to send messages directly. Return your complete output as plain text — it will be delivered to the user automatically." + : "Return your summary as plain text; it will be delivered automatically. If the task explicitly calls for messaging a specific external recipient, note who/where it should go instead of sending it yourself."; + return `${params.commandBody}\n\n${instruction}`.trim(); } export async function runCronIsolatedAgentTurn(params: { @@ -478,7 +486,11 @@ export async function runCronIsolatedAgentTurn(params: { // Internal/trusted source - use original format commandBody = `${base}\n${timeLine}`.trim(); } - commandBody = appendCronDeliveryInstruction({ commandBody, deliveryRequested }); + commandBody = appendCronDeliveryInstruction({ + commandBody, + deliveryRequested, + messageToolDisabled: toolPolicy.messageToolDisabled, + }); const existingSkillsSnapshot = cronSession.sessionEntry.skillsSnapshot; const skillsSnapshot = resolveCronSkillsSnapshot({ @@ -825,9 +837,16 @@ export async function runCronIsolatedAgentTurn(params: { // Skip delivery for heartbeat-only responses (HEARTBEAT_OK with no real content). const ackMaxChars = resolveHeartbeatAckMaxChars(agentCfg); const skipHeartbeatDelivery = deliveryRequested && isHeartbeatOnlyResponse(payloads, ackMaxChars); + // Only treat a matching message-tool send as the delivery path when the + // message tool was actually available. When the tool was disabled (announce + // mode), any `didSendViaMessagingTool` state is stale from a prior turn or + // subagent — counting it would suppress announce delivery, causing zero + // delivery to the user. + // See: https://github.com/openclaw/openclaw/issues/42244 const skipMessagingToolDelivery = deliveryContract === "shared" && deliveryRequested && + !toolPolicy.messageToolDisabled && finalRunResult.didSendViaMessagingTool === true && (finalRunResult.messagingToolSentTargets ?? []).some((target) => matchesMessagingToolDeliveryTarget(target, {