From 1f26e32f5f90b6c3f73ef472fc49d5f7a6e49477 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Apr 2026 20:36:02 +0100 Subject: [PATCH] fix(agents): strip empty assistant transcript text --- docs/reference/transcript-hygiene.md | 6 ++ ...ssistant-text-blocks-but-preserves.test.ts | 91 +++++++++++++++++-- src/agents/pi-embedded-helpers/images.ts | 57 ++++++------ 3 files changed, 121 insertions(+), 33 deletions(-) diff --git a/docs/reference/transcript-hygiene.md b/docs/reference/transcript-hygiene.md index 99d4c97354a..c53ee188cdb 100644 --- a/docs/reference/transcript-hygiene.md +++ b/docs/reference/transcript-hygiene.md @@ -21,6 +21,7 @@ Scope includes: - Thought signature cleanup - Thinking signature cleanup - Image payload sanitization +- Blank text-block cleanup before provider replay - User-input provenance tagging (for inter-session routed prompts) - Empty assistant error-turn repair for Bedrock Converse replay @@ -73,6 +74,9 @@ Implementation: - `sanitizeSessionMessagesImages` in `src/agents/pi-embedded-helpers/images.ts` - `sanitizeContentBlocksImages` in `src/agents/tool-images.ts` - Max image side is configurable via `agents.defaults.imageMaxDimensionPx` (default: `1200`). +- Blank text blocks are removed while this pass walks replay content. Assistant + turns that become empty are dropped from the replay copy; user and tool-result + turns that become empty receive a non-empty omitted-content placeholder. --- @@ -155,6 +159,8 @@ inter-session user turns that only have provenance metadata. before replay. Bedrock Converse rejects assistant messages with `content: []`, so persisted assistant turns with `stopReason: "error"` and empty content are also repaired on disk before load. +- Assistant stream-error turns that contain only blank text blocks are dropped + from the in-memory replay copy instead of replaying an invalid blank block. - Claude thinking blocks with missing, empty, or blank replay signatures are stripped before Converse replay. If that empties an assistant turn, OpenClaw keeps turn shape with non-empty omitted-reasoning text. diff --git a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts index 603475125d8..fdf26cda4be 100644 --- a/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts +++ b/src/agents/pi-embedded-helpers.sanitize-session-messages-images.removes-empty-assistant-text-blocks-but-preserves.test.ts @@ -220,7 +220,7 @@ describe("sanitizeSessionMessagesImages", () => { expect(out).toHaveLength(1); expect(out[0]?.role).toBe("user"); }); - it("keeps empty assistant error messages", async () => { + it("drops empty assistant error messages", async () => { const input = castAgentMessages([ { role: "user", content: "hello", timestamp: nextTimestamp() } satisfies UserMessage, { @@ -233,10 +233,68 @@ describe("sanitizeSessionMessagesImages", () => { const out = await sanitizeSessionMessagesImages(input, "test"); - expect(out).toHaveLength(3); + expect(out).toHaveLength(1); expect(out[0]?.role).toBe("user"); - expect(out[1]?.role).toBe("assistant"); - expect(out[2]?.role).toBe("assistant"); + }); + it("removes empty text blocks from user and tool result messages", async () => { + const input = [ + { + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "hello" }, + ], + timestamp: nextTimestamp(), + } satisfies UserMessage, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + isError: false, + content: [ + { type: "text", text: " " }, + { type: "text", text: "result" }, + ], + timestamp: nextTimestamp(), + } satisfies ToolResultMessage, + ]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect(out[0]?.role).toBe("user"); + expect((out[0] as { content?: Array<{ text?: string }> }).content).toEqual([ + { type: "text", text: "hello" }, + ]); + expect(out[1]?.role).toBe("toolResult"); + expect((out[1] as { content?: Array<{ text?: string }> }).content).toEqual([ + { type: "text", text: "result" }, + ]); + }); + it("uses a non-empty placeholder when user or tool result content becomes empty", async () => { + const input = [ + { + role: "user", + content: [{ type: "text", text: "" }], + timestamp: nextTimestamp(), + } satisfies UserMessage, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + isError: false, + content: [{ type: "text", text: " " }], + timestamp: nextTimestamp(), + } satisfies ToolResultMessage, + ]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect((out[0] as { content?: Array<{ text?: string }> }).content).toEqual([ + { type: "text", text: "[empty content omitted]" }, + ]); + expect((out[1] as { content?: Array<{ text?: string }> }).content).toEqual([ + { type: "text", text: "[empty content omitted]" }, + ]); }); it("leaves non-assistant messages unchanged", async () => { const input = [ @@ -338,7 +396,6 @@ describe("sanitizeSessionMessagesImages", () => { expect(content?.map((block) => block.type)).toEqual([ "thinking", "text", - "text", "redacted_thinking", "text", ]); @@ -347,12 +404,32 @@ describe("sanitizeSessionMessagesImages", () => { thinking: "first", thought_signature: "sig-1", }); - expect(content?.[1]).toMatchObject({ type: "text", text: "" }); - expect(content?.[3]).toMatchObject({ + expect(content?.[1]).toMatchObject({ type: "text", text: "visible" }); + expect(content?.[2]).toMatchObject({ type: "redacted_thinking", thought_signature: "sig-2", }); }); + + it("drops empty assistant text blocks in images-only mode", async () => { + const input = castAgentMessages([ + makeOpenAiResponsesAssistantMessage( + [ + { type: "text", text: " " }, + { type: "text", text: "visible" }, + ], + "stop", + ), + ]); + + const out = await sanitizeSessionMessagesImages(input, "test", { + sanitizeMode: "images-only", + }); + + expectSingleAssistantContentEntry(out, (entry) => { + expect(entry.text).toBe("visible"); + }); + }); }); }); diff --git a/src/agents/pi-embedded-helpers/images.ts b/src/agents/pi-embedded-helpers/images.ts index d663de83aab..4812642d8f9 100644 --- a/src/agents/pi-embedded-helpers/images.ts +++ b/src/agents/pi-embedded-helpers/images.ts @@ -6,13 +6,26 @@ import { sanitizeContentBlocksImages } from "../tool-images.js"; import { stripThoughtSignatures } from "./bootstrap.js"; type ContentBlock = AgentToolResult["content"][number]; +const EMPTY_CONTENT_PLACEHOLDER = "[empty content omitted]"; -function isThinkingOrRedactedBlock(block: unknown): boolean { - if (!block || typeof block !== "object") { - return false; +function dropEmptyTextBlocks(content: T[]): T[] { + return content.filter((block) => { + if (!block || typeof block !== "object") { + return true; + } + const rec = block as { type?: unknown; text?: unknown }; + if (rec.type !== "text" || typeof rec.text !== "string") { + return true; + } + return rec.text.trim().length > 0; + }); +} + +function ensureNonEmptyContent(content: T[]): T[] { + if (content.length > 0) { + return content; } - const rec = block as { type?: unknown }; - return rec.type === "thinking" || rec.type === "redacted_thinking"; + return [{ type: "text", text: EMPTY_CONTENT_PLACEHOLDER }] as T[]; } export function isEmptyAssistantMessageContent( @@ -87,7 +100,7 @@ export async function sanitizeSessionMessagesImages( label, imageSanitization, )) as unknown as typeof toolMsg.content; - out.push({ ...toolMsg, content: nextContent }); + out.push({ ...toolMsg, content: ensureNonEmptyContent(dropEmptyTextBlocks(nextContent)) }); continue; } @@ -95,12 +108,12 @@ export async function sanitizeSessionMessagesImages( const userMsg = msg as Extract; const content = userMsg.content; if (Array.isArray(content)) { - const nextContent = (await sanitizeContentBlocksImages( + const nextContent = await sanitizeContentBlocksImages( content as unknown as ContentBlock[], label, imageSanitization, - )) as unknown as typeof userMsg.content; - out.push({ ...userMsg, content: nextContent }); + ); + out.push({ ...userMsg, content: ensureNonEmptyContent(dropEmptyTextBlocks(nextContent)) }); continue; } } @@ -115,7 +128,10 @@ export async function sanitizeSessionMessagesImages( label, imageSanitization, )) as unknown as typeof assistantMsg.content; - out.push({ ...assistantMsg, content: nextContent }); + const finalContent = dropEmptyTextBlocks(nextContent); + if (finalContent.length > 0) { + out.push({ ...assistantMsg, content: finalContent }); + } } else { out.push(assistantMsg); } @@ -128,28 +144,17 @@ export async function sanitizeSessionMessagesImages( : stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini if (!allowNonImageSanitization) { const nextContent = (await sanitizeContentBlocksImages( - strippedContent as unknown as ContentBlock[], + dropEmptyTextBlocks(strippedContent) as unknown as ContentBlock[], label, imageSanitization, )) as unknown as typeof assistantMsg.content; - out.push({ ...assistantMsg, content: nextContent }); + if (nextContent.length > 0) { + out.push({ ...assistantMsg, content: nextContent }); + } continue; } - const filteredContent = - options?.preserveSignatures && - strippedContent.some((block) => isThinkingOrRedactedBlock(block)) - ? strippedContent - : strippedContent.filter((block) => { - if (!block || typeof block !== "object") { - return true; - } - const rec = block as { type?: unknown; text?: unknown }; - if (rec.type !== "text" || typeof rec.text !== "string") { - return true; - } - return rec.text.trim().length > 0; - }); + const filteredContent = dropEmptyTextBlocks(strippedContent); const finalContent = (await sanitizeContentBlocksImages( filteredContent as unknown as ContentBlock[], label,