Skip to content

Commit 9d33da6

Browse files
committed
fix(agents): sanitize blank Bedrock user replay
1 parent 3d6d081 commit 9d33da6

10 files changed

Lines changed: 257 additions & 14 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
1212

1313
### Fixes
1414

15+
- 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.
1516
- 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.
1617
- 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.
1718
- 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.

src/agents/pi-embedded-runner/replay-history.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ describe("normalizeAssistantReplayContent", () => {
6262
expect(repaired.content).toEqual([{ type: "text", text: FALLBACK_TEXT }]);
6363
});
6464

65+
it("drops blank user text messages from replay", () => {
66+
const messages = [
67+
userMessage("before"),
68+
{
69+
role: "user",
70+
content: [{ type: "text", text: "" }],
71+
timestamp: 0,
72+
} as unknown as AgentMessage,
73+
userMessage("after"),
74+
];
75+
const out = normalizeAssistantReplayContent(messages);
76+
expect(out).not.toBe(messages);
77+
expect(out).toEqual([messages[0], messages[2]]);
78+
});
79+
80+
it("removes blank user text blocks while preserving non-text content", () => {
81+
const imageBlock = { type: "image", data: "AA==", mimeType: "image/png" };
82+
const messages = [
83+
{
84+
role: "user",
85+
content: [{ type: "text", text: " " }, imageBlock],
86+
timestamp: 0,
87+
} as unknown as AgentMessage,
88+
];
89+
const out = normalizeAssistantReplayContent(messages);
90+
expect(out).not.toBe(messages);
91+
expect((out[0] as { content: unknown[] }).content).toEqual([imageBlock]);
92+
});
93+
6594
it("preserves nonzero-usage silent-reply turns (stopReason=stop, content=[]) untouched", () => {
6695
// run.empty-error-retry.test.ts treats `stopReason:"stop"` + `content:[]`
6796
// as a legitimate NO_REPLY / silent-reply, NOT a crash. Substituting the

src/agents/pi-embedded-runner/replay-history.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,39 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[]
240240
const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set<string>(["delivery-mirror", "gateway-injected"]);
241241
const OMITTED_INBOUND_METADATA_TEXT = "[assistant copied inbound metadata omitted]";
242242

243+
function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null {
244+
if (!message || message.role !== "user") {
245+
return message;
246+
}
247+
const replayContent = (message as { content?: unknown }).content;
248+
if (typeof replayContent === "string") {
249+
return replayContent.trim() ? message : null;
250+
}
251+
if (!Array.isArray(replayContent)) {
252+
return message;
253+
}
254+
255+
let touched = false;
256+
const sanitizedContent = replayContent.filter((block) => {
257+
if (!block || typeof block !== "object") {
258+
return true;
259+
}
260+
if ((block as { type?: unknown }).type !== "text") {
261+
return true;
262+
}
263+
const text = (block as { text?: unknown }).text;
264+
if (typeof text !== "string" || text.trim().length > 0) {
265+
return true;
266+
}
267+
touched = true;
268+
return false;
269+
});
270+
if (sanitizedContent.length === 0) {
271+
return null;
272+
}
273+
return touched ? ({ ...message, content: sanitizedContent } as AgentMessage) : message;
274+
}
275+
243276
function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean {
244277
if (!message || message.role !== "assistant") {
245278
return false;
@@ -257,6 +290,16 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent
257290
let touched = false;
258291
const out: AgentMessage[] = [];
259292
for (const message of messages) {
293+
if (message?.role === "user") {
294+
const sanitizedUserMessage = sanitizeUserReplayContent(message);
295+
if (sanitizedUserMessage) {
296+
out.push(sanitizedUserMessage);
297+
}
298+
if (sanitizedUserMessage !== message) {
299+
touched = true;
300+
}
301+
continue;
302+
}
260303
if (!message || message.role !== "assistant") {
261304
out.push(message);
262305
continue;

src/agents/session-file-repair.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,65 @@ describe("repairSessionFileIfNeeded", () => {
145145
]);
146146
});
147147

148+
it("drops persisted blank user text messages", async () => {
149+
const { file } = await createTempSessionPath();
150+
const { header, message } = buildSessionHeaderAndMessage();
151+
const blankUserEntry = {
152+
type: "message",
153+
id: "msg-blank",
154+
parentId: null,
155+
timestamp: new Date().toISOString(),
156+
message: {
157+
role: "user",
158+
content: [{ type: "text", text: "" }],
159+
},
160+
};
161+
const original = `${JSON.stringify(header)}\n${JSON.stringify(blankUserEntry)}\n${JSON.stringify(message)}\n`;
162+
await fs.writeFile(file, original, "utf-8");
163+
164+
const warn = vi.fn();
165+
const result = await repairSessionFileIfNeeded({ sessionFile: file, warn });
166+
167+
expect(result.repaired).toBe(true);
168+
expect(result.droppedBlankUserMessages).toBe(1);
169+
expect(warn.mock.calls[0]?.[0]).toContain("dropped 1 blank user message(s)");
170+
171+
const repaired = await fs.readFile(file, "utf-8");
172+
const repairedLines = repaired.trim().split("\n");
173+
expect(repairedLines).toHaveLength(2);
174+
expect(JSON.parse(repairedLines[1])?.id).toBe("msg-1");
175+
});
176+
177+
it("removes blank user text blocks while preserving media blocks", async () => {
178+
const { file } = await createTempSessionPath();
179+
const { header } = buildSessionHeaderAndMessage();
180+
const mediaUserEntry = {
181+
type: "message",
182+
id: "msg-media",
183+
parentId: null,
184+
timestamp: new Date().toISOString(),
185+
message: {
186+
role: "user",
187+
content: [
188+
{ type: "text", text: " " },
189+
{ type: "image", data: "AA==", mimeType: "image/png" },
190+
],
191+
},
192+
};
193+
const original = `${JSON.stringify(header)}\n${JSON.stringify(mediaUserEntry)}\n`;
194+
await fs.writeFile(file, original, "utf-8");
195+
196+
const result = await repairSessionFileIfNeeded({ sessionFile: file });
197+
198+
expect(result.repaired).toBe(true);
199+
expect(result.rewrittenUserMessages).toBe(1);
200+
const repaired = await fs.readFile(file, "utf-8");
201+
const repairedEntry = JSON.parse(repaired.trim().split("\n")[1] ?? "{}");
202+
expect(repairedEntry.message.content).toEqual([
203+
{ type: "image", data: "AA==", mimeType: "image/png" },
204+
]);
205+
});
206+
148207
it("reports both drops and rewrites in the warn message when both occur", async () => {
149208
const { file } = await createTempSessionPath();
150209
const { header } = buildSessionHeaderAndMessage();

src/agents/session-file-repair.ts

Lines changed: 106 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ type RepairReport = {
66
repaired: boolean;
77
droppedLines: number;
88
rewrittenAssistantMessages?: number;
9+
droppedBlankUserMessages?: number;
10+
rewrittenUserMessages?: number;
911
backupPath?: string;
1012
reason?: string;
1113
};
@@ -21,7 +23,7 @@ type RepairReport = {
2123

2224
type SessionMessageEntry = {
2325
type: "message";
24-
message: { role: "assistant"; content: unknown[] } & Record<string, unknown>;
26+
message: { role: string; content?: unknown } & Record<string, unknown>;
2527
} & Record<string, unknown>;
2628

2729
function isSessionHeader(entry: unknown): entry is { type: string; id: string } {
@@ -69,13 +71,71 @@ function rewriteAssistantEntryWithEmptyContent(entry: SessionMessageEntry): Sess
6971
};
7072
}
7173

72-
function buildRepairSummaryParts(droppedLines: number, rewrittenAssistantMessages: number): string {
74+
type UserEntryRepair =
75+
| { kind: "drop" }
76+
| { kind: "rewrite"; entry: SessionMessageEntry }
77+
| { kind: "keep" };
78+
79+
function repairUserEntryWithBlankTextContent(entry: SessionMessageEntry): UserEntryRepair {
80+
const content = entry.message.content;
81+
if (typeof content === "string") {
82+
return content.trim() ? { kind: "keep" } : { kind: "drop" };
83+
}
84+
if (!Array.isArray(content)) {
85+
return { kind: "keep" };
86+
}
87+
88+
let touched = false;
89+
const nextContent = content.filter((block) => {
90+
if (!block || typeof block !== "object") {
91+
return true;
92+
}
93+
if ((block as { type?: unknown }).type !== "text") {
94+
return true;
95+
}
96+
const text = (block as { text?: unknown }).text;
97+
if (typeof text !== "string" || text.trim().length > 0) {
98+
return true;
99+
}
100+
touched = true;
101+
return false;
102+
});
103+
if (nextContent.length === 0) {
104+
return { kind: "drop" };
105+
}
106+
if (!touched) {
107+
return { kind: "keep" };
108+
}
109+
return {
110+
kind: "rewrite",
111+
entry: {
112+
...entry,
113+
message: {
114+
...entry.message,
115+
content: nextContent,
116+
},
117+
},
118+
};
119+
}
120+
121+
function buildRepairSummaryParts(params: {
122+
droppedLines: number;
123+
rewrittenAssistantMessages: number;
124+
droppedBlankUserMessages: number;
125+
rewrittenUserMessages: number;
126+
}): string {
73127
const parts: string[] = [];
74-
if (droppedLines > 0) {
75-
parts.push(`dropped ${droppedLines} malformed line(s)`);
128+
if (params.droppedLines > 0) {
129+
parts.push(`dropped ${params.droppedLines} malformed line(s)`);
76130
}
77-
if (rewrittenAssistantMessages > 0) {
78-
parts.push(`rewrote ${rewrittenAssistantMessages} assistant message(s)`);
131+
if (params.rewrittenAssistantMessages > 0) {
132+
parts.push(`rewrote ${params.rewrittenAssistantMessages} assistant message(s)`);
133+
}
134+
if (params.droppedBlankUserMessages > 0) {
135+
parts.push(`dropped ${params.droppedBlankUserMessages} blank user message(s)`);
136+
}
137+
if (params.rewrittenUserMessages > 0) {
138+
parts.push(`rewrote ${params.rewrittenUserMessages} user message(s)`);
79139
}
80140
// Caller only invokes this once at least one counter is non-zero, so the
81141
// empty-array branch is unreachable in production. Kept for defensive output.
@@ -108,6 +168,8 @@ export async function repairSessionFileIfNeeded(params: {
108168
const entries: unknown[] = [];
109169
let droppedLines = 0;
110170
let rewrittenAssistantMessages = 0;
171+
let droppedBlankUserMessages = 0;
172+
let rewrittenUserMessages = 0;
111173

112174
for (const line of lines) {
113175
if (!line.trim()) {
@@ -120,6 +182,24 @@ export async function repairSessionFileIfNeeded(params: {
120182
rewrittenAssistantMessages += 1;
121183
continue;
122184
}
185+
if (
186+
entry &&
187+
typeof entry === "object" &&
188+
(entry as { type?: unknown }).type === "message" &&
189+
typeof (entry as { message?: unknown }).message === "object" &&
190+
((entry as { message: { role?: unknown } }).message?.role ?? undefined) === "user"
191+
) {
192+
const repairedUser = repairUserEntryWithBlankTextContent(entry as SessionMessageEntry);
193+
if (repairedUser.kind === "drop") {
194+
droppedBlankUserMessages += 1;
195+
continue;
196+
}
197+
if (repairedUser.kind === "rewrite") {
198+
entries.push(repairedUser.entry);
199+
rewrittenUserMessages += 1;
200+
continue;
201+
}
202+
}
123203
entries.push(entry);
124204
} catch {
125205
droppedLines += 1;
@@ -137,7 +217,12 @@ export async function repairSessionFileIfNeeded(params: {
137217
return { repaired: false, droppedLines, reason: "invalid session header" };
138218
}
139219

140-
if (droppedLines === 0 && rewrittenAssistantMessages === 0) {
220+
if (
221+
droppedLines === 0 &&
222+
rewrittenAssistantMessages === 0 &&
223+
droppedBlankUserMessages === 0 &&
224+
rewrittenUserMessages === 0
225+
) {
141226
return { repaired: false, droppedLines: 0 };
142227
}
143228

@@ -169,15 +254,26 @@ export async function repairSessionFileIfNeeded(params: {
169254
repaired: false,
170255
droppedLines,
171256
rewrittenAssistantMessages,
257+
droppedBlankUserMessages,
258+
rewrittenUserMessages,
172259
reason: `repair failed: ${err instanceof Error ? err.message : "unknown error"}`,
173260
};
174261
}
175262

176263
params.warn?.(
177-
`session file repaired: ${buildRepairSummaryParts(
264+
`session file repaired: ${buildRepairSummaryParts({
178265
droppedLines,
179266
rewrittenAssistantMessages,
180-
)} (${path.basename(sessionFile)})`,
267+
droppedBlankUserMessages,
268+
rewrittenUserMessages,
269+
})} (${path.basename(sessionFile)})`,
181270
);
182-
return { repaired: true, droppedLines, rewrittenAssistantMessages, backupPath };
271+
return {
272+
repaired: true,
273+
droppedLines,
274+
rewrittenAssistantMessages,
275+
droppedBlankUserMessages,
276+
rewrittenUserMessages,
277+
backupPath,
278+
};
183279
}

src/auto-reply/heartbeat-filter.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
isHeartbeatOkResponse,
55
isHeartbeatUserMessage,
66
} from "./heartbeat-filter.js";
7-
import { HEARTBEAT_PROMPT } from "./heartbeat.js";
7+
import { HEARTBEAT_PROMPT, HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js";
88

99
describe("isHeartbeatUserMessage", () => {
1010
it("matches heartbeat prompts", () => {
@@ -25,6 +25,13 @@ describe("isHeartbeatUserMessage", () => {
2525
"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.",
2626
}),
2727
).toBe(true);
28+
29+
expect(
30+
isHeartbeatUserMessage({
31+
role: "user",
32+
content: HEARTBEAT_TRANSCRIPT_PROMPT,
33+
}),
34+
).toBe(true);
2835
});
2936

3037
it("ignores quoted or non-user token mentions", () => {
@@ -97,6 +104,8 @@ describe("filterHeartbeatPairs", () => {
97104
{ role: "assistant", content: "Hi there!" },
98105
{ role: "user", content: HEARTBEAT_PROMPT },
99106
{ role: "assistant", content: "HEARTBEAT_OK" },
107+
{ role: "user", content: HEARTBEAT_TRANSCRIPT_PROMPT },
108+
{ role: "assistant", content: "HEARTBEAT_OK" },
100109
{ role: "user", content: "What time is it?" },
101110
{ role: "assistant", content: "It is 3pm." },
102111
];

src/auto-reply/heartbeat-filter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { stripHeartbeatToken } from "./heartbeat.js";
2+
import { HEARTBEAT_TRANSCRIPT_PROMPT } from "./heartbeat.js";
23

34
const HEARTBEAT_TASK_PROMPT_PREFIX =
45
"Run the following periodic tasks (only those due based on their intervals):";
@@ -46,6 +47,9 @@ export function isHeartbeatUserMessage(
4647
return false;
4748
}
4849
const normalizedHeartbeatPrompt = heartbeatPrompt?.trim();
50+
if (trimmed === HEARTBEAT_TRANSCRIPT_PROMPT) {
51+
return true;
52+
}
4953
if (normalizedHeartbeatPrompt && trimmed.startsWith(normalizedHeartbeatPrompt)) {
5054
return true;
5155
}

src/auto-reply/heartbeat.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type HeartbeatTask = {
1313
// Keep it tight and avoid encouraging the model to invent/rehash "open loops" from prior chat context.
1414
export const HEARTBEAT_PROMPT =
1515
"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.";
16+
export const HEARTBEAT_TRANSCRIPT_PROMPT = "[OpenClaw heartbeat poll]";
1617
export const DEFAULT_HEARTBEAT_EVERY = "30m";
1718
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
1819

0 commit comments

Comments
 (0)