mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
681e8c5ab1
commit
7d1d721d62
@@ -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.");
|
||||
|
||||
@@ -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 () => []);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user