diff --git a/CHANGELOG.md b/CHANGELOG.md index f3dcb300dd2..b8812b358f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Doctor/config: restore legacy group chat config migrations for `routing.allowFrom`, `routing.groupChat.*`, and `channels.telegram.requireMention` so upgrades keep WhatsApp, Telegram, and iMessage group mention gates and history settings instead of leaving configs invalid or silently blocked. Thanks @scoootscooob. - 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. - Claude CLI: honor non-off `/think` levels by passing Claude Code's session-scoped `--effort` flag through the CLI backend seam, so chat bridges no longer show an inert thinking control. Fixes #77303. Thanks @Petr1t. diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 695c15bde84..ff6b452c36a 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -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.", @@ -2894,10 +2897,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"], diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 314c3df6490..918e4c04d81 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -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([ @@ -940,13 +941,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.", @@ -998,7 +1002,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( @@ -2056,6 +2064,83 @@ function buildQuery(params: { ].join("\n"); } +function stripExternalUntrustedBlocks(text: string): string { + return text.replace( + /<<]*>>>[\s\S]*?<<]*>>>/g, + " ", + ); +} + +function stripJsonFences(text: string): string { + return text.replace(/```(?:json)?\s*[\s\S]*?```/gi, " "); +} + +function stripActiveMemoryXmlBlocks(text: string): string { + return text.replace(/[\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; @@ -2224,6 +2309,7 @@ async function runRecallSubagent(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; modelRef?: { provider: string; model: string }; @@ -2278,6 +2364,7 @@ async function runRecallSubagent(params: { const prompt = buildRecallPrompt({ config: params.config, query: params.query, + searchQuery: params.searchQuery, }); const { messageChannel, messageProvider } = resolveRecallRunChannelContext({ api: params.api, @@ -2367,6 +2454,7 @@ async function maybeResolveActiveRecall(params: { messageProvider?: string; channelId?: string; query: string; + searchQuery: string; currentModelProviderId?: string; currentModelId?: string; }): Promise { @@ -2444,7 +2532,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)}`, ); } @@ -2813,11 +2903,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, @@ -2827,6 +2922,7 @@ export default definePluginEntry({ messageProvider: ctx.messageProvider, channelId: ctx.channelId, query, + searchQuery, currentModelProviderId: ctx.modelProviderId, currentModelId: ctx.modelId, });