mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(auto-reply): skip commented heartbeat scaffolding
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:<id>` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
|
||||
|
||||
@@ -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(
|
||||
"<!-- 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", () => {
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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("<!--")) {
|
||||
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.
|
||||
* 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++) {
|
||||
|
||||
Reference in New Issue
Block a user