diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 1fd54ecb1..831ce3369 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -76,6 +76,8 @@ ### Fixed +- Fixed Mnemopi scoped recall reporting "No relevant memories found" when every scoped target failed internally; target failures now warn, and total recall failure preserves the underlying engine error while healthy targets remain available ([#7364](https://github.com/can1357/oh-my-pi/issues/7364)). + - Fixed sessions without a granted `write` tool hiding discoverable and MCP tools behind the unusable `xd://` transport; those sessions now disable device mounting and expose the tools directly without gaining write access. - Fixed collab guest prompts being sent to models as unframed developer context, so guest messages now retain their transcript attribution while reaching the model as prioritized user interjections ([#7288](https://github.com/can1357/oh-my-pi/issues/7288)). - Fixed `/memory stats` and `/memory diagnose` showing "Memory stats is not available for the off backend" when memory is off, in both the TUI and ACP/RPC slash-command handlers; the off backend now says memory is off directly instead of naming itself as an unsupported backend ([#7251](https://github.com/can1357/oh-my-pi/pull/7251) by [@KennethHoff](https://github.com/KennethHoff)). diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index 57d781c19..15e95bf35 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -4,7 +4,7 @@ import type * as MnemopiNs from "@oh-my-pi/pi-mnemopi"; import type { Mnemopi, RecallResult } from "@oh-my-pi/pi-mnemopi"; import type * as MnemopiCoreNs from "@oh-my-pi/pi-mnemopi/core"; import type { LocalModelInitializer } from "@oh-my-pi/pi-mnemopi/core"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, toError } from "@oh-my-pi/pi-utils"; import { composeRecallQuery, formatCurrentTime, @@ -386,6 +386,8 @@ export class MnemopiSessionState { const merged: RecallResult[] = []; const byId = new Map(); const byContent = new Map(); + const failures: Array<{ bank: string; error: Error }> = []; + let successfulTargets = 0; const sharedFallbackQuery = deriveSharedRecallFallbackQuery( query, this.scoped.retain.bank, @@ -394,24 +396,35 @@ export class MnemopiSessionState { for (const target of this.scoped.recall) { const queries = target.bank === this.scoped.global?.bank && sharedFallbackQuery ? [query, sharedFallbackQuery] : [query]; + let targetSucceeded = false; try { for (const recallQuery of queries) { const results = await target.memory.recallEnhanced(recallQuery, this.config.recallLimit, { includeFacts: true, channelId: target.bank, }); + targetSucceeded = true; for (const result of results) { mergeRecallResult(merged, byId, byContent, result); } } } catch (error) { - if (this.config.debug) { - logger.debug("Mnemopi: scoped recall target failed", { - bank: target.bank, - error: String(error), - }); - } + const failure = toError(error); + failures.push({ bank: target.bank, error: failure }); + logger.warn("Mnemopi: scoped recall target failed", { + bank: target.bank, + error: failure.message, + }); } + if (targetSucceeded) successfulTargets++; + } + if (successfulTargets === 0 && failures.length > 0) { + if (failures.length === 1) throw failures[0].error; + const details = failures.map(({ bank, error }) => `${bank}: ${error.message}`).join("; "); + throw new AggregateError( + failures.map(({ error }) => error), + `Mnemopi recall failed for all scoped targets (${details})`, + ); } merged.sort(compareRecallResults); if (merged.length > this.config.recallLimit) merged.length = this.config.recallLimit; @@ -575,7 +588,16 @@ export class MnemopiSessionState { if (!lastUser) return; const query = composeRecallQuery(lastUser.content, messages, this.config.recallContextTurns); const truncated = truncateRecallQuery(query, lastUser.content, this.config.recallMaxQueryChars); - const context = await this.recallForContext(truncated); + let context: string | undefined; + try { + context = await this.recallForContext(truncated); + } catch (error) { + logger.warn("Mnemopi: auto-recall failed", { + bank: this.config.bank, + error: toError(error).message, + }); + return; + } this.hasRecalledForFirstTurn = true; if (!context) return; this.lastRecallSnippet = context; diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index b162f82ea..37e7b5505 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -470,6 +470,18 @@ describe("Mnemopi backend lifecycle", () => { tempDbPath = undefined; }); + it("keeps background auto-recall engine failures from escaping", async () => { + const entries = [{ type: "message", message: { role: "user", content: "existing memory" } }]; + const state = registerMnemopiState(makeMnemopiConfig({ autoRecall: true }), { + entries: () => entries, + }); + vi.spyOn(state.getScopedRecallTargets()[0].memory, "recallEnhanced").mockRejectedValue( + new TypeError("mmrRerankIndices is not a function"), + ); + + await expect(state.maybeRecallOnAgentStart()).resolves.toBeUndefined(); + expect(state.hasRecalledForFirstTurn).toBe(false); + }); it("auto-retain stores only the not-yet-retained suffix", async () => { const entries = Array.from({ length: 4 }, (_, index) => ({ type: "message", @@ -1180,6 +1192,34 @@ describe("recall.execute (Mnemopi backend)", () => { expect(result.content[0]).toEqual({ type: "text", text: "No relevant memories found." }); }); + it("surfaces recall engine failures instead of the no-results sentinel", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemopi" }); + const state = registerMnemopiState(); + const failure = new TypeError("mmrRerankIndices is not a function"); + vi.spyOn(state.getScopedRecallTargets()[0].memory, "recallEnhanced").mockRejectedValue(failure); + + const tool = MemoryRecallTool.createIf(makeSession(settings))!; + await expect(tool.execute("call-mnemopi-failure", { query: "existing memory" })).rejects.toThrow(failure); + }); + + it("keeps healthy scoped targets available when another target fails", async () => { + const settings = Settings.isolated({ "memory.backend": "mnemopi" }); + const state = registerMnemopiState( + makeMnemopiConfig({ + scoping: "per-project-tagged", + bank: "project-bank", + globalBank: "global-bank", + }), + ); + vi.spyOn(state.getScopedRecallTargets()[0].memory, "recallEnhanced").mockRejectedValue( + new Error("project bank unavailable"), + ); + + const tool = MemoryRecallTool.createIf(makeSession(settings))!; + const result = await tool.execute("call-mnemopi-partial-failure", { query: "nonexistent query" }); + expect(result.content[0]).toEqual({ type: "text", text: "No relevant memories found." }); + }); + it("returns a populated text block when a retained memory exists", async () => { const settings = Settings.isolated({ "memory.backend": "mnemopi" }); registerMnemopiState();