mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
fix: honor heartbeat timeoutSeconds (#64491)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ const EXPECTED_HEARTBEAT_KEYS = [
|
||||
"includeSystemPromptSection",
|
||||
"ackMaxChars",
|
||||
"suppressToolErrorWarnings",
|
||||
"timeoutSeconds",
|
||||
"lightContext",
|
||||
"isolatedSession",
|
||||
"target",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user