diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index c85ee37554a..941f384913b 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -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.", ), ]), }, diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 481f8671280..8f25eacf36b 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -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 { 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 { + 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 { diff --git a/extensions/active-memory/openclaw.plugin.json b/extensions/active-memory/openclaw.plugin.json index 34fd1c51f2c..993db463557 100644 --- a/extensions/active-memory/openclaw.plugin.json +++ b/extensions/active-memory/openclaw.plugin.json @@ -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." } } } diff --git a/extensions/memory-core/src/memory-tool-manager-mock.ts b/extensions/memory-core/src/memory-tool-manager-mock.ts index 4397a4d817a..74f8e15d800 100644 --- a/extensions/memory-core/src/memory-tool-manager-mock.ts +++ b/extensions/memory-core/src/memory-tool-manager-mock.ts @@ -1,12 +1,20 @@ import { vi } from "vitest"; +import type { MemorySearchRuntimeDebug } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; -export type SearchImpl = () => Promise; +export type SearchImpl = (opts?: { + maxResults?: number; + minScore?: number; + sessionKey?: string; + qmdSearchModeOverride?: "query" | "search" | "vsearch"; + onDebug?: (debug: MemorySearchRuntimeDebug) => void; +}) => Promise; 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 | undefined; let searchImpl: SearchImpl = async () => []; let readFileImpl: (params: MemoryReadParams) => Promise = async (params) => ({ text: "", @@ -14,7 +22,7 @@ let readFileImpl: (params: MemoryReadParams) => Promise = asyn }); const stubManager = { - search: vi.fn(async () => await searchImpl()), + search: vi.fn(async (_query: string, opts?: Parameters[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 | 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 ?? diff --git a/extensions/memory-core/src/memory/manager.ts b/extensions/memory-core/src/memory/manager.ts index 0508c3b5f09..8a173400f15 100644 --- a/extensions/memory-core/src/memory/manager.ts +++ b/extensions/memory-core/src/memory/manager.ts @@ -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 { + opts?.onDebug?.({ backend: "builtin" }); let hasIndexedContent = this.hasIndexedContent(); if (!hasIndexedContent) { try { diff --git a/extensions/memory-core/src/memory/qmd-manager.ts b/extensions/memory-core/src/memory/qmd-manager.ts index c9b900ccf7d..9c599829e35 100644 --- a/extensions/memory-core/src/memory/qmd-manager.ts +++ b/extensions/memory-core/src/memory/qmd-manager.ts @@ -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 { 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)); } diff --git a/extensions/memory-core/src/memory/search-manager.ts b/extensions/memory-core/src/memory/search-manager.ts index fa67f351619..6edad209029 100644 --- a/extensions/memory-core/src/memory/search-manager.ts +++ b/extensions/memory-core/src/memory/search-manager.ts @@ -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 { diff --git a/extensions/memory-core/src/tools.test.ts b/extensions/memory-core/src/tools.test.ts index 276cd754d83..97689736771 100644 --- a/extensions/memory-core/src/tools.test.ts +++ b/extensions/memory-core/src/tools.test.ts @@ -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), + ); + }); }); diff --git a/extensions/memory-core/src/tools.ts b/extensions/memory-core/src/tools.ts index c7b54710e82..5ca3ae82421 100644 --- a/extensions/memory-core/src/tools.ts +++ b/extensions/memory-core/src/tools.ts @@ -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 = []; 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); diff --git a/src/memory-host-sdk/engine-storage.ts b/src/memory-host-sdk/engine-storage.ts index a1dc489d6fc..c8f348903c4 100644 --- a/src/memory-host-sdk/engine-storage.ts +++ b/src/memory-host-sdk/engine-storage.ts @@ -26,6 +26,7 @@ export type { MemoryEmbeddingProbeResult, MemoryProviderStatus, MemorySearchManager, + MemorySearchRuntimeDebug, MemorySearchResult, MemorySource, MemorySyncProgressUpdate, diff --git a/src/memory-host-sdk/host/types.ts b/src/memory-host-sdk/host/types.ts index 880384df71a..8707d8b355b 100644 --- a/src/memory-host-sdk/host/types.ts +++ b/src/memory-host-sdk/host/types.ts @@ -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; readFile(params: { relPath: string; diff --git a/src/memory-host-sdk/runtime-files.ts b/src/memory-host-sdk/runtime-files.ts index 02de9c2f4bf..9dc819c5637 100644 --- a/src/memory-host-sdk/runtime-files.ts +++ b/src/memory-host-sdk/runtime-files.ts @@ -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";