diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a95d7fd09c..a30e57c5f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Web UI/Assistant text: strip internal `...` scaffolding from rendered assistant messages (while preserving code-fence literals), preventing memory-context leakage in chat output for models that echo internal blocks. (#29851) Thanks @Valkster70. - Dashboard/Sessions: allow authenticated Control UI clients to delete and patch sessions while still blocking regular webchat clients from session mutation RPCs, fixing Dashboard session delete failures. (#21264) Thanks @jskoiz. - Podman/Quadlet setup: fix `sed` escaping and UID mismatch in Podman Quadlet setup. (#26414) Thanks @KnHack and @vincentkoc. - Browser/Navigate: resolve the correct `targetId` in navigate responses after renderer swaps. (#25326) Thanks @stone-jin and @vincentkoc. diff --git a/src/shared/text/assistant-visible-text.test.ts b/src/shared/text/assistant-visible-text.test.ts new file mode 100644 index 00000000000..234d37b96da --- /dev/null +++ b/src/shared/text/assistant-visible-text.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { stripAssistantInternalScaffolding } from "./assistant-visible-text.js"; + +describe("stripAssistantInternalScaffolding", () => { + it("strips reasoning tags", () => { + const input = ["", "secret", "", "Visible"].join("\n"); + expect(stripAssistantInternalScaffolding(input)).toBe("Visible"); + }); + + it("strips relevant-memories scaffolding blocks", () => { + const input = [ + "", + "The following memories may be relevant to this conversation:", + "- Internal memory note", + "", + "", + "User-visible answer", + ].join("\n"); + expect(stripAssistantInternalScaffolding(input)).toBe("User-visible answer"); + }); + + it("supports relevant_memories tag variants", () => { + const input = [ + "", + "Internal memory note", + "", + "Visible", + ].join("\n"); + expect(stripAssistantInternalScaffolding(input)).toBe("Visible"); + }); + + it("keeps relevant-memories tags inside fenced code", () => { + const input = [ + "```xml", + "", + "sample", + "", + "```", + "", + "Visible text", + ].join("\n"); + expect(stripAssistantInternalScaffolding(input)).toBe(input); + }); + + it("hides unfinished relevant-memories blocks", () => { + const input = ["Hello", "", "internal-only"].join("\n"); + expect(stripAssistantInternalScaffolding(input)).toBe("Hello\n"); + }); +}); diff --git a/src/shared/text/assistant-visible-text.ts b/src/shared/text/assistant-visible-text.ts new file mode 100644 index 00000000000..38bcd5ff995 --- /dev/null +++ b/src/shared/text/assistant-visible-text.ts @@ -0,0 +1,47 @@ +import { findCodeRegions, isInsideCode } from "./code-regions.js"; +import { stripReasoningTagsFromText } from "./reasoning-tags.js"; + +const MEMORY_TAG_RE = /<\s*(\/?)\s*relevant[-_]memories\b[^<>]*>/gi; +const MEMORY_TAG_QUICK_RE = /<\s*\/?\s*relevant[-_]memories\b/i; + +function stripRelevantMemoriesTags(text: string): string { + if (!text || !MEMORY_TAG_QUICK_RE.test(text)) { + return text; + } + MEMORY_TAG_RE.lastIndex = 0; + + const codeRegions = findCodeRegions(text); + let result = ""; + let lastIndex = 0; + let inMemoryBlock = false; + + for (const match of text.matchAll(MEMORY_TAG_RE)) { + const idx = match.index ?? 0; + if (isInsideCode(idx, codeRegions)) { + continue; + } + + const isClose = match[1] === "/"; + if (!inMemoryBlock) { + result += text.slice(lastIndex, idx); + if (!isClose) { + inMemoryBlock = true; + } + } else if (isClose) { + inMemoryBlock = false; + } + + lastIndex = idx + match[0].length; + } + + if (!inMemoryBlock) { + result += text.slice(lastIndex); + } + + return result; +} + +export function stripAssistantInternalScaffolding(text: string): string { + const withoutReasoning = stripReasoningTagsFromText(text, { mode: "preserve", trim: "start" }); + return stripRelevantMemoriesTags(withoutReasoning).trimStart(); +} diff --git a/ui/src/ui/chat/message-extract.test.ts b/ui/src/ui/chat/message-extract.test.ts index 70dd28e001f..93df4b371af 100644 --- a/ui/src/ui/chat/message-extract.test.ts +++ b/ui/src/ui/chat/message-extract.test.ts @@ -23,6 +23,25 @@ describe("extractTextCached", () => { expect(extractTextCached(message)).toBe("plain text"); expect(extractTextCached(message)).toBe("plain text"); }); + + it("strips assistant relevant-memories scaffolding", () => { + const message = { + role: "assistant", + content: [ + { + type: "text", + text: [ + "", + "Internal memory context", + "", + "Final user answer", + ].join("\n"), + }, + ], + }; + expect(extractText(message)).toBe("Final user answer"); + expect(extractTextCached(message)).toBe("Final user answer"); + }); }); describe("extractThinkingCached", () => { diff --git a/ui/src/ui/format.test.ts b/ui/src/ui/format.test.ts index 239bdd213ec..e272b5c6ca4 100644 --- a/ui/src/ui/format.test.ts +++ b/ui/src/ui/format.test.ts @@ -68,4 +68,34 @@ describe("stripThinkingTags", () => { expect(stripThinkingTags("")).toBe("Hello"); }); + + it("strips blocks", () => { + const input = [ + "", + "The following memories may be relevant to this conversation:", + "- Internal memory note", + "", + "", + "User-visible answer", + ].join("\n"); + expect(stripThinkingTags(input)).toBe("User-visible answer"); + }); + + it("keeps relevant-memories tags in fenced code blocks", () => { + const input = [ + "```xml", + "", + "sample", + "", + "```", + "", + "Visible text", + ].join("\n"); + expect(stripThinkingTags(input)).toBe(input); + }); + + it("hides unfinished block tails", () => { + const input = ["Hello", "", "internal-only"].join("\n"); + expect(stripThinkingTags(input)).toBe("Hello\n"); + }); }); diff --git a/ui/src/ui/format.ts b/ui/src/ui/format.ts index da3d544f199..1d3f24bfadf 100644 --- a/ui/src/ui/format.ts +++ b/ui/src/ui/format.ts @@ -1,6 +1,6 @@ import { formatDurationHuman } from "../../../src/infra/format-time/format-duration.ts"; import { formatRelativeTimestamp } from "../../../src/infra/format-time/format-relative.ts"; -import { stripReasoningTagsFromText } from "../../../src/shared/text/reasoning-tags.js"; +import { stripAssistantInternalScaffolding } from "../../../src/shared/text/assistant-visible-text.js"; export { formatRelativeTimestamp, formatDurationHuman }; @@ -56,5 +56,5 @@ export function parseList(input: string): string[] { } export function stripThinkingTags(value: string): string { - return stripReasoningTagsFromText(value, { mode: "preserve", trim: "start" }); + return stripAssistantInternalScaffolding(value); }