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
This commit is contained in:
Vincent Koc 2026-03-29 20:07:32 -07:00 committed by GitHub
parent e7984272a7
commit da35718cb2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 350 additions and 52 deletions

View File

@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- Agents/memory flush: keep daily memory flush files append-only during embedded attempts so compaction writes do not overwrite earlier notes. (#53725) Thanks @HPluseven.
- 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.
- 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.

View File

@ -1941,6 +1941,189 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("uses an explicit mcporter search tool override with flat query args", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
searchTool: "hybrid_search",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
expect(args[1]).toBe("qmd.hybrid_search");
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
minScore: 0,
collection: "workspace-main",
});
expect(callArgs).not.toHaveProperty("searches");
expect(callArgs).not.toHaveProperty("collections");
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
await manager.close();
});
it('uses unified v2 args when the explicit mcporter search tool override is "query"', async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "search",
searchTool: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
expect(args[1]).toBe("qmd.query");
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
expect(callArgs).toHaveProperty("searches", [{ type: "lex", query: "hello" }]);
expect(callArgs).toHaveProperty("collections", ["workspace-main"]);
expect(callArgs).not.toHaveProperty("query");
expect(callArgs).not.toHaveProperty("minScore");
expect(callArgs).not.toHaveProperty("collection");
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
await manager.close();
});
it('reuses the cached v1 tool across collections when the explicit mcporter override is "query"', async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "search",
searchTool: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [
{ path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" },
{ path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" },
],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
const selectors: string[] = [];
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
const selector = args[1] ?? "";
selectors.push(selector);
if (selector === "qmd.query") {
queueMicrotask(() => {
child.stderr.emit("data", "MCP error -32602: Tool query not found");
child.closeWith(1);
});
return child;
}
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
expect(selector).toBe("qmd.search");
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
minScore: 0,
});
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
expect(selectors).toEqual(["qmd.query", "qmd.search", "qmd.search"]);
await manager.close();
});
it("uses an explicit mcporter search tool override across multiple collections", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
searchTool: "hybrid_search",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [
{ path: path.join(workspaceDir, "notes-a"), pattern: "**/*.md", name: "workspace-a" },
{ path: path.join(workspaceDir, "notes-b"), pattern: "**/*.md", name: "workspace-b" },
],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
const selectors: string[] = [];
const collections: string[] = [];
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
selectors.push(args[1] ?? "");
const callArgs = JSON.parse(args[args.indexOf("--args") + 1]);
collections.push(String(callArgs.collection ?? ""));
expect(callArgs).toMatchObject({
query: "hello",
limit: 6,
minScore: 0,
});
expect(callArgs).not.toHaveProperty("searches");
expect(callArgs).not.toHaveProperty("collections");
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
await manager.search("hello", { sessionKey: "agent:main:slack:dm:u123" });
expect(selectors).toEqual(["qmd.hybrid_search", "qmd.hybrid_search"]);
expect(collections).toEqual(["workspace-a-main", "workspace-b-main"]);
await manager.close();
});
it("does not pin v1 fallback when only the serialized query text contains tool-not-found words", async () => {
cfg = {
...cfg,

View File

@ -158,6 +158,49 @@ type ManagedCollection = {
type QmdManagerMode = "full" | "status";
type QmdCollectionPatternFlag = "--glob" | "--mask";
type BuiltinQmdMcpTool = "query" | "search" | "vector_search" | "deep_search";
type QmdMcporterSearchParams =
| {
mcporter: ResolvedQmdMcporterConfig;
tool: string;
searchCommand?: string;
explicitToolOverride: true;
query: string;
limit: number;
minScore: number;
collection?: string;
timeoutMs: number;
}
| {
mcporter: ResolvedQmdMcporterConfig;
tool: BuiltinQmdMcpTool;
searchCommand?: string;
explicitToolOverride: false;
query: string;
limit: number;
minScore: number;
collection?: string;
timeoutMs: number;
};
type QmdMcporterAcrossCollectionsParams =
| {
tool: string;
searchCommand?: string;
explicitToolOverride: true;
query: string;
limit: number;
minScore: number;
collectionNames: string[];
}
| {
tool: BuiltinQmdMcpTool;
searchCommand?: string;
explicitToolOverride: false;
query: string;
limit: number;
minScore: number;
collectionNames: string[];
};
export class QmdMemoryManager implements MemorySearchManager {
static async create(params: {
@ -816,18 +859,44 @@ export class QmdMemoryManager implements MemorySearchManager {
return [];
}
const qmdSearchCommand = this.qmd.searchMode;
const explicitSearchTool = this.qmd.searchTool;
const mcporterEnabled = this.qmd.mcporter.enabled;
const runSearchAttempt = async (
allowMissingCollectionRepair: boolean,
): Promise<QmdQueryResult[]> => {
try {
if (mcporterEnabled) {
const tool = this.resolveQmdMcpTool(qmdSearchCommand);
const minScore = opts?.minScore ?? 0;
if (explicitSearchTool) {
if (collectionNames.length > 1) {
return await this.runMcporterAcrossCollections({
tool: explicitSearchTool,
searchCommand: qmdSearchCommand,
explicitToolOverride: true,
query: trimmed,
limit,
minScore,
collectionNames,
});
}
return await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: explicitSearchTool,
searchCommand: qmdSearchCommand,
explicitToolOverride: true,
query: trimmed,
limit,
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
});
}
const tool = this.resolveQmdMcpTool(qmdSearchCommand);
if (collectionNames.length > 1) {
return await this.runMcporterAcrossCollections({
tool,
searchCommand: qmdSearchCommand,
explicitToolOverride: false,
query: trimmed,
limit,
minScore,
@ -838,6 +907,7 @@ export class QmdMemoryManager implements MemorySearchManager {
mcporter: this.qmd.mcporter,
tool,
searchCommand: qmdSearchCommand,
explicitToolOverride: false,
query: trimmed,
limit,
minScore,
@ -1378,9 +1448,7 @@ export class QmdMemoryManager implements MemorySearchManager {
*/
private qmdMcpToolVersion: "v2" | "v1" | null = null;
private resolveQmdMcpTool(
searchCommand: string,
): "query" | "search" | "vector_search" | "deep_search" {
private resolveQmdMcpTool(searchCommand: string): BuiltinQmdMcpTool {
if (this.qmdMcpToolVersion === "v2") {
return "query";
}
@ -1498,16 +1566,9 @@ export class QmdMemoryManager implements MemorySearchManager {
});
}
private async runQmdSearchViaMcporter(params: {
mcporter: ResolvedQmdMcporterConfig;
tool: "query" | "search" | "vector_search" | "deep_search";
searchCommand?: string;
query: string;
limit: number;
minScore: number;
collection?: string;
timeoutMs: number;
}): Promise<QmdQueryResult[]> {
private async runQmdSearchViaMcporter(
params: QmdMcporterSearchParams,
): Promise<QmdQueryResult[]> {
await this.ensureMcporterDaemonStarted(params.mcporter);
// If the version is already known as v1 but we received a stale "query" tool name
@ -1519,24 +1580,24 @@ export class QmdMemoryManager implements MemorySearchManager {
: params.tool;
const selector = `${params.mcporter.serverName}.${effectiveTool}`;
const callArgs: Record<string, unknown> =
effectiveTool === "query"
? {
// QMD 1.1+ "query" tool accepts typed sub-queries via `searches` array.
// Derive sub-query types from searchCommand to respect searchMode config.
// Note: minScore is intentionally omitted — QMD 1.1+'s query tool uses
// its own reranking pipeline and does not accept a minScore parameter.
searches: this.buildV2Searches(params.query, params.searchCommand),
limit: params.limit,
}
: {
// QMD 1.x tools accept a flat query string.
query: params.query,
limit: params.limit,
minScore: params.minScore,
};
const useUnifiedQueryTool = effectiveTool === "query";
const callArgs: Record<string, unknown> = useUnifiedQueryTool
? {
// QMD 1.1+ "query" tool accepts typed sub-queries via `searches` array.
// Derive sub-query types from searchCommand to respect searchMode config.
// Note: minScore is intentionally omitted — QMD 1.1+'s query tool uses
// its own reranking pipeline and does not accept a minScore parameter.
searches: this.buildV2Searches(params.query, params.searchCommand),
limit: params.limit,
}
: {
// QMD 1.x tools accept a flat query string.
query: params.query,
limit: params.limit,
minScore: params.minScore,
};
if (params.collection) {
if (effectiveTool === "query") {
if (useUnifiedQueryTool) {
callArgs.collections = [params.collection];
} else {
callArgs.collection = params.collection;
@ -1559,7 +1620,7 @@ export class QmdMemoryManager implements MemorySearchManager {
{ timeoutMs: Math.max(params.timeoutMs + 2_000, 5_000) },
);
// If we got here with the v2 "query" tool, confirm v2 for future calls.
if (effectiveTool === "query" && this.qmdMcpToolVersion === null) {
if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) {
this.markQmdV2();
}
} catch (err) {
@ -1572,12 +1633,19 @@ export class QmdMemoryManager implements MemorySearchManager {
// race condition where concurrent searches both probe with "query" while
// the version is null — the second call would otherwise fail after the
// first sets the version to "v1".
if (effectiveTool === "query" && this.isQueryToolNotFoundError(err)) {
if (useUnifiedQueryTool && this.isQueryToolNotFoundError(err)) {
this.markQmdV1Fallback();
const v1Tool = this.resolveQmdMcpTool(params.searchCommand ?? "query");
return this.runQmdSearchViaMcporter({
...params,
mcporter: params.mcporter,
tool: v1Tool,
searchCommand: params.searchCommand,
explicitToolOverride: false,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: params.collection,
timeoutMs: params.timeoutMs,
});
}
throw err;
@ -2379,26 +2447,34 @@ export class QmdMemoryManager implements MemorySearchManager {
return `file:${hints.preferredCollection}:${collectionRelativePath}`;
}
private async runMcporterAcrossCollections(params: {
tool: "query" | "search" | "vector_search" | "deep_search";
searchCommand?: string;
query: string;
limit: number;
minScore: number;
collectionNames: string[];
}): Promise<QmdQueryResult[]> {
private async runMcporterAcrossCollections(
params: QmdMcporterAcrossCollectionsParams,
): Promise<QmdQueryResult[]> {
const bestByDocId = new Map<string, QmdQueryResult>();
for (const collectionName of params.collectionNames) {
const parsed = await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: params.tool,
searchCommand: params.searchCommand,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
});
const parsed = params.explicitToolOverride
? await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: params.tool,
searchCommand: params.searchCommand,
explicitToolOverride: true,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
})
: await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
tool: params.tool,
searchCommand: params.searchCommand,
explicitToolOverride: false,
query: params.query,
limit: params.limit,
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
});
for (const entry of parsed) {
if (typeof entry.docid !== "string" || !entry.docid.trim()) {
continue;

View File

@ -143,6 +143,22 @@ describe("resolveMemoryBackendConfig", () => {
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.searchMode).toBe("vsearch");
});
it("resolves qmd mcporter search tool override", () => {
const cfg = {
agents: { defaults: { workspace: "/tmp/memory-test" } },
memory: {
backend: "qmd",
qmd: {
searchMode: "query",
searchTool: " hybrid_search ",
},
},
} as OpenClawConfig;
const resolved = resolveMemoryBackendConfig({ cfg, agentId: "main" });
expect(resolved.qmd?.searchMode).toBe("query");
expect(resolved.qmd?.searchTool).toBe("hybrid_search");
});
});
describe("memorySearch.extraPaths integration", () => {

View File

@ -62,6 +62,7 @@ export type ResolvedQmdConfig = {
command: string;
mcporter: ResolvedQmdMcporterConfig;
searchMode: MemoryQmdSearchMode;
searchTool?: string;
collections: ResolvedQmdCollection[];
sessions: ResolvedQmdSessionConfig;
update: ResolvedQmdUpdateConfig;
@ -202,6 +203,11 @@ function resolveSearchMode(raw?: MemoryQmdConfig["searchMode"]): MemoryQmdSearch
return DEFAULT_QMD_SEARCH_MODE;
}
function resolveSearchTool(raw?: MemoryQmdConfig["searchTool"]): string | undefined {
const value = raw?.trim();
return value ? value : undefined;
}
function resolveSessionConfig(
cfg: MemoryQmdConfig["sessions"],
workspaceDir: string,
@ -346,6 +352,7 @@ export function resolveMemoryBackendConfig(params: {
command,
mcporter: resolveMcporterConfig(qmdCfg?.mcporter),
searchMode: resolveSearchMode(qmdCfg?.searchMode),
searchTool: resolveSearchTool(qmdCfg?.searchTool),
collections,
includeDefaultMemory,
sessions: resolveSessionConfig(qmdCfg?.sessions, workspaceDir),

View File

@ -10887,6 +10887,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
},
],
},
searchTool: {
type: "string",
minLength: 1,
},
includeDefaultMemory: {
type: "boolean",
},
@ -13622,6 +13626,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
help: 'Selects the QMD retrieval path: "query" uses standard query flow, "search" uses search-oriented retrieval, and "vsearch" emphasizes vector retrieval. Keep default unless tuning relevance quality.',
tags: ["storage"],
},
"memory.qmd.searchTool": {
label: "QMD Search Tool Override",
help: "Overrides the exact mcporter tool name used for QMD searches while preserving `searchMode` as the semantic retrieval mode. Use this only when your QMD MCP server exposes a custom tool such as `hybrid_search` and keep it unset for the normal built-in tool mapping.",
tags: ["storage"],
},
"memory.qmd.includeDefaultMemory": {
label: "QMD Include Default Memory",
help: "Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.",

View File

@ -42,6 +42,7 @@ const TARGET_KEYS = [
"memory.citations",
"memory.backend",
"memory.qmd.searchMode",
"memory.qmd.searchTool",
"memory.qmd.scope",
"memory.qmd.includeDefaultMemory",
"memory.qmd.mcporter.enabled",

View File

@ -891,6 +891,8 @@ export const FIELD_HELP: Record<string, string> = {
"Automatically starts the mcporter daemon when mcporter-backed QMD mode is enabled (default: true). Keep enabled unless process lifecycle is managed externally by your service supervisor.",
"memory.qmd.searchMode":
'Selects the QMD retrieval path: "query" uses standard query flow, "search" uses search-oriented retrieval, and "vsearch" emphasizes vector retrieval. Keep default unless tuning relevance quality.',
"memory.qmd.searchTool":
"Overrides the exact mcporter tool name used for QMD searches while preserving `searchMode` as the semantic retrieval mode. Use this only when your QMD MCP server exposes a custom tool such as `hybrid_search` and keep it unset for the normal built-in tool mapping.",
"memory.qmd.includeDefaultMemory":
"Automatically indexes default memory files (MEMORY.md and memory/**/*.md) into QMD collections. Keep enabled unless you want indexing controlled only through explicit custom paths.",
"memory.qmd.paths":

View File

@ -391,6 +391,7 @@ export const FIELD_LABELS: Record<string, string> = {
"memory.qmd.mcporter.serverName": "QMD MCPorter Server Name",
"memory.qmd.mcporter.startDaemon": "QMD MCPorter Start Daemon",
"memory.qmd.searchMode": "QMD Search Mode",
"memory.qmd.searchTool": "QMD Search Tool Override",
"memory.qmd.includeDefaultMemory": "QMD Include Default Memory",
"memory.qmd.paths": "QMD Extra Paths",
"memory.qmd.paths.path": "QMD Path",

View File

@ -14,6 +14,7 @@ export type MemoryQmdConfig = {
command?: string;
mcporter?: MemoryQmdMcporterConfig;
searchMode?: MemoryQmdSearchMode;
searchTool?: string;
includeDefaultMemory?: boolean;
paths?: MemoryQmdIndexPath[];
sessions?: MemoryQmdSessionConfig;

View File

@ -102,6 +102,7 @@ const MemoryQmdSchema = z
command: z.string().optional(),
mcporter: MemoryQmdMcporterSchema.optional(),
searchMode: z.union([z.literal("query"), z.literal("search"), z.literal("vsearch")]).optional(),
searchTool: z.string().trim().min(1).optional(),
includeDefaultMemory: z.boolean().optional(),
paths: z.array(MemoryQmdPathSchema).optional(),
sessions: MemoryQmdSessionSchema.optional(),