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:
JustYannicc
2026-01-24 04:19:01 +00:00
committed by GitHub
parent 71203829d8
commit dd06028827
9 changed files with 357 additions and 3 deletions

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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 });
}
});
});

View File

@@ -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 });