fix(agents): sanitize blank Bedrock user replay

This commit is contained in:
Peter Steinberger
2026-04-27 08:02:38 +01:00
parent 3d6d08116d
commit 9d33da6ddf
10 changed files with 258 additions and 15 deletions

View File

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

View File

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

View File

@@ -240,6 +240,39 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set<string>(["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;

View File

@@ -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();

View File

@@ -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<string, unknown>;
message: { role: string; content?: unknown } & Record<string, unknown>;
} & Record<string, unknown>;
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,
};
}

View File

@@ -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." },
];

View File

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

View File

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

View File

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

View File

@@ -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]";