Skip to content

Commit d42089c

Browse files
committed
fix(memory-core): preserve sibling supplement results when one search rejects (#77897)
1 parent e28ad6a commit d42089c

2 files changed

Lines changed: 214 additions & 5 deletions

File tree

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import {
2+
clearMemoryPluginState,
3+
registerMemoryCorpusSupplement,
4+
type MemoryCorpusSearchResult,
5+
type MemoryCorpusSupplement,
6+
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
7+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8+
import { searchMemoryCorpusSupplements } from "./tools.shared.js";
9+
10+
function buildResult(overrides: Partial<MemoryCorpusSearchResult> = {}): MemoryCorpusSearchResult {
11+
return {
12+
corpus: "wiki",
13+
path: "wiki/example.md",
14+
score: 0.5,
15+
snippet: "snippet",
16+
...overrides,
17+
};
18+
}
19+
20+
function buildSupplement(handler: MemoryCorpusSupplement["search"]): MemoryCorpusSupplement {
21+
return {
22+
search: handler,
23+
get: async () => null,
24+
};
25+
}
26+
27+
describe("searchMemoryCorpusSupplements partial-failure tolerance (issue #77897)", () => {
28+
let warnSpy: ReturnType<typeof vi.spyOn>;
29+
30+
beforeEach(() => {
31+
clearMemoryPluginState();
32+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
33+
});
34+
35+
afterEach(() => {
36+
clearMemoryPluginState();
37+
warnSpy.mockRestore();
38+
});
39+
40+
it("returns empty array when no supplements registered", async () => {
41+
const out = await searchMemoryCorpusSupplements({ query: "anything", corpus: "all" });
42+
expect(out).toEqual([]);
43+
});
44+
45+
it("returns surviving results when one supplement rejects", async () => {
46+
registerMemoryCorpusSupplement(
47+
"good",
48+
buildSupplement(async () => [
49+
buildResult({ corpus: "wiki", path: "wiki/a.md", score: 0.8, snippet: "alpha" }),
50+
]),
51+
);
52+
registerMemoryCorpusSupplement(
53+
"bad",
54+
buildSupplement(async () => {
55+
throw new Error("supplement exploded");
56+
}),
57+
);
58+
59+
const out = await searchMemoryCorpusSupplements({ query: "alpha", corpus: "all" });
60+
61+
expect(out).toHaveLength(1);
62+
expect(out[0]).toMatchObject({ path: "wiki/a.md", snippet: "alpha" });
63+
expect(warnSpy).toHaveBeenCalledTimes(1);
64+
const message = String(warnSpy.mock.calls[0]?.[0] ?? "");
65+
expect(message).toContain('memory-core: corpus supplement "bad" search failed');
66+
expect(message).toContain("supplement exploded");
67+
});
68+
69+
it("returns empty array (not rejection) when every supplement rejects", async () => {
70+
registerMemoryCorpusSupplement(
71+
"bad-1",
72+
buildSupplement(async () => {
73+
throw new Error("e1");
74+
}),
75+
);
76+
registerMemoryCorpusSupplement(
77+
"bad-2",
78+
buildSupplement(async () => {
79+
throw new Error("e2");
80+
}),
81+
);
82+
83+
await expect(
84+
searchMemoryCorpusSupplements({ query: "anything", corpus: "all" }),
85+
).resolves.toEqual([]);
86+
expect(warnSpy).toHaveBeenCalledTimes(2);
87+
});
88+
89+
it("merges results from multiple successful supplements and orders by score desc, path asc", async () => {
90+
registerMemoryCorpusSupplement(
91+
"wiki",
92+
buildSupplement(async () => [
93+
buildResult({ corpus: "wiki", path: "wiki/b.md", score: 0.5 }),
94+
buildResult({ corpus: "wiki", path: "wiki/a.md", score: 0.5 }),
95+
]),
96+
);
97+
registerMemoryCorpusSupplement(
98+
"notes",
99+
buildSupplement(async () => [
100+
buildResult({ corpus: "notes", path: "notes/x.md", score: 0.9 }),
101+
]),
102+
);
103+
104+
const out = await searchMemoryCorpusSupplements({ query: "x", corpus: "all" });
105+
106+
expect(out.map((r) => r.path)).toEqual(["notes/x.md", "wiki/a.md", "wiki/b.md"]);
107+
expect(warnSpy).not.toHaveBeenCalled();
108+
});
109+
110+
it("respects maxResults clamp (≥1) after merging", async () => {
111+
registerMemoryCorpusSupplement(
112+
"wiki",
113+
buildSupplement(async () =>
114+
Array.from({ length: 25 }, (_, i) =>
115+
buildResult({
116+
corpus: "wiki",
117+
path: `wiki/${String(i).padStart(2, "0")}.md`,
118+
score: 1 - i / 100,
119+
}),
120+
),
121+
),
122+
);
123+
124+
const five = await searchMemoryCorpusSupplements({
125+
query: "x",
126+
corpus: "all",
127+
maxResults: 5,
128+
});
129+
expect(five).toHaveLength(5);
130+
131+
const zeroClampedToOne = await searchMemoryCorpusSupplements({
132+
query: "x",
133+
corpus: "all",
134+
maxResults: 0,
135+
});
136+
expect(zeroClampedToOne).toHaveLength(1);
137+
});
138+
139+
it("never queries supplements when corpus is 'memory' or 'sessions'", async () => {
140+
const search = vi.fn(async () => [buildResult()]);
141+
registerMemoryCorpusSupplement("wiki", buildSupplement(search));
142+
143+
await expect(searchMemoryCorpusSupplements({ query: "x", corpus: "memory" })).resolves.toEqual(
144+
[],
145+
);
146+
await expect(
147+
searchMemoryCorpusSupplements({ query: "x", corpus: "sessions" }),
148+
).resolves.toEqual([]);
149+
150+
expect(search).not.toHaveBeenCalled();
151+
});
152+
153+
it("preserves results when a supplement returns a Promise that rejects with non-Error reason", async () => {
154+
registerMemoryCorpusSupplement(
155+
"good",
156+
buildSupplement(async () => [buildResult({ path: "wiki/a.md" })]),
157+
);
158+
registerMemoryCorpusSupplement(
159+
"string-throw",
160+
buildSupplement(async () => {
161+
// eslint-disable-next-line @typescript-eslint/only-throw-error
162+
throw "raw string failure";
163+
}),
164+
);
165+
registerMemoryCorpusSupplement(
166+
"object-throw",
167+
buildSupplement(async () => {
168+
// eslint-disable-next-line @typescript-eslint/only-throw-error
169+
throw { code: "ESUPP", detail: "structured failure" };
170+
}),
171+
);
172+
173+
const out = await searchMemoryCorpusSupplements({ query: "x", corpus: "all" });
174+
expect(out).toHaveLength(1);
175+
expect(out[0]?.path).toBe("wiki/a.md");
176+
177+
const messages = warnSpy.mock.calls.map((c) => String(c[0] ?? ""));
178+
expect(
179+
messages.some((m) => m.includes('"string-throw"') && m.includes("raw string failure")),
180+
).toBe(true);
181+
expect(messages.some((m) => m.includes('"object-throw"') && m.includes("ESUPP"))).toBe(true);
182+
});
183+
});

extensions/memory-core/src/tools.shared.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,23 @@ export async function searchMemoryCorpusSupplements(params: {
162162
if (supplements.length === 0) {
163163
return [];
164164
}
165-
const results = (
166-
await Promise.all(
167-
supplements.map(async (registration) => await registration.supplement.search(params)),
168-
)
169-
).flat();
165+
// Use allSettled so a single misbehaving supplement does not discard sibling
166+
// results. Invariant: result ⊇ ⋃_{s succeeds} s.search(params).
167+
const settled = await Promise.allSettled(
168+
supplements.map(async (registration) => await registration.supplement.search(params)),
169+
);
170+
const results: MemoryCorpusSearchResult[] = [];
171+
for (let i = 0; i < settled.length; i++) {
172+
const outcome = settled[i];
173+
if (outcome.status === "fulfilled") {
174+
results.push(...outcome.value);
175+
} else {
176+
const pluginId = supplements[i]?.pluginId ?? "<unknown>";
177+
console.warn(
178+
`memory-core: corpus supplement "${pluginId}" search failed; sibling results preserved (${formatSupplementError(outcome.reason)}).`,
179+
);
180+
}
181+
}
170182
return results
171183
.toSorted((left, right) => {
172184
if (left.score !== right.score) {
@@ -177,6 +189,20 @@ export async function searchMemoryCorpusSupplements(params: {
177189
.slice(0, Math.max(1, params.maxResults ?? 10));
178190
}
179191

192+
function formatSupplementError(reason: unknown): string {
193+
if (reason instanceof Error) {
194+
return reason.message || reason.name || "Error";
195+
}
196+
if (typeof reason === "string") {
197+
return reason;
198+
}
199+
try {
200+
return JSON.stringify(reason);
201+
} catch {
202+
return String(reason);
203+
}
204+
}
205+
180206
export async function getMemoryCorpusSupplementResult(params: {
181207
lookup: string;
182208
fromLine?: number;

0 commit comments

Comments
 (0)