fix: respect disabled heartbeat guidance

This commit is contained in:
Peter Steinberger
2026-04-08 00:51:13 +01:00
parent 4d3c72a521
commit 51633fc13a
6 changed files with 168 additions and 19 deletions

View 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");

View File

@@ -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(

View File

@@ -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({

View File

@@ -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);