mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-08 06:54:24 +00:00
fix(heartbeat): default target none and internalize relay prompts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
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