mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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 heada5b4f6a932. - Required merge gates passed before the squash merge. Prepared head SHA:a5b4f6a932Review: 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:
@@ -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.
|
||||
|
||||
@@ -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 () => [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user