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)
|
## 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.
|
- 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: 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.
|
- 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
|
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)
|
const DEFAULT_BOOTSTRAP_TEMPLATE = `# BOOTSTRAP.md - First Run Ritual (delete after)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
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 =
|
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_EVERY = "30m";
|
||||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
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 SessionChatType = "direct" | "group" | "room";
|
||||||
|
|
||||||
export type SessionEntry = {
|
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;
|
sessionId: string;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
sessionFile?: string;
|
sessionFile?: string;
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export type AgentDefaultsConfig = {
|
|||||||
| "none";
|
| "none";
|
||||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||||
to?: string;
|
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;
|
prompt?: string;
|
||||||
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
|
||||||
ackMaxChars?: number;
|
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 () => {
|
it("can include reasoning payloads when enabled", async () => {
|
||||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-"));
|
||||||
const storePath = path.join(tmpDir, "sessions.json");
|
const storePath = path.join(tmpDir, "sessions.json");
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resolveAgentIdFromSessionKey,
|
resolveAgentIdFromSessionKey,
|
||||||
resolveMainSessionKey,
|
resolveMainSessionKey,
|
||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
|
saveSessionStore,
|
||||||
updateSessionStore,
|
updateSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
@@ -277,6 +278,35 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
|
|
||||||
const mediaUrls =
|
const mediaUrls =
|
||||||
replyPayload.mediaUrls ?? (replyPayload.mediaUrl ? [replyPayload.mediaUrl] : []);
|
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.
|
// Reasoning payloads are text-only; any attachments stay on the main reply.
|
||||||
const previewText = shouldSkipMain
|
const previewText = shouldSkipMain
|
||||||
? reasoningPayloads
|
? reasoningPayloads
|
||||||
@@ -339,6 +369,20 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
deps: opts.deps,
|
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({
|
emitHeartbeatEvent({
|
||||||
status: "sent",
|
status: "sent",
|
||||||
to: delivery.to,
|
to: delivery.to,
|
||||||
|
|||||||
Reference in New Issue
Block a user