mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
feat(heartbeat): skip API calls when HEARTBEAT.md is effectively empty (#1535)
* feat: skip heartbeat API calls when HEARTBEAT.md is effectively empty - Added isHeartbeatContentEffectivelyEmpty() to detect files with only headers/comments - Modified runHeartbeatOnce() to check HEARTBEAT.md content before polling the LLM - Returns early with 'empty-heartbeat-file' reason when no actionable tasks exist - Preserves existing behavior when file is missing (lets LLM decide) - Added comprehensive test coverage for empty file detection - Saves API calls/costs when heartbeat file has no meaningful content * chore: update HEARTBEAT.md template to be effectively empty by default Changed instruction text to comment format so new workspaces benefit from heartbeat optimization immediately. Users still get clear guidance on usage. * fix: only treat markdown headers (# followed by space) as comments, not #TODO etc * refactor: simplify regex per code review suggestion * docs: clarify heartbeat empty file behavior (#1535) (thanks @JustYannicc) --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.clawd.bot
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
|
||||
|
||||
@@ -162,6 +162,10 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||
safe to include every 30 minutes.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
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.
|
||||
|
||||
Example `HEARTBEAT.md`:
|
||||
|
||||
@@ -5,4 +5,5 @@ read_when:
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
|
||||
@@ -182,6 +182,8 @@ By default, Clawdbot 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`), Clawdbot 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`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
|
||||
@@ -971,6 +971,10 @@ Heartbeats run every **30m** by default. Tune or disable them:
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot 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).
|
||||
|
||||
### Do I need to add a “bot account” to a WhatsApp group?
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
stripHeartbeatToken,
|
||||
} from "./heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
describe("stripHeartbeatToken", () => {
|
||||
@@ -105,3 +109,76 @@ describe("stripHeartbeatToken", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeartbeatContentEffectivelyEmpty", () => {
|
||||
it("returns false for undefined/null (missing file should not skip)", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty string", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for whitespace only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for header-only content", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for comments only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for default template content (header + comment)", () => {
|
||||
const defaultTemplate = `# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
`;
|
||||
// Note: The template has actual text content, so it's NOT effectively empty
|
||||
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for header with only empty lines", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when actionable content exists", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for content with tasks after header", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
|
||||
- Task 1
|
||||
- Task 2
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mixed content with non-comment text", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Tasks
|
||||
Check the server logs
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats markdown headers as comments (effectively empty)", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Section 1
|
||||
### Subsection
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,38 @@ export const HEARTBEAT_PROMPT =
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* - Comment lines (lines starting with #)
|
||||
* - Empty lines
|
||||
*
|
||||
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||
* decide what to do. This function is only for when the file exists but has no content.
|
||||
*/
|
||||
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||
if (content === undefined || content === null) return false;
|
||||
if (typeof content !== "string") return false;
|
||||
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines
|
||||
if (!trimmed) continue;
|
||||
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
// Found a non-empty, non-comment line - there's actionable content
|
||||
return false;
|
||||
}
|
||||
// All lines were either empty or comments
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
|
||||
@@ -793,4 +793,209 @@ describe("runHeartbeatOnce", () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
// Create effectively empty HEARTBEAT.md (only header and comments)
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
"# HEARTBEAT.md\n\n## Tasks\n\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should skip without making API call
|
||||
expect(res.status).toBe("skipped");
|
||||
if (res.status === "skipped") {
|
||||
expect(res.reason).toBe("empty-heartbeat-file");
|
||||
}
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
expect(sendWhatsApp).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md has actionable content", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
// Create HEARTBEAT.md with actionable content
|
||||
await fs.writeFile(
|
||||
path.join(workspaceDir, "HEARTBEAT.md"),
|
||||
"# HEARTBEAT.md\n\n- Check server logs\n- Review pending PRs\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Checked logs and PRs" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should run and make API call
|
||||
expect(res.status).toBe("ran");
|
||||
expect(replySpy).toHaveBeenCalled();
|
||||
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("runs heartbeat when HEARTBEAT.md does not exist (lets LLM decide)", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||
const storePath = path.join(tmpDir, "sessions.json");
|
||||
const workspaceDir = path.join(tmpDir, "workspace");
|
||||
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
|
||||
try {
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
// Don't create HEARTBEAT.md - it doesn't exist
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
heartbeat: { every: "5m", target: "whatsapp" },
|
||||
},
|
||||
},
|
||||
channels: { whatsapp: { allowFrom: ["*"] } },
|
||||
session: { store: storePath },
|
||||
};
|
||||
const sessionKey = resolveMainSessionKey(cfg);
|
||||
|
||||
await fs.writeFile(
|
||||
storePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
[sessionKey]: {
|
||||
sessionId: "sid",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: {
|
||||
sendWhatsApp,
|
||||
getQueueSize: () => 0,
|
||||
nowMs: () => 0,
|
||||
webAuthExists: async () => true,
|
||||
hasActiveWebListener: () => true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should run (not skip) - let LLM decide since file doesn't exist
|
||||
expect(res.status).toBe("ran");
|
||||
expect(replySpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
replySpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { resolveAgentConfig, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../agents/agent-scope.js";
|
||||
import { resolveUserTimezone } from "../agents/date-time.js";
|
||||
import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
DEFAULT_HEARTBEAT_EVERY,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
resolveHeartbeatPrompt as resolveHeartbeatPromptText,
|
||||
stripHeartbeatToken,
|
||||
} from "../auto-reply/heartbeat.js";
|
||||
@@ -440,6 +449,25 @@ export async function runHeartbeatOnce(opts: {
|
||||
return { status: "skipped", reason: "requests-in-flight" };
|
||||
}
|
||||
|
||||
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
||||
// This saves API calls/costs when the file is effectively empty (only comments/headers).
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||
try {
|
||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||
emitHeartbeatEvent({
|
||||
status: "skipped",
|
||||
reason: "empty-heartbeat-file",
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return { status: "skipped", reason: "empty-heartbeat-file" };
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or can't be read - proceed with heartbeat.
|
||||
// The LLM prompt says "if it exists" so this is expected behavior.
|
||||
}
|
||||
|
||||
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);
|
||||
const previousUpdatedAt = entry?.updatedAt;
|
||||
const delivery = resolveHeartbeatDeliveryTarget({ cfg, entry, heartbeat });
|
||||
|
||||
Reference in New Issue
Block a user