heartbeat: add empty HEARTBEAT.md compatibility policy

This commit is contained in:
Tak Hoffman
2026-02-14 05:39:54 -06:00
parent e21a7aad54
commit a775d39e64
15 changed files with 233 additions and 50 deletions

View File

@@ -16,8 +16,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
@@ -52,6 +50,7 @@ Docs: https://docs.openclaw.ai
- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
- Heartbeat: add `agents.defaults.heartbeat.emptyFilePolicy` (`skip`|`run`) with compatibility defaults (`skip` for existing config files, `run` for fresh installs) so empty/comment-only `HEARTBEAT.md` behavior is explicit and predictable.
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.

View File

@@ -89,7 +89,7 @@ Common signatures:
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
- `requests-in-flight` → main lane busy; heartbeat deferred.
- `empty-heartbeat-file``HEARTBEAT.md` exists but has no actionable content.
- `empty-heartbeat-file``HEARTBEAT.md` is comments-only and `heartbeat.emptyFilePolicy` is `skip`.
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
## Timezone and activeHours gotchas

View File

@@ -209,6 +209,10 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `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).
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
- `emptyFilePolicy`: behavior when `HEARTBEAT.md` exists but has only comments/headers.
- `run`: heartbeat still runs.
- `skip`: skip this heartbeat run with reason `empty-heartbeat-file`.
- Default when unset: `skip` for existing config files (compat), `run` for fresh installs.
- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
- `"local"`: always uses the host system timezone.
@@ -297,9 +301,14 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
safe to include every 30 minutes.
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 `HEARTBEAT.md` exists, OpenClaw includes it as optional guidance for heartbeat runs.
If the file is missing, heartbeat still runs and the model decides what to do.
If `HEARTBEAT.md` only has comments/headers, `heartbeat.emptyFilePolicy` controls behavior:
- `run`: heartbeat still runs (fresh-install default)
- `skip`: heartbeat skips this tick (`empty-heartbeat-file`; compat default for existing config files)
To disable heartbeat entirely (scheduler off), set `agents.defaults.heartbeat.every: "0m"`.
Keep it tiny (short checklist or reminders) to avoid prompt bloat.

View File

@@ -7,6 +7,10 @@ read_when:
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Leave this file missing, empty, or comments-only if you don't want extra heartbeat guidance.
# Set agents.defaults.heartbeat.emptyFilePolicy: "run" if you want heartbeat to run even when this file is comments-only.
# Set agents.defaults.heartbeat.every: "0m" in config to disable heartbeat entirely.
# Add tasks below when you want the agent to check something periodically.

View File

@@ -159,10 +159,13 @@ Example:
By default, OpenClaw runs a heartbeat every 30 minutes with the 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.`
Set `agents.defaults.heartbeat.every: "0m"` to disable.
Set `agents.defaults.heartbeat.every: "0m"` to disable heartbeat scheduling entirely.
- 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 `HEARTBEAT.md` exists, OpenClaw includes it as optional heartbeat guidance.
- If the file is missing, heartbeat still runs and the model decides what to do.
- If `HEARTBEAT.md` only has comments/headers, behavior depends on `agents.defaults.heartbeat.emptyFilePolicy`:
- `run`: heartbeat still runs (fresh-install default)
- `skip`: heartbeat is skipped for that tick (compat default for existing config files)
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat.
- Heartbeats run full agent turns — shorter intervals burn more tokens.

View File

@@ -189,8 +189,8 @@ export async function ensureAgentWorkspace(params?: {
const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME);
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
// HEARTBEAT.md is intentionally NOT created from template.
// Per docs: "If the file is missing, the heartbeat still runs and the model decides what to do."
// Creating it from template (which is effectively empty) would cause heartbeat to be skipped.
// It's optional workspace guidance, and heartbeat behavior is controlled by config
// (`agents.defaults.heartbeat.every`) rather than file presence/content.
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
const isBrandNewWorkspace = await (async () => {

View File

@@ -115,4 +115,38 @@ describe("config pruning defaults", () => {
expect(cfg.agents?.defaults?.contextPruning?.mode).toBe("off");
});
});
it("defaults emptyFilePolicy to skip for existing config files", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { heartbeat: { every: "30m" } } } }, null, 2),
"utf-8",
);
const cfg = loadConfig();
expect(cfg.agents?.defaults?.heartbeat?.emptyFilePolicy).toBe("skip");
});
});
it("keeps explicit emptyFilePolicy when configured", async () => {
await withTempHome(async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{ agents: { defaults: { heartbeat: { every: "30m", emptyFilePolicy: "run" } } } },
null,
2,
),
"utf-8",
);
const cfg = loadConfig();
expect(cfg.agents?.defaults?.heartbeat?.emptyFilePolicy).toBe("run");
});
});
});

View File

