mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix: respect disabled heartbeat guidance
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user