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

@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
- Messaging tool dedupe: treat originating channel metadata as authoritative for same-target `message.send` suppression in proactive runs (heartbeat/cron/exec-event), including synthetic-provider contexts, so `delivery-mirror` transcript entries no longer cause duplicate Telegram sends. (#25835) Thanks @jadeathena84-arch.
- Cron/Heartbeat delivery: stop inheriting cached session `lastThreadId` for heartbeat-mode target resolution unless a thread/topic is explicitly requested, so announce-mode cron and heartbeat deliveries stay on top-level destinations instead of leaking into active conversation threads. (#25730) Thanks @markshields-tl.
- Heartbeat defaults/prompts: switch the implicit heartbeat delivery target from `last` to `none` (opt-in for external delivery), and use internal-only cron/exec heartbeat prompt wording when delivery is disabled so background checks do not nudge user-facing relay behavior. (#25871, #24638, #25851)
- Security/Sandbox media: restrict sandbox media tmp-path allowances to OpenClaw-managed tmp roots instead of broad host `os.tmpdir()` trust, and add outbound/channel guardrails (tmp-path lint + media-root smoke tests) to prevent regressions in local media attachment reads. Thanks @tdjackey for reporting.
- Config/Plugins: treat stale removed `google-antigravity-auth` plugin references as compatibility warnings (not hard validation errors) across `plugins.entries`, `plugins.allow`, `plugins.deny`, and `plugins.slots.memory`, so startup no longer fails after antigravity removal. (#25538, #25862) Thanks @chilu18.
- Security/Message actions: enforce local media root checks for `sendAttachment` and `setGroupIcon` when `sandboxRoot` is unset, preventing attachment hydration from reading arbitrary host files via local absolute paths. Thanks @GCXWLP for reporting.

View File

@@ -62,7 +62,7 @@ The agent reads this on each heartbeat and handles all items in one turn.
defaults: {
heartbeat: {
every: "30m", // interval
target: "last", // where to deliver alerts
target: "last", // explicit alert delivery target (default is "none")
activeHours: { start: "08:00", end: "22:00" }, // optional
},
},

View File

@@ -800,7 +800,7 @@ Periodic heartbeat runs.
includeReasoning: false,
session: "main",
to: "+15555550123",
target: "last", // last | whatsapp | telegram | discord | ... | none
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
prompt: "Read HEARTBEAT.md if it exists...",
ackMaxChars: 300,
suppressToolErrorWarnings: false,

View File

@@ -19,7 +19,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence.
2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended).
3. Decide where heartbeat messages should go (`target: "last"` is the default).
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: restrict heartbeats to active hours (local time).
@@ -31,7 +31,7 @@ Example config:
defaults: {
heartbeat: {
every: "30m",
target: "last",
target: "last", // explicit delivery to last contact (default is "none")
// activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too
},
@@ -87,7 +87,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-6",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id
prompt: "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.",
@@ -120,7 +120,7 @@ Example: two agents, only the second agent runs heartbeats.
defaults: {
heartbeat: {
every: "30m",
target: "last",
target: "last", // explicit delivery to last contact (default is "none")
},
},
list: [
@@ -149,7 +149,7 @@ Restrict heartbeats to business hours in a specific timezone:
defaults: {
heartbeat: {
every: "30m",
target: "last",
target: "last", // explicit delivery to last contact (default is "none")
activeHours: {
start: "09:00",
end: "22:00",
@@ -212,9 +212,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
- Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups).
- `target`:
- `last` (default): deliver to the last used external channel.
- `last`: deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
- `none`: run the heartbeat but **do not deliver** externally.
- `none` (default): run the heartbeat but **do not deliver** externally.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id). For Telegram topics/threads, use `<chatId>:topic:<messageThreadId>`.
- `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
- `prompt`: overrides the default prompt body (not merged).

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") {