mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 12:01:25 +00:00
fix(heartbeat): default target none and internalize relay prompts
This commit is contained in:
21
src/infra/heartbeat-events-filter.test.ts
Normal file
21
src/infra/heartbeat-events-filter.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user