From e274b5a040b82f55a32cfc306d3228e1c53d291d Mon Sep 17 00:00:00 2001 From: void Date: Thu, 15 Jan 2026 16:24:52 -0800 Subject: [PATCH] fix: heartbeat prompt + dedupe (#980) (thanks @voidserf) - tighten default heartbeat prompt guidance - suppress duplicate heartbeat alerts within 24h Co-authored-by: void --- CHANGELOG.md | 1 + src/agents/workspace.ts | 7 ++- src/auto-reply/heartbeat.ts | 4 +- src/config/sessions/types.ts | 7 +++ src/config/types.agent-defaults.ts | 2 +- ...tbeat-runner.returns-default-unset.test.ts | 55 +++++++++++++++++++ src/infra/heartbeat-runner.ts | 44 +++++++++++++++ 7 files changed, 117 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c58e61cf..9d7159bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2026.1.15 (unreleased) +- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf. - Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj. - Daemon: fix profile-aware service label resolution (env-driven) and add coverage for launchd/systemd/schtasks. (#969) — thanks @bjesuiter. - Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts. diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index fc2c2cae9..8ff2ffa1d 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -90,7 +90,12 @@ Add whatever else you want the assistant to know about your local toolchain. const DEFAULT_HEARTBEAT_TEMPLATE = `# HEARTBEAT.md -Keep this file empty unless you want a tiny checklist. Keep it small. +Optional: keep a tiny checklist for heartbeat runs. + +Guidance (to avoid nagging): +- Only report items that are truly new or changed. +- Do not invent tasks from old chat context. +- If nothing needs attention, reply HEARTBEAT_OK. `; const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after) diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index 47e34b04e..8b07d4df8 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -1,7 +1,9 @@ import { HEARTBEAT_TOKEN } from "./tokens.js"; +// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset). +// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context. export const HEARTBEAT_PROMPT = - "Consider outstanding tasks and HEARTBEAT.md guidance from the workspace context (if present). Checkup sometimes on your human during (user local) day time."; + "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."; export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 2d607ba8d..ab3f87c42 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -10,6 +10,13 @@ export type SessionChannelId = ChannelId | "webchat"; export type SessionChatType = "direct" | "group" | "room"; export type SessionEntry = { + /** + * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). + * Stored on the main session entry. + */ + lastHeartbeatText?: string; + /** Timestamp (ms) when lastHeartbeatText was delivered. */ + lastHeartbeatSentAt?: number; sessionId: string; updatedAt: number; sessionFile?: string; diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index a42292984..8a70ff464 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -163,7 +163,7 @@ export type AgentDefaultsConfig = { | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; - /** Override the heartbeat prompt body (default: "Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time."). */ + /** Override the heartbeat prompt body (default: "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."). */ prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index e05ab3978..40592757d 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -242,6 +242,61 @@ describe("runHeartbeatOnce", () => { } }); + it("suppresses duplicate heartbeat payloads within 24h", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { every: "5m", target: "whatsapp", to: "+1555" }, + }, + }, + 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", + lastHeartbeatText: "Final alert", + lastHeartbeatSentAt: 0, + }, + }, + null, + 2, + ), + ); + + replySpy.mockResolvedValue([{ text: "Final alert" }]); + const sendWhatsApp = vi.fn().mockResolvedValue({ messageId: "m1", toJid: "jid" }); + + await runHeartbeatOnce({ + cfg, + deps: { + sendWhatsApp, + getQueueSize: () => 0, + nowMs: () => 60_000, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + expect(sendWhatsApp).toHaveBeenCalledTimes(0); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("can include reasoning payloads when enabled", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 7ffad2ed9..0f03d773e 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -17,6 +17,7 @@ import { resolveAgentIdFromSessionKey, resolveMainSessionKey, resolveStorePath, + saveSessionStore, updateSessionStore, } from "../config/sessions.js"; import { formatErrorMessage } from "../infra/errors.js"; @@ -277,6 +278,35 @@ export async function runHeartbeatOnce(opts: { const mediaUrls = replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []); + + // Suppress duplicate heartbeats (same payload) within a short window. + // This prevents "nagging" when nothing changed but the model repeats the same items. + const prevHeartbeatText = typeof entry?.lastHeartbeatText === "string" ? entry.lastHeartbeatText : ""; + const prevHeartbeatAt = typeof entry?.lastHeartbeatSentAt === "number" ? entry.lastHeartbeatSentAt : undefined; + const isDuplicateMain = + !shouldSkipMain && + !mediaUrls.length && + Boolean(prevHeartbeatText.trim()) && + normalized.text.trim() === prevHeartbeatText.trim() && + typeof prevHeartbeatAt === "number" && + startedAt - prevHeartbeatAt < 24 * 60 * 60 * 1000; + + if (isDuplicateMain) { + await restoreHeartbeatUpdatedAt({ + storePath, + sessionKey, + updatedAt: previousUpdatedAt, + }); + emitHeartbeatEvent({ + status: "skipped", + reason: "duplicate", + preview: normalized.text.slice(0, 200), + durationMs: Date.now() - startedAt, + hasMedia: false, + }); + return { status: "ran", durationMs: Date.now() - startedAt }; + } + // Reasoning payloads are text-only; any attachments stay on the main reply. const previewText = shouldSkipMain ? reasoningPayloads @@ -339,6 +369,20 @@ export async function runHeartbeatOnce(opts: { deps: opts.deps, }); + // Record last delivered heartbeat payload for dedupe. + if (!shouldSkipMain && normalized.text.trim()) { + const store = loadSessionStore(storePath); + const current = store[sessionKey]; + if (current) { + store[sessionKey] = { + ...current, + lastHeartbeatText: normalized.text, + lastHeartbeatSentAt: startedAt, + }; + await saveSessionStore(storePath, store); + } + } + emitHeartbeatEvent({ status: "sent", to: delivery.to,