fix: heartbeat prompt + dedupe (#980) (thanks @voidserf)
- tighten default heartbeat prompt guidance - suppress duplicate heartbeat alerts within 24h Co-authored-by: void <voidserf@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user