mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:00:43 +00:00
fix(agents): sanitize blank Bedrock user replay
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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." },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]";
|
||||
|
||||
Reference in New Issue
Block a user