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