From 72e228e14b9f58679c1f797bd613026bedc6e2ce Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 16 Feb 2026 13:29:24 -0600 Subject: [PATCH] Heartbeat: allow suppressing tool warnings (#18497) * Heartbeat: allow suppressing tool warnings * Changelog: note heartbeat tool-warning suppression --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 2 ++ docs/gateway/heartbeat.md | 1 + src/agents/pi-embedded-runner/run.ts | 1 + src/agents/pi-embedded-runner/run/params.ts | 2 ++ src/agents/pi-embedded-runner/run/payloads.e2e.test.ts | 9 +++++++++ src/agents/pi-embedded-runner/run/payloads.ts | 6 ++++++ src/auto-reply/reply/agent-runner-execution.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/auto-reply/types.ts | 2 ++ src/config/schema.help.ts | 4 ++++ src/config/types.agent-defaults.ts | 2 ++ src/config/zod-schema.agent-runtime.ts | 1 + src/infra/heartbeat-runner.ts | 5 +++-- 14 files changed, 36 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dd2919ebc5..581dd62f711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus. - Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96. - Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x. +- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. - Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope. - CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091) - CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index bba2a2ad774..dcdb86b48cf 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -718,6 +718,7 @@ Periodic heartbeat runs. target: "last", // last | whatsapp | telegram | discord | ... | none prompt: "Read HEARTBEAT.md if it exists...", ackMaxChars: 300, + suppressToolErrorWarnings: false, }, }, }, @@ -725,6 +726,7 @@ Periodic heartbeat runs. ``` - `every`: duration string (ms/s/m/h). Default: `30m`. +- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. - Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - Heartbeats run full agent turns — shorter intervals burn more tokens. diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md index 6c467d2ae10..a450218f2ce 100644 --- a/docs/gateway/heartbeat.md +++ b/docs/gateway/heartbeat.md @@ -209,6 +209,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele - `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped. - `prompt`: overrides the default prompt body (not merged). - `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery. +- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. - `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`. - Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone. - `"local"`: always uses the host system timezone. diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 1c986e78ea7..cfd4f82fad1 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -922,6 +922,7 @@ export async function runEmbeddedPiAgent( verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, toolResultFormat: resolvedToolResultFormat, + suppressToolErrorWarnings: params.suppressToolErrorWarnings, inlineToolResultsAllowed: false, }); diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 428774ce1d0..cdb8ff6a26d 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -74,6 +74,8 @@ export type RunEmbeddedPiAgentParams = { verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; + /** If true, suppress tool error warning payloads for this run (including mutating tools). */ + suppressToolErrorWarnings?: boolean; execOverrides?: Pick; bashElevated?: ExecElevatedDefaults; timeoutMs: number; diff --git a/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts b/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts index a1457a03b3f..cff0921e867 100644 --- a/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts +++ b/src/agents/pi-embedded-runner/run/payloads.e2e.test.ts @@ -252,6 +252,15 @@ describe("buildEmbeddedRunPayloads", () => { expect(payloads[0]?.text).toContain("connection timeout"); }); + it("suppresses mutating tool errors when suppressToolErrorWarnings is enabled", () => { + const payloads = buildPayloads({ + lastToolError: { toolName: "exec", error: "command not found" }, + suppressToolErrorWarnings: true, + }); + + expect(payloads).toHaveLength(0); + }); + it("shows recoverable tool errors for mutating tools", () => { const payloads = buildPayloads({ lastToolError: { toolName: "message", meta: "reply", error: "text required" }, diff --git a/src/agents/pi-embedded-runner/run/payloads.ts b/src/agents/pi-embedded-runner/run/payloads.ts index 9ccbf76f972..ba42a507583 100644 --- a/src/agents/pi-embedded-runner/run/payloads.ts +++ b/src/agents/pi-embedded-runner/run/payloads.ts @@ -48,7 +48,11 @@ function shouldShowToolErrorWarning(params: { lastToolError: LastToolError; hasUserFacingReply: boolean; suppressToolErrors: boolean; + suppressToolErrorWarnings?: boolean; }): boolean { + if (params.suppressToolErrorWarnings) { + return false; + } const isMutatingToolError = params.lastToolError.mutatingAction ?? isLikelyMutatingToolName(params.lastToolError.toolName); if (isMutatingToolError) { @@ -71,6 +75,7 @@ export function buildEmbeddedRunPayloads(params: { verboseLevel?: VerboseLevel; reasoningLevel?: ReasoningLevel; toolResultFormat?: ToolResultFormat; + suppressToolErrorWarnings?: boolean; inlineToolResultsAllowed: boolean; }): Array<{ text?: string; @@ -247,6 +252,7 @@ export function buildEmbeddedRunPayloads(params: { lastToolError: params.lastToolError, hasUserFacingReply: hasUserFacingAssistantReply, suppressToolErrors: Boolean(params.config?.messages?.suppressToolErrors), + suppressToolErrorWarnings: params.suppressToolErrorWarnings, }); // Always surface mutating tool failures so we do not silently confirm actions that did not happen. diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index e713fc71092..3355ea4f9ee 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -312,6 +312,7 @@ export async function runAgentTurnWithFallback(params: { } return isMarkdownCapableMessageChannel(channel) ? "markdown" : "plain"; })(), + suppressToolErrorWarnings: params.opts?.suppressToolErrorWarnings, bashElevated: params.followupRun.run.bashElevated, timeoutMs: params.followupRun.run.timeoutMs, runId, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 5ecb37043a6..9280a8fecf9 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -166,6 +166,7 @@ export function createFollowupRunner(params: { thinkLevel: queued.run.thinkLevel, verboseLevel: queued.run.verboseLevel, reasoningLevel: queued.run.reasoningLevel, + suppressToolErrorWarnings: opts?.suppressToolErrorWarnings, execOverrides: queued.run.execOverrides, bashElevated: queued.run.bashElevated, timeoutMs: queued.run.timeoutMs, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 0b06db40120..50f97655236 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -29,6 +29,8 @@ export type GetReplyOptions = { isHeartbeat?: boolean; /** Resolved heartbeat model override (provider/model string from merged per-agent config). */ heartbeatModelOverride?: string; + /** If true, suppress tool error warning payloads for this run. */ + suppressToolErrorWarnings?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; onReasoningStream?: (payload: ReplyPayload) => Promise | void; /** Called when a thinking/reasoning block ends. */ diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index e3cb311a728..11c92956dfc 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -17,6 +17,10 @@ export const FIELD_HELP: Record = { "Optional allowlist of skills for this agent (omit = all skills; empty = no skills).", "agents.list[].identity.avatar": "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", + "agents.defaults.heartbeat.suppressToolErrorWarnings": + "Suppress tool error warning payloads during heartbeat runs.", + "agents.list[].heartbeat.suppressToolErrorWarnings": + "Suppress tool error warning payloads during heartbeat runs.", "discovery.mdns.mode": 'mDNS broadcast mode ("minimal" default, "full" includes cliPath/sshPort, "off" disables mDNS).', "gateway.auth.token": diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index 164350e3922..4c9dba0a238 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -220,6 +220,8 @@ export type AgentDefaultsConfig = { prompt?: string; /** Max chars allowed after HEARTBEAT_OK before delivery (default: 30). */ ackMaxChars?: number; + /** Suppress tool error warning payloads during heartbeat runs. */ + suppressToolErrorWarnings?: boolean; /** * When enabled, deliver the model's reasoning payload for heartbeat runs (when available) * as a separate message prefixed with `Reasoning:` (same as `/reasoning on`). diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 61794fc6fb2..c049e63537a 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -29,6 +29,7 @@ export const HeartbeatSchema = z accountId: z.string().optional(), prompt: z.string().optional(), ackMaxChars: z.number().int().nonnegative().optional(), + suppressToolErrorWarnings: z.boolean().optional(), }) .strict() .superRefine((val, ctx) => { diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5063463a67a..b83693c2ffd 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -540,9 +540,10 @@ export async function runHeartbeatOnce(opts: { try { const heartbeatModelOverride = heartbeat?.model?.trim() || undefined; + const suppressToolErrorWarnings = heartbeat?.suppressToolErrorWarnings === true; const replyOpts = heartbeatModelOverride - ? { isHeartbeat: true, heartbeatModelOverride } - : { isHeartbeat: true }; + ? { isHeartbeat: true, heartbeatModelOverride, suppressToolErrorWarnings } + : { isHeartbeat: true, suppressToolErrorWarnings }; const replyResult = await getReplyFromConfig(ctx, replyOpts, cfg); const replyPayload = resolveHeartbeatReplyPayload(replyResult); const includeReasoning = heartbeat?.includeReasoning === true;