mirror of https://github.com/openclaw/openclaw.git
feat(heartbeat): add directPolicy and restore default direct delivery
This commit is contained in:
parent
ee594e2fdb
commit
8a006a3260
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:<id>`, 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:<id>` 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.
|
||||
|
||||
</Accordion>
|
||||
|
|
|
|||
|
|
@ -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:<id>`, 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 `<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).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:<id>` 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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:<id>` targets is blocked; those runs still execute but skip outbound delivery.
|
||||
- By default, heartbeat delivery to DM-style `user:<id>` 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1238,6 +1238,10 @@ export const FIELD_HELP: Record<string, string> = {
|
|||
"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":
|
||||
|
|
|
|||
|
|
@ -402,6 +402,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue