mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix(agents): strip empty assistant transcript text
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user