Web UI: strip relevant-memories scaffolding
This commit is contained in:
@@ -77,6 +77,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Web UI/Assistant text: strip internal `<relevant-memories>...</relevant-memories>` 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.
|
||||
|
||||
49
src/shared/text/assistant-visible-text.test.ts
Normal file
49
src/shared/text/assistant-visible-text.test.ts
Normal file
@@ -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 = ["<thinking>", "secret", "</thinking>", "Visible"].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Visible");
|
||||
});
|
||||
|
||||
it("strips relevant-memories scaffolding blocks", () => {
|
||||
const input = [
|
||||
"<relevant-memories>",
|
||||
"The following memories may be relevant to this conversation:",
|
||||
"- Internal memory note",
|
||||
"</relevant-memories>",
|
||||
"",
|
||||
"User-visible answer",
|
||||
].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("User-visible answer");
|
||||
});
|
||||
|
||||
it("supports relevant_memories tag variants", () => {
|
||||
const input = [
|
||||
"<relevant_memories>",
|
||||
"Internal memory note",
|
||||
"</relevant_memories>",
|
||||
"Visible",
|
||||
].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Visible");
|
||||
});
|
||||
|
||||
it("keeps relevant-memories tags inside fenced code", () => {
|
||||
const input = [
|
||||
"```xml",
|
||||
"<relevant-memories>",
|
||||
"sample",
|
||||
"</relevant-memories>",
|
||||
"```",
|
||||
"",
|
||||
"Visible text",
|
||||
].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("hides unfinished relevant-memories blocks", () => {
|
||||
const input = ["Hello", "<relevant-memories>", "internal-only"].join("\n");
|
||||
expect(stripAssistantInternalScaffolding(input)).toBe("Hello\n");
|
||||
});
|
||||
});
|
||||
47
src/shared/text/assistant-visible-text.ts
Normal file
47
src/shared/text/assistant-visible-text.ts
Normal file
@@ -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();
|
||||
}
|
||||
@@ -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: [
|
||||
"<relevant-memories>",
|
||||
"Internal memory context",
|
||||
"</relevant-memories>",
|
||||
"Final user answer",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractText(message)).toBe("Final user answer");
|
||||
expect(extractTextCached(message)).toBe("Final user answer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinkingCached", () => {
|
||||
|
||||
@@ -68,4 +68,34 @@ describe("stripThinkingTags", () => {
|
||||
expect(stripThinkingTags("<final\nHello")).toBe("<final\nHello");
|
||||
expect(stripThinkingTags("Hello</final>")).toBe("Hello");
|
||||
});
|
||||
|
||||
it("strips <relevant-memories> blocks", () => {
|
||||
const input = [
|
||||
"<relevant-memories>",
|
||||
"The following memories may be relevant to this conversation:",
|
||||
"- Internal memory note",
|
||||
"</relevant-memories>",
|
||||
"",
|
||||
"User-visible answer",
|
||||
].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("User-visible answer");
|
||||
});
|
||||
|
||||
it("keeps relevant-memories tags in fenced code blocks", () => {
|
||||
const input = [
|
||||
"```xml",
|
||||
"<relevant-memories>",
|
||||
"sample",
|
||||
"</relevant-memories>",
|
||||
"```",
|
||||
"",
|
||||
"Visible text",
|
||||
].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("hides unfinished <relevant-memories> block tails", () => {
|
||||
const input = ["Hello", "<relevant-memories>", "internal-only"].join("\n");
|
||||
expect(stripThinkingTags(input)).toBe("Hello\n");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user