From 1d19d7ec46a8fba71433c31b844be039f5a2f102 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 4 Jun 2026 17:01:59 -0700 Subject: [PATCH] fix(auto-reply): skip commented heartbeat scaffolding --- docs/gateway/heartbeat.md | 2 +- docs/gateway/troubleshooting.md | 2 +- docs/help/faq-first-run.md | 2 +- docs/help/faq.md | 5 +++-- docs/help/troubleshooting.md | 2 +- docs/start/openclaw.md | 2 +- src/auto-reply/heartbeat.test.ts | 33 +++++++++++++++++++++++++++++++ src/auto-reply/heartbeat.ts | 34 ++++++++++++++++++++++++++++++-- 8 files changed, 73 insertions(+), 9 deletions(-) diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index da032262dd83..2c91ef093cc1 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -387,7 +387,7 @@ On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabl On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn. If the file exists and has non-whitespace content, the heartbeat collaboration-mode instructions point Codex at the file and tell it to read before proceeding. -If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do. +If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do. Keep it tiny (short checklist or reminders) to avoid prompt bloat. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index f310f0707bd6..6e455aa287f4 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -691,7 +691,7 @@ Look for: - `cron: scheduler disabled; jobs will not run automatically` → cron disabled. - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - - `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank lines / markdown headers, so OpenClaw skips the model call. + - `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding, so OpenClaw skips the model call. - `heartbeat skipped` with `reason=no-tasks-due` → `HEARTBEAT.md` contains a `tasks:` block, but none of the tasks are due on this tick. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. - `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`. diff --git a/docs/help/faq-first-run.md b/docs/help/faq-first-run.md index 8d2f8cc588ae..51e8066c5366 100644 --- a/docs/help/faq-first-run.md +++ b/docs/help/faq-first-run.md @@ -67,7 +67,7 @@ and troubleshooting see the main [FAQ](/help/faq). Common heartbeat skip reasons: - `quiet-hours`: outside the configured active-hours window - - `empty-heartbeat-file`: `HEARTBEAT.md` exists but only contains blank/header-only scaffolding + - `empty-heartbeat-file`: `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding - `no-tasks-due`: `HEARTBEAT.md` task mode is active but none of the task intervals are due yet - `alerts-disabled`: all heartbeat visibility is disabled (`showOk`, `showAlerts`, and `useIndicator` are all off) diff --git a/docs/help/faq.md b/docs/help/faq.md index ea1ac663d1c1..2a8ad0e0664d 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -1350,8 +1350,9 @@ lives on the [First-run FAQ](/help/faq-first-run). } ``` - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown - headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. + If `HEARTBEAT.md` exists but is effectively empty (only blank lines, + Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, + or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. If the file is missing, the heartbeat still runs and the model decides what to do. Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat). diff --git a/docs/help/troubleshooting.md b/docs/help/troubleshooting.md index 38e676012f2f..748fb5d0425f 100644 --- a/docs/help/troubleshooting.md +++ b/docs/help/troubleshooting.md @@ -364,7 +364,7 @@ flowchart TD - `cron: scheduler disabled; jobs will not run automatically` → cron is disabled. - `heartbeat skipped` with `reason=quiet-hours` → outside configured active hours. - - `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank/header-only scaffolding. + - `heartbeat skipped` with `reason=empty-heartbeat-file` → `HEARTBEAT.md` exists but only contains blank, comment, header, fence, or empty-checklist scaffolding. - `heartbeat skipped` with `reason=no-tasks-due` → `HEARTBEAT.md` task mode is active but none of the task intervals are due yet. - `heartbeat skipped` with `reason=alerts-disabled` → all heartbeat visibility is disabled (`showOk`, `showAlerts`, and `useIndicator` are all off). - `requests-in-flight` → main lane busy; heartbeat wake was deferred. diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 7f4364c03d2a..607c80797831 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -172,7 +172,7 @@ By default, OpenClaw runs a heartbeat every 30 minutes with the prompt: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.` Set `agents.defaults.heartbeat.every: "0m"` to disable. -- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. +- If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. - By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active. diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index 3618c1daf341..ea2399edea94 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -192,6 +192,28 @@ describe("isHeartbeatContentEffectivelyEmpty", () => { it("returns true for comments only", () => { expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true); expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true); + expect( + isHeartbeatContentEffectivelyEmpty( + "", + ), + ).toBe(true); + expect( + isHeartbeatContentEffectivelyEmpty(``), + ).toBe(true); + expect( + isHeartbeatContentEffectivelyEmpty(``), + ).toBe(true); + expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true); + expect(isHeartbeatContentEffectivelyEmpty("\n# Header")).toBe(true); + expect(isHeartbeatContentEffectivelyEmpty("Reminder ")).toBe(false); }); it("returns false when a template includes plain instructional prose", () => { @@ -305,4 +327,15 @@ interval: should-not-bleed }, ]); }); + + it("ignores task blocks inside HTML comments", () => { + const content = ` +`; + expect(parseHeartbeatTasks(content)).toEqual([]); + }); }); diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 9e2e66abc7ff..7262cb77f31a 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -24,12 +24,42 @@ export const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]"; export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; +function stripLeadingHtmlCommentScaffolding( + line: string, + state: { inHtmlComment: boolean }, +): string { + let remaining = line; + while (state.inHtmlComment || remaining.trimStart().startsWith(""); + if (commentEnd === -1) { + state.inHtmlComment = true; + return ""; + } + + state.inHtmlComment = false; + if (searchText === remaining) { + remaining = remaining.slice(commentEnd + 3); + } else { + const leadingWidth = remaining.length - searchText.length; + remaining = remaining.slice(0, leadingWidth) + searchText.slice(commentEnd + 3); + } + } + return remaining; +} + +function stripHeartbeatHtmlComments(content: string): string[] { + const state = { inHtmlComment: false }; + return content.split("\n").map((line) => stripLeadingHtmlCommentScaffolding(line, state)); +} + /** * Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks. * This allows skipping heartbeat API calls when no tasks are configured. * * A file is considered effectively empty if it contains only: * - Whitespace / empty lines + * - Markdown/HTML comments * - Markdown ATX headers (`#`, `##`, ...) * - Markdown fence markers such as ``` or ```markdown * - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `) @@ -45,7 +75,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | return false; } - const lines = content.split("\n"); + const lines = stripHeartbeatHtmlComments(content); for (const line of lines) { const trimmed = line.trim(); // Skip empty lines @@ -224,7 +254,7 @@ export function stripHeartbeatToken( */ export function parseHeartbeatTasks(content: string): HeartbeatTask[] { const tasks: HeartbeatTask[] = []; - const lines = content.split("\n"); + const lines = stripHeartbeatHtmlComments(content); let inTasksBlock = false; for (let i = 0; i < lines.length; i++) {