fix(memory): cap per-corpus results in corpus=all to prevent memory-hit starvation (#77337)

Wiki and memory scores use incomparable scales: wiki uses integer point sums
(up to ~100+) while memory uses cosine similarity (0-1). A raw-score sort across
both corpora allowed wiki hits to fill maxResults slots completely, returning 0
memory results even when memory search found matching content.

Fix: when corpus=all and supplement results are present, cap each corpus at
ceil(maxResults/2) before the joint sort, guaranteeing both corpora are
represented in the final slice.

Fixes #77337.
This commit is contained in:
HCL
2026-05-04 22:11:12 +08:00
committed by clawsweeper
parent 89db1e5440
commit 5add41c150
3 changed files with 97 additions and 2 deletions

View File

@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys.
- Telegram: clean up tool-only draft previews after assistant message boundaries so transient `Surfacing...` tool-status bubbles do not linger when no matching final preview arrives. Thanks @BunsDev.
- Cron: surface failed isolated-run diagnostics in `cron show`, status, and run history when requested tools are unavailable, so blocked cron runs report the actual tool-policy failure instead of a misleading green result. Fixes #75763. Thanks @RyanSandoval.
- TUI/escape abort: track the in-flight runId after `chat.send` resolves so pressing Esc during the gap before the first gateway event aborts the run instead of repeatedly printing `no active run`. Fixes #1296. Thanks @Lukavyi and @romneyda.

View File

@@ -282,6 +282,88 @@ describe("memory tools", () => {
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,
},
{
path: "memory/note-b.md",
startLine: 1,
endLine: 2,
score: 0.8,
snippet: "Memory result B",
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.
expect(corpora).toContain("memory");
expect(corpora).toContain("wiki");
expect(details.results.length).toBeLessThanOrEqual(5);
});
it("merges memory and wiki corpus search results for corpus=all", async () => {
registerMemoryCorpusSupplement("memory-wiki", {
search: async () => [

View File

@@ -319,14 +319,26 @@ export function createMemorySearchTool(options: {
corpus: requestedCorpus,
})
: [];
const results = [...surfacedMemoryResults, ...supplementResults]
// When both corpora are present (corpus=all), wiki and memory scores
// use incomparable scales (integer point vs cosine similarity), so a
// raw-score sort lets wiki hits starve memory hits (#77337).
// Cap each corpus at ceil(maxResults/2) before the joint sort so both
// corpora are guaranteed representation in the final slice.
const effectiveMax = Math.max(1, maxResults ?? 10);
const perCorpusCap =
requestedCorpus === "all" && supplementResults.length > 0
? Math.ceil(effectiveMax / 2)
: effectiveMax;
const cappedMemory = surfacedMemoryResults.slice(0, perCorpusCap);
const cappedSupplements = supplementResults.slice(0, perCorpusCap);
const results = [...cappedMemory, ...cappedSupplements]
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
})
.slice(0, Math.max(1, maxResults ?? 10));
.slice(0, effectiveMax);
return jsonResult({
results,
provider,