Merge PR #7367: fix(mnemopi): surface scoped recall failures (@roboomp)
This commit is contained in:
@@ -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)).
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user