Merge PR #7367: fix(mnemopi): surface scoped recall failures (@roboomp)

This commit is contained in:
can1357
2026-08-02 20:53:21 +02:00
3 changed files with 72 additions and 8 deletions
+2
View File
@@ -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)).
+30 -8
View File
@@ -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<string, number>();
const byContent = new Map<string, number>();
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;
@@ -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();