fix(agents): strip empty assistant transcript text

This commit is contained in:
Peter Steinberger
2026-04-28 20:36:02 +01:00
parent 1824ceba54
commit 1f26e32f5f
3 changed files with 121 additions and 33 deletions

View File

@@ -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.

View File

@@ -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");
});
});
});
});

View File

@@ -6,13 +6,26 @@ import { sanitizeContentBlocksImages } from "../tool-images.js";
import { stripThoughtSignatures } from "./bootstrap.js";
type ContentBlock = AgentToolResult<unknown>["content"][number];
const EMPTY_CONTENT_PLACEHOLDER = "[empty content omitted]";
function isThinkingOrRedactedBlock(block: unknown): boolean {
if (!block || typeof block !== "object") {
return false;
function dropEmptyTextBlocks<T>(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<T>(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<AgentMessage, { role: "user" }>;
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,