Files
openclaw/extensions/memory-core/src/tools.citations.test.ts
hcl d5edeae6ee fix(memory): prevent memory-hit starvation in corpus=all by capping per-corpus results (#77337) (#77356)
Summary:
- The PR adds balanced, backfilled all-corpus result merging for `memory_search` and `wiki_search`, regression tests, and a changelog entry for #77337.
- Reproducibility: yes. Current main is source-reproducible: both affected paths fetch both corpora for `corpus=all`, raw-sort wiki integer scores against memory similarity scores, and slice to `maxResults`.

Automerge notes:
- Ran the ClawSweeper repair loop before final review.
- Included post-review commit in the final squash: fix(memory): prevent all-corpus memory hit starvation

Validation:
- ClawSweeper review passed for head a5b4f6a932.
- Required merge gates passed before the squash merge.

Prepared head SHA: a5b4f6a932
Review: https://github.com/openclaw/openclaw/pull/77356#issuecomment-4371767658

Co-authored-by: HCL <chenglunhu@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
2026-05-04 14:49:14 +00:00

425 lines
14 KiB
TypeScript

import fs from "node:fs/promises";
import path from "node:path";
import {
clearMemoryPluginState,
registerMemoryCorpusSupplement,
} from "openclaw/plugin-sdk/memory-host-core";
import { beforeEach, describe, expect, it } from "vitest";
import {
getMemorySearchManagerMockCalls,
getReadAgentMemoryFileMockCalls,
resetMemoryToolMockState,
setMemoryBackend,
setMemoryReadFileImpl,
setMemorySearchImpl,
setMemoryWorkspaceDir,
type MemoryReadParams,
} from "./memory-tool-manager-mock.js";
import { createMemoryCoreTestHarness } from "./test-helpers.js";
import {
asOpenClawConfig,
createAutoCitationsMemorySearchTool,
createDefaultMemoryToolConfig,
createMemoryGetToolOrThrow,
createMemorySearchToolOrThrow,
expectUnavailableMemorySearchDetails,
} from "./tools.test-helpers.js";
const { createTempWorkspace } = createMemoryCoreTestHarness();
async function waitFor<T>(task: () => Promise<T>, timeoutMs: number = 1500): Promise<T> {
const startedAt = Date.now();
let lastError: unknown;
while (Date.now() - startedAt < timeoutMs) {
try {
return await task();
} catch (error) {
lastError = error;
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
}
}
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("Timed out waiting for async test condition");
}
beforeEach(() => {
clearMemoryPluginState();
resetMemoryToolMockState({
backend: "builtin",
searchImpl: async () => [
{
path: "MEMORY.md",
startLine: 5,
endLine: 7,
score: 0.9,
snippet: "@@ -5,3 @@\nAssistant: noted",
source: "memory" as const,
},
],
readFileImpl: async (params: MemoryReadParams) => ({
text: "",
path: params.relPath,
from: params.from ?? 1,
lines: params.lines ?? 120,
}),
});
});
describe("memory search citations", () => {
it("appends source information when citations are enabled", async () => {
setMemoryBackend("builtin");
const cfg = asOpenClawConfig({
memory: { citations: "on" },
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_on", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).toMatch(/Source: MEMORY.md#L5-L7/);
expect(details.results[0]?.citation).toBe("MEMORY.md#L5-L7");
});
it("leaves snippet untouched when citations are off", async () => {
setMemoryBackend("builtin");
const cfg = asOpenClawConfig({
memory: { citations: "off" },
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_off", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
expect(details.results[0]?.citation).toBeUndefined();
});
it("clamps decorated snippets to qmd injected budget", async () => {
setMemoryBackend("qmd");
const cfg = asOpenClawConfig({
memory: { citations: "on", backend: "qmd", qmd: { limits: { maxInjectedChars: 20 } } },
agents: { list: [{ id: "main", default: true }] },
});
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_citations_qmd", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string; citation?: string }> };
expect(details.results[0]?.snippet.length).toBeLessThanOrEqual(20);
});
it("honors auto mode for direct chats", async () => {
setMemoryBackend("builtin");
const tool = createAutoCitationsMemorySearchTool("agent:main:discord:dm:u123");
const result = await tool.execute("auto_mode_direct", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string }> };
expect(details.results[0]?.snippet).toMatch(/Source:/);
});
it("suppresses citations for auto mode in group chats", async () => {
setMemoryBackend("builtin");
const tool = createAutoCitationsMemorySearchTool("agent:main:discord:group:c123");
const result = await tool.execute("auto_mode_group", { query: "notes" });
const details = result.details as { results: Array<{ snippet: string }> };
expect(details.results[0]?.snippet).not.toMatch(/Source:/);
});
});
describe("memory tools", () => {
it("does not throw when memory_search fails (e.g. embeddings 429)", async () => {
setMemorySearchImpl(async () => {
throw new Error("openai embeddings failed: 429 insufficient_quota");
});
const cfg = createDefaultMemoryToolConfig();
const tool = createMemorySearchToolOrThrow({ config: cfg });
const result = await tool.execute("call_1", { query: "hello" });
expectUnavailableMemorySearchDetails(result.details, {
error: "openai embeddings failed: 429 insufficient_quota",
warning: "Memory search is unavailable because the embedding provider quota is exhausted.",
action: "Top up or switch embedding provider, then retry memory_search.",
});
});
it("does not throw when memory_get fails", async () => {
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
throw new Error("path required");
});
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_2", { path: "memory/NOPE.md" });
expect(result.details).toEqual({
path: "memory/NOPE.md",
text: "",
disabled: true,
error: "path required",
});
});
it("returns empty text without error when file does not exist (ENOENT)", async () => {
setMemoryReadFileImpl(async (_params: MemoryReadParams) => {
return { text: "", path: "memory/2026-02-19.md", from: 1, lines: 0 };
});
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_enoent", { path: "memory/2026-02-19.md" });
expect(result.details).toEqual({
text: "",
path: "memory/2026-02-19.md",
from: 1,
lines: 0,
});
});
it("uses the builtin direct memory file path for memory_get", async () => {
setMemoryBackend("builtin");
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_builtin_fast_path", { path: "memory/2026-02-19.md" });
expect(result.details).toEqual({
text: "",
path: "memory/2026-02-19.md",
from: 1,
lines: 120,
});
expect(getReadAgentMemoryFileMockCalls()).toBe(1);
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it("returns truncation metadata and a continuation notice for partial memory_get results", async () => {
setMemoryBackend("builtin");
setMemoryReadFileImpl(async (params: MemoryReadParams) => ({
path: params.relPath,
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
from: params.from ?? 1,
lines: 40,
truncated: true,
nextFrom: 41,
}));
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_partial", { path: "memory/partial.md" });
expect(result.details).toEqual({
path: "memory/partial.md",
text: "alpha\nbeta\n\n[More content available. Use from=41 to continue.]",
from: 1,
lines: 40,
truncated: true,
nextFrom: 41,
});
});
it("persists short-term recall events from memory_search tool hits", async () => {
const workspaceDir = await createTempWorkspace("memory-tools-recall-");
try {
setMemoryBackend("builtin");
setMemoryWorkspaceDir(workspaceDir);
setMemorySearchImpl(async () => [
{
path: "memory/2026-04-03.md",
startLine: 1,
endLine: 2,
score: 0.95,
snippet: "Move backups to S3 Glacier.",
source: "memory" as const,
},
]);
const tool = createMemorySearchToolOrThrow();
await tool.execute("call_recall_persist", { query: "glacier backup" });
const storePath = path.join(workspaceDir, "memory", ".dreams", "short-term-recall.json");
const storeRaw = await waitFor(async () => await fs.readFile(storePath, "utf-8"));
const store = JSON.parse(storeRaw) as {
entries?: Record<string, { path: string; recallCount: number }>;
};
const entries = Object.values(store.entries ?? {});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
path: "memory/2026-04-03.md",
recallCount: 1,
});
} finally {
await fs.rm(workspaceDir, { recursive: true, force: true });
}
});
it("searches registered wiki corpus supplements without calling memory search", async () => {
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 4,
snippet: "Alpha wiki entry",
},
],
get: async () => null,
});
const tool = createMemorySearchToolOrThrow();
const result = await tool.execute("call_wiki_only", { query: "alpha", corpus: "wiki" });
expect(result.details).toMatchObject({
results: [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 4,
snippet: "Alpha wiki entry",
},
],
});
expect(getMemorySearchManagerMockCalls()).toBe(0);
});
it("includes memory results in corpus=all even when wiki scores are numerically higher (#77337)", async () => {
// Wiki uses integer point scores (up to ~100+); memory uses cosine similarity (0-1).
// Raw-score sort would starve memory hits when maxResults <= number of wiki hits.
setMemorySearchImpl(async () => [
{
path: "memory/note-a.md",
startLine: 1,
endLine: 2,
score: 0.9,
snippet: "Memory result A",
source: "memory" as const,
},
]);
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [
{
corpus: "wiki",
path: "w1.md",
title: "W1",
kind: "entity",
score: 50,
snippet: "wiki 1",
},
{
corpus: "wiki",
path: "w2.md",
title: "W2",
kind: "entity",
score: 40,
snippet: "wiki 2",
},
{
corpus: "wiki",
path: "w3.md",
title: "W3",
kind: "entity",
score: 30,
snippet: "wiki 3",
},
{
corpus: "wiki",
path: "w4.md",
title: "W4",
kind: "entity",
score: 20,
snippet: "wiki 4",
},
{
corpus: "wiki",
path: "w5.md",
title: "W5",
kind: "entity",
score: 10,
snippet: "wiki 5",
},
],
get: async () => null,
});
const tool = createMemorySearchToolOrThrow();
const result = await tool.execute("call_all_starvation", {
query: "note",
corpus: "all",
maxResults: 5,
});
const details = result.details as { results: Array<{ corpus: string; path: string }> };
const corpora = details.results.map((r) => r.corpus);
// Memory results must appear despite lower numeric scores, and the spare
// memory quota should be backfilled by the remaining wiki result.
expect(corpora).toContain("memory");
expect(corpora).toContain("wiki");
expect(details.results).toHaveLength(5);
expect(
details.results.filter((entry) => entry.corpus === "wiki").map((entry) => entry.path),
).toEqual(["w1.md", "w2.md", "w3.md", "w4.md"]);
});
it("merges memory and wiki corpus search results for corpus=all", async () => {
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [
{
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
score: 1.1,
snippet: "Alpha wiki entry",
},
],
get: async () => null,
});
const tool = createMemorySearchToolOrThrow();
const result = await tool.execute("call_all_corpus", { query: "alpha", corpus: "all" });
const details = result.details as { results: Array<{ corpus: string; path: string }> };
expect(details.results.map((entry) => [entry.corpus, entry.path])).toEqual([
["wiki", "entities/alpha.md"],
["memory", "MEMORY.md"],
]);
expect(getMemorySearchManagerMockCalls()).toBe(1);
});
it("falls back to a wiki corpus supplement for memory_get corpus=all", async () => {
setMemoryReadFileImpl(async () => {
throw new Error("path required");
});
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [],
get: async () => ({
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
content: "Alpha wiki entry",
fromLine: 3,
lineCount: 5,
}),
});
const tool = createMemoryGetToolOrThrow();
const result = await tool.execute("call_get_all_fallback", {
path: "entities/alpha.md",
from: 3,
lines: 5,
corpus: "all",
});
expect(result.details).toEqual({
corpus: "wiki",
path: "entities/alpha.md",
title: "Alpha",
kind: "entity",
text: "Alpha wiki entry",
fromLine: 3,
lineCount: 5,
});
});
});