diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b567798..495c2caa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index d691d5fbf..fc7d9d964 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -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`: diff --git a/docs/reference/templates/HEARTBEAT.md b/docs/reference/templates/HEARTBEAT.md index 45d86581f..9cbbab982 100644 --- a/docs/reference/templates/HEARTBEAT.md +++ b/docs/reference/templates/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. diff --git a/docs/start/clawd.md b/docs/start/clawd.md index 106c9c05c..8da004b02 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -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. diff --git a/docs/start/faq.md b/docs/start/faq.md index a3efb2b0b..f832f3a94 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -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? diff --git a/src/auto-reply/heartbeat.test.ts b/src/auto-reply/heartbeat.test.ts index b9141605f..dd952e037 100644 --- a/src/auto-reply/heartbeat.test.ts +++ b/src/auto-reply/heartbeat.test.ts @@ -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); + }); +}); diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 8b07d4df8..50567ad87 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -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; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index e52c578e7..621f895fa 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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 }); + } + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index a371b4dbb..9c8210acb 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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 });