fix(memory): qualify stale memory search results (#117706)

* fix(memory): qualify stale search results

* fix(memory): satisfy staleness checks

* fix(memory): normalize absent staleness
This commit is contained in:
Peter Steinberger
2026-08-01 17:40:14 -07:00
committed by GitHub
parent 681e8c5ab1
commit 7d1d721d62
11 changed files with 155 additions and 37 deletions
+6
View File
@@ -64,6 +64,12 @@ openclaw memory search [query] [--query <text>] [--agent <id>] [--max-results <n
- `--max-results <n>`: cap result count (positive integer).
- `--min-score <n>`: filter out matches below this score.
If the index remains dirty after the bounded search-time refresh, human output
warns that matches may be incomplete. With `--json`, the response adds
`stale: true`, plus `warning` and `action` fields describing how to rebuild the
index. Treat an empty `results` array as authoritative only when `stale` is
absent.
## `memory promote`
Rank short-term candidates from `memory/YYYY-MM-DD.md` and optionally append
@@ -2,6 +2,7 @@ import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveMemorySearchStaleness } from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import { resolveMemoryDreamingConfig } from "openclaw/plugin-sdk/memory-core-host-status";
import {
buildCliMemorySearchSessionKey,
@@ -40,27 +41,6 @@ import {
resolveShortTermRecallStorePath,
} from "./short-term-promotion.js";
const { accent, heading, info, muted, success, warn } = theme;
function formatMemoryIndexIdentityWarning(
status: ReturnType<MemoryManager["status"]>,
agentId: string,
): {
reason: string;
fix: string;
} | null {
const indexIdentity = asRecord(asRecord(status.custom)?.indexIdentity);
const reason =
(indexIdentity?.status === "mismatched" || indexIdentity?.status === "missing") &&
typeof indexIdentity.reason === "string"
? indexIdentity.reason
: undefined;
if (!reason) {
return null;
}
return {
reason,
fix: `Run: openclaw memory status --index --agent ${agentId}`,
};
}
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") {
return shortenHomeInString(
@@ -284,10 +264,9 @@ export async function runMemorySearch(
process.exitCode = 1;
return;
}
const workspaceDir =
typeof (manager as { status?: () => { workspaceDir?: string } }).status === "function"
? manager.status().workspaceDir
: undefined;
const status = manager.status();
const staleness = resolveMemorySearchStaleness(status, agentId);
const workspaceDir = status.workspaceDir;
if (dreamingEnabled) {
await recordShortTermRecalls({
workspaceDir,
@@ -300,17 +279,11 @@ export async function runMemorySearch(
});
}
if (opts.json) {
defaultRuntime.writeJson({ results });
defaultRuntime.writeJson({ results, ...staleness });
return;
}
const identityWarning =
typeof manager.status === "function"
? formatMemoryIndexIdentityWarning(manager.status(), agentId)
: null;
if (identityWarning) {
defaultRuntime.error(
`Memory index warning: ${identityWarning.reason}. Vector memory search is paused until the index is rebuilt. ${identityWarning.fix}`,
);
if (staleness) {
defaultRuntime.error(`${staleness.warning} ${staleness.action}`);
}
if (results.length === 0) {
defaultRuntime.log("No matches.");
+48 -1
View File
@@ -199,7 +199,12 @@ describe("memory cli", () => {
}
function mockManager(manager: Record<string, unknown>) {
getMemorySearchManager.mockResolvedValueOnce({ manager });
getMemorySearchManager.mockResolvedValueOnce({
manager: {
...(manager.search && !manager.status ? { status: () => makeMemoryStatus() } : {}),
...manager,
},
});
}
function setupMemoryStatusWithInactiveSecretDiagnostics(close: ReturnType<typeof vi.fn>) {
@@ -1723,6 +1728,48 @@ describe("memory cli", () => {
expect(close).toHaveBeenCalled();
});
it("qualifies json search results when the index remains stale", async () => {
const close = vi.fn(async () => {});
const reason = "index was built for model old-embed, expected new-embed";
mockManager({
search: vi.fn(async () => []),
status: () =>
makeMemoryStatus({
dirty: true,
custom: { indexIdentity: { status: "mismatched", reason } },
}),
close,
});
const writeJson = spyRuntimeJson(defaultRuntime);
await runMemoryCli(["search", "hidden codeword", "--agent", "main", "--json"]);
expect(firstWrittenJsonArg(writeJson)).toEqual({
results: [],
stale: true,
warning: `Memory index is stale: ${reason}. Search results may be incomplete.`,
action: "Run: openclaw memory status --index --agent main",
});
});
it("warns before reporting no matches from a dirty index", async () => {
const close = vi.fn(async () => {});
mockManager({
search: vi.fn(async () => []),
status: () => makeMemoryStatus({ dirty: true }),
close,
});
const error = spyRuntimeErrors(defaultRuntime);
const log = spyRuntimeLogs(defaultRuntime);
await runMemoryCli(["search", "hidden codeword"]);
expect(error).toHaveBeenCalledWith(
"Memory index is dirty. Search results may be incomplete. Run: openclaw memory status --index --agent main",
);
expect(log).toHaveBeenCalledWith("No matches.");
});
it("prints no candidates when promote has no short-term recall data", async () => {
await withTempWorkspace(async (workspaceDir) => {
const close = vi.fn(async () => {});
@@ -38,6 +38,7 @@ type MemoryManagerParams = {
let backend: MemoryBackend = "builtin";
let resolvedBackend: MemoryBackend | undefined;
let workspaceDir = "/workspace";
let statusDirty = false;
let customStatus: Record<string, unknown> | undefined;
let searchImpl: SearchImpl = async () => [];
let closeImpl: () => Promise<void> = async () => {};
@@ -62,7 +63,7 @@ const stubManager = {
backend,
files: 1,
chunks: 1,
dirty: false,
dirty: statusDirty,
workspaceDir,
dbPath: "/workspace/.memory/index.sqlite",
provider: "builtin",
@@ -113,6 +114,10 @@ export function setMemoryCustomStatus(next: Record<string, unknown> | undefined)
customStatus = next;
}
export function setMemoryStatusDirty(next: boolean): void {
statusDirty = next;
}
export function setMemorySearchImpl(next: SearchImpl): void {
searchImpl = next;
}
@@ -145,6 +150,7 @@ export function resetMemoryToolMockState(overrides?: {
backend = overrides?.backend ?? "builtin";
resolvedBackend = undefined;
workspaceDir = "/workspace";
statusDirty = false;
customStatus = undefined;
getManagerImpl = undefined;
searchImpl = overrides?.searchImpl ?? (async () => []);
+22
View File
@@ -18,6 +18,7 @@ import {
setResolvedMemoryBackend,
setMemorySearchImpl,
setMemorySearchManagerImpl,
setMemoryStatusDirty,
} from "./memory-tool-manager.test-mocks.js";
import { applyProjectRanking } from "./memory/project-ranking.js";
import {
@@ -978,6 +979,27 @@ describe("memory_search unavailable payloads", () => {
expect(searchCalls).toBe(2);
});
it("qualifies empty results when the index remains dirty after retry", async () => {
setMemoryStatusDirty(true);
setMemorySearchImpl(async () => []);
const tool = createMemorySearchToolOrThrow({
config: {
agents: { list: [{ id: "main", default: true }] },
memory: { citations: "off" },
},
});
const result = await tool.execute("dirty-index", { query: "hidden codeword" });
expect(result.details).toMatchObject({
results: [],
stale: true,
warning: "Memory index is dirty. Search results may be incomplete.",
action: "Run: openclaw memory status --index --agent main",
});
expect(getMemorySyncMockCalls()).toBe(1);
});
it("keeps the zero-hit bootstrap retry for one-shot qmd searches", async () => {
setMemoryBackend("qmd");
let searchCalls = 0;
+7 -1
View File
@@ -1,6 +1,7 @@
// Memory Core plugin module implements tools behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
resolveMemorySearchStaleness,
stripMemoryAnnotationCarriers,
type MemoryReadResult,
type MemorySource,
@@ -479,7 +480,7 @@ export function createMemorySearchTool(options: {
label: "Memory Search",
name: "memory_search",
description:
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true, memory retrieval is unavailable; you must tell the user and include the warning/action guidance.",
"Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts) before answering questions about prior work, decisions, dates, people, preferences, or todos. Optional `corpus=wiki` or `corpus=all` also searches registered compiled-wiki supplements. `corpus=memory` restricts hits to indexed memory files (excludes session transcript chunks from ranking). `corpus=sessions` restricts hits to indexed session transcripts (same visibility rules as session history tools). If response has disabled=true or stale=true, you must tell the user and include the warning/action guidance.",
parameters: MemorySearchSchema,
execute:
({ cfg, agentId }) =>
@@ -609,6 +610,9 @@ export function createMemorySearchTool(options: {
let fallback: unknown;
let searchMode: string | undefined;
let pausedIndexIdentityReason: string | undefined;
let staleness:
| Exclude<ReturnType<typeof resolveMemorySearchStaleness>, null>
| undefined;
let managerMs: number | undefined;
let managerCacheState: string | undefined;
let searchDebug:
@@ -757,6 +761,7 @@ export function createMemorySearchTool(options: {
rawResults = rawResults.filter((hit) => hit.source === "memory");
}
const status = activeMemory.manager.status();
staleness = resolveMemorySearchStaleness(status, agentId) ?? undefined;
const payloadResults = rawResults.map((result) => ({
...result,
snippet: stripMemoryAnnotationCarriers(result.snippet),
@@ -853,6 +858,7 @@ export function createMemorySearchTool(options: {
fallback,
citations: citationsMode,
mode: searchMode,
...staleness,
debug: searchDebug,
});
} finally {
@@ -33,6 +33,7 @@ export {
type MemoryReadResult,
} from "./host/read-file-shared.js";
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
export { resolveMemorySearchStaleness } from "./host/types.js";
export type {
ResolvedMemoryBackendConfig,
ResolvedQmdConfig,
@@ -161,6 +161,28 @@ export type MemoryProviderStatus = {
custom?: Record<string, unknown>;
};
export function resolveMemorySearchStaleness(
status: Pick<MemoryProviderStatus, "dirty" | "custom">,
agentId?: string,
): { stale: true; warning: string; action: string } | null {
const identity = status.custom?.indexIdentity as Record<string, unknown> | undefined;
const identityReason =
(identity?.status === "mismatched" || identity?.status === "missing") &&
typeof identity.reason === "string"
? identity.reason.trim()
: undefined;
if (!status.dirty && !identityReason) {
return null;
}
return {
stale: true,
warning: identityReason
? `Memory index is stale: ${identityReason}. Search results may be incomplete.`
: "Memory index is dirty. Search results may be incomplete.",
action: `Run: openclaw memory status --index${agentId?.trim() ? ` --agent ${agentId.trim()}` : ""}`,
};
}
/** Search/read/sync/status contract implemented by memory managers. */
export interface MemorySearchManager {
search(
@@ -58,6 +58,7 @@ function createStubManager() {
status: vi.fn(() => ({
backend: "builtin" as const,
provider: "none",
dirty: false,
custom: { searchMode: "fts-only" },
})),
close: vi.fn(async () => undefined),
@@ -224,4 +225,32 @@ describe("memory.search gateway method", () => {
}),
);
});
it("qualifies results from a dirty index", async () => {
const cfg = createConfig(testState.workspaceDir);
const manager = createStubManager();
manager.status.mockReturnValue({
backend: "builtin",
provider: "none",
dirty: true,
custom: { searchMode: "fts-only" },
});
getActiveMemorySearchManager.mockResolvedValue({ manager });
const respond = await invokeMemorySearch({ query: "hidden codeword" }, cfg);
expect(respond).toHaveBeenCalledWith(
true,
{
agentId: "main",
provider: "none",
searchMode: "fts-only",
results: [],
stale: true,
warning: "Memory index is dirty. Search results may be incomplete.",
action: "Run: openclaw memory status --index --agent main",
},
undefined,
);
});
});
@@ -6,6 +6,7 @@ import type {
MemorySearchManager,
MemorySearchResult,
} from "../../memory-host-sdk/host/types.js";
import { resolveMemorySearchStaleness } from "../../memory-host-sdk/host/types.js";
import { getActiveMemorySearchManager } from "../../plugins/memory-runtime.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -18,6 +19,9 @@ export type MemorySearchResponse = {
provider: string;
searchMode: "hybrid" | "fts-only";
results: MemorySearchResult[];
stale?: true;
warning?: string;
action?: string;
};
function resolveSearchMode(status: MemoryProviderStatus): MemorySearchResponse["searchMode"] {
@@ -142,6 +146,7 @@ export const memorySearchHandlers: GatewayRequestHandlers = {
provider: status.provider,
searchMode: resolveSearchMode(status),
results,
...resolveMemorySearchStaleness(status, agentId),
};
respond(true, payload, undefined);
} catch (error) {
@@ -47,6 +47,7 @@ export {
remapChunkLines,
requireNodeSqlite,
resolveMemoryBackendConfig,
resolveMemorySearchStaleness,
runWithConcurrency,
splitCuratedMarkdownEntries,
statRegularFile,