diff --git a/CHANGELOG.md b/CHANGELOG.md index 035d687467..553ee28f80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Docs: https://docs.openclaw.ai - Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07. - Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07. - Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07. +- Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824) - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy. diff --git a/src/memory/qmd-manager.test.ts b/src/memory/qmd-manager.test.ts index e2e8c1d727..05907170b3 100644 --- a/src/memory/qmd-manager.test.ts +++ b/src/memory/qmd-manager.test.ts @@ -733,6 +733,112 @@ describe("QmdMemoryManager", () => { await manager.close(); }); + + it("treats plain-text no-results stdout as an empty result set", async () => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stdout.emit("data", "No results found."); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + await manager.close(); + }); + + it("treats plain-text no-results stdout without punctuation as empty", async () => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stdout.emit("data", "No results found\n\n"); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + await manager.close(); + }); + + it("treats plain-text no-results stderr as an empty result set", async () => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stderr.emit("data", "No results found.\n"); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), + ).resolves.toEqual([]); + await manager.close(); + }); + + it("throws when stdout is empty without the no-results marker", async () => { + spawnMock.mockImplementation((_cmd: string, args: string[]) => { + if (args[0] === "query") { + const child = createMockChild({ autoClose: false }); + setTimeout(() => { + child.stdout.emit("data", " \n"); + child.stderr.emit("data", "unexpected parser error"); + child.closeWith(0); + }, 0); + return child; + } + return createMockChild(); + }); + + const resolved = resolveMemoryBackendConfig({ cfg, agentId }); + const manager = await QmdMemoryManager.create({ cfg, agentId, resolved }); + expect(manager).toBeTruthy(); + if (!manager) { + throw new Error("manager missing"); + } + + await expect( + manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), + ).rejects.toThrow(/qmd query returned invalid JSON/); + await manager.close(); + }); + describe("model cache symlink", () => { let defaultModelsDir: string; let customModelsDir: string; @@ -815,6 +921,7 @@ describe("QmdMemoryManager", () => { await manager!.close(); }); }); + }); }); async function waitForCondition(check: () => boolean, timeoutMs: number): Promise { diff --git a/src/memory/qmd-manager.ts b/src/memory/qmd-manager.ts index 70c8391287..5a34b7ced3 100644 --- a/src/memory/qmd-manager.ts +++ b/src/memory/qmd-manager.ts @@ -269,21 +269,16 @@ export class QmdMemoryManager implements MemorySearchManager { } const args = ["query", trimmed, "--json", "-n", String(limit), ...collectionFilterArgs]; let stdout: string; + let stderr: string; try { const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs }); stdout = result.stdout; + stderr = result.stderr; } catch (err) { log.warn(`qmd query failed: ${String(err)}`); throw err instanceof Error ? err : new Error(String(err)); } - let parsed: QmdQueryResult[] = []; - try { - parsed = JSON.parse(stdout); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - log.warn(`qmd query returned invalid JSON: ${message}`); - throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err }); - } + const parsed = this.parseQmdQueryJson(stdout, stderr); const results: MemorySearchResult[] = []; for (const entry of parsed) { const doc = await this.resolveDocLocation(entry.docid); @@ -981,6 +976,42 @@ export class QmdMemoryManager implements MemorySearchManager { ]); } + private parseQmdQueryJson(stdout: string, stderr: string): QmdQueryResult[] { + const trimmedStdout = stdout.trim(); + const trimmedStderr = stderr.trim(); + const stdoutIsMarker = Boolean(trimmedStdout) && this.isQmdNoResultsOutput(trimmedStdout); + const stderrIsMarker = Boolean(trimmedStderr) && this.isQmdNoResultsOutput(trimmedStderr); + if (stdoutIsMarker || (!trimmedStdout && stderrIsMarker)) { + return []; + } + if (!trimmedStdout) { + const context = trimmedStderr ? ` (stderr: ${this.summarizeQmdStderr(trimmedStderr)})` : ""; + const message = `stdout empty${context}`; + log.warn(`qmd query returned invalid JSON: ${message}`); + throw new Error(`qmd query returned invalid JSON: ${message}`); + } + try { + const parsed = JSON.parse(trimmedStdout) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("qmd query JSON response was not an array"); + } + return parsed as QmdQueryResult[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.warn(`qmd query returned invalid JSON: ${message}`); + throw new Error(`qmd query returned invalid JSON: ${message}`, { cause: err }); + } + } + + private isQmdNoResultsOutput(raw: string): boolean { + const normalized = raw.trim().toLowerCase().replace(/\s+/g, " "); + return normalized === "no results found" || normalized === "no results found."; + } + + private summarizeQmdStderr(raw: string): string { + return raw.length <= 120 ? raw : `${raw.slice(0, 117)}...`; + } + private buildCollectionFilterArgs(): string[] { const names = this.qmd.collections.map((collection) => collection.name).filter(Boolean); if (names.length === 0) {