mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 03:03:24 -04:00
heartbeat: add empty HEARTBEAT.md compatibility policy
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user