fix(active-memory): bound recall search query

This commit is contained in:
SYU8384
2026-05-05 03:14:26 +09:00
parent 0e702f1063
commit 051bac2e2f
3 changed files with 149 additions and 5 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- fix(gateway): clamp unbound websocket auth scopes [AI]. (#77413) Thanks @pgondhi987.
- Gate zalouser startup name matching [AI]. (#77411) Thanks @pgondhi987.
- Active Memory: send a bounded latest-message search query to the recall worker so channel/runtime metadata does not become the memory search string. Fixes #65309. Thanks @joeykrug, @westley3601, @pimenov, and @tasi333.
- fix(device-pair): require pairing scope for pair command [AI]. (#76377) Thanks @pgondhi987.
- fix(qqbot): keep private commands off framework surface [AI]. (#77212) Thanks @pgondhi987.
- Memory/wiki: preserve representation from both corpora in `corpus=all` searches while backfilling unused result capacity, so memory hits are not starved by numerically higher wiki integer scores. Fixes #77337. Thanks @hclsys.

View File

@@ -1074,9 +1074,12 @@ describe("active-memory plugin", () => {
"Your job is to search memory and return only the most relevant memory context for that model.",
);
expect(runParams?.prompt).toContain(
"You receive conversation context, including the user's latest message.",
"You receive a bounded search query plus conversation context, including the user's latest message.",
);
expect(runParams?.prompt).toContain("Use only the available memory tools.");
expect(runParams?.prompt).toContain(
"Use the bounded search query as the memory_search or memory_recall query.",
);
expect(runParams?.prompt).toContain("Prefer memory_recall when available.");
expect(runParams?.prompt).toContain(
"If memory_recall is unavailable, use memory_search and memory_get.",
@@ -2867,10 +2870,54 @@ describe("active-memory plugin", () => {
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain("Bounded memory search query:\nwhat should i grab on the way?");
expect(prompt).toContain("Conversation context:\nwhat should i grab on the way?");
expect(prompt).not.toContain("Recent conversation tail:");
});
it("sends a bounded latest-message query instead of channel metadata to memory search", async () => {
api.pluginConfig = {
agents: ["main"],
queryMode: "recent",
};
plugin.register(api as unknown as OpenClawPluginApi);
await hooks.before_prompt_build(
{
prompt: [
"Conversation info:",
"Sender: discord:user-123",
"Untrusted Discord message body",
"---",
"do you remember my flight preferences?",
].join("\n"),
messages: [
{ role: "user", content: "i have a flight tomorrow" },
{ role: "assistant", content: "got it" },
],
},
{
agentId: "main",
trigger: "user",
sessionKey: "agent:main:main",
messageProvider: "webchat",
},
);
const prompt = runEmbeddedPiAgent.mock.calls.at(-1)?.[0]?.prompt;
expect(prompt).toContain(
"Bounded memory search query:\ndo you remember my flight preferences?",
);
expect(prompt).toContain(
"Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.",
);
expect(prompt).toContain("Conversation context:");
expect(prompt).toContain("Conversation info:");
expect(prompt).not.toContain("Bounded memory search query:\nConversation info:");
expect(prompt).not.toContain("Bounded memory search query:\nSender:");
expect(prompt).not.toContain("Bounded memory search query:\nUntrusted Discord message body");
});
it("supports full mode by sending the whole conversation", async () => {
api.pluginConfig = {
agents: ["main"],

View File

@@ -46,6 +46,7 @@ const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000;
const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000;
const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024;
const TIMEOUT_PARTIAL_DATA_GRACE_MS = 50;
const MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS = 480;
const TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS = 25;
const NO_RECALL_VALUES = new Set([
@@ -932,13 +933,16 @@ function buildPromptStyleLines(style: ActiveMemoryPromptStyle): string[] {
function buildRecallPrompt(params: {
config: ResolvedActiveRecallPluginConfig;
query: string;
searchQuery: string;
}): string {
const defaultInstructions = [
"You are a memory search agent.",
"Another model is preparing the final user-facing answer.",
"Your job is to search memory and return only the most relevant memory context for that model.",
"You receive conversation context, including the user's latest message.",
"You receive a bounded search query plus conversation context, including the user's latest message.",
"Use only the available memory tools.",
"Use the bounded search query as the memory_search or memory_recall query.",
"Do not use channel metadata, provider metadata, debug output, or the full conversation context as the memory tool query.",
"Prefer memory_recall when available.",
"If memory_recall is unavailable, use memory_search and memory_get.",
"When searching for preference or habit recall, use a permissive recall limit or memory_search threshold before deciding that no useful memory exists.",
@@ -990,7 +994,11 @@ function buildRecallPrompt(params: {
]
.filter((section) => section.length > 0)
.join("\n\n");
return `${instructionBlock}\n\nConversation context:\n${params.query}`;
return [
instructionBlock,
`Bounded memory search query:\n${params.searchQuery}`,
`Conversation context:\n${params.query}`,
].join("\n\n");
}
function isEnabledForAgent(
@@ -2048,6 +2056,83 @@ function buildQuery(params: {
].join("\n");
}
function stripExternalUntrustedBlocks(text: string): string {
return text.replace(
/<<<EXTERNAL_UNTRUSTED_CONTENT\b[^>]*>>>[\s\S]*?<<<END_EXTERNAL_UNTRUSTED_CONTENT\b[^>]*>>>/g,
" ",
);
}
function stripJsonFences(text: string): string {
return text.replace(/```(?:json)?\s*[\s\S]*?```/gi, " ");
}
function stripActiveMemoryXmlBlocks(text: string): string {
return text.replace(/<active_memory_plugin>[\s\S]*?<\/active_memory_plugin>/gi, " ");
}
function normalizeSearchQueryText(text: string): string {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => {
if (!line) {
return false;
}
if (/^(conversation info|sender|untrusted context)\b/i.test(line)) {
return false;
}
if (/^(source: external|---|untrusted discord message body)$/i.test(line)) {
return false;
}
if (/^⚠️?\s*Agent couldn't generate a response/i.test(line)) {
return false;
}
if (/^Please try again\.?$/i.test(line)) {
return false;
}
return true;
})
.join(" ")
.replace(/\s+/g, " ")
.trim();
}
function clampSearchQuery(text: string): string {
const normalized = text.replace(/\s+/g, " ").trim();
return normalized.length > MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS
? normalized.slice(0, MAX_ACTIVE_MEMORY_SEARCH_QUERY_CHARS).trim()
: normalized;
}
function buildSearchQuery(params: {
latestUserMessage: string;
recentTurns?: ActiveRecallRecentTurn[];
}): string {
const latest = clampSearchQuery(
normalizeSearchQueryText(
stripActiveMemoryXmlBlocks(
stripJsonFences(stripExternalUntrustedBlocks(params.latestUserMessage)),
),
),
);
if (latest.length >= 12 || !params.recentTurns?.length) {
return latest || clampSearchQuery(params.latestUserMessage);
}
const previousUser = [...params.recentTurns]
.toReversed()
.find((turn) => turn.role === "user" && turn.text.trim() !== params.latestUserMessage.trim());
if (!previousUser) {
return latest || clampSearchQuery(params.latestUserMessage);
}
const context = clampSearchQuery(
normalizeSearchQueryText(stripRecalledContextNoise(previousUser.text)),
)
.slice(0, 120)
.trim();
return clampSearchQuery(context ? `${context} ${latest}` : latest);
}
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
@@ -2216,6 +2301,7 @@ async function runRecallSubagent(params: {
messageProvider?: string;
channelId?: string;
query: string;
searchQuery: string;
currentModelProviderId?: string;
currentModelId?: string;
modelRef?: { provider: string; model: string };
@@ -2270,6 +2356,7 @@ async function runRecallSubagent(params: {
const prompt = buildRecallPrompt({
config: params.config,
query: params.query,
searchQuery: params.searchQuery,
});
const { messageChannel, messageProvider } = resolveRecallRunChannelContext({
api: params.api,
@@ -2359,6 +2446,7 @@ async function maybeResolveActiveRecall(params: {
messageProvider?: string;
channelId?: string;
query: string;
searchQuery: string;
currentModelProviderId?: string;
currentModelId?: string;
}): Promise<ActiveRecallResult> {
@@ -2436,7 +2524,9 @@ async function maybeResolveActiveRecall(params: {
if (params.config.logging) {
params.api.logger.info?.(
`${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(params.query.length)}`,
`${logPrefix} start timeoutMs=${String(params.config.timeoutMs)} queryChars=${String(
params.query.length,
)} searchQueryChars=${String(params.searchQuery.length)}`,
);
}
@@ -2805,11 +2895,16 @@ export default definePluginEntry({
});
return undefined;
}
const recentTurns = extractRecentTurns(event.messages);
const query = buildQuery({
latestUserMessage: event.prompt,
recentTurns: extractRecentTurns(event.messages),
recentTurns,
config,
});
const searchQuery = buildSearchQuery({
latestUserMessage: event.prompt,
recentTurns,
});
const result = await maybeResolveActiveRecall({
api,
config,
@@ -2819,6 +2914,7 @@ export default definePluginEntry({
messageProvider: ctx.messageProvider,
channelId: ctx.channelId,
query,
searchQuery,
currentModelProviderId: ctx.modelProviderId,
currentModelId: ctx.modelId,
});