From 51633fc13a553c831cf7ec4a4dbe38e9bef6fec0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 8 Apr 2026 00:51:13 +0100 Subject: [PATCH] fix: respect disabled heartbeat guidance --- docs/concepts/system-prompt.md | 12 ++-- docs/gateway/heartbeat.md | 10 +++- src/agents/bootstrap-files.test.ts | 23 ++++++++ src/agents/bootstrap-files.ts | 12 ++-- src/agents/heartbeat-system-prompt.test.ts | 66 ++++++++++++++++++++++ src/agents/heartbeat-system-prompt.ts | 64 ++++++++++++++++++--- 6 files changed, 168 insertions(+), 19 deletions(-) diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 5f1136d372f..463f1888abe 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -43,7 +43,7 @@ The prompt is intentionally compact and uses fixed sections: - **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available. - **Current Date & Time**: user-local time, timezone, and time format. - **Reply Tags**: optional reply tag syntax for supported providers. -- **Heartbeats**: heartbeat prompt and ack behavior. +- **Heartbeats**: heartbeat prompt and ack behavior, when heartbeats are enabled for the default agent. - **Runtime**: host, OS, node, model, repo root (when detected), thinking level (one line). - **Reasoning**: current visibility level + /reasoning toggle hint. @@ -103,10 +103,12 @@ Bootstrap files are trimmed and appended under **Project Context** so the model - `BOOTSTRAP.md` (only on brand-new workspaces) - `MEMORY.md` when present, otherwise `memory.md` as a lowercase fallback -All of these files are **injected into the context window** on every turn, which -means they consume tokens. Keep them concise — especially `MEMORY.md`, which can -grow over time and lead to unexpectedly high context usage and more frequent -compaction. +All of these files are **injected into the context window** on every turn unless +a file-specific gate applies. `HEARTBEAT.md` is omitted on normal runs when +heartbeats are disabled for the default agent or +`agents.defaults.heartbeat.includeSystemPromptSection` is false. Keep injected +files concise — especially `MEMORY.md`, which can grow over time and lead to +unexpectedly high context usage and more frequent compaction. > **Note:** `memory/*.md` daily files are **not** injected automatically. They > are accessed on demand via the `memory_search` and `memory_get` tools, so they diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index d846daa2b6f..9c5d9cb9d6e 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -54,7 +54,10 @@ Example config: - Prompt body (configurable via `agents.defaults.heartbeat.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.` - The heartbeat prompt is sent **verbatim** as the user message. The system - prompt includes a “Heartbeat” section and the run is flagged internally. + prompt includes a “Heartbeat” section only when heartbeats are enabled for the + default agent, and the run is flagged internally. +- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` + from bootstrap context so the model does not see heartbeat-only instructions. - Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window. @@ -330,6 +333,11 @@ 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. +On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is +enabled for the default agent. Disabling the heartbeat cadence with `0m` or +setting `includeSystemPromptSection: false` omits it from normal bootstrap +context. + 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. That skip is reported as `reason=empty-heartbeat-file`. diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts index e3a19743cb1..720f2ca5799 100644 --- a/src/agents/bootstrap-files.test.ts +++ b/src/agents/bootstrap-files.test.ts @@ -156,6 +156,29 @@ describe("resolveBootstrapContextForRun", () => { expect(files.some((file) => file.name === "AGENTS.md")).toBe(true); }); + it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat cadence is disabled", async () => { + const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); + await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "repo rules", "utf8"); + + const files = await resolveBootstrapFilesForRun({ + workspaceDir, + config: { + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + list: [{ id: "main" }], + }, + }, + }); + + expect(files.some((file) => file.name === "HEARTBEAT.md")).toBe(false); + expect(files.some((file) => file.name === "AGENTS.md")).toBe(true); + }); + it("keeps HEARTBEAT.md for actual heartbeat runs even when the prompt section is disabled", async () => { const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-"); await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8"); diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts index 5bc5cc889c9..b28e136d2fc 100644 --- a/src/agents/bootstrap-files.ts +++ b/src/agents/bootstrap-files.ts @@ -2,9 +2,10 @@ import fs from "node:fs/promises"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentContextInjection } from "../config/types.agent-defaults.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; -import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js"; +import { resolveSessionAgentIds } from "./agent-scope.js"; import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js"; import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js"; +import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import { buildBootstrapContextFiles, @@ -163,10 +164,11 @@ function shouldExcludeHeartbeatBootstrapFile(params: { if (sessionAgentId !== defaultAgentId) { return false; } - const defaults = params.config.agents?.defaults?.heartbeat; - const overrides = resolveAgentConfig(params.config, sessionAgentId)?.heartbeat; - const merged = !defaults && !overrides ? overrides : { ...defaults, ...overrides }; - return merged?.includeSystemPromptSection === false; + return !shouldIncludeHeartbeatGuidanceForSystemPrompt({ + config: params.config, + agentId: sessionAgentId, + defaultAgentId, + }); } function filterHeartbeatBootstrapFile( diff --git a/src/agents/heartbeat-system-prompt.test.ts b/src/agents/heartbeat-system-prompt.test.ts index 973cdae8d5e..7ff964c73bf 100644 --- a/src/agents/heartbeat-system-prompt.test.ts +++ b/src/agents/heartbeat-system-prompt.test.ts @@ -20,6 +20,72 @@ describe("resolveHeartbeatPromptForSystemPrompt", () => { ).toBeUndefined(); }); + it("omits the heartbeat section when the default cadence is disabled", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { + heartbeat: { + every: "0m", + }, + }, + }, + }, + agentId: "main", + defaultAgentId: "main", + }), + ).toBeUndefined(); + }); + + it("omits the heartbeat section when the default-agent override disables cadence", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + defaults: { + heartbeat: { + every: "30m", + }, + }, + list: [ + { + id: "main", + heartbeat: { + every: "0m", + }, + }, + ], + }, + }, + agentId: "main", + defaultAgentId: "main", + }), + ).toBeUndefined(); + }); + + it("omits the heartbeat section when only a non-default agent has explicit heartbeat config", () => { + expect( + resolveHeartbeatPromptForSystemPrompt({ + config: { + agents: { + list: [ + { id: "main", default: true }, + { + id: "ops", + heartbeat: { + every: "30m", + }, + }, + ], + }, + }, + agentId: "main", + defaultAgentId: "main", + }), + ).toBeUndefined(); + }); + it("honors default-agent overrides for the prompt text", () => { expect( resolveHeartbeatPromptForSystemPrompt({ diff --git a/src/agents/heartbeat-system-prompt.ts b/src/agents/heartbeat-system-prompt.ts index 6a271a3f7e2..5b69bfebb7c 100644 --- a/src/agents/heartbeat-system-prompt.ts +++ b/src/agents/heartbeat-system-prompt.ts @@ -1,7 +1,13 @@ -import { resolveHeartbeatPrompt as resolveHeartbeatPromptText } from "../auto-reply/heartbeat.js"; +import { + DEFAULT_HEARTBEAT_EVERY, + resolveHeartbeatPrompt as resolveHeartbeatPromptText, +} from "../auto-reply/heartbeat.js"; +import { parseDurationMs } from "../cli/parse-duration.js"; import type { OpenClawConfig } from "../config/config.js"; import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js"; -import { resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { listAgentEntries, resolveAgentConfig, resolveDefaultAgentId } from "./agent-scope.js"; type HeartbeatConfig = AgentDefaultsConfig["heartbeat"]; @@ -20,18 +26,60 @@ function resolveHeartbeatConfigForSystemPrompt( return { ...defaults, ...overrides }; } +function isHeartbeatEnabledByAgentPolicy(config: OpenClawConfig, agentId: string): boolean { + const resolvedAgentId = normalizeAgentId(agentId); + const agents = listAgentEntries(config); + const hasExplicitHeartbeatAgents = agents.some((entry) => Boolean(entry?.heartbeat)); + if (hasExplicitHeartbeatAgents) { + return agents.some( + (entry) => Boolean(entry?.heartbeat) && normalizeAgentId(entry.id) === resolvedAgentId, + ); + } + return resolvedAgentId === resolveDefaultAgentId(config); +} + +function isHeartbeatCadenceEnabled(heartbeat?: HeartbeatConfig): boolean { + const rawEvery = heartbeat?.every ?? DEFAULT_HEARTBEAT_EVERY; + const trimmedEvery = normalizeOptionalString(rawEvery) ?? ""; + if (!trimmedEvery) { + return false; + } + try { + return parseDurationMs(trimmedEvery, { defaultUnit: "m" }) > 0; + } catch { + return false; + } +} + +export function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: { + config?: OpenClawConfig; + agentId?: string; + defaultAgentId?: string; +}): boolean { + const defaultAgentId = params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); + const agentId = params.agentId ?? defaultAgentId; + if (!agentId || normalizeAgentId(agentId) !== normalizeAgentId(defaultAgentId)) { + return false; + } + if (params.config && !isHeartbeatEnabledByAgentPolicy(params.config, agentId)) { + return false; + } + const heartbeat = resolveHeartbeatConfigForSystemPrompt(params.config, agentId); + if (heartbeat?.includeSystemPromptSection === false) { + return false; + } + return isHeartbeatCadenceEnabled(heartbeat); +} + export function resolveHeartbeatPromptForSystemPrompt(params: { config?: OpenClawConfig; agentId?: string; defaultAgentId?: string; }): string | undefined { - const defaultAgentId = params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); - const agentId = params.agentId ?? defaultAgentId; - if (!agentId || agentId !== defaultAgentId) { - return undefined; - } + const agentId = + params.agentId ?? params.defaultAgentId ?? resolveDefaultAgentId(params.config ?? {}); const heartbeat = resolveHeartbeatConfigForSystemPrompt(params.config, agentId); - if (heartbeat?.includeSystemPromptSection === false) { + if (!shouldIncludeHeartbeatGuidanceForSystemPrompt(params)) { return undefined; } return resolveHeartbeatPromptText(heartbeat?.prompt);