fix: honor heartbeat timeoutSeconds (#64491)

This commit is contained in:
Peter Steinberger
2026-04-10 23:08:23 +01:00
parent 2e8b6eac8d
commit c94888dbee
12 changed files with 154 additions and 12 deletions

View File

@@ -152,6 +152,8 @@ Docs: https://docs.openclaw.ai
- Plugins/context engines: preserve `plugins.slots.contextEngine` through normalization and keep explicitly selected workspace context-engine plugins enabled, so loader diagnostics and plugin activation stop dropping that slot selection. (#64192) Thanks @hclsys.
- Heartbeat: stop top-level `interval:` and `prompt:` fields outside the `tasks:` block from bleeding into the last parsed heartbeat task. (#64488) Thanks @Rahulkumar070.
- Agents/OpenAI replay: preserve malformed function-call arguments in stored assistant history, avoid double-encoding preserved raw strings on replay, and coerce replayed string args back to objects at Anthropic and Google provider boundaries. (#61956) Thanks @100yenadmin.
- Heartbeat/config: accept and honor `agents.defaults.heartbeat.timeoutSeconds` and per-agent heartbeat timeout overrides for heartbeat agent turns. (#64491) Thanks @cedillarack.
## 2026.4.9
### Changes

View File

@@ -1,4 +1,4 @@
1977d4698bb80b9aa99315f1114a61b5692bd5630f2ac4a225d81ddc5459d588 config-baseline.json
d1ee5c4d01deac5cf8ea284cafcd8b6c952b2554d40947d2463d08e314acfcda config-baseline.core.json
995d3ffac3a4a982f9a97d7583c081eb8b48e2d1ed80fa4db5633f2b5c0f543a config-baseline.json
44b2f2fc7fe0092346d33a16936c576e8767b83d13808491e0cb418cd69ecf1b config-baseline.core.json
e1f94346a8507ce3dec763b598e79f3bb89ff2e33189ce977cc87d3b05e71c1d config-baseline.channel.json
0fb10e5cb00e7da2cd07c959e0e3397ecb2fdcf15e13a7eae06a2c5b2346bb10 config-baseline.plugin.json
6c19997f1fb2aff4315f2cb9c7d9e299b403fbc0f9e78e3412cc7fe1c655f222 config-baseline.plugin.json

View File

@@ -1224,6 +1224,7 @@ Periodic heartbeat runs.
prompt: "Read HEARTBEAT.md if it exists...",
ackMaxChars: 300,
suppressToolErrorWarnings: false,
timeoutSeconds: 45,
},
},
},
@@ -1233,6 +1234,7 @@ Periodic heartbeat runs.
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds`.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.

View File

@@ -146,6 +146,7 @@ Example: two agents, only the second agent runs heartbeats.
every: "1h",
target: "whatsapp",
to: "+15551234567",
timeoutSeconds: 45,
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.",
},
},

View File

@@ -15,6 +15,7 @@ const EXPECTED_HEARTBEAT_KEYS = [
"includeSystemPromptSection",
"ackMaxChars",
"suppressToolErrorWarnings",
"timeoutSeconds",
"lightContext",
"isolatedSession",
"target",

View File

@@ -4804,6 +4804,14 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
title: "Heartbeat Suppress Tool Error Warnings",
description: "Suppress tool error warning payloads during heartbeat runs.",
},
timeoutSeconds: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Heartbeat Timeout (Seconds)",
description:
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds.",
},
lightContext: {
type: "boolean",
},
@@ -6046,9 +6054,17 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
},
suppressToolErrorWarnings: {
type: "boolean",
title: "Agent Heartbeat Suppress Tool Error Warnings",
title: "Heartbeat Suppress Tool Error Warnings",
description: "Suppress tool error warning payloads during heartbeat runs.",
},
timeoutSeconds: {
type: "integer",
exclusiveMinimum: 0,
maximum: 9007199254740991,
title: "Heartbeat Timeout (Seconds)",
description:
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat/default agent timeout.",
},
lightContext: {
type: "boolean",
},
@@ -25420,6 +25436,19 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Suppress tool error warning payloads during heartbeat runs.",
tags: ["automation"],
},
"agents.list.*.heartbeat.suppressToolErrorWarnings": {
label: "Heartbeat Suppress Tool Error Warnings",
tags: ["automation"],
},
"agents.defaults.heartbeat.timeoutSeconds": {
label: "Heartbeat Timeout (Seconds)",
help: "Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds.",
tags: ["performance", "automation"],
},
"agents.list.*.heartbeat.timeoutSeconds": {
label: "Heartbeat Timeout (Seconds)",
tags: ["performance", "automation"],
},
"agents.defaults.sandbox.browser.network": {
label: "Sandbox Browser Network",
help: "Docker network for sandbox browser containers (default: openclaw-sandbox-browser). Avoid bridge if you need stricter isolation.",
@@ -26483,6 +26512,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Suppress tool error warning payloads during heartbeat runs.",
tags: ["automation"],
},
"agents.list[].heartbeat.timeoutSeconds": {
label: "Agent Heartbeat Timeout (Seconds)",
help: "Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat/default agent timeout.",
tags: ["performance", "automation"],
},
"agents.list[].sandbox.browser.network": {
label: "Agent Sandbox Browser Network",
help: "Per-agent override for sandbox browser Docker network.",

View File

@@ -224,6 +224,10 @@ export const FIELD_HELP: Record<string, string> = {
"Suppress tool error warning payloads during heartbeat runs.",
"agents.list[].heartbeat.suppressToolErrorWarnings":
"Suppress tool error warning payloads during heartbeat runs.",
"agents.defaults.heartbeat.timeoutSeconds":
"Maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use agents.defaults.timeoutSeconds.",
"agents.list[].heartbeat.timeoutSeconds":
"Per-agent maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to inherit the merged heartbeat/default agent timeout.",
browser:
"Browser runtime controls for local or remote CDP attachment, profile routing, and screenshot/snapshot behavior. Keep defaults unless your automation workflow requires custom browser transport settings.",
"browser.enabled":

View File

@@ -557,6 +557,9 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.defaults.heartbeat.directPolicy": "Heartbeat Direct Policy",
"agents.list.*.heartbeat.directPolicy": "Heartbeat Direct Policy",
"agents.defaults.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
"agents.list.*.heartbeat.suppressToolErrorWarnings": "Heartbeat Suppress Tool Error Warnings",
"agents.defaults.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)",
"agents.list.*.heartbeat.timeoutSeconds": "Heartbeat Timeout (Seconds)",
"agents.defaults.sandbox.browser.network": "Sandbox Browser Network",
"agents.defaults.sandbox.browser.cdpSourceRange": "Sandbox Browser CDP Source Port Range",
"agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin":
@@ -787,6 +790,7 @@ export const FIELD_LABELS: Record<string, string> = {
"agents.list[].identity.avatar": "Agent Avatar",
"agents.list[].heartbeat.suppressToolErrorWarnings":
"Agent Heartbeat Suppress Tool Error Warnings",
"agents.list[].heartbeat.timeoutSeconds": "Agent Heartbeat Timeout (Seconds)",
"agents.list[].sandbox.browser.network": "Agent Sandbox Browser Network",
"agents.list[].sandbox.browser.cdpSourceRange": "Agent Sandbox Browser CDP Source Port Range",
"agents.list[].sandbox.docker.dangerouslyAllowContainerNamespaceJoin":

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { AgentDefaultsSchema } from "./zod-schema.agent-defaults.js";
import { AgentEntrySchema } from "./zod-schema.agent-runtime.js";
describe("agent defaults schema", () => {
it("accepts subagent archiveAfterMinutes=0 to disable archiving", () => {
@@ -53,4 +54,22 @@ describe("agent defaults schema", () => {
})!;
expect(result.embeddedPi?.executionContract).toBe("strict-agentic");
});
it("accepts positive heartbeat timeoutSeconds on defaults and agent entries", () => {
const defaults = AgentDefaultsSchema.parse({
heartbeat: { timeoutSeconds: 45 },
})!;
const agent = AgentEntrySchema.parse({
id: "ops",
heartbeat: { timeoutSeconds: 45 },
});
expect(defaults.heartbeat?.timeoutSeconds).toBe(45);
expect(agent.heartbeat?.timeoutSeconds).toBe(45);
});
it("rejects zero heartbeat timeoutSeconds", () => {
expect(() => AgentDefaultsSchema.parse({ heartbeat: { timeoutSeconds: 0 } })).toThrow();
expect(() => AgentEntrySchema.parse({ id: "ops", heartbeat: { timeoutSeconds: 0 } })).toThrow();
});
});

View File

@@ -79,6 +79,7 @@ describe("runHeartbeatOnce heartbeat model override", () => {
async function runDefaultsHeartbeat(params: {
model?: string;
suppressToolErrorWarnings?: boolean;
timeoutSeconds?: number;
lightContext?: boolean;
isolatedSession?: boolean;
}) {
@@ -92,6 +93,7 @@ describe("runHeartbeatOnce heartbeat model override", () => {
target: "whatsapp",
model: params.model,
suppressToolErrorWarnings: params.suppressToolErrorWarnings,
timeoutSeconds: params.timeoutSeconds,
lightContext: params.lightContext,
isolatedSession: params.isolatedSession,
},
@@ -132,6 +134,16 @@ describe("runHeartbeatOnce heartbeat model override", () => {
);
});
it("passes heartbeat timeoutSeconds as a reply-run timeout override", async () => {
const replyOpts = await runDefaultsHeartbeat({ timeoutSeconds: 45 });
expect(replyOpts).toEqual(
expect.objectContaining({
isHeartbeat: true,
timeoutOverrideSeconds: 45,
}),
);
});
it("passes bootstrapContextMode when heartbeat lightContext is enabled", async () => {
const replyOpts = await runDefaultsHeartbeat({ lightContext: true });
expect(replyOpts).toEqual(
@@ -290,6 +302,52 @@ describe("runHeartbeatOnce heartbeat model override", () => {
});
});
it("passes per-agent heartbeat timeout override after merging defaults", async () => {
await withHeartbeatFixture(async ({ tmpDir, storePath, replySpy, seedSession }) => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: {
every: "30m",
timeoutSeconds: 120,
},
},
list: [
{ id: "main", default: true },
{
id: "ops",
workspace: tmpDir,
heartbeat: {
every: "5m",
target: "whatsapp",
timeoutSeconds: 45,
},
},
],
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: storePath },
};
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: "ops" });
const result = await runHeartbeatWithSeed({
seedSession,
cfg,
agentId: "ops",
sessionKey,
replySpy,
});
expect(result.replySpy).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
isHeartbeat: true,
timeoutOverrideSeconds: 45,
}),
cfg,
);
});
});
it("does not pass heartbeatModelOverride when no heartbeat model is configured", async () => {
const replyOpts = await runDefaultsHeartbeat({ model: undefined });
expect(replyOpts).toEqual(

View File

@@ -933,16 +933,18 @@ export async function runHeartbeatOnce(opts: {
try {
const heartbeatModelOverride = normalizeOptionalString(heartbeat?.model);
const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true;
const timeoutOverrideSeconds =
typeof heartbeat?.timeoutSeconds === "number" ? heartbeat.timeoutSeconds : undefined;
const bootstrapContextMode: "lightweight" | undefined =
heartbeat?.lightContext === true ? "lightweight" : undefined;
const replyOpts = heartbeatModelOverride
? {
isHeartbeat: true,
heartbeatModelOverride,
suppressToolErrorWarnings,
bootstrapContextMode,
}
: { isHeartbeat: true, suppressToolErrorWarnings, bootstrapContextMode };
const replyOpts = {
isHeartbeat: true,
...(heartbeatModelOverride ? { heartbeatModelOverride } : {}),
suppressToolErrorWarnings,
// Heartbeat timeout is a per-run override so user turns keep the global default.
timeoutOverrideSeconds,
bootstrapContextMode,
};
const getReplyFromConfig =
opts.deps?.getReplyFromConfig ?? (await loadHeartbeatRunnerRuntime()).getReplyFromConfig;
const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg);

View File

@@ -75,6 +75,21 @@ export const HEARTBEAT_CONFIG_HONOR_INVENTORY: ConfigHonorInventoryRow[] = [
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: ["src/infra/heartbeat-runner.model-override.test.ts"],
},
{
key: "timeoutSeconds",
schemaPaths: [
"agents.defaults.heartbeat.timeoutSeconds",
"agents.list.*.heartbeat.timeoutSeconds",
],
typePaths: ["src/config/types.agent-defaults.ts", "src/config/zod-schema.agent-runtime.ts"],
mergePaths: ["src/infra/heartbeat-runner.ts"],
consumerPaths: ["src/infra/heartbeat-runner.ts", "src/auto-reply/reply/get-reply.ts"],
reloadPaths: ["src/gateway/config-reload-plan.ts"],
testPaths: [
"src/config/zod-schema.agent-defaults.test.ts",
"src/infra/heartbeat-runner.model-override.test.ts",
],
},
{
key: "lightContext",
schemaPaths: ["agents.defaults.heartbeat.lightContext", "agents.list.*.heartbeat.lightContext"],