From 744852d31345366d73b0a6154e81b4673cecf13d Mon Sep 17 00:00:00 2001 From: justyannicc Date: Fri, 23 Jan 2026 18:09:31 +0000 Subject: [PATCH] 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 --- pnpm-lock.yaml | 2 + src/auto-reply/heartbeat.test.ts | 79 ++++++- src/auto-reply/heartbeat.ts | 30 +++ ...tbeat-runner.returns-default-unset.test.ts | 205 ++++++++++++++++++ src/infra/heartbeat-runner.ts | 30 ++- 5 files changed, 344 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 529005132..4dc7665a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,6 +376,8 @@ importers: specifier: ^4.3.5 version: 4.3.5 + extensions/open-prose: {} + extensions/signal: {} extensions/slack: {} 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..6da804580 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -7,6 +7,36 @@ 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 comment/header lines (markdown headers are comments for our purposes) + if (trimmed.startsWith("#")) 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 });