mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
29cfd195d1
* 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
149 lines
5.1 KiB
TypeScript
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;
|
|
}
|
|
};
|
|
}
|