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>
This commit is contained in:
hcl
2026-05-04 22:49:14 +08:00
committed by GitHub
parent 89db1e5440
commit d5edeae6ee
5 changed files with 232 additions and 16 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,84 @@ 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,
},
]);
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 () => [

View File

@@ -5,6 +5,7 @@ import {
jsonResult,
readNumberParam,
readStringParam,
type MemoryCorpusSearchResult,
type OpenClawConfig,
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import type {
@@ -35,6 +36,50 @@ import {
searchMemoryCorpusSupplements,
} from "./tools.shared.js";
type MemorySearchToolResult =
| (Record<string, unknown> & { corpus: "memory"; score: number; path: string })
| MemoryCorpusSearchResult;
function sortMemorySearchToolResults<T extends { score: number; path: string }>(results: T[]): T[] {
return results.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.path.localeCompare(right.path);
});
}
function mergeMemorySearchCorpusResults(params: {
memoryResults: MemorySearchToolResult[];
supplementResults: MemorySearchToolResult[];
maxResults: number;
balanceCorpora: boolean;
}): MemorySearchToolResult[] {
const memoryResults = sortMemorySearchToolResults(params.memoryResults);
const supplementResults = sortMemorySearchToolResults(params.supplementResults);
if (!params.balanceCorpora || memoryResults.length === 0 || supplementResults.length === 0) {
return sortMemorySearchToolResults([...memoryResults, ...supplementResults]).slice(
0,
params.maxResults,
);
}
const perCorpusCap = Math.ceil(params.maxResults / 2);
const selectedMemory = memoryResults.slice(0, perCorpusCap);
const selectedSupplements = supplementResults.slice(0, perCorpusCap);
const selected = [...selectedMemory, ...selectedSupplements];
if (selected.length < params.maxResults) {
selected.push(
...sortMemorySearchToolResults([
...memoryResults.slice(selectedMemory.length),
...supplementResults.slice(selectedSupplements.length),
]).slice(0, params.maxResults - selected.length),
);
}
return sortMemorySearchToolResults(selected).slice(0, params.maxResults);
}
function buildRecallKey(
result: Pick<MemorySearchResult, "source" | "path" | "startLine" | "endLine">,
): string {
@@ -319,14 +364,15 @@ export function createMemorySearchTool(options: {
corpus: requestedCorpus,
})
: [];
const results = [...surfacedMemoryResults, ...supplementResults]
.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));
// Wiki and memory scores use incomparable scales, so corpus=all first
// balances candidate selection and then backfills any unused slots.
const effectiveMax = Math.max(1, maxResults ?? 10);
const results = mergeMemorySearchCorpusResults({
memoryResults: surfacedMemoryResults,
supplementResults,
maxResults: effectiveMax,
balanceCorpora: requestedCorpus === "all",
});
return jsonResult({
results,
provider,

View File

@@ -578,6 +578,62 @@ describe("searchMemoryWiki", () => {
});
});
it("includes memory results and backfills wiki capacity for all-corpus search", async () => {
const { rootDir, config } = await createQueryVault({
initialize: true,
config: {
search: { backend: "shared", corpus: "all" },
},
});
for (const index of [1, 2, 3, 4, 5]) {
await fs.writeFile(
path.join(rootDir, "entities", `alpha-${index}.md`),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: `entity.alpha.${index}`,
title: `Alpha ${index}`,
},
body: `# Alpha ${index}\n\nalpha wiki ${index}\n`,
}),
"utf8",
);
}
const manager = createMemoryManager({
searchResults: [
{
path: "MEMORY.md",
startLine: 4,
endLine: 8,
score: 0.9,
snippet: "alpha durable memory",
source: "memory",
citation: "MEMORY.md#L4-L8",
},
],
});
getActiveMemorySearchManagerMock.mockResolvedValue({ manager });
const results = await searchMemoryWiki({
config,
appConfig: createAppConfig(),
query: "alpha",
maxResults: 5,
});
expect(results).toHaveLength(5);
expect(results.some((result) => result.corpus === "memory")).toBe(true);
expect(
results.filter((result) => result.corpus === "wiki").map((result) => result.path),
).toEqual([
"entities/alpha-1.md",
"entities/alpha-2.md",
"entities/alpha-3.md",
"entities/alpha-4.md",
]);
expect(manager.search).toHaveBeenCalledWith("alpha", { maxResults: 5 });
});
it("uses the active session agent for shared memory search", async () => {
const { config } = await createQueryVault({
initialize: true,

View File

@@ -183,6 +183,43 @@ type QuerySearchOverrides = {
searchCorpus?: WikiSearchCorpus;
};
function sortWikiSearchResults(results: WikiSearchResult[]): WikiSearchResult[] {
return results.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.title.localeCompare(right.title);
});
}
function mergeWikiSearchCorpusResults(params: {
wikiResults: WikiSearchResult[];
memoryResults: WikiSearchResult[];
maxResults: number;
balanceCorpora: boolean;
}): WikiSearchResult[] {
const wikiResults = sortWikiSearchResults(params.wikiResults);
const memoryResults = sortWikiSearchResults(params.memoryResults);
if (!params.balanceCorpora || wikiResults.length === 0 || memoryResults.length === 0) {
return sortWikiSearchResults([...wikiResults, ...memoryResults]).slice(0, params.maxResults);
}
const perCorpusCap = Math.ceil(params.maxResults / 2);
const selectedWiki = wikiResults.slice(0, perCorpusCap);
const selectedMemory = memoryResults.slice(0, perCorpusCap);
const selected = [...selectedWiki, ...selectedMemory];
if (selected.length < params.maxResults) {
selected.push(
...sortWikiSearchResults([
...wikiResults.slice(selectedWiki.length),
...memoryResults.slice(selectedMemory.length),
]).slice(0, params.maxResults - selected.length),
);
}
return sortWikiSearchResults(selected).slice(0, params.maxResults);
}
async function listWikiMarkdownFiles(rootDir: string): Promise<string[]> {
const files = (
await Promise.all(
@@ -1219,14 +1256,12 @@ export async function searchMemoryWiki(params: {
)
: [];
return [...wikiResults, ...memoryResults]
.toSorted((left, right) => {
if (left.score !== right.score) {
return right.score - left.score;
}
return left.title.localeCompare(right.title);
})
.slice(0, maxResults);
return mergeWikiSearchCorpusResults({
wikiResults,
memoryResults,
maxResults,
balanceCorpora: effectiveConfig.search.corpus === "all",
});
}
export async function getMemoryWikiPage(params: {