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:
Tak Hoffman
2026-04-11 20:56:21 -05:00
committed by GitHub
parent 753bd39d52
commit 885209ed03
12 changed files with 523 additions and 27 deletions

View File

@@ -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.",
),
]),
},

View File

@@ -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, "&amp;")
@@ -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 {

View File

@@ -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."
}
}
}

View File

@@ -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 ??

View File

@@ -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 {

View File

@@ -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));
}

View File

@@ -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 {

View File

@@ -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),
);
});
});

View File

@@ -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);

View File

@@ -26,6 +26,7 @@ export type {
MemoryEmbeddingProbeResult,
MemoryProviderStatus,
MemorySearchManager,
MemorySearchRuntimeDebug,
MemorySearchResult,
MemorySource,
MemorySyncProgressUpdate,

View File

@@ -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;

View File

@@ -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";