Skip to content

Commit da35718

Browse files
authored
fix(memory): add qmd mcporter search tool override (#57363)
* fix(memory): add qmd mcporter search tool override * fix(memory): tighten qmd search tool override guards * chore(config): drop generated docs baselines from qmd pr * fix(memory): keep explicit qmd query override on v2 args * docs(changelog): normalize qmd search tool attribution * fix(memory): reuse v1 qmd tool after query fallback
1 parent e798427 commit da35718

11 files changed

Lines changed: 350 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
4848
- Gateway/health: carry webhook-vs-polling account mode from channel descriptors into runtime snapshots so passive channels like LINE and BlueBubbles skip false stale-socket health failures. (#47488) Thanks @karesansui-u.
4949
- Agents/MCP: reuse bundled MCP runtimes across turns in the same session, while recreating them when MCP config changes and disposing stale runtimes cleanly on session rollover. (#55090) Thanks @allan0509.
5050
- Memory/QMD: honor `memory.qmd.update.embedInterval` even when regular QMD update cadence is disabled or slower by arming a dedicated embed-cadence maintenance timer, while avoiding redundant timers when regular updates are already frequent enough. (#37326) Thanks @barronlroth.
51+
- Memory/QMD: add `memory.qmd.searchTool` as an exact mcporter tool override, so custom QMD MCP tools such as `hybrid_search` can be used without weakening the validated `searchMode` config surface. (#27801) Thanks @keramblock.
5152
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
5253
- Web UI/markdown: stop bare auto-links from swallowing adjacent CJK text while preserving valid mixed-script path and query characters in rendered links. (#48410) Thanks @jnuyao.
5354
- BlueBubbles/iMessage: coalesce URL-only inbound messages with their link-preview balloon again so sharing a bare link no longer drops the URL from agent context. Thanks @vincentkoc.

extensions/memory-core/src/memory/qmd-manager.test.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,6 +1941,189 @@ describe("QmdMemoryManager", () => {
19411941
await manager.close();
19421942
});
19431943

1944+
it("uses an explicit mcporter search tool override with flat query args", async () => {
1945+
cfg = {
1946+
...cfg,
1947+
memory: {
1948+
backend: "qmd",
1949+
qmd: {
1950+
includeDefaultMemory: false,
1951+
searchMode: "query",
1952+
searchTool: "hybrid_search",
1953+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
1954+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
1955+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
1956+
},
1957+
},
1958+
} as OpenClawConfig;
1959+
1960+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
1961+
const child = createMockChild({ autoClose: false });
1962+
if (isMcporterCommand(cmd) && args[0] === "call") {
1963+
expect(args[1]).toBe("qmd.hybrid_search");
1964+
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
1965+
expect(callArgs).toMatchObject({
1966+
query: "hello",
1967+
limit: 6,
1968+
minScore: 0,
1969+
collection: "workspace-main",
1970+
});
1971+
expect(callArgs).not.toHaveProperty("searches");
1972+
expect(callArgs).not.toHaveProperty("collections");
1973+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
1974+
return child;
1975+
}
1976+
emitAndClose(child, "stdout", "[]");
1977+
return child;
1978+
});
1979+
1980+
const { manager } = await createManager();
1981+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
1982+
await manager.close();
1983+
});
1984+
1985+
it('uses unified v2 args when the explicit mcporter search tool override is "query"', async () => {
1986+
cfg = {
1987+
...cfg,
1988+
memory: {
1989+
backend: "qmd",
1990+
qmd: {
1991+
includeDefaultMemory: false,
1992+
searchMode: "search",
1993+
searchTool: "query",
1994+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
1995+
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
1996+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
1997+
},
1998+
},
1999+
} as OpenClawConfig;
2000+
2001+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
2002+
const child = createMockChild({ autoClose: false });
2003+
if (isMcporterCommand(cmd) && args[0] === "call") {
2004+
expect(args[1]).toBe("qmd.query");
2005+
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
2006+
expect(callArgs).toHaveProperty("searches", [{ type: "lex", query: "hello" }]);
2007+
expect(callArgs).toHaveProperty("collections", ["workspace-main"]);
2008+
expect(callArgs).not.toHaveProperty("query");
2009+
expect(callArgs).not.toHaveProperty("minScore");
2010+
expect(callArgs).not.toHaveProperty("collection");
2011+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
2012+
return child;
2013+
}
2014+
emitAndClose(child, "stdout", "[]");
2015+
return child;
2016+
});
2017+
2018+
const { manager } = await createManager();
2019+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
2020+
await manager.close();
2021+
});
2022+
2023+
it('reuses the cached v1 tool across collections when the explicit mcporter override is "query"', async () => {
2024+
cfg = {
2025+
...cfg,
2026+
memory: {
2027+
backend: "qmd",
2028+
qmd: {
2029+
includeDefaultMemory: false,
2030+
searchMode: "search",
2031+
searchTool: "query",
2032+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2033+
paths: [
2034+
{ path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" },
2035+
{ path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" },
2036+
],
2037+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
2038+
},
2039+
},
2040+
} as OpenClawConfig;
2041+
2042+
const selectors: string[] = [];
2043+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
2044+
const child = createMockChild({ autoClose: false });
2045+
if (isMcporterCommand(cmd) && args[0] === "call") {
2046+
const selector = args[1] ?? "";
2047+
selectors.push(selector);
2048+
if (selector === "qmd.query") {
2049+
queueMicrotask(() => {
2050+
child.stderr.emit("data", "MCP error -32602: Tool query not found");
2051+
child.closeWith(1);
2052+
});
2053+
return child;
2054+
}
2055+
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
2056+
expect(selector).toBe("qmd.search");
2057+
expect(callArgs).toMatchObject({
2058+
query: "hello",
2059+
limit: 6,
2060+
minScore: 0,
2061+
});
2062+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
2063+
return child;
2064+
}
2065+
emitAndClose(child, "stdout", "[]");
2066+
return child;
2067+
});
2068+
2069+
const { manager } = await createManager();
2070+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
2071+
2072+
expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]);
2073+
2074+
await manager.close();
2075+
});
2076+
2077+
it("uses an explicit mcporter search tool override across multiple collections", async () => {
2078+
cfg = {
2079+
...cfg,
2080+
memory: {
2081+
backend: "qmd",
2082+
qmd: {
2083+
includeDefaultMemory: false,
2084+
searchMode: "query",
2085+
searchTool: "hybrid_search",
2086+
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
2087+
paths: [
2088+
{ path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" },
2089+
{ path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" },
2090+
],
2091+
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
2092+
},
2093+
},
2094+
} as OpenClawConfig;
2095+
2096+
const selectors: string[] = [];
2097+
const collections: string[] = [];
2098+
spawnMock.mockImplementation((cmd: string, args: string[]) => {
2099+
const child = createMockChild({ autoClose: false });
2100+
if (isMcporterCommand(cmd) && args[0] === "call") {
2101+
selectors.push(args[1] ?? "");
2102+
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
2103+
collections.push(String(callArgs.collection ?? ""));
2104+
expect(callArgs).toMatchObject({
2105+
query: "hello",
2106+
limit: 6,
2107+
minScore: 0,
2108+
});
2109+
expect(callArgs).not.toHaveProperty("searches");
2110+
expect(callArgs).not.toHaveProperty("collections");
2111+
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
2112+
return child;
2113+
}
2114+
emitAndClose(child, "stdout", "[]");
2115+
return child;
2116+
});
2117+
2118+
const { manager } = await createManager();
2119+
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
2120+
2121+
expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]);
2122+
expect(collections).toEqual(["workspace-a-main", "workspace-b-main"]);
2123+
2124+
await manager.close();
2125+
});
2126+
19442127
it("does not pin v1 fallback when only the serialized query text contains tool-not-found words", async () => {
19452128
cfg = {
19462129
...cfg,

0 commit comments

Comments
 (0)