fix(heartbeat): default target none and internalize relay prompts

This commit is contained in:
Peter Steinberger
2026-02-25 00:33:32 +00:00
parent 4d89548e59
commit e2362d352d
9 changed files with 191 additions and 30 deletions

View File

@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildCronEventPrompt, buildExecEventPrompt } from "./heartbeat-events-filter.js";
describe("heartbeat event prompts", () => {
it("builds user-relay cron prompt by default", () => {
const prompt = buildCronEventPrompt(["Cron: rotate logs"]);
expect(prompt).toContain("Please relay this reminder to the user");
});
it("builds internal-only cron prompt when delivery is disabled", () => {
const prompt = buildCronEventPrompt(["Cron: rotate logs"], { deliverToUser: false });
expect(prompt).toContain("Handle this reminder internally");
expect(prompt).not.toContain("Please relay this reminder to the user");
});
it("builds internal-only exec prompt when delivery is disabled", () => {
const prompt = buildExecEventPrompt({ deliverToUser: false });
expect(prompt).toContain("Handle the result internally");
expect(prompt).not.toContain("Please relay the command output to the user");
});
});

View File

@@ -3,14 +3,33 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
// Build a dynamic prompt for cron events by embedding the actual event content.
// This ensures the model sees the reminder text directly instead of relying on
// "shown in the system messages above" which may not be visible in context.
export function buildCronEventPrompt(pendingEvents: string[]): string {
export function buildCronEventPrompt(
pendingEvents: string[],
opts?: {
deliverToUser?: boolean;
},
): string {
const deliverToUser = opts?.deliverToUser ?? true;
const eventText = pendingEvents.join("\n").trim();
if (!eventText) {
if (!deliverToUser) {
return (
"A scheduled cron event was triggered, but no event content was found. " +
"Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up."
);
}
return (
"A scheduled cron event was triggered, but no event content was found. " +
"Reply HEARTBEAT_OK."
);
}
if (!deliverToUser) {
return (
"A scheduled reminder has been triggered. The reminder content is:\n\n" +
eventText +
"\n\nHandle this reminder internally. Do not relay it to the user unless explicitly requested."
);
}
return (
"A scheduled reminder has been triggered. The reminder content is:\n\n" +
eventText +
@@ -18,6 +37,21 @@ export function buildCronEventPrompt(pendingEvents: string[]): string {
);
}
export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string {
const deliverToUser = opts?.deliverToUser ?? true;
if (!deliverToUser) {
return (
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Handle the result internally. Do not relay it to the user unless explicitly requested."
);
}
return (
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong."
);
}
const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.

View File

@@ -239,12 +239,12 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
},
{
name: "use last route by default",
name: "target defaults to none when unset",
cfg: {},
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" },
expected: {
channel: "whatsapp",
to: "+1555",
channel: "none",
reason: "target-none",
accountId: undefined,
lastChannel: "whatsapp",
lastAccountId: undefined,
@@ -271,7 +271,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
entry: { ...baseEntry, lastChannel: "webchat", lastTo: "web" },
expected: {
channel: "none",
reason: "no-target",
reason: "target-none",
accountId: undefined,
lastChannel: undefined,
lastAccountId: undefined,
@@ -294,7 +294,10 @@ describe("resolveHeartbeatDeliveryTarget", () => {
},
{
name: "normalize prefixed whatsapp group targets",
cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } },
cfg: {
agents: { defaults: { heartbeat: { target: "last" } } },
channels: { whatsapp: { allowFrom: ["+1555"] } },
},
entry: {
...baseEntry,
lastChannel: "whatsapp",
@@ -927,7 +930,7 @@ describe("runHeartbeatOnce", () => {
try {
const cfg: OpenClawConfig = {
agents: {
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
defaults: { workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" } },
list: [{ id: "work", default: true }],
},
channels: { whatsapp: { allowFrom: ["*"] } },
@@ -1148,4 +1151,110 @@ describe("runHeartbeatOnce", () => {
}
}
});
it("uses an internal-only cron prompt when heartbeat delivery target is none", async () => {
const tmpDir = await createCaseDir("hb-cron-target-none");
const storePath = path.join(tmpDir, "sessions.json");
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "none" },
},
},
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",
},
}),
);
enqueueSystemEvent("Cron: rotate logs", {
sessionKey,
contextKey: "cron:rotate-logs",
});
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "Handled internally" });
const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
try {
const res = await runHeartbeatOnce({
cfg,
reason: "interval",
deps: createHeartbeatDeps(sendWhatsApp),
});
expect(res.status).toBe("ran");
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
expect(calledCtx.Provider).toBe("cron-event");
expect(calledCtx.Body).toContain("Handle this reminder internally");
expect(calledCtx.Body).not.toContain("Please relay this reminder to the user");
} finally {
replySpy.mockRestore();
}
});
it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => {
const tmpDir = await createCaseDir("hb-exec-target-none");
const storePath = path.join(tmpDir, "sessions.json");
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: tmpDir,
heartbeat: { every: "5m", target: "none" },
},
},
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",
},
}),
);
enqueueSystemEvent("exec finished: backup completed", {
sessionKey,
contextKey: "exec:backup",
});
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
replySpy.mockResolvedValue({ text: "Handled internally" });
const sendWhatsApp = vi
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
try {
const res = await runHeartbeatOnce({
cfg,
reason: "exec-event",
deps: createHeartbeatDeps(sendWhatsApp),
});
expect(res.status).toBe("ran");
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
expect(calledCtx.Provider).toBe("exec-event");
expect(calledCtx.Body).toContain("Handle the result internally");
expect(calledCtx.Body).not.toContain("Please relay the command output to the user");
} finally {
replySpy.mockRestore();
}
});
});

