fix(agent-core,memory-core): keep compaction summary and memory snippet truncation UTF-16 safe (#102542)

* fix(agent-core,memory-core): keep compaction summary and memory snippet truncation UTF-16 safe

* fix: make compaction and memory truncation UTF-16 safe

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
wings1029 2026-07-09 17:08:11 +08:00 committed by GitHub
parent b8a801930a
commit f7cc6ebe1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 91 additions and 4 deletions

View file

@ -3588,4 +3588,70 @@ describe("short-term promotion", () => {
});
});
});
describe("UTF-16 snippet bounds", () => {
it("stores a complete-code-point short-term recall snippet", async () => {
await withTempWorkspace(async (workspaceDir) => {
const prefix = "y".repeat(testing.SHORT_TERM_RECALL_MAX_SNIPPET_CHARS - 1);
await recordShortTermRecalls({
workspaceDir,
query: "utf16 recall",
results: [
{
path: "memory/2026-04-03.md",
source: "memory",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: `${prefix}🚀tail`,
},
],
});
const entries = Object.values(await readRecallStoreEntries(workspaceDir));
expect(entries).toHaveLength(1);
expect(readEntrySnippet(entries[0])).toBe(prefix);
});
});
it("writes a complete-code-point promoted MEMORY.md snippet", async () => {
await withTempWorkspace(async (workspaceDir) => {
const prefix = "a".repeat(7);
const snippet = `${prefix}🚀tail`;
await writeDailyMemoryNote(workspaceDir, "2026-04-03", [snippet]);
await recordShortTermRecalls({
workspaceDir,
query: "utf16 promotion",
results: [
{
path: "memory/2026-04-03.md",
source: "memory",
startLine: 1,
endLine: 1,
score: 0.9,
snippet,
},
],
});
const ranked = await rankShortTermPromotionCandidates({
workspaceDir,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
});
await applyShortTermPromotions({
workspaceDir,
candidates: ranked,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
maxPromotedSnippetTokens: 2,
});
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain(`- ${prefix}... [`);
expect(memoryText).not.toContain("🚀");
});
});
});
});

View file

@ -16,6 +16,7 @@ import {
normalizeStringEntries,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
deriveConceptTags,
MAX_CONCEPT_TAGS,
@ -359,7 +360,7 @@ function truncateShortTermSnippet(snippet: string): string {
if (snippet.length <= SHORT_TERM_RECALL_MAX_SNIPPET_CHARS) {
return snippet;
}
return snippet.slice(0, SHORT_TERM_RECALL_MAX_SNIPPET_CHARS).trimEnd();
return truncateUtf16Safe(snippet, SHORT_TERM_RECALL_MAX_SNIPPET_CHARS).trimEnd();
}
function enforceShortTermRecallSnippetCap(store: ShortTermRecallStore): void {
@ -2351,7 +2352,7 @@ function truncatePromotedSnippet(snippet: string, maxTokens: number): string {
if (limit === 0 || snippet.length <= limit) {
return snippet;
}
const hardLimit = snippet.slice(0, limit);
const hardLimit = truncateUtf16Safe(snippet, limit);
const sentenceBoundary = Math.max(
hardLimit.lastIndexOf(". "),
hardLimit.lastIndexOf("! "),

View file

@ -96,6 +96,7 @@
},
"dependencies": {
"@openclaw/ai": "workspace:*",
"@openclaw/normalization-core": "workspace:*",
"typebox": "1.3.3"
}
}

View file

@ -33,4 +33,18 @@ describe("serializeConversation", () => {
expect(serializeConversation(messages)).toBe(`[Tool result]: ${expected}`);
});
it("keeps truncated tool results UTF-16 safe and reports the exact omitted count", () => {
const prefix = "a".repeat(1_999);
const messages = [
{
role: "toolResult",
content: [{ type: "toolResult", content: `${prefix}🚀tail` }],
},
] as unknown as Message[];
expect(serializeConversation(messages)).toBe(
`[Tool result]: ${prefix}\n\n[... 6 more characters truncated]`,
);
});
});

View file

@ -1,4 +1,5 @@
// Agent Core helper module supports utils behavior.
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import type { Message } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
@ -105,8 +106,9 @@ function truncateForSummary(text: string, maxChars: number): string {
if (text.length <= maxChars) {
return text;
}
const truncatedChars = text.length - maxChars;
return `${text.slice(0, maxChars)}\n\n[... ${truncatedChars} more characters truncated]`;
const sliced = truncateUtf16Safe(text, maxChars);
const truncatedChars = text.length - sliced.length;
return `${sliced}\n\n[... ${truncatedChars} more characters truncated]`;
}
/** Extract text that compaction both estimates and includes in summary prompts. */

3
pnpm-lock.yaml generated
View file

@ -1864,6 +1864,9 @@ importers:
'@openclaw/ai':
specifier: workspace:*
version: link:../ai
'@openclaw/normalization-core':
specifier: workspace:*
version: link:../normalization-core
typebox:
specifier: 1.3.3
version: 1.3.3