diff --git a/CHANGELOG.md b/CHANGELOG.md index 149a517fd64..d0fa2607a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ Docs: https://docs.openclaw.ai - Branding/Docs + Apple surfaces: replace remaining `bot.molt` launchd label, bundle-id, logging subsystem, and command examples with `ai.openclaw` across docs, iOS app surfaces, helper scripts, and CLI test fixtures. - Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow. - Onboarding/Security: clarify onboarding security notices that OpenClaw is personal-by-default (single trusted operator boundary) and shared/multi-user setups require explicit lock-down/hardening. +- Heartbeat/Config: replace heartbeat DM toggle with `agents.defaults.heartbeat.directPolicy` (`allow` | `block`; also supported per-agent via `agents.list[].heartbeat.directPolicy`) for clearer delivery semantics. + +### Breaking + +- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override). ### Fixes diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index b03a0daa4fc..8d147b23fd7 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -800,6 +800,7 @@ Periodic heartbeat runs. includeReasoning: false, session: "main", to: "+15555550123", + directPolicy: "allow", // allow (default) | block target: "none", // default: none | options: last | whatsapp | telegram | discord | ... prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, @@ -812,7 +813,7 @@ Periodic heartbeat runs. - `every`: duration string (ms/s/m/h). Default: `30m`. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. -- Heartbeats never deliver to direct/DM chat targets when the destination can be classified as direct (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs); those runs still execute, but outbound delivery is skipped. +- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3f7403d4647..ff3179d28e2 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -239,7 +239,8 @@ When validation fails: ``` - `every`: duration string (`30m`, `2h`). Set `0m` to disable. - - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` (DM-style `user:` heartbeat delivery is blocked) + - `target`: `last` | `whatsapp` | `telegram` | `discord` | `none` + - `directPolicy`: `allow` (default) or `block` for DM-style heartbeat targets - See [Heartbeat](/gateway/heartbeat) for the full guide. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index cf7ea489c40..70f4b968233 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -215,7 +215,9 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `last`: deliver to the last used external channel. - explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`. - `none` (default): run the heartbeat but **do not deliver** externally. -- Direct/DM heartbeat destinations are blocked when target parsing identifies a direct chat (for example `user:`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). +- `directPolicy`: controls direct/DM delivery behavior: + - `allow` (default): allow direct/DM heartbeat delivery. + - `block`: suppress direct/DM delivery (`reason=dm-blocked`). - `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). @@ -236,7 +238,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `session` only affects the run context; delivery is controlled by `target` and `to`. - To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session. -- Heartbeat deliveries never send to direct/DM targets when the destination is identified as direct; those runs still execute, but outbound delivery is skipped. +- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn. - If the main queue is busy, the heartbeat is skipped and retried later. - If `target` resolves to no external destination, the run still happens but no outbound message is sent. diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 23483076102..45963f15579 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -174,7 +174,7 @@ Common signatures: - `cron: timer tick failed` → scheduler tick failed; check file/log/runtime errors. - `heartbeat skipped` with `reason=quiet-hours` → outside active hours window. - `heartbeat: unknown accountId` → invalid account id for heartbeat delivery target. -- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style `user:` destination (blocked by design). +- `heartbeat skipped` with `reason=dm-blocked` → heartbeat target resolved to a DM-style destination while `agents.defaults.heartbeat.directPolicy` (or per-agent override) is set to `block`. Related: diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md index 058f2fa67fe..671efe420c7 100644 --- a/docs/start/openclaw.md +++ b/docs/start/openclaw.md @@ -164,7 +164,7 @@ Set `agents.defaults.heartbeat.every: "0m"` to disable. - If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), OpenClaw skips the heartbeat run to save API calls. - If the file is missing, the heartbeat still runs and the model decides what to do. - If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat. -- Heartbeat delivery to DM-style `user:` targets is blocked; those runs still execute but skip outbound delivery. +- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active. - Heartbeats run full agent turns — shorter intervals burn more tokens. ```json5 diff --git a/src/config/config.plugin-validation.test.ts b/src/config/config.plugin-validation.test.ts index d9e6b3190e1..62584f138de 100644 --- a/src/config/config.plugin-validation.test.ts +++ b/src/config/config.plugin-validation.test.ts @@ -234,4 +234,32 @@ describe("config plugin validation", () => { }); } }); + + it("accepts heartbeat directPolicy enum values", async () => { + const home = await createCaseHome(); + const res = validateInHome(home, { + agents: { + defaults: { heartbeat: { target: "last", directPolicy: "block" } }, + list: [{ id: "pi", heartbeat: { directPolicy: "allow" } }], + }, + }); + expect(res.ok).toBe(true); + }); + + it("rejects invalid heartbeat directPolicy values", async () => { + const home = await createCaseHome(); + const res = validateInHome(home, { + agents: { + defaults: { heartbeat: { directPolicy: "maybe" } }, + list: [{ id: "pi" }], + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + const hasIssue = res.issues.some( + (issue) => issue.path === "agents.defaults.heartbeat.directPolicy", + ); + expect(hasIssue).toBe(true); + } + }); }); diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index a479ec0a853..f32433e1333 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1238,6 +1238,10 @@ export const FIELD_HELP: Record = { "Shows degraded/error heartbeat alerts when true so operator channels surface problems promptly. Keep enabled in production so broken channel states are visible.", "channels.defaults.heartbeat.useIndicator": "Enables concise indicator-style heartbeat rendering instead of verbose status text where supported. Use indicator mode for dense dashboards with many active channels.", + "agents.defaults.heartbeat.directPolicy": + 'Controls whether heartbeat delivery may target direct/DM chats: "allow" (default) permits DM delivery and "block" suppresses direct-target sends.', + "agents.list.*.heartbeat.directPolicy": + 'Per-agent override for heartbeat direct/DM delivery policy; use "block" for agents that should only send heartbeat alerts to non-DM destinations.', "channels.telegram.configWrites": "Allow Telegram to write config in response to channel events/commands (default: true).", "channels.telegram.botToken": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index cd28b1fafb8..8c0c6350d7b 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -402,6 +402,8 @@ export const FIELD_LABELS: Record = { "Compaction Memory Flush Soft Threshold", "agents.defaults.compaction.memoryFlush.prompt": "Compaction Memory Flush Prompt", "agents.defaults.compaction.memoryFlush.systemPrompt": "Compaction Memory Flush System Prompt", + "agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy", + "agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy", "agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings", "agents.defaults.sandbox.browser.network": "Sandbox Browser Network", "agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range", diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index e8eac685086..afc65e3daec 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -213,6 +213,8 @@ export type AgentDefaultsConfig = { session?: string; /** Delivery target ("last", "none", or a channel id). */ target?: "last" | "none" | ChannelId; + /** Direct/DM delivery policy. Default: "allow". */ + directPolicy?: "allow" | "block"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). Supports :topic:NNN suffix for Telegram topics. */ to?: string; /** Optional account id for multi-account channels. */ diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index c477cc1743b..9df0776b956 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -26,6 +26,7 @@ export const HeartbeatSchema = z session: z.string().optional(), includeReasoning: z.boolean().optional(), target: z.string().optional(), + directPolicy: z.union([z.literal("allow"), z.literal("block")]).optional(), to: z.string().optional(), accountId: z.string().optional(), prompt: z.string().optional(), diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 0ec2afcafdd..c4f45b5e039 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -325,6 +325,30 @@ describe("resolveHeartbeatDeliveryTarget", () => { lastAccountId: undefined, }, }, + { + name: "allow direct target by default", + cfg: { agents: { defaults: { heartbeat: { target: "last" } } } }, + entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + expected: { + channel: "telegram", + to: "5232990709", + accountId: undefined, + lastChannel: "telegram", + lastAccountId: undefined, + }, + }, + { + name: "block direct target when directPolicy is block", + cfg: { agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } } } }, + entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + expected: { + channel: "none", + reason: "dm-blocked", + accountId: undefined, + lastChannel: "telegram", + lastAccountId: undefined, + }, + }, ]; for (const testCase of cases) { expect( diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 8f120702de0..cbad502cdde 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -301,7 +301,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("63448508"); }); - it("blocks heartbeat delivery to Slack DMs and avoids inherited threadId", () => { + it("allows heartbeat delivery to Slack DMs and avoids inherited threadId by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -317,12 +317,34 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("slack"); + expect(resolved.to).toBe("user:U123"); + expect(resolved.threadId).toBeUndefined(); + }); + + it("blocks heartbeat delivery to Slack DMs when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-outbound", + updatedAt: 1, + lastChannel: "slack", + lastTo: "user:U123", + lastThreadId: "1739142736.000100", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); expect(resolved.threadId).toBeUndefined(); }); - it("blocks heartbeat delivery to Discord DMs", () => { + it("allows heartbeat delivery to Discord DMs by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -337,11 +359,11 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); + expect(resolved.channel).toBe("discord"); + expect(resolved.to).toBe("user:12345"); }); - it("blocks heartbeat delivery to Telegram direct chats", () => { + it("allows heartbeat delivery to Telegram direct chats by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -356,6 +378,26 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("telegram"); + expect(resolved.to).toBe("5232990709"); + }); + + it("blocks heartbeat delivery to Telegram direct chats when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-telegram-direct", + updatedAt: 1, + lastChannel: "telegram", + lastTo: "5232990709", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); }); @@ -379,7 +421,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("-1001234567890"); }); - it("blocks heartbeat delivery to WhatsApp direct chats", () => { + it("allows heartbeat delivery to WhatsApp direct chats by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -394,8 +436,8 @@ describe("resolveSessionDeliveryTarget", () => { }, }); - expect(resolved.channel).toBe("none"); - expect(resolved.reason).toBe("dm-blocked"); + expect(resolved.channel).toBe("whatsapp"); + expect(resolved.to).toBe("+15551234567"); }); it("keeps heartbeat delivery to WhatsApp groups", () => { @@ -417,7 +459,7 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.to).toBe("120363140186826074@g.us"); }); - it("uses session chatType hint when target parser cannot classify", () => { + it("uses session chatType hint when target parser cannot classify and allows direct by default", () => { const cfg: OpenClawConfig = {}; const resolved = resolveHeartbeatDeliveryTarget({ cfg, @@ -433,6 +475,27 @@ describe("resolveSessionDeliveryTarget", () => { }, }); + expect(resolved.channel).toBe("imessage"); + expect(resolved.to).toBe("chat-guid-unknown-shape"); + }); + + it("blocks session chatType direct hints when directPolicy is block", () => { + const cfg: OpenClawConfig = {}; + const resolved = resolveHeartbeatDeliveryTarget({ + cfg, + entry: { + sessionId: "sess-heartbeat-imessage-direct", + updatedAt: 1, + lastChannel: "imessage", + lastTo: "chat-guid-unknown-shape", + chatType: "direct", + }, + heartbeat: { + target: "last", + directPolicy: "block", + }, + }); + expect(resolved.channel).toBe("none"); expect(resolved.reason).toBe("dm-blocked"); }); diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index d9411e2223c..89e68e57566 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -330,7 +330,7 @@ export function resolveHeartbeatDeliveryTarget(params: { to: resolved.to, sessionChatType: sessionChatTypeHint, }); - if (deliveryChatType === "direct") { + if (deliveryChatType === "direct" && heartbeat?.directPolicy === "block") { return buildNoHeartbeatDeliveryTarget({ reason: "dm-blocked", accountId: effectiveAccountId,