diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5290e71c8..25c1e1e90cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 8896eeb24a8..7525a77eb11 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 892b3f96781..23968447579 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 9c5d9cb9d6e..a20c4bcf368 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -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.", }, }, diff --git a/src/config/heartbeat-config-honor.inventory.test.ts b/src/config/heartbeat-config-honor.inventory.test.ts index 225ca73f497..2080f53e30b 100644 --- a/src/config/heartbeat-config-honor.inventory.test.ts +++ b/src/config/heartbeat-config-honor.inventory.test.ts @@ -15,6 +15,7 @@ const EXPECTED_HEARTBEAT_KEYS = [ "includeSystemPromptSection", "ackMaxChars", "suppressToolErrorWarnings", + "timeoutSeconds", "lightContext", "isolatedSession", "target", diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 43e9e17ff40..228f7c487b1 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 23275b71d1b..85d806c926b 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -224,6 +224,10 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index e40e1907c43..b6891630768 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -557,6 +557,9 @@ export const FIELD_LABELS: Record = { "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 = { "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": diff --git a/src/config/zod-schema.agent-defaults.test.ts b/src/config/zod-schema.agent-defaults.test.ts index 870b3963293..62f99649bcc 100644 --- a/src/config/zod-schema.agent-defaults.test.ts +++ b/src/config/zod-schema.agent-defaults.test.ts @@ -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(); + }); }); diff --git a/src/infra/heartbeat-runner.model-override.test.ts b/src/infra/heartbeat-runner.model-override.test.ts index 515c2821321..637644aaa71 100644 --- a/src/infra/heartbeat-runner.model-override.test.ts +++ b/src/infra/heartbeat-runner.model-override.test.ts @@ -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( diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 8c23cf82d41..10917972592 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -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); diff --git a/test/helpers/config/heartbeat-config-honor.inventory.ts b/test/helpers/config/heartbeat-config-honor.inventory.ts index ba917d93e19..c8eef3b1ce5 100644 --- a/test/helpers/config/heartbeat-config-honor.inventory.ts +++ b/test/helpers/config/heartbeat-config-honor.inventory.ts @@ -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"],