Files
openclaw/extensions/memory-lancedb/auto-recall.ts
Josh Avant 29cfd195d1 fix(memory): honor turn tool policy during automatic recall (#126482)
* fix(memory): bind prompt recall to turn tool policy

* test(plugins): update hook contract inventory

* fix(memory): bind recall to active run lifecycle

* docs(plugins): define prompt authority contract

* test(plugins): track prompt authority type guard

* fix(plugins): revalidate prompt authority per handler
2026-08-19 19:21:51 -07:00

149 lines
5.1 KiB
TypeScript

import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import type { OpenClawPluginApi } from "./api.js";
import type { MemoryConfig } from "./config.js";
import {
type Embeddings,
isMemoryRecallTimeoutError,
MemoryRecallEmbeddingError,
runWithTimeout,
} from "./embeddings.js";
import type { MemoryDB } from "./lancedb-store.js";
import { dropMediaNoteLines } from "./memory-capture-sanitization.js";
import {
cleanMemorySearchResults,
extractLatestUserText,
formatRelevantMemoriesContext,
normalizeRecallQuery,
} from "./memory-policy.js";
const AUTO_RECALL_TIMEOUT_MS = 15_000;
const AUTO_RECALL_OVERFETCH_LIMIT = 10;
const AUTO_RECALL_RESULT_CAP = 3;
type AutoRecallToolAuthority = {
allows(toolName: string): boolean;
assertActive(): void;
};
type AutoRecallHookContext = {
agentId?: string;
toolAuthority?: AutoRecallToolAuthority;
};
type AutoRecallHookEvent = {
prompt: string;
messages: unknown[];
};
export function createAutoRecallHook(params: {
logger: OpenClawPluginApi["logger"];
db: MemoryDB;
embeddings: Embeddings;
resolveCurrentConfig: () => MemoryConfig;
resolveEnabledAgentId: (rawAgentId: string | undefined) => string | undefined;
readCooldown: (agentId: string) => { error: string } | undefined;
recordCooldown: (agentId: string, error: string) => void;
}) {
return async (event: AutoRecallHookEvent, ctx: AutoRecallHookContext) => {
const currentCfg = params.resolveCurrentConfig();
const recallMaxChars = currentCfg.recallMaxChars;
if (!currentCfg.autoRecall) {
return undefined;
}
const toolAuthority = ctx.toolAuthority;
if (!toolAuthority) {
params.logger.debug?.(
"memory-lancedb: auto-recall skipped because this prompt has no turn tool authority",
);
return undefined;
}
toolAuthority.assertActive();
if (!toolAuthority.allows("memory_recall")) {
params.logger.debug?.("memory-lancedb: auto-recall skipped by turn tool policy");
return undefined;
}
const agentId = params.resolveEnabledAgentId(ctx.agentId);
if (!agentId || !event.prompt || event.prompt.length < 5) {
return undefined;
}
// One hung embedding request must not stall both automatic and explicit recall.
// Keep the breaker per agent so unrelated memory namespaces still probe.
const cooldown = params.readCooldown(agentId);
if (cooldown) {
params.logger.debug?.(
`memory-lancedb: auto-recall skipped during recall cooldown: ${cooldown.error}`,
);
return undefined;
}
try {
const recallQuery = normalizeRecallQuery(
dropMediaNoteLines(extractLatestUserText(event.messages) ?? event.prompt),
recallMaxChars,
);
if (!recallQuery) {
return undefined;
}
let recallPhase: "embedding" | "search" = "embedding";
toolAuthority.assertActive();
const recall = await runWithTimeout({
timeoutMs: AUTO_RECALL_TIMEOUT_MS,
task: async (deadlineAtMs) => {
let vector: number[];
try {
vector = await params.embeddings.embed(
agentId,
recallQuery,
currentCfg.embedding,
Math.max(1, deadlineAtMs - Date.now()),
);
} catch (error) {
throw new MemoryRecallEmbeddingError(error);
}
toolAuthority.assertActive();
// Keep one end-to-end deadline, but only let embedding timeouts trip
// the shared breaker. LanceDB stalls remain retryable next turn.
recallPhase = "search";
return await params.db.search(agentId, vector, AUTO_RECALL_OVERFETCH_LIMIT, 0.3, {
timeoutMs: Math.max(0, deadlineAtMs - Date.now()),
});
},
});
toolAuthority.assertActive();
if (recall.status === "timeout") {
if (recallPhase === "embedding") {
params.recordCooldown(
agentId,
`auto-recall timed out after ${Math.round(AUTO_RECALL_TIMEOUT_MS / 1000)}s`,
);
}
params.logger.warn?.(
`memory-lancedb: auto-recall timed out after ${AUTO_RECALL_TIMEOUT_MS}ms; skipping memory injection to avoid stalling agent startup`,
);
return undefined;
}
const cleanResults = cleanMemorySearchResults(recall.value)
.map(({ result, text }) => ({ category: result.entry.category, text }))
.slice(0, AUTO_RECALL_RESULT_CAP);
if (cleanResults.length === 0) {
return undefined;
}
params.logger.info?.(
`memory-lancedb: injecting ${cleanResults.length} memories into context`,
);
const context = formatRelevantMemoriesContext(cleanResults, recallMaxChars);
return context ? { prependContext: context } : undefined;
} catch (err) {
if (
err instanceof MemoryRecallEmbeddingError &&
isMemoryRecallTimeoutError(err.originalError)
) {
params.recordCooldown(agentId, formatErrorMessage(err.originalError));
}
params.logger.warn(`memory-lancedb: recall failed: ${String(err)}`);
return undefined;
}
};
}