View File

@@ -44,6 +44,7 @@ import { escapeRegExp } from "../utils.js";
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
import {
buildExecEventPrompt,
buildCronEventPrompt,
isCronSystemEvent,
isExecCompletionEvent,
@@ -95,15 +96,7 @@ export type HeartbeatSummary = {
ackMaxChars: number;
};
const DEFAULT_HEARTBEAT_TARGET = "last";
// Prompt used when an async exec has completed and the result should be relayed to the user.
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
// instead of just "HEARTBEAT_OK".
const EXEC_EVENT_PROMPT =
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong.";
const DEFAULT_HEARTBEAT_TARGET = "none";
export { isCronSystemEvent };
type HeartbeatAgentState = {
@@ -615,12 +608,12 @@ export async function runHeartbeatOnce(opts: {
if (delivery.reason === "unknown-account") {
log.warn("heartbeat: unknown accountId", {
accountId: delivery.accountId ?? heartbeatAccountId ?? null,
target: heartbeat?.target ?? "last",
target: heartbeat?.target ?? "none",
});
} else if (heartbeatAccountId) {
log.info("heartbeat: using explicit accountId", {
accountId: delivery.accountId ?? heartbeatAccountId,
target: heartbeat?.target ?? "last",
target: heartbeat?.target ?? "none",
channel: delivery.channel,
});
}
@@ -654,10 +647,13 @@ export async function runHeartbeatOnce(opts: {
.map((event) => event.text);
const hasExecCompletion = pendingEvents.some(isExecCompletionEvent);
const hasCronEvents = cronEvents.length > 0;
const canRelayToUser = Boolean(
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
);
const prompt = hasExecCompletion
? EXEC_EVENT_PROMPT
? buildExecEventPrompt({ deliverToUser: canRelayToUser })
: hasCronEvents
? buildCronEventPrompt(cronEvents)
? buildCronEventPrompt(cronEvents, { deliverToUser: canRelayToUser })
: resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),

View File

@@ -210,7 +210,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
const { cfg, entry } = params;
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
const rawTarget = heartbeat?.target;
let target: HeartbeatTarget = "last";
let target: HeartbeatTarget = "none";
if (rawTarget === "none" || rawTarget === "last") {
target = rawTarget;
} else if (typeof rawTarget === "string") {