mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:10:45 +00:00
feat: default active memory QMD recall to search (#65068)
* feat(active-memory): default QMD recall to search * feat(active-memory): surface search debug telemetry * fix(active-memory): avoid forking qmd managers
This commit is contained in:
@@ -474,6 +474,79 @@ describe("active-memory plugin", () => {
|
||||
messageChannel: "webchat",
|
||||
messageProvider: "webchat",
|
||||
sessionKey: expect.stringMatching(/^agent:main:main:active-memory:[a-f0-9]{12}$/),
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
config: {
|
||||
qmd: {
|
||||
searchMode: "search",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets active memory inherit the main QMD search mode when configured", async () => {
|
||||
api.config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "github-copilot/gpt-5.4-mini",
|
||||
},
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
},
|
||||
},
|
||||
};
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
qmd: {
|
||||
searchMode: "inherit",
|
||||
},
|
||||
};
|
||||
await plugin.register(api as unknown as OpenClawPluginApi);
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order? inherit-qmd-mode-check",
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
trigger: "user",
|
||||
sessionKey: "agent:main:main",
|
||||
messageProvider: "webchat",
|
||||
},
|
||||
);
|
||||
|
||||
expect(runEmbeddedPiAgent.mock.calls.at(-1)?.[0]).toMatchObject({
|
||||
config: {
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
config: {
|
||||
qmd: {
|
||||
searchMode: "inherit",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -827,13 +900,25 @@ describe("active-memory plugin", () => {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 0,
|
||||
};
|
||||
runEmbeddedPiAgent.mockResolvedValueOnce({
|
||||
payloads: [{ text: "User prefers lemon pepper wings, and blue cheese still wins." }],
|
||||
runEmbeddedPiAgent.mockImplementationOnce(async () => {
|
||||
return {
|
||||
meta: {
|
||||
activeMemorySearchDebug: {
|
||||
backend: "qmd",
|
||||
configuredMode: "search",
|
||||
effectiveMode: "query",
|
||||
fallback: "unsupported-search-flags",
|
||||
searchMs: 2590,
|
||||
hits: 3,
|
||||
},
|
||||
},
|
||||
payloads: [{ text: "User prefers lemon pepper wings, and blue cheese still wins." }],
|
||||
};
|
||||
});
|
||||
|
||||
await hooks.before_prompt_build(
|
||||
{
|
||||
prompt: "what wings should i order?",
|
||||
prompt: "what wings should i order? debug telemetry",
|
||||
messages: [],
|
||||
},
|
||||
{ agentId: "main", trigger: "user", sessionKey, messageProvider: "webchat" },
|
||||
@@ -856,7 +941,7 @@ describe("active-memory plugin", () => {
|
||||
lines: expect.arrayContaining([
|
||||
expect.stringContaining("🧩 Active Memory: ok"),
|
||||
expect.stringContaining(
|
||||
"🔎 Active Memory Debug: User prefers lemon pepper wings, and blue cheese still wins.",
|
||||
"🔎 Active Memory Debug: backend=qmd configuredMode=search effectiveMode=query fallback=unsupported-search-flags searchMs=2590 hits=3 | User prefers lemon pepper wings, and blue cheese still wins.",
|
||||
),
|
||||
]),
|
||||
},
|
||||
|
||||
@@ -26,6 +26,7 @@ const DEFAULT_RECENT_ASSISTANT_CHARS = 180;
|
||||
const DEFAULT_CACHE_TTL_MS = 15_000;
|
||||
const DEFAULT_MAX_CACHE_ENTRIES = 1000;
|
||||
const DEFAULT_QUERY_MODE = "recent" as const;
|
||||
const DEFAULT_QMD_SEARCH_MODE = "search" as const;
|
||||
const DEFAULT_TRANSCRIPT_DIR = "active-memory";
|
||||
const TOGGLE_STATE_FILE = "session-toggles.json";
|
||||
|
||||
@@ -81,8 +82,13 @@ type ActiveRecallPluginConfig = {
|
||||
cacheTtlMs?: number;
|
||||
persistTranscripts?: boolean;
|
||||
transcriptDir?: string;
|
||||
qmd?: {
|
||||
searchMode?: ActiveMemoryQmdSearchMode;
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveMemoryQmdSearchMode = "inherit" | "search" | "vsearch" | "query";
|
||||
|
||||
type ResolvedActiveRecallPluginConfig = {
|
||||
enabled: boolean;
|
||||
agents: string[];
|
||||
@@ -111,6 +117,9 @@ type ResolvedActiveRecallPluginConfig = {
|
||||
cacheTtlMs: number;
|
||||
persistTranscripts: boolean;
|
||||
transcriptDir: string;
|
||||
qmd: {
|
||||
searchMode: ActiveMemoryQmdSearchMode;
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveRecallRecentTurn = {
|
||||
@@ -123,13 +132,29 @@ type PluginDebugEntry = {
|
||||
lines: string[];
|
||||
};
|
||||
|
||||
type ActiveMemorySearchDebug = {
|
||||
backend?: string;
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
searchMs?: number;
|
||||
hits?: number;
|
||||
};
|
||||
|
||||
type ActiveRecallResult =
|
||||
| {
|
||||
status: "empty" | "timeout" | "unavailable";
|
||||
elapsedMs: number;
|
||||
summary: string | null;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
}
|
||||
| { status: "ok"; elapsedMs: number; rawReply: string; summary: string };
|
||||
| {
|
||||
status: "ok";
|
||||
elapsedMs: number;
|
||||
rawReply: string;
|
||||
summary: string;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
};
|
||||
|
||||
type CachedActiveRecallResult = {
|
||||
expiresAt: number;
|
||||
@@ -238,6 +263,13 @@ function normalizePromptConfigText(value: unknown): string | undefined {
|
||||
return text ? text : undefined;
|
||||
}
|
||||
|
||||
function resolveQmdSearchMode(value: unknown): ActiveMemoryQmdSearchMode {
|
||||
if (value === "inherit" || value === "search" || value === "vsearch" || value === "query") {
|
||||
return value;
|
||||
}
|
||||
return DEFAULT_QMD_SEARCH_MODE;
|
||||
}
|
||||
|
||||
function hasDeprecatedModelFallbackPolicy(pluginConfig: unknown): boolean {
|
||||
const raw = asRecord(pluginConfig);
|
||||
return raw ? Object.hasOwn(raw, "modelFallbackPolicy") : false;
|
||||
@@ -551,6 +583,7 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi
|
||||
const raw = (
|
||||
pluginConfig && typeof pluginConfig === "object" ? pluginConfig : {}
|
||||
) as ActiveRecallPluginConfig;
|
||||
const qmd = asRecord(raw.qmd);
|
||||
const allowedChatTypes = Array.isArray(raw.allowedChatTypes)
|
||||
? raw.allowedChatTypes.filter(
|
||||
(value): value is ActiveMemoryChatType =>
|
||||
@@ -598,6 +631,36 @@ function normalizePluginConfig(pluginConfig: unknown): ResolvedActiveRecallPlugi
|
||||
cacheTtlMs: clampInt(raw.cacheTtlMs, DEFAULT_CACHE_TTL_MS, 1000, 120_000),
|
||||
persistTranscripts: raw.persistTranscripts === true,
|
||||
transcriptDir: normalizeTranscriptDir(raw.transcriptDir),
|
||||
qmd: {
|
||||
searchMode: resolveQmdSearchMode(qmd?.searchMode),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyActiveMemoryRuntimeConfigSnapshot(
|
||||
cfg: OpenClawConfig,
|
||||
pluginConfig: ResolvedActiveRecallPluginConfig,
|
||||
): OpenClawConfig {
|
||||
const existingEntry = asRecord(cfg.plugins?.entries?.["active-memory"]);
|
||||
const existingPluginConfig = asRecord(existingEntry?.config);
|
||||
return {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
"active-memory": {
|
||||
...existingEntry,
|
||||
config: {
|
||||
...existingPluginConfig,
|
||||
qmd: {
|
||||
...asRecord(existingPluginConfig?.qmd),
|
||||
searchMode: pluginConfig.qmd.searchMode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -928,12 +991,45 @@ function buildPluginStatusLine(params: {
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function buildPluginDebugLine(summary: string | null | undefined): string | null {
|
||||
const cleaned = sanitizeDebugText(summary ?? "");
|
||||
if (!cleaned) {
|
||||
return null;
|
||||
function buildPluginDebugLine(params: {
|
||||
summary?: string | null;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
}): string | null {
|
||||
const cleaned = sanitizeDebugText(params.summary ?? "");
|
||||
const debugParts: string[] = [];
|
||||
const backend = sanitizeDebugText(params.searchDebug?.backend ?? "");
|
||||
if (backend) {
|
||||
debugParts.push(`backend=${backend}`);
|
||||
}
|
||||
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${cleaned}`;
|
||||
const configuredMode = sanitizeDebugText(params.searchDebug?.configuredMode ?? "");
|
||||
if (configuredMode) {
|
||||
debugParts.push(`configuredMode=${configuredMode}`);
|
||||
}
|
||||
const effectiveMode = sanitizeDebugText(params.searchDebug?.effectiveMode ?? "");
|
||||
if (effectiveMode) {
|
||||
debugParts.push(`effectiveMode=${effectiveMode}`);
|
||||
}
|
||||
const fallback = sanitizeDebugText(params.searchDebug?.fallback ?? "");
|
||||
if (fallback) {
|
||||
debugParts.push(`fallback=${fallback}`);
|
||||
}
|
||||
if (typeof params.searchDebug?.searchMs === "number" && Number.isFinite(params.searchDebug.searchMs)) {
|
||||
debugParts.push(`searchMs=${Math.max(0, Math.round(params.searchDebug.searchMs))}`);
|
||||
}
|
||||
if (typeof params.searchDebug?.hits === "number" && Number.isFinite(params.searchDebug.hits)) {
|
||||
debugParts.push(`hits=${Math.max(0, Math.floor(params.searchDebug.hits))}`);
|
||||
}
|
||||
const prefix = debugParts.join(" ");
|
||||
if (prefix && cleaned) {
|
||||
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${prefix} | ${cleaned}`;
|
||||
}
|
||||
if (prefix) {
|
||||
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${prefix}`;
|
||||
}
|
||||
if (cleaned) {
|
||||
return `${ACTIVE_MEMORY_DEBUG_PREFIX} ${cleaned}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeDebugText(text: string): string {
|
||||
@@ -954,12 +1050,16 @@ async function persistPluginStatusLines(params: {
|
||||
sessionKey?: string;
|
||||
statusLine?: string;
|
||||
debugSummary?: string | null;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
}): Promise<void> {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
const debugLine = buildPluginDebugLine(params.debugSummary);
|
||||
const debugLine = buildPluginDebugLine({
|
||||
summary: params.debugSummary,
|
||||
searchDebug: params.searchDebug,
|
||||
});
|
||||
const agentId = params.agentId.trim();
|
||||
if (!agentId && (params.statusLine || debugLine)) {
|
||||
return;
|
||||
@@ -1020,6 +1120,97 @@ async function persistPluginStatusLines(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function readActiveMemorySearchDebug(
|
||||
sessionFile: string,
|
||||
): Promise<ActiveMemorySearchDebug | undefined> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(sessionFile, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
const lines = raw
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const line = lines[index];
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
const record = asRecord(parsed);
|
||||
const nestedMessage = asRecord(record?.message);
|
||||
const topLevelMessage =
|
||||
record?.role === "toolResult" || record?.toolName === "memory_search" ? record : undefined;
|
||||
const message = nestedMessage ?? topLevelMessage;
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
const role = normalizeOptionalString(message.role);
|
||||
const toolName = normalizeOptionalString(message.toolName);
|
||||
if (role !== "toolResult" || toolName !== "memory_search") {
|
||||
continue;
|
||||
}
|
||||
const details = asRecord(message.details);
|
||||
const debug = asRecord(details?.debug);
|
||||
if (!debug) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
backend: normalizeOptionalString(debug.backend),
|
||||
configuredMode: normalizeOptionalString(debug.configuredMode),
|
||||
effectiveMode: normalizeOptionalString(debug.effectiveMode),
|
||||
fallback: normalizeOptionalString(debug.fallback),
|
||||
searchMs:
|
||||
typeof debug.searchMs === "number" && Number.isFinite(debug.searchMs)
|
||||
? debug.searchMs
|
||||
: undefined,
|
||||
hits:
|
||||
typeof debug.hits === "number" && Number.isFinite(debug.hits) ? debug.hits : undefined,
|
||||
};
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeSearchDebug(value: unknown): ActiveMemorySearchDebug | undefined {
|
||||
const debug = asRecord(value);
|
||||
if (!debug) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized: ActiveMemorySearchDebug = {
|
||||
backend: normalizeOptionalString(debug.backend),
|
||||
configuredMode: normalizeOptionalString(debug.configuredMode),
|
||||
effectiveMode: normalizeOptionalString(debug.effectiveMode),
|
||||
fallback: normalizeOptionalString(debug.fallback),
|
||||
searchMs:
|
||||
typeof debug.searchMs === "number" && Number.isFinite(debug.searchMs)
|
||||
? debug.searchMs
|
||||
: undefined,
|
||||
hits: typeof debug.hits === "number" && Number.isFinite(debug.hits) ? debug.hits : undefined,
|
||||
};
|
||||
return normalized.backend ||
|
||||
normalized.configuredMode ||
|
||||
normalized.effectiveMode ||
|
||||
normalized.fallback ||
|
||||
typeof normalized.searchMs === "number" ||
|
||||
typeof normalized.hits === "number"
|
||||
? normalized
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function readActiveMemorySearchDebugFromRunResult(result: unknown): ActiveMemorySearchDebug | undefined {
|
||||
const record = asRecord(result);
|
||||
const meta = asRecord(record?.meta);
|
||||
return (
|
||||
normalizeSearchDebug(meta?.activeMemorySearchDebug) ??
|
||||
normalizeSearchDebug(meta?.memorySearchDebug) ??
|
||||
normalizeSearchDebug(record?.activeMemorySearchDebug) ??
|
||||
normalizeSearchDebug(record?.memorySearchDebug)
|
||||
);
|
||||
}
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
@@ -1252,7 +1443,11 @@ async function runRecallSubagent(params: {
|
||||
currentModelProviderId?: string;
|
||||
currentModelId?: string;
|
||||
abortSignal?: AbortSignal;
|
||||
}): Promise<{ rawReply: string; transcriptPath?: string }> {
|
||||
}): Promise<{
|
||||
rawReply: string;
|
||||
transcriptPath?: string;
|
||||
searchDebug?: ActiveMemorySearchDebug;
|
||||
}> {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.api.config, params.agentId);
|
||||
const agentDir = resolveAgentDir(params.api.config, params.agentId);
|
||||
const modelRef = getModelRef(params.api, params.agentId, params.config, {
|
||||
@@ -1309,6 +1504,7 @@ async function runRecallSubagent(params: {
|
||||
});
|
||||
|
||||
try {
|
||||
const embeddedConfig = applyActiveMemoryRuntimeConfigSnapshot(params.api.config, params.config);
|
||||
const result = await params.api.runtime.agent.runEmbeddedPiAgent({
|
||||
sessionId: subagentSessionId,
|
||||
sessionKey: subagentSessionKey,
|
||||
@@ -1318,7 +1514,7 @@ async function runRecallSubagent(params: {
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: params.api.config,
|
||||
config: embeddedConfig,
|
||||
prompt,
|
||||
provider: modelRef.provider,
|
||||
model: modelRef.model,
|
||||
@@ -1351,9 +1547,13 @@ async function runRecallSubagent(params: {
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
.trim();
|
||||
const searchDebug =
|
||||
(await readActiveMemorySearchDebug(sessionFile)) ??
|
||||
readActiveMemorySearchDebugFromRunResult(result);
|
||||
return {
|
||||
rawReply: rawReply || "NONE",
|
||||
transcriptPath: params.config.persistTranscripts ? sessionFile : undefined,
|
||||
searchDebug,
|
||||
};
|
||||
} finally {
|
||||
if (tempDir) {
|
||||
@@ -1390,6 +1590,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
statusLine: `${buildPluginStatusLine({ result: cached, config: params.config })} cached`,
|
||||
debugSummary: cached.summary,
|
||||
searchDebug: cached.searchDebug,
|
||||
});
|
||||
if (params.config.logging) {
|
||||
params.api.logger.info?.(
|
||||
@@ -1412,7 +1613,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
timeoutId.unref?.();
|
||||
|
||||
try {
|
||||
const { rawReply, transcriptPath } = await runRecallSubagent({
|
||||
const { rawReply, transcriptPath, searchDebug } = await runRecallSubagent({
|
||||
...params,
|
||||
abortSignal: controller.signal,
|
||||
});
|
||||
@@ -1430,11 +1631,13 @@ async function maybeResolveActiveRecall(params: {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
rawReply,
|
||||
summary,
|
||||
searchDebug,
|
||||
}
|
||||
: {
|
||||
status: "empty",
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
summary: null,
|
||||
searchDebug,
|
||||
};
|
||||
if (params.config.logging) {
|
||||
params.api.logger.info?.(
|
||||
@@ -1447,6 +1650,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
sessionKey: params.sessionKey,
|
||||
statusLine: buildPluginStatusLine({ result, config: params.config }),
|
||||
debugSummary: result.summary,
|
||||
searchDebug: result.searchDebug,
|
||||
});
|
||||
if (shouldCacheResult(result)) {
|
||||
setCachedResult(cacheKey, result, params.config.cacheTtlMs);
|
||||
@@ -1469,6 +1673,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
statusLine: buildPluginStatusLine({ result, config: params.config }),
|
||||
searchDebug: result.searchDebug,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
@@ -1486,6 +1691,7 @@ async function maybeResolveActiveRecall(params: {
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
statusLine: buildPluginStatusLine({ result, config: params.config }),
|
||||
searchDebug: result.searchDebug,
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
|
||||
@@ -54,7 +54,17 @@
|
||||
"logging": { "type": "boolean" },
|
||||
"persistTranscripts": { "type": "boolean" },
|
||||
"transcriptDir": { "type": "string" },
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 }
|
||||
"cacheTtlMs": { "type": "integer", "minimum": 1000, "maximum": 120000 },
|
||||
"qmd": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"searchMode": {
|
||||
"type": "string",
|
||||
"enum": ["inherit", "search", "vsearch", "query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
@@ -120,6 +130,10 @@
|
||||
"transcriptDir": {
|
||||
"label": "Transcript Directory",
|
||||
"help": "Relative directory under the agent sessions folder used when transcript persistence is enabled."
|
||||
},
|
||||
"qmd.searchMode": {
|
||||
"label": "QMD Search Mode",
|
||||
"help": "Override the QMD search mode used by the blocking memory sub-agent. Defaults to fast lexical search; use inherit to match the main memory backend setting."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { vi } from "vitest";
|
||||
import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
|
||||
export type SearchImpl = () => Promise<unknown[]>;
|
||||
export type SearchImpl = (opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
}) => Promise<unknown[]>;
|
||||
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
|
||||
export type MemoryReadResult = { text: string; path: string };
|
||||
type MemoryBackend = "builtin" | "qmd";
|
||||
|
||||
let backend: MemoryBackend = "builtin";
|
||||
let workspaceDir = "/workspace";
|
||||
let customStatus: Record<string, unknown> | undefined;
|
||||
let searchImpl: SearchImpl = async () => [];
|
||||
let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = async (params) => ({
|
||||
text: "",
|
||||
@@ -14,7 +22,7 @@ let readFileImpl: (params: MemoryReadParams) => Promise<MemoryReadResult> = asyn
|
||||
});
|
||||
|
||||
const stubManager = {
|
||||
search: vi.fn(async () => await searchImpl()),
|
||||
search: vi.fn(async (_query: string, opts?: Parameters<SearchImpl>[0]) => await searchImpl(opts)),
|
||||
readFile: vi.fn(async (params: MemoryReadParams) => await readFileImpl(params)),
|
||||
status: () => ({
|
||||
backend,
|
||||
@@ -28,6 +36,7 @@ const stubManager = {
|
||||
requestedProvider: "builtin",
|
||||
sources: ["memory" as const],
|
||||
sourceCounts: [{ source: "memory" as const, files: 1, chunks: 1 }],
|
||||
custom: customStatus,
|
||||
}),
|
||||
sync: vi.fn(),
|
||||
probeVectorAvailability: vi.fn(async () => true),
|
||||
@@ -60,6 +69,10 @@ export function setMemoryWorkspaceDir(next: string): void {
|
||||
workspaceDir = next;
|
||||
}
|
||||
|
||||
export function setMemoryStatusCustom(next: Record<string, unknown> | undefined): void {
|
||||
customStatus = next;
|
||||
}
|
||||
|
||||
export function setMemorySearchImpl(next: SearchImpl): void {
|
||||
searchImpl = next;
|
||||
}
|
||||
@@ -77,6 +90,7 @@ export function resetMemoryToolMockState(overrides?: {
|
||||
}): void {
|
||||
backend = overrides?.backend ?? "builtin";
|
||||
workspaceDir = "/workspace";
|
||||
customStatus = undefined;
|
||||
searchImpl = overrides?.searchImpl ?? (async () => []);
|
||||
readFileImpl =
|
||||
overrides?.readFileImpl ??
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type MemoryEmbeddingProbeResult,
|
||||
type MemoryProviderStatus,
|
||||
type MemorySearchManager,
|
||||
type MemorySearchRuntimeDebug,
|
||||
type MemorySearchResult,
|
||||
type MemorySource,
|
||||
type MemorySyncProgressUpdate,
|
||||
@@ -291,8 +292,11 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
},
|
||||
): Promise<MemorySearchResult[]> {
|
||||
opts?.onDebug?.({ backend: "builtin" });
|
||||
let hasIndexedContent = this.hasIndexedContent();
|
||||
if (!hasIndexedContent) {
|
||||
try {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
type MemoryEmbeddingProbeResult,
|
||||
type MemoryProviderStatus,
|
||||
type MemorySearchManager,
|
||||
type MemorySearchRuntimeDebug,
|
||||
type MemorySearchResult,
|
||||
type MemorySource,
|
||||
type MemorySyncProgressUpdate,
|
||||
@@ -884,7 +885,13 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
},
|
||||
): Promise<MemorySearchResult[]> {
|
||||
if (!this.isScopeAllowed(opts?.sessionKey)) {
|
||||
this.logScopeDenied(opts?.sessionKey);
|
||||
@@ -906,7 +913,9 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
log.warn("qmd query skipped: no managed collections configured");
|
||||
return [];
|
||||
}
|
||||
const qmdSearchCommand = this.qmd.searchMode;
|
||||
const qmdSearchCommand = opts?.qmdSearchModeOverride ?? this.qmd.searchMode;
|
||||
let effectiveSearchMode: "query" | "search" | "vsearch" = qmdSearchCommand;
|
||||
let searchFallbackReason: string | undefined;
|
||||
const explicitSearchTool = this.qmd.searchTool;
|
||||
const mcporterEnabled = this.qmd.mcporter.enabled;
|
||||
const runSearchAttempt = async (
|
||||
@@ -986,6 +995,8 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
qmdSearchCommand !== "query" &&
|
||||
this.isUnsupportedQmdOptionError(err)
|
||||
) {
|
||||
effectiveSearchMode = "query";
|
||||
searchFallbackReason = "unsupported-search-flags";
|
||||
log.warn(
|
||||
`qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`,
|
||||
);
|
||||
@@ -1045,6 +1056,12 @@ export class QmdMemoryManager implements MemorySearchManager {
|
||||
source: doc.source,
|
||||
});
|
||||
}
|
||||
opts?.onDebug?.({
|
||||
backend: "qmd",
|
||||
configuredMode: qmdSearchCommand,
|
||||
effectiveMode: effectiveSearchMode,
|
||||
fallback: searchFallbackReason,
|
||||
});
|
||||
return this.clampResultsByInjectedChars(this.diversifyResultsBySource(results, limit));
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
resolveMemoryBackendConfig,
|
||||
type MemoryEmbeddingProbeResult,
|
||||
type MemorySearchManager,
|
||||
type MemorySearchRuntimeDebug,
|
||||
type MemorySyncProgressUpdate,
|
||||
type ResolvedQmdConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
|
||||
@@ -126,7 +127,13 @@ class BorrowedMemoryManager implements MemorySearchManager {
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
},
|
||||
) {
|
||||
return await this.inner.search(query, opts);
|
||||
}
|
||||
@@ -191,7 +198,13 @@ class FallbackMemoryManager implements MemorySearchManager {
|
||||
|
||||
async search(
|
||||
query: string,
|
||||
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
},
|
||||
) {
|
||||
if (!this.primaryFailed) {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { beforeEach, describe, it } from "vitest";
|
||||
import { resetMemoryToolMockState, setMemorySearchImpl } from "./memory-tool-manager-mock.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resetMemoryToolMockState,
|
||||
setMemoryBackend,
|
||||
setMemorySearchImpl,
|
||||
} from "./memory-tool-manager-mock.js";
|
||||
import {
|
||||
createMemorySearchToolOrThrow,
|
||||
expectUnavailableMemorySearchDetails,
|
||||
@@ -37,4 +41,66 @@ describe("memory_search unavailable payloads", () => {
|
||||
action: "Check embedding provider configuration and retry memory_search.",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns structured search debug metadata for qmd results", async () => {
|
||||
setMemoryBackend("qmd");
|
||||
setMemorySearchImpl(async (opts) => {
|
||||
opts?.onDebug?.({
|
||||
backend: "qmd",
|
||||
configuredMode: opts.qmdSearchModeOverride ?? "query",
|
||||
effectiveMode: "query",
|
||||
fallback: "unsupported-search-flags",
|
||||
});
|
||||
return [
|
||||
{
|
||||
path: "MEMORY.md",
|
||||
startLine: 1,
|
||||
endLine: 2,
|
||||
score: 0.9,
|
||||
snippet: "ramen",
|
||||
source: "memory",
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const tool = createMemorySearchToolOrThrow({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
"active-memory": {
|
||||
config: {
|
||||
qmd: {
|
||||
searchMode: "search",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
memory: {
|
||||
backend: "qmd",
|
||||
qmd: {
|
||||
searchMode: "query",
|
||||
limits: {
|
||||
maxInjectedChars: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
agentSessionKey: "agent:main:main:active-memory:debug",
|
||||
});
|
||||
const result = await tool.execute("debug", { query: "favorite food" });
|
||||
expect(result.details).toMatchObject({
|
||||
mode: "query",
|
||||
debug: {
|
||||
backend: "qmd",
|
||||
configuredMode: "search",
|
||||
effectiveMode: "query",
|
||||
fallback: "unsupported-search-flags",
|
||||
hits: 1,
|
||||
},
|
||||
});
|
||||
expect((result.details as { debug?: { searchMs?: number } }).debug?.searchMs).toEqual(
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
type AnyAgentTool,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-core";
|
||||
import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import type {
|
||||
MemorySearchResult,
|
||||
MemorySearchRuntimeDebug,
|
||||
} from "openclaw/plugin-sdk/memory-core-host-runtime-files";
|
||||
import {
|
||||
resolveMemoryCorePluginConfig,
|
||||
resolveMemoryDeepDreamingConfig,
|
||||
@@ -71,6 +74,36 @@ function queueShortTermRecallTracking(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeActiveMemoryQmdSearchMode(value: unknown): "inherit" | "search" | "vsearch" | "query" {
|
||||
return value === "inherit" || value === "search" || value === "vsearch" || value === "query"
|
||||
? value
|
||||
: "search";
|
||||
}
|
||||
|
||||
function isActiveMemorySessionKey(sessionKey?: string): boolean {
|
||||
return typeof sessionKey === "string" && sessionKey.includes(":active-memory:");
|
||||
}
|
||||
|
||||
function resolveActiveMemoryQmdSearchModeOverride(
|
||||
cfg: OpenClawConfig,
|
||||
sessionKey?: string,
|
||||
): "search" | "vsearch" | "query" | undefined {
|
||||
if (!isActiveMemorySessionKey(sessionKey)) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = cfg.plugins?.entries?.["active-memory"];
|
||||
const entryRecord =
|
||||
entry && typeof entry === "object" && !Array.isArray(entry)
|
||||
? (entry as { config?: unknown })
|
||||
: undefined;
|
||||
const pluginConfig =
|
||||
entryRecord?.config && typeof entryRecord.config === "object" && !Array.isArray(entryRecord.config)
|
||||
? (entryRecord.config as { qmd?: { searchMode?: unknown } })
|
||||
: undefined;
|
||||
const searchMode = normalizeActiveMemoryQmdSearchMode(pluginConfig?.qmd?.searchMode);
|
||||
return searchMode === "inherit" ? undefined : searchMode;
|
||||
}
|
||||
|
||||
async function getSupplementMemoryReadResult(params: {
|
||||
relPath: string;
|
||||
from?: number;
|
||||
@@ -176,17 +209,37 @@ export function createMemorySearchTool(options: {
|
||||
mode: citationsMode,
|
||||
sessionKey: options.agentSessionKey,
|
||||
});
|
||||
const searchStartedAt = Date.now();
|
||||
let rawResults: MemorySearchResult[] = [];
|
||||
let surfacedMemoryResults: Array<MemorySearchResult & { corpus: "memory" }> = [];
|
||||
let provider: string | undefined;
|
||||
let model: string | undefined;
|
||||
let fallback: unknown;
|
||||
let searchMode: string | undefined;
|
||||
let searchDebug:
|
||||
| {
|
||||
backend: string;
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
searchMs: number;
|
||||
hits: number;
|
||||
}
|
||||
| undefined;
|
||||
if (shouldQueryMemory && memory && !("error" in memory)) {
|
||||
const runtimeDebug: MemorySearchRuntimeDebug[] = [];
|
||||
const qmdSearchModeOverride = resolveActiveMemoryQmdSearchModeOverride(
|
||||
cfg,
|
||||
options.agentSessionKey,
|
||||
);
|
||||
rawResults = await memory.manager.search(query, {
|
||||
maxResults,
|
||||
minScore,
|
||||
sessionKey: options.agentSessionKey,
|
||||
qmdSearchModeOverride,
|
||||
onDebug: (debug) => {
|
||||
runtimeDebug.push(debug);
|
||||
},
|
||||
});
|
||||
const status = memory.manager.status();
|
||||
const decorated = decorateCitations(rawResults, includeCitations);
|
||||
@@ -213,7 +266,16 @@ export function createMemorySearchTool(options: {
|
||||
provider = status.provider;
|
||||
model = status.model;
|
||||
fallback = status.fallback;
|
||||
searchMode = (status.custom as { searchMode?: string } | undefined)?.searchMode;
|
||||
const latestDebug = runtimeDebug.at(-1);
|
||||
searchMode = latestDebug?.effectiveMode;
|
||||
searchDebug = {
|
||||
backend: status.backend,
|
||||
configuredMode: latestDebug?.configuredMode,
|
||||
effectiveMode: status.backend === "qmd" ? (latestDebug?.effectiveMode ?? latestDebug?.configuredMode) : "n/a",
|
||||
fallback: latestDebug?.fallback,
|
||||
searchMs: Math.max(0, Date.now() - searchStartedAt),
|
||||
hits: rawResults.length,
|
||||
};
|
||||
}
|
||||
const supplementResults = shouldQuerySupplements
|
||||
? await searchMemoryCorpusSupplements({
|
||||
@@ -238,6 +300,7 @@ export function createMemorySearchTool(options: {
|
||||
fallback,
|
||||
citations: citationsMode,
|
||||
mode: searchMode,
|
||||
debug: searchDebug,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = formatErrorMessage(err);
|
||||
|
||||
@@ -26,6 +26,7 @@ export type {
|
||||
MemoryEmbeddingProbeResult,
|
||||
MemoryProviderStatus,
|
||||
MemorySearchManager,
|
||||
MemorySearchRuntimeDebug,
|
||||
MemorySearchResult,
|
||||
MemorySource,
|
||||
MemorySyncProgressUpdate,
|
||||
|
||||
@@ -21,6 +21,13 @@ export type MemorySyncProgressUpdate = {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type MemorySearchRuntimeDebug = {
|
||||
backend: "builtin" | "qmd";
|
||||
configuredMode?: string;
|
||||
effectiveMode?: string;
|
||||
fallback?: string;
|
||||
};
|
||||
|
||||
export type MemoryProviderStatus = {
|
||||
backend: "builtin" | "qmd";
|
||||
provider: string;
|
||||
@@ -61,7 +68,13 @@ export type MemoryProviderStatus = {
|
||||
export interface MemorySearchManager {
|
||||
search(
|
||||
query: string,
|
||||
opts?: { maxResults?: number; minScore?: number; sessionKey?: string },
|
||||
opts?: {
|
||||
maxResults?: number;
|
||||
minScore?: number;
|
||||
sessionKey?: string;
|
||||
qmdSearchModeOverride?: "query" | "search" | "vsearch";
|
||||
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
|
||||
},
|
||||
): Promise<MemorySearchResult[]>;
|
||||
readFile(params: {
|
||||
relPath: string;
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
export { listMemoryFiles, normalizeExtraMemoryPaths } from "./host/internal.js";
|
||||
export { readAgentMemoryFile } from "./host/read-file.js";
|
||||
export { resolveMemoryBackendConfig } from "./host/backend-config.js";
|
||||
export type { MemorySearchManager, MemorySearchResult } from "./host/types.js";
|
||||
export type { MemorySearchManager, MemorySearchRuntimeDebug, MemorySearchResult } from "./host/types.js";
|
||||
|
||||
Reference in New Issue
Block a user