fix(auto-reply): skip commented heartbeat scaffolding

This commit is contained in:
Vincent Koc
2026-06-04 17:01:59 -07:00
parent 87d053c0cb
commit 1d19d7ec46
8 changed files with 73 additions and 9 deletions

View File

@@ -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. 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. Keep it tiny (short checklist or reminders) to avoid prompt bloat.

View File

@@ -691,7 +691,7 @@ Look for:
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled. - `cron: scheduler disabled; jobs will not run automatically` → cron disabled.
- `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `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=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 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: 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`. - `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`.

View File

@@ -67,7 +67,7 @@ and troubleshooting see the main [FAQ](/help/faq).
Common heartbeat skip reasons: Common heartbeat skip reasons:
- `quiet-hours`: outside the configured active-hours window - `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 - `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) - `alerts-disabled`: all heartbeat visibility is disabled (`showOk`, `showAlerts`, and `useIndicator` are all off)

View File

@@ -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 If `HEARTBEAT.md` exists but is effectively empty (only blank lines,
headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. 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 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). Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).

View File

@@ -364,7 +364,7 @@ flowchart TD
- `cron: scheduler disabled; jobs will not run automatically` → cron is disabled. - `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=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=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). - `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. - `requests-in-flight` → main lane busy; heartbeat wake was deferred.

View File

@@ -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.` `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. 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 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. - 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:<id>` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active. - By default, heartbeat delivery to DM-style `user:<id>` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.

View File

@@ -192,6 +192,28 @@ describe("isHeartbeatContentEffectivelyEmpty", () => {
it("returns true for comments only", () => { it("returns true for comments only", () => {
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true); expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true); expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
expect(
isHeartbeatContentEffectivelyEmpty(
"<!-- Heartbeat template; comments-only content prevents scheduled heartbeat API calls. -->",
),
).toBe(true);
expect(
isHeartbeatContentEffectivelyEmpty(`<!--
Heartbeat template.
Keep this comment-only file quiet.
-->`),
).toBe(true);
expect(
isHeartbeatContentEffectivelyEmpty(`<!--
tasks:
- name: inbox
interval: 30m
prompt: Check inbox
-->`),
).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("<!-- One --> <!-- Two -->")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("<!-- One -->\n# Header")).toBe(true);
expect(isHeartbeatContentEffectivelyEmpty("Reminder <!-- not scaffolding -->")).toBe(false);
}); });
it("returns false when a template includes plain instructional prose", () => { 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 = `<!--
tasks:
- name: inbox
interval: 30m
prompt: Check inbox
-->
`;
expect(parseHeartbeatTasks(content)).toEqual([]);
});
}); });

View File

@@ -24,12 +24,42 @@ export const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]";
export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_EVERY = "30m";
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; 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("<!--")) {
const searchText = state.inHtmlComment ? remaining : remaining.trimStart();
const commentEnd = searchText.indexOf("-->");
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. * 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. * This allows skipping heartbeat API calls when no tasks are configured.
* *
* A file is considered effectively empty if it contains only: * A file is considered effectively empty if it contains only:
* - Whitespace / empty lines * - Whitespace / empty lines
* - Markdown/HTML comments
* - Markdown ATX headers (`#`, `##`, ...) * - Markdown ATX headers (`#`, `##`, ...)
* - Markdown fence markers such as ``` or ```markdown * - Markdown fence markers such as ``` or ```markdown
* - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `) * - Empty list item stubs (`- `, `- [ ]`, `* `, `+ `)
@@ -45,7 +75,7 @@ export function isHeartbeatContentEffectivelyEmpty(content: string | undefined |
return false; return false;
} }
const lines = content.split("\n"); const lines = stripHeartbeatHtmlComments(content);
for (const line of lines) { for (const line of lines) {
const trimmed = line.trim(); const trimmed = line.trim();
// Skip empty lines // Skip empty lines
@@ -224,7 +254,7 @@ export function stripHeartbeatToken(
*/ */
export function parseHeartbeatTasks(content: string): HeartbeatTask[] { export function parseHeartbeatTasks(content: string): HeartbeatTask[] {
const tasks: HeartbeatTask[] = []; const tasks: HeartbeatTask[] = [];
const lines = content.split("\n"); const lines = stripHeartbeatHtmlComments(content);
let inTasksBlock = false; let inTasksBlock = false;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {