From e2362d352d14617a7f725d8b2254ed5fe4c450aa Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 25 Feb 2026 00:33:32 +0000 Subject: [PATCH] fix(heartbeat): default target none and internalize relay prompts --- CHANGELOG.md | 1 + docs/automation/cron-vs-heartbeat.md | 2 +- docs/gateway/configuration-reference.md | 2 +- docs/gateway/heartbeat.md | 14 +- src/infra/heartbeat-events-filter.test.ts | 21 +++ src/infra/heartbeat-events-filter.ts | 36 +++++- ...tbeat-runner.returns-default-unset.test.ts | 121 +++++++++++++++++- src/infra/heartbeat-runner.ts | 22 ++-- src/infra/outbound/targets.ts | 2 +- 9 files changed, 191 insertions(+), 30 deletions(-) create mode 100644 src/infra/heartbeat-events-filter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05d205455..efb19ccd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md index c25cbcb80..9676d960d 100644 --- a/docs/automation/cron-vs-heartbeat.md +++ b/docs/automation/cron-vs-heartbeat.md @@ -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 }, }, diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 432e472f2..58c1d6fd5 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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, diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index b682da0f8..e22d09906 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -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 | (core or plugin, e.g. "bluebubbles") + target: "last", // default: none | options: last | none | (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 `:topic:`. - `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). diff --git a/src/infra/heartbeat-events-filter.test.ts b/src/infra/heartbeat-events-filter.test.ts new file mode 100644 index 000000000..dab2250dd --- /dev/null +++ b/src/infra/heartbeat-events-filter.test.ts @@ -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"); + }); +}); diff --git a/src/infra/heartbeat-events-filter.ts b/src/infra/heartbeat-events-filter.ts index f5042bb0b..1682c3b30 100644 --- a/src/infra/heartbeat-events-filter.ts +++ b/src/infra/heartbeat-events-filter.ts @@ -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. diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 908f45ebb..2f1748bae 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -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>() + .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>() + .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(); + } + }); }); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index ad2c091f1..b7ae733e6 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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), diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index 8a33353bb..f03918423 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -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") {