From 9d33da6ddf2c2c113dada6b2519fa0812257cce4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:02:38 +0100 Subject: [PATCH] fix(agents): sanitize blank Bedrock user replay --- CHANGELOG.md | 1 + .../pi-embedded-runner/replay-history.test.ts | 29 +++++ .../pi-embedded-runner/replay-history.ts | 43 +++++++ src/agents/session-file-repair.test.ts | 59 +++++++++ src/agents/session-file-repair.ts | 118 ++++++++++++++++-- src/auto-reply/heartbeat-filter.test.ts | 11 +- src/auto-reply/heartbeat-filter.ts | 4 + src/auto-reply/heartbeat.ts | 1 + .../reply/get-reply-run.media-only.test.ts | 4 +- src/auto-reply/reply/get-reply-run.ts | 3 +- 10 files changed, 258 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e4a339334f..aae4ddfd4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/Bedrock: stop heartbeat runs from persisting blank user transcript turns and repair existing blank user text messages before replay, preventing AWS Bedrock `ContentBlock` blank-text validation failures. Fixes #72640 and #72622. Thanks @goldzulu. - Process/Windows: decode command stdout and stderr from raw bytes with console-codepage awareness, while preserving valid UTF-8 output and multibyte characters split across chunks. Fixes #50519. Thanks @iready, @kevinten10, @zhangyongjie1997, @knightplat-blip, @heiqishi666, and @slepybear. - Agents/bootstrap: dedupe hook-injected bootstrap context files by workspace-relative path and store normalized resolved paths so duplicate relative and absolute hook paths no longer depend on the process cwd. (#59344; fixes #59319; related #56721, #56725, and #57587) Thanks @koen666. - Agents/bootstrap: refresh cached workspace bootstrap snapshots on long-lived main-session turns when `AGENTS.md`, `SOUL.md`, `MEMORY.md`, or `TOOLS.md` change on disk, while preserving unchanged snapshot identity through the workspace file cache. (#64871; related #43901, #26497, #28594, #30896) Thanks @aimqwest and @mikejuyoon. diff --git a/src/agents/pi-embedded-runner/replay-history.test.ts b/src/agents/pi-embedded-runner/replay-history.test.ts index 5942e9189d4..30ff9488654 100644 --- a/src/agents/pi-embedded-runner/replay-history.test.ts +++ b/src/agents/pi-embedded-runner/replay-history.test.ts @@ -62,6 +62,35 @@ describe("normalizeAssistantReplayContent", () => { expect(repaired.content).toEqual([{ type: "text", text: FALLBACK_TEXT }]); }); + it("drops blank user text messages from replay", () => { + const messages = [ + userMessage("before"), + { + role: "user", + content: [{ type: "text", text: "" }], + timestamp: 0, + } as unknown as AgentMessage, + userMessage("after"), + ]; + const out = normalizeAssistantReplayContent(messages); + expect(out).not.toBe(messages); + expect(out).toEqual([messages[0], messages[2]]); + }); + + it("removes blank user text blocks while preserving non-text content", () => { + const imageBlock = { type: "image", data: "AA==", mimeType: "image/png" }; + const messages = [ + { + role: "user", + content: [{ type: "text", text: " " }, imageBlock], + timestamp: 0, + } as unknown as AgentMessage, + ]; + const out = normalizeAssistantReplayContent(messages); + expect(out).not.toBe(messages); + expect((out[0] as { content: unknown[] }).content).toEqual([imageBlock]); + }); + it("preserves nonzero-usage silent-reply turns (stopReason=stop, content=[]) untouched", () => { // run.empty-error-retry.test.ts treats `stopReason:"stop"` + `content:[]` // as a legitimate NO_REPLY / silent-reply, NOT a crash. Substituting the diff --git a/src/agents/pi-embedded-runner/replay-history.ts b/src/agents/pi-embedded-runner/replay-history.ts index 9742293d39e..a20bbda61b8 100644 --- a/src/agents/pi-embedded-runner/replay-history.ts +++ b/src/agents/pi-embedded-runner/replay-history.ts @@ -240,6 +240,39 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[] const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set(["delivery-mirror", "gateway-injected"]); const OMITTED_INBOUND_METADATA_TEXT = "[assistant copied inbound metadata omitted]"; +function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null { + if (!message || message.role !== "user") { + return message; + } + const replayContent = (message as { content?: unknown }).content; + if (typeof replayContent === "string") { + return replayContent.trim() ? message : null; + } + if (!Array.isArray(replayContent)) { + return message; + } + + let touched = false; + const sanitizedContent = replayContent.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + if ((block as { type?: unknown }).type !== "text") { + return true; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string" || text.trim().length > 0) { + return true; + } + touched = true; + return false; + }); + if (sanitizedContent.length === 0) { + return null; + } + return touched ? ({ ...message, content: sanitizedContent } as AgentMessage) : message; +} + function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean { if (!message || message.role !== "assistant") { return false; @@ -257,6 +290,16 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent let touched = false; const out: AgentMessage[] = []; for (const message of messages) { + if (message?.role === "user") { + const sanitizedUserMessage = sanitizeUserReplayContent(message); + if (sanitizedUserMessage) { + out.push(sanitizedUserMessage); + } + if (sanitizedUserMessage !== message) { + touched = true; + } + continue; + } if (!message || message.role !== "assistant") { out.push(message); continue; diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts index 53796f7459a..07a37963322 100644 --- a/src/agents/session-file-repair.test.ts +++ b/src/agents/session-file-repair.test.ts @@ -145,6 +145,65 @@ describe("repairSessionFileIfNeeded", () => { ]); }); + it("drops persisted blank user text messages", async () => { + const { file } = await createTempSessionPath(); + const { header, message } = buildSessionHeaderAndMessage(); + const blankUserEntry = { + type: "message", + id: "msg-blank", + parentId: null, + timestamp: new Date().toISOString(), + message: { + role: "user", + content: [{ type: "text", text: "" }], + }, + }; + const original = `${JSON.stringify(header)}\n${JSON.stringify(blankUserEntry)}\n${JSON.stringify(message)}\n`; + await fs.writeFile(file, original, "utf-8"); + + const warn = vi.fn(); + const result = await repairSessionFileIfNeeded({ sessionFile: file, warn }); + + expect(result.repaired).toBe(true); + expect(result.droppedBlankUserMessages).toBe(1); + expect(warn.mock.calls[0]?.[0]).toContain("dropped 1 blank user message(s)"); + + const repaired = await fs.readFile(file, "utf-8"); + const repairedLines = repaired.trim().split("\n"); + expect(repairedLines).toHaveLength(2); + expect(JSON.parse(repairedLines[1])?.id).toBe("msg-1"); + }); + + it("removes blank user text blocks while preserving media blocks", async () => { + const { file } = await createTempSessionPath(); + const { header } = buildSessionHeaderAndMessage(); + const mediaUserEntry = { + type: "message", + id: "msg-media", + parentId: null, + timestamp: new Date().toISOString(), + message: { + role: "user", + content: [ + { type: "text", text: " " }, + { type: "image", data: "AA==", mimeType: "image/png" }, + ], + }, + }; + const original = `${JSON.stringify(header)}\n${JSON.stringify(mediaUserEntry)}\n`; + await fs.writeFile(file, original, "utf-8"); + + const result = await repairSessionFileIfNeeded({ sessionFile: file }); + + expect(result.repaired).toBe(true); + expect(result.rewrittenUserMessages).toBe(1); + const repaired = await fs.readFile(file, "utf-8"); + const repairedEntry = JSON.parse(repaired.trim().split("\n")[1] ?? "{}"); + expect(repairedEntry.message.content).toEqual([ + { type: "image", data: "AA==", mimeType: "image/png" }, + ]); + }); + it("reports both drops and rewrites in the warn message when both occur", async () => { const { file } = await createTempSessionPath(); const { header } = buildSessionHeaderAndMessage(); diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index 7c465c176cd..1db80eb7a73 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -6,6 +6,8 @@ type RepairReport = { repaired: boolean; droppedLines: number; rewrittenAssistantMessages?: number; + droppedBlankUserMessages?: number; + rewrittenUserMessages?: number; backupPath?: string; reason?: string; }; @@ -21,7 +23,7 @@ type RepairReport = { type SessionMessageEntry = { type: "message"; - message: { role: "assistant"; content: unknown[] } & Record; + message: { role: string; content?: unknown } & Record; } & Record; function isSessionHeader(entry: unknown): entry is { type: string; id: string } { @@ -69,13 +71,71 @@ function rewriteAssistantEntryWithEmptyContent(entry: SessionMessageEntry): Sess }; } -function buildRepairSummaryParts(droppedLines: number, rewrittenAssistantMessages: number): string { - const parts: string[] = []; - if (droppedLines > 0) { - parts.push(`dropped ${droppedLines} malformed line(s)`); +type UserEntryRepair = + | { kind: "drop" } + | { kind: "rewrite"; entry: SessionMessageEntry } + | { kind: "keep" }; + +function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEntryRepair { + const content = entry.message.content; + if (typeof content === "string") { + return content.trim() ? { kind: "keep" } : { kind: "drop" }; } - if (rewrittenAssistantMessages > 0) { - parts.push(`rewrote ${rewrittenAssistantMessages} assistant message(s)`); + if (!Array.isArray(content)) { + return { kind: "keep" }; + } + + let touched = false; + const nextContent = content.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + if ((block as { type?: unknown }).type !== "text") { + return true; + } + const text = (block as { text?: unknown }).text; + if (typeof text !== "string" || text.trim().length > 0) { + return true; + } + touched = true; + return false; + }); + if (nextContent.length === 0) { + return { kind: "drop" }; + } + if (!touched) { + return { kind: "keep" }; + } + return { + kind: "rewrite", + entry: { + ...entry, + message: { + ...entry.message, + content: nextContent, + }, + }, + }; +} + +function buildRepairSummaryParts(params: { + droppedLines: number; + rewrittenAssistantMessages: number; + droppedBlankUserMessages: number; + rewrittenUserMessages: number; +}): string { + const parts: string[] = []; + if (params.droppedLines > 0) { + parts.push(`dropped ${params.droppedLines} malformed line(s)`); + } + if (params.rewrittenAssistantMessages > 0) { + parts.push(`rewrote ${params.rewrittenAssistantMessages} assistant message(s)`); + } + if (params.droppedBlankUserMessages > 0) { + parts.push(`dropped ${params.droppedBlankUserMessages} blank user message(s)`); + } + if (params.rewrittenUserMessages > 0) { + parts.push(`rewrote ${params.rewrittenUserMessages} user message(s)`); } // Caller only invokes this once at least one counter is non-zero, so the // empty-array branch is unreachable in production. Kept for defensive output. @@ -108,6 +168,8 @@ export async function repairSessionFileIfNeeded(params: { const entries: unknown[] = []; let droppedLines = 0; let rewrittenAssistantMessages = 0; + let droppedBlankUserMessages = 0; + let rewrittenUserMessages = 0; for (const line of lines) { if (!line.trim()) { @@ -120,6 +182,24 @@ export async function repairSessionFileIfNeeded(params: { rewrittenAssistantMessages += 1; continue; } + if ( + entry && + typeof entry === "object" && + (entry as { type?: unknown }).type === "message" && + typeof (entry as { message?: unknown }).message === "object" && + ((entry as { message: { role?: unknown } }).message?.role ?? undefined) === "user" + ) { + const repairedUser = repairUserEntryWithBlankTextContent(entry as SessionMessageEntry); + if (repairedUser.kind === "drop") { + droppedBlankUserMessages += 1; + continue; + } + if (repairedUser.kind === "rewrite") { + entries.push(repairedUser.entry); + rewrittenUserMessages += 1; + continue; + } + } entries.push(entry); } catch { droppedLines += 1; @@ -137,7 +217,12 @@ export async function repairSessionFileIfNeeded(params: { return { repaired: false, droppedLines, reason: "invalid session header" }; } - if (droppedLines === 0 && rewrittenAssistantMessages === 0) { + if ( + droppedLines === 0 && + rewrittenAssistantMessages === 0 && + droppedBlankUserMessages === 0 && + rewrittenUserMessages === 0 + ) { return { repaired: false, droppedLines: 0 }; } @@ -169,15 +254,26 @@ export async function repairSessionFileIfNeeded(params: { repaired: false, droppedLines, rewrittenAssistantMessages, + droppedBlankUserMessages, + rewrittenUserMessages, reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`, }; } params.warn?.( - `session file repaired: ${buildRepairSummaryParts( + `session file repaired: ${buildRepairSummaryParts({ droppedLines, rewrittenAssistantMessages, - )} (${path.basename(sessionFile)})`, + droppedBlankUserMessages, + rewrittenUserMessages, + })} (${path.basename(sessionFile)})`, ); - return { repaired: true, droppedLines, rewrittenAssistantMessages, backupPath }; + return { + repaired: true, + droppedLines, + rewrittenAssistantMessages, + droppedBlankUserMessages, + rewrittenUserMessages, + backupPath, + }; } diff --git a/src/auto-reply/heartbeat-filter.test.ts b/src/auto-reply/heartbeat-filter.test.ts index a8faa453701..be7c3b59481 100644 --- a/src/auto-reply/heartbeat-filter.test.ts +++ b/src/auto-reply/heartbeat-filter.test.ts @@ -4,7 +4,7 @@ import { isHeartbeatOkResponse, isHeartbeatUserMessage, } from "./heartbeat-filter.js"; -import { HEARTBEAT_PROMPT } from "./heartbeat.js"; +import { HEARTBEAT_PROMPT, HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js"; describe("isHeartbeatUserMessage", () => { it("matches heartbeat prompts", () => { @@ -25,6 +25,13 @@ describe("isHeartbeatUserMessage", () => { "Run the following periodic tasks (only those due based on their intervals):\n\n- email-check: Check for urgent unread emails\n\nAfter completing all due tasks, reply HEARTBEAT_OK.", }), ).toBe(true); + + expect( + isHeartbeatUserMessage({ + role: "user", + content: HEARTBEAT_TRANSCRIPT_PROMPT, + }), + ).toBe(true); }); it("ignores quoted or non-user token mentions", () => { @@ -97,6 +104,8 @@ describe("filterHeartbeatPairs", () => { { role: "assistant", content: "Hi there!" }, { role: "user", content: HEARTBEAT_PROMPT }, { role: "assistant", content: "HEARTBEAT_OK" }, + { role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT }, + { role: "assistant", content: "HEARTBEAT_OK" }, { role: "user", content: "What time is it?" }, { role: "assistant", content: "It is 3pm." }, ]; diff --git a/src/auto-reply/heartbeat-filter.ts b/src/auto-reply/heartbeat-filter.ts index f7b2eda0267..057389db76b 100644 --- a/src/auto-reply/heartbeat-filter.ts +++ b/src/auto-reply/heartbeat-filter.ts @@ -1,4 +1,5 @@ import { stripHeartbeatToken } from "./heartbeat.js"; +import { HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js"; const HEARTBEAT_TASK_PROMPT_PREFIX = "Run the following periodic tasks (only those due based on their intervals):"; @@ -46,6 +47,9 @@ export function isHeartbeatUserMessage( return false; } const normalizedHeartbeatPrompt = heartbeatPrompt?.trim(); + if (trimmed === HEARTBEAT_TRANSCRIPT_PROMPT) { + return true; + } if (normalizedHeartbeatPrompt && trimmed.startsWith(normalizedHeartbeatPrompt)) { return true; } diff --git a/src/auto-reply/heartbeat.ts b/src/auto-reply/heartbeat.ts index c9906c052dd..98fba1e22a0 100644 --- a/src/auto-reply/heartbeat.ts +++ b/src/auto-reply/heartbeat.ts @@ -13,6 +13,7 @@ export type HeartbeatTask = { // Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context. export const HEARTBEAT_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."; +export const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]"; export const DEFAULT_HEARTBEAT_EVERY = "30m"; export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300; diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index e36e6e9234e..91bc70a1645 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -1014,8 +1014,8 @@ describe("runPreparedReply media-only handling", () => { const call = vi.mocked(runReplyAgent).mock.calls.at(-1)?.[0]; expect(call?.commandBody).toContain(heartbeatPrompt); expect(call?.followupRun.prompt).toContain(heartbeatPrompt); - expect(call?.transcriptCommandBody).toBe(""); - expect(call?.followupRun.transcriptPrompt).toBe(""); + expect(call?.transcriptCommandBody).toBe("[OpenClaw heartbeat poll]"); + expect(call?.followupRun.transcriptPrompt).toBe("[OpenClaw heartbeat poll]"); }); it("uses inbound origin channel for run messageProvider", async () => { await runPreparedReply( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index aa47ed34315..84ad7bd4ccc 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -27,6 +27,7 @@ import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; import { resolveEnvelopeFormatOptions } from "../envelope.js"; +import { HEARTBEAT_TRANSCRIPT_PROMPT } from "../heartbeat.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { type ElevatedLevel, @@ -499,7 +500,7 @@ export async function runPreparedReply( ? baseBodyForPrompt : [inboundUserContext, "[User sent media without caption]"].filter(Boolean).join("\n\n"); const transcriptBodyBase = isHeartbeat - ? "" + ? HEARTBEAT_TRANSCRIPT_PROMPT : hasUserBody ? baseBodyFinal : "[User sent media without caption]";