@@ -1,3 +1,4 @@
import type { AgentDefaultsConfig } from "./types.agent-defaults.js";
import type { OpenClawConfig } from "./types.js";
import type { ModelDefinitionConfig } from "./types.models.js";
import { DEFAULT_CONTEXT_TOKENS } from "../agents/defaults.js";
@@ -440,6 +441,46 @@ export function applyContextPruningDefaults(cfg: OpenClawConfig): OpenClawConfig
};
}
export type HeartbeatEmptyFilePolicyDefaultsOptions = {
configFileExists: boolean;
};
export function applyHeartbeatEmptyFilePolicyDefaults(
cfg: OpenClawConfig,
options: HeartbeatEmptyFilePolicyDefaultsOptions,
): OpenClawConfig {
const defaults = cfg.agents?.defaults;
if (!defaults) {
return cfg;
}
const defaultHeartbeat = defaults.heartbeat;
const hasDefaultPolicy = typeof defaultHeartbeat?.emptyFilePolicy === "string";
if (hasDefaultPolicy) {
return cfg;
}
// Backward compatibility: preserve legacy skip behavior for existing config files.
// Fresh installs (no config file yet) default to "run".
const fallbackPolicy: NonNullable<AgentDefaultsConfig["heartbeat"]>["emptyFilePolicy"] =
options.configFileExists ? "skip" : "run";
const nextDefaults = {
...defaults,
heartbeat: {
...defaultHeartbeat,
emptyFilePolicy: fallbackPolicy,
},
};
return {
...cfg,
agents: {
...cfg.agents,
defaults: nextDefaults,
},
};
}
export function applyCompactionDefaults(cfg: OpenClawConfig): OpenClawConfig {
const defaults = cfg.agents?.defaults;
if (!defaults) {

View File

@@ -19,6 +19,7 @@ import {
applyCompactionDefaults,
applyContextPruningDefaults,
applyAgentDefaults,
applyHeartbeatEmptyFilePolicyDefaults,
applyLoggingDefaults,
applyMessageDefaults,
applyModelDefaults,
@@ -610,10 +611,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
warnIfConfigFromFuture(validated.config, deps.logger);
const cfg = applyModelDefaults(
applyCompactionDefaults(
applyContextPruningDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
applyHeartbeatEmptyFilePolicyDefaults(
applyContextPruningDefaults(
applyAgentDefaults(
applySessionDefaults(applyLoggingDefaults(applyMessageDefaults(validated.config))),
),
),
{ configFileExists: true },
),
),
);
@@ -663,8 +667,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const config = applyTalkApiKey(
applyModelDefaults(
applyCompactionDefaults(
applyContextPruningDefaults(
applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))),
applyHeartbeatEmptyFilePolicyDefaults(
applyContextPruningDefaults(
applyAgentDefaults(applySessionDefaults(applyMessageDefaults({}))),
),
{ configFileExists: false },
),
),
),
@@ -796,10 +803,13 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
config: normalizeConfigPaths(
applyTalkApiKey(
applyModelDefaults(
applyAgentDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
applyHeartbeatEmptyFilePolicyDefaults(
applyAgentDefaults(
applySessionDefaults(
applyLoggingDefaults(applyMessageDefaults(validated.config)),
),
),
{ configFileExists: true },
),
),
),

View File

@@ -143,6 +143,10 @@ export const FIELD_HELP: Record<string, string> = {
"agents.defaults.envelopeTimestamp":
'Include absolute timestamps in message envelopes ("on" or "off").',
"agents.defaults.envelopeElapsed": 'Include elapsed time in message envelopes ("on" or "off").',
"agents.defaults.heartbeat.emptyFilePolicy":
'Behavior when HEARTBEAT.md has only comments/headers: "skip" to preserve legacy behavior or "run" to keep heartbeat running.',
"agents.list[].heartbeat.emptyFilePolicy":
'Per-agent override for empty HEARTBEAT.md behavior ("skip" or "run").',
"agents.defaults.models": "Configured model catalog (keys are full provider/model IDs).",
"agents.defaults.memorySearch":
"Vector search over MEMORY.md and memory/*.md (per-agent overrides supported).",

View File

@@ -194,6 +194,8 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.humanDelay.mode": "Human Delay Mode",
"agents.defaults.humanDelay.minMs": "Human Delay Min (ms)",
"agents.defaults.humanDelay.maxMs": "Human Delay Max (ms)",
"agents.defaults.heartbeat.emptyFilePolicy": "Heartbeat Empty File Policy",
"agents.list[].heartbeat.emptyFilePolicy": "Agent Heartbeat Empty File Policy",
"agents.defaults.cliBackends": "CLI Backends",
"commands.native": "Native Commands",
"commands.nativeSkills": "Native Skill Commands",

View File

@@ -190,6 +190,12 @@ export type AgentDefaultsConfig = {
prompt?: string;
/** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */
ackMaxChars?: number;
/**
* Behavior when HEARTBEAT.md exists but has no actionable content (comments/headers only).
* - "skip": skip this heartbeat run
* - "run": continue heartbeat run (default for fresh installs)
*/
emptyFilePolicy?: "skip" | "run";
/**
* When enabled, deliver the model's reasoning payload for heartbeat runs (when available)
* as a separate message prefixed with `Reasoning:` (same as `/reasoning on`).

View File

@@ -28,6 +28,7 @@ export const HeartbeatSchema = z
accountId: z.string().optional(),
prompt: z.string().optional(),
ackMaxChars: z.number().int().nonnegative().optional(),
emptyFilePolicy: z.union([z.literal("skip"), z.literal("run")]).optional(),
})
.strict()
.superRefine((val, ctx) => {

View File

@@ -948,7 +948,7 @@ describe("runHeartbeatOnce", () => {
}
});
it("skips heartbeat when HEARTBEAT.md is effectively empty (saves API calls)", async () => {
it("runs heartbeat when HEARTBEAT.md is effectively empty", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
@@ -991,6 +991,73 @@ describe("runHeartbeatOnce", () => {
),
);
replySpy.mockResolvedValue({ text: "heartbeat ran" });
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
});
const res = await runHeartbeatOnce({
cfg,
deps: {
sendWhatsApp,
getQueueSize: () => 0,
nowMs: () => 0,
webAuthExists: async () => true,
hasActiveWebListener: () => true,
},
});
expect(res.status).toBe("ran");
expect(replySpy).toHaveBeenCalled();
expect(sendWhatsApp).toHaveBeenCalledTimes(1);
} finally {
replySpy.mockRestore();
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("skips heartbeat when HEARTBEAT.md is effectively empty and emptyFilePolicy=skip", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
const replySpy = vi.spyOn(replyModule, "getReplyFromConfig");
try {
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(
path.join(workspaceDir, "HEARTBEAT.md"),
"# HEARTBEAT.md\n\n## Tasks\n\n",
"utf-8",
);
const cfg: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
heartbeat: { every: "5m", target: "whatsapp", emptyFilePolicy: "skip" },
},
},
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",
},
},
null,
2,
),
);
const sendWhatsApp = vi.fn().mockResolvedValue({
messageId: "m1",
toJid: "jid",
@@ -1007,7 +1074,6 @@ describe("runHeartbeatOnce", () => {
},
});
// Should skip without making API call
expect(res.status).toBe("skipped");
if (res.status === "skipped") {
expect(res.reason).toBe("empty-heartbeat-file");
@@ -1020,7 +1086,7 @@ describe("runHeartbeatOnce", () => {
}
});
it("does not skip wake-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
it("runs wake-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");
@@ -1088,7 +1154,7 @@ describe("runHeartbeatOnce", () => {
}
});
it("does not skip hook-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
it("runs hook-triggered heartbeat when HEARTBEAT.md is effectively empty", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-hb-"));
const storePath = path.join(tmpDir, "sessions.json");
const workspaceDir = path.join(tmpDir, "workspace");

View File

@@ -92,6 +92,7 @@ export type HeartbeatSummary = {
};
const DEFAULT_HEARTBEAT_TARGET = "last";
const DEFAULT_EMPTY_FILE_POLICY: NonNullable<HeartbeatConfig>["emptyFilePolicy"] = "run";
// 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
@@ -252,6 +253,10 @@ function resolveHeartbeatAckMaxChars(cfg: OpenClawConfig, heartbeat?: HeartbeatC
);
}
function resolveHeartbeatEmptyFilePolicy(cfg: OpenClawConfig, heartbeat?: HeartbeatConfig) {
return heartbeat?.emptyFilePolicy ?? cfg.agents?.defaults?.heartbeat?.emptyFilePolicy ?? "run";
}
function resolveHeartbeatSession(
cfg: OpenClawConfig,
agentId?: string,
@@ -424,33 +429,32 @@ export async function runHeartbeatOnce(opts: {
return { status: "skipped", reason: "requests-in-flight" };
}
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
// This saves API calls/costs when the file is effectively empty (only comments/headers).
// EXCEPTION: Don't skip for exec events, cron events, or explicit wake requests -
// they have pending system events to process regardless of HEARTBEAT.md content.
const isExecEventReason = opts.reason === "exec-event";
const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
!isExecEventReason &&
!isCronEventReason &&
!isWakeReason
) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
durationMs: Date.now() - startedAt,
});
return { status: "skipped", reason: "empty-heartbeat-file" };
const emptyFilePolicy =
resolveHeartbeatEmptyFilePolicy(cfg, heartbeat) ?? DEFAULT_EMPTY_FILE_POLICY;
if (emptyFilePolicy === "skip") {
const isExecEventReason = opts.reason === "exec-event";
const isCronEventReason = Boolean(opts.reason?.startsWith("cron:"));
const isWakeReason = opts.reason === "wake" || Boolean(opts.reason?.startsWith("hook:"));
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&
!isExecEventReason &&
!isCronEventReason &&
!isWakeReason
) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
durationMs: Date.now() - startedAt,
});
return { status: "skipped", reason: "empty-heartbeat-file" };
}
} catch {
// File missing/unreadable: proceed with heartbeat.
}
} catch {
// File doesn't exist or can't be read - proceed with heartbeat.
// The LLM prompt says "if it exists" so this is expected behavior.
}
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg, agentId, heartbeat);