Skip to content

Commit e2362d3

Browse files
committed
fix(heartbeat): default target none and internalize relay prompts
1 parent 4d89548 commit e2362d3

File tree

9 files changed

+191
-30
lines changed

9 files changed

+191
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Docs: https://docs.openclaw.ai
4343
- 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.
4444
- 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.
4545
- 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.
46+
- 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)
4647
- 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.
4748
- 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.
4849
- 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.

docs/automation/cron-vs-heartbeat.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ The agent reads this on each heartbeat and handles all items in one turn.
6262
defaults: {
6363
heartbeat: {
6464
every: "30m", // interval
65-
target: "last", // where to deliver alerts
65+
target: "last", // explicit alert delivery target (default is "none")
6666
activeHours: { start: "08:00", end: "22:00" }, // optional
6767
},
6868
},

docs/gateway/configuration-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -800,7 +800,7 @@ Periodic heartbeat runs.
800800
includeReasoning: false,
801801
session: "main",
802802
to: "+15555550123",
803-
target: "last", // last | whatsapp | telegram | discord | ... | none
803+
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
804804
prompt: "Read HEARTBEAT.md if it exists...",
805805
ackMaxChars: 300,
806806
suppressToolErrorWarnings: false,

docs/gateway/heartbeat.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
1919

2020
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence.
2121
2. Create a tiny `HEARTBEAT.md` checklist in the agent workspace (optional but recommended).
22-
3. Decide where heartbeat messages should go (`target: "last"` is the default).
22+
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
2323
4. Optional: enable heartbeat reasoning delivery for transparency.
2424
5. Optional: restrict heartbeats to active hours (local time).
2525

@@ -31,7 +31,7 @@ Example config:
3131
defaults: {
3232
heartbeat: {
3333
every: "30m",
34-
target: "last",
34+
target: "last", // explicit delivery to last contact (default is "none")
3535
// activeHours: { start: "08:00", end: "24:00" },
3636
// includeReasoning: true, // optional: send separate `Reasoning:` message too
3737
},
@@ -87,7 +87,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
8787
every: "30m", // default: 30m (0m disables)
8888
model: "anthropic/claude-opus-4-6",
8989
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
90-
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
90+
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
9191
to: "+15551234567", // optional channel-specific override
9292
accountId: "ops-bot", // optional multi-account channel id
9393
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.
120120
defaults: {
121121
heartbeat: {
122122
every: "30m",
123-
target: "last",
123+
target: "last", // explicit delivery to last contact (default is "none")
124124
},
125125
},
126126
list: [
@@ -149,7 +149,7 @@ Restrict heartbeats to business hours in a specific timezone:
149149
defaults: {
150150
heartbeat: {
151151
every: "30m",
152-
target: "last",
152+
target: "last", // explicit delivery to last contact (default is "none")
153153
activeHours: {
154154
start: "09:00",
155155
end: "22:00",
@@ -212,9 +212,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele
212212
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
213213
- Session key formats: see [Sessions](/concepts/session) and [Groups](/channels/groups).
214214
- `target`:
215-
- `last` (default): deliver to the last used external channel.
215+
- `last`: deliver to the last used external channel.
216216
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
217-
- `none`: run the heartbeat but **do not deliver** externally.
217+
- `none` (default): run the heartbeat but **do not deliver** externally.
218218
- `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>`.
219219
- `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.
220220
- `prompt`: overrides the default prompt body (not merged).
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildCronEventPrompt, buildExecEventPrompt } from "./heartbeat-events-filter.js";
3+
4+
describe("heartbeat event prompts", () => {
5+
it("builds user-relay cron prompt by default", () => {
6+
const prompt = buildCronEventPrompt(["Cron: rotate logs"]);
7+
expect(prompt).toContain("Please relay this reminder to the user");
8+
});
9+
10+
it("builds internal-only cron prompt when delivery is disabled", () => {
11+
const prompt = buildCronEventPrompt(["Cron: rotate logs"], { deliverToUser: false });
12+
expect(prompt).toContain("Handle this reminder internally");
13+
expect(prompt).not.toContain("Please relay this reminder to the user");
14+
});
15+
16+
it("builds internal-only exec prompt when delivery is disabled", () => {
17+
const prompt = buildExecEventPrompt({ deliverToUser: false });
18+
expect(prompt).toContain("Handle the result internally");
19+
expect(prompt).not.toContain("Please relay the command output to the user");
20+
});
21+
});

src/infra/heartbeat-events-filter.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,55 @@ import { HEARTBEAT_TOKEN } from "../auto-reply/tokens.js";
33
// Build a dynamic prompt for cron events by embedding the actual event content.
44
// This ensures the model sees the reminder text directly instead of relying on
55
// "shown in the system messages above" which may not be visible in context.
6-
export function buildCronEventPrompt(pendingEvents: string[]): string {
6+
export function buildCronEventPrompt(
7+
pendingEvents: string[],
8+
opts?: {
9+
deliverToUser?: boolean;
10+
},
11+
): string {
12+
const deliverToUser = opts?.deliverToUser ?? true;
713
const eventText = pendingEvents.join("\n").trim();
814
if (!eventText) {
15+
if (!deliverToUser) {
16+
return (
17+
"A scheduled cron event was triggered, but no event content was found. " +
18+
"Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up."
19+
);
20+
}
921
return (
1022
"A scheduled cron event was triggered, but no event content was found. " +
1123
"Reply HEARTBEAT_OK."
1224
);
1325
}
26+
if (!deliverToUser) {
27+
return (
28+
"A scheduled reminder has been triggered. The reminder content is:\n\n" +
29+
eventText +
30+
"\n\nHandle this reminder internally. Do not relay it to the user unless explicitly requested."
31+
);
32+
}
1433
return (
1534
"A scheduled reminder has been triggered. The reminder content is:\n\n" +
1635
eventText +
1736
"\n\nPlease relay this reminder to the user in a helpful and friendly way."
1837
);
1938
}
2039

40+
export function buildExecEventPrompt(opts?: { deliverToUser?: boolean }): string {
41+
const deliverToUser = opts?.deliverToUser ?? true;
42+
if (!deliverToUser) {
43+
return (
44+
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
45+
"Handle the result internally. Do not relay it to the user unless explicitly requested."
46+
);
47+
}
48+
return (
49+
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
50+
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
51+
"If it failed, explain what went wrong."
52+
);
53+
}
54+
2155
const HEARTBEAT_OK_PREFIX = HEARTBEAT_TOKEN.toLowerCase();
2256

2357
// Detect heartbeat-specific noise so cron reminders don't trigger on non-reminder events.

src/infra/heartbeat-runner.returns-default-unset.test.ts

Lines changed: 115 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -239,12 +239,12 @@ describe("resolveHeartbeatDeliveryTarget", () => {
239239
},
240240
},
241241
{
242-
name: "use last route by default",
242+
name: "target defaults to none when unset",
243243
cfg: {},
244244
entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1555" },
245245
expected: {
246-
channel: "whatsapp",
247-
to: "+1555",
246+
channel: "none",
247+
reason: "target-none",
248248
accountId: undefined,
249249
lastChannel: "whatsapp",
250250
lastAccountId: undefined,
@@ -271,7 +271,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
271271
entry: { ...baseEntry, lastChannel: "webchat", lastTo: "web" },
272272
expected: {
273273
channel: "none",
274-
reason: "no-target",
274+
reason: "target-none",
275275
accountId: undefined,
276276
lastChannel: undefined,
277277
lastAccountId: undefined,
@@ -294,7 +294,10 @@ describe("resolveHeartbeatDeliveryTarget", () => {
294294
},
295295
{
296296
name: "normalize prefixed whatsapp group targets",
297-
cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } },
297+
cfg: {
298+
agents: { defaults: { heartbeat: { target: "last" } } },
299+
channels: { whatsapp: { allowFrom: ["+1555"] } },
300+
},
298301
entry: {
299302
...baseEntry,
300303
lastChannel: "whatsapp",
@@ -927,7 +930,7 @@ describe("runHeartbeatOnce", () => {
927930
try {
928931
const cfg: OpenClawConfig = {
929932
agents: {
930-
defaults: { workspace: tmpDir, heartbeat: { every: "5m" } },
933+
defaults: { workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" } },
931934
list: [{ id: "work", default: true }],
932935
},
933936
channels: { whatsapp: { allowFrom: ["*"] } },
@@ -1148,4 +1151,110 @@ describe("runHeartbeatOnce", () => {
11481151
}
11491152
}
11501153
});
1154+
1155+
it("uses an internal-only cron prompt when heartbeat delivery target is none", async () => {
1156+
const tmpDir = await createCaseDir("hb-cron-target-none");
1157+
const storePath = path.join(tmpDir, "sessions.json");
1158+
const cfg: OpenClawConfig = {
1159+
agents: {
1160+
defaults: {
1161+
workspace: tmpDir,
1162+
heartbeat: { every: "5m", target: "none" },
1163+
},
1164+
},
1165+
channels: { whatsapp: { allowFrom: ["*"] } },
1166+
session: { store: storePath },
1167+
};
1168+
const sessionKey = resolveMainSessionKey(cfg);
1169+
await fs.writeFile(
1170+
storePath,
1171+
JSON.stringify({
1172+
[sessionKey]: {
1173+
sessionId: "sid",
1174+
updatedAt: Date.now(),
1175+
lastChannel: "whatsapp",
1176+
lastTo: "+1555",
1177+
},
1178+
}),
1179+
);
1180+
enqueueSystemEvent("Cron: rotate logs", {
1181+
sessionKey,
1182+
contextKey: "cron:rotate-logs",
1183+
});
1184+
1185+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
1186+
replySpy.mockResolvedValue({ text: "Handled internally" });
1187+
const sendWhatsApp = vi
1188+
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
1189+
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
1190+
1191+
try {
1192+
const res = await runHeartbeatOnce({
1193+
cfg,
1194+
reason: "interval",
1195+
deps: createHeartbeatDeps(sendWhatsApp),
1196+
});
1197+
expect(res.status).toBe("ran");
1198+
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
1199+
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
1200+
expect(calledCtx.Provider).toBe("cron-event");
1201+
expect(calledCtx.Body).toContain("Handle this reminder internally");
1202+
expect(calledCtx.Body).not.toContain("Please relay this reminder to the user");
1203+
} finally {
1204+
replySpy.mockRestore();
1205+
}
1206+
});
1207+
1208+
it("uses an internal-only exec prompt when heartbeat delivery target is none", async () => {
1209+
const tmpDir = await createCaseDir("hb-exec-target-none");
1210+
const storePath = path.join(tmpDir, "sessions.json");
1211+
const cfg: OpenClawConfig = {
1212+
agents: {
1213+
defaults: {
1214+
workspace: tmpDir,
1215+
heartbeat: { every: "5m", target: "none" },
1216+
},
1217+
},
1218+
channels: { whatsapp: { allowFrom: ["*"] } },
1219+
session: { store: storePath },
1220+
};
1221+
const sessionKey = resolveMainSessionKey(cfg);
1222+
await fs.writeFile(
1223+
storePath,
1224+
JSON.stringify({
1225+
[sessionKey]: {
1226+
sessionId: "sid",
1227+
updatedAt: Date.now(),
1228+
lastChannel: "whatsapp",
1229+
lastTo: "+1555",
1230+
},
1231+
}),
1232+
);
1233+
enqueueSystemEvent("exec finished: backup completed", {
1234+
sessionKey,
1235+
contextKey: "exec:backup",
1236+
});
1237+
1238+
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
1239+
replySpy.mockResolvedValue({ text: "Handled internally" });
1240+
const sendWhatsApp = vi
1241+
.fn<NonNullable<HeartbeatDeps["sendWhatsApp"]>>()
1242+
.mockResolvedValue({ messageId: "m1", toJid: "jid" });
1243+
1244+
try {
1245+
const res = await runHeartbeatOnce({
1246+
cfg,
1247+
reason: "exec-event",
1248+
deps: createHeartbeatDeps(sendWhatsApp),
1249+
});
1250+
expect(res.status).toBe("ran");
1251+
expect(sendWhatsApp).toHaveBeenCalledTimes(0);
1252+
const calledCtx = replySpy.mock.calls[0]?.[0] as { Provider?: string; Body?: string };
1253+
expect(calledCtx.Provider).toBe("exec-event");
1254+
expect(calledCtx.Body).toContain("Handle the result internally");
1255+
expect(calledCtx.Body).not.toContain("Please relay the command output to the user");
1256+
} finally {
1257+
replySpy.mockRestore();
1258+
}
1259+
});
11511260
});

src/infra/heartbeat-runner.ts

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { escapeRegExp } from "../utils.js";
4444
import { formatErrorMessage, hasErrnoCode } from "./errors.js";
4545
import { isWithinActiveHours } from "./heartbeat-active-hours.js";
4646
import {
47+
buildExecEventPrompt,
4748
buildCronEventPrompt,
4849
isCronSystemEvent,
4950
isExecCompletionEvent,
@@ -95,15 +96,7 @@ export type HeartbeatSummary = {
9596
ackMaxChars: number;
9697
};
9798

98-
const DEFAULT_HEARTBEAT_TARGET = "last";
99-
100-
// Prompt used when an async exec has completed and the result should be relayed to the user.
101-
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
102-
// instead of just "HEARTBEAT_OK".
103-
const EXEC_EVENT_PROMPT =
104-
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
105-
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
106-
"If it failed, explain what went wrong.";
99+
const DEFAULT_HEARTBEAT_TARGET = "none";
107100
export { isCronSystemEvent };
108101

109102
type HeartbeatAgentState = {
@@ -615,12 +608,12 @@ export async function runHeartbeatOnce(opts: {
615608
if (delivery.reason === "unknown-account") {
616609
log.warn("heartbeat: unknown accountId", {
617610
accountId: delivery.accountId ?? heartbeatAccountId ?? null,
618-
target: heartbeat?.target ?? "last",
611+
target: heartbeat?.target ?? "none",
619612
});
620613
} else if (heartbeatAccountId) {
621614
log.info("heartbeat: using explicit accountId", {
622615
accountId: delivery.accountId ?? heartbeatAccountId,
623-
target: heartbeat?.target ?? "last",
616+
target: heartbeat?.target ?? "none",
624617
channel: delivery.channel,
625618
});
626619
}
@@ -654,10 +647,13 @@ export async function runHeartbeatOnce(opts: {
654647
.map((event) => event.text);
655648
const hasExecCompletion = pendingEvents.some(isExecCompletionEvent);
656649
const hasCronEvents = cronEvents.length > 0;
650+
const canRelayToUser = Boolean(
651+
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
652+
);
657653
const prompt = hasExecCompletion
658-
? EXEC_EVENT_PROMPT
654+
? buildExecEventPrompt({ deliverToUser: canRelayToUser })
659655
: hasCronEvents
660-
? buildCronEventPrompt(cronEvents)
656+
? buildCronEventPrompt(cronEvents, { deliverToUser: canRelayToUser })
661657
: resolveHeartbeatPrompt(cfg, heartbeat);
662658
const ctx = {
663659
Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),

src/infra/outbound/targets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
210210
const { cfg, entry } = params;
211211
const heartbeat = params.heartbeat ?? cfg.agents?.defaults?.heartbeat;
212212
const rawTarget = heartbeat?.target;
213-
let target: HeartbeatTarget = "last";
213+
let target: HeartbeatTarget = "none";
214214
if (rawTarget === "none" || rawTarget === "last") {
215215
target = rawTarget;
216216
} else if (typeof rawTarget === "string") {

0 commit comments

Comments
 (0)