From 0ab1c658c29879bf350a82d1ad7ccd7374f49650 Mon Sep 17 00:00:00 2001 From: linjiaqiang Date: Tue, 24 Mar 2026 17:10:42 +0800 Subject: [PATCH 1/3] fix: memory_recall with includeFullText returns L2 content instead of L0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an agent explicitly calls memory_recall with includeFullText: true, it signals active intent to retrieve detailed memory content. Previously this still returned r.entry.text (which stores the L0 abstract), making includeFullText a no-op. Fix: resolve full content via L2 → L1 → L0 fallback chain so agents get the complete narrative when they ask for it. Also expose fullText in sanitizeMemoryForSerialization so the details.memories payload carries L2 content for callers that inspect the structured response. Co-Authored-By: Claude Sonnet 4.6 --- src/tools.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index 2761d41..ac1a519 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -102,16 +102,20 @@ function deriveManualMemoryLayer(category: string): "durable" | "working" { } function sanitizeMemoryForSerialization(results: RetrievalResult[]) { - return results.map((r) => ({ - id: r.entry.id, - text: r.entry.text, - category: getDisplayCategoryTag(r.entry), - rawCategory: r.entry.category, - scope: r.entry.scope, - importance: r.entry.importance, - score: r.score, - sources: r.sources, - })); + return results.map((r) => { + const metadata = parseSmartMetadata(r.entry.metadata, r.entry); + return { + id: r.entry.id, + text: r.entry.text, + fullText: metadata.l2_content || metadata.l1_overview || r.entry.text, + category: getDisplayCategoryTag(r.entry), + rawCategory: r.entry.category, + scope: r.entry.scope, + importance: r.entry.importance, + score: r.score, + sources: r.sources, + }; + }); } const _warnedMissingAgentId = new Set(); @@ -604,8 +608,8 @@ export function registerMemoryRecallTool( const categoryTag = getDisplayCategoryTag(r.entry); const metadata = parseSmartMetadata(r.entry.metadata, r.entry); const base = includeFullText - ? r.entry.text - : metadata.l0_abstract || r.entry.text; + ? (metadata.l2_content || metadata.l1_overview || r.entry.text) + : (metadata.l0_abstract || r.entry.text); const inline = normalizeInlineText(base); const rendered = includeFullText ? inline From 915d982c7799b70708430872e36b3e5b31d779a2 Mon Sep 17 00:00:00 2001 From: linjiaqiang Date: Wed, 25 Mar 2026 03:17:39 +0800 Subject: [PATCH 2/3] test: add regression test for memory_recall includeFullText L2 content Verify that passing includeFullText:true to memory_recall returns L2 content (not the L0 abstract) in both the rendered text output and details.memories[].fullText. Also asserts that details.memories[].text still carries L0 for backwards compatibility. Co-Authored-By: Claude Sonnet 4.6 --- test/recall-text-cleanup.test.mjs | 54 +++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/test/recall-text-cleanup.test.mjs b/test/recall-text-cleanup.test.mjs index 4badd4b..fa1d04a 100644 --- a/test/recall-text-cleanup.test.mjs +++ b/test/recall-text-cleanup.test.mjs @@ -426,6 +426,60 @@ describe("recall text cleanup", () => { assert.doesNotMatch(lines[0], /…$/, "full text mode should not force preview truncation"); }); + it("includeFullText=true renders L2 content in output, not L0 abstract", async () => { + const l0 = "short L0 abstract"; + const l2 = "Full L2 narrative: the user resolved a concurrent-write conflict by adding proper-lockfile as a write guard around all LanceDB mutation calls. Prevention: always acquire the lock before any store.add / store.update call."; + + const results = [ + { + entry: { + id: "case-1", + text: l0, + category: "fact", + scope: "global", + importance: 0.85, + timestamp: Date.now(), + metadata: stringifySmartMetadata( + buildSmartMetadata( + { text: l0, category: "fact", importance: 0.85 }, + { + l0_abstract: l0, + l1_overview: "## Conflict\n- LanceDB concurrent write resolved via proper-lockfile", + l2_content: l2, + memory_category: "cases", + fact_key: "cases:lancedb-write-conflict", + }, + ), + ), + }, + score: 0.95, + sources: { vector: { score: 0.95, rank: 1 } }, + }, + ]; + + // default (summary) mode should show L0 + const toolSummary = createTool(registerMemoryRecallTool, makeRecallContext(results)); + const resSummary = await toolSummary.execute(null, { query: "lancedb conflict" }); + const summaryLines = extractRenderedMemoryRecallLines(resSummary.content[0].text); + assert.equal(summaryLines.length, 1); + assert.match(summaryLines[0], new RegExp(l0.slice(0, 20))); + assert.doesNotMatch(summaryLines[0], /Full L2 narrative/); + + // includeFullText=true should show L2 in rendered output + const toolFull = createTool(registerMemoryRecallTool, makeRecallContext(results)); + const resFull = await toolFull.execute(null, { query: "lancedb conflict", includeFullText: true }); + const fullLines = extractRenderedMemoryRecallLines(resFull.content[0].text); + assert.equal(fullLines.length, 1); + assert.match(fullLines[0], /Full L2 narrative/, "rendered line should contain L2 content"); + assert.doesNotMatch(fullLines[0], new RegExp(`^.*\\[case-1\\].*${l0.slice(0, 15)}`), "rendered line should not be the L0 abstract"); + + // details.memories[].fullText should carry L2 + assert.equal(resFull.details.memories[0].fullText, l2, "details.memories[0].fullText should be L2 content"); + // details.memories[].text still carries L0 for backwards compatibility + assert.equal(resFull.details.memories[0].text, l0, "details.memories[0].text should still be L0 for compatibility"); + }); + + it("applies auto-recall item/char budgets before injecting context", async () => { MemoryRetriever.prototype.retrieve = async () => makeManyResults(5); From 5580454162008cc7d89380841c8764b0eaa5c59b Mon Sep 17 00:00:00 2001 From: linjiaqiang Date: Wed, 25 Mar 2026 09:41:48 +0800 Subject: [PATCH 3/3] fix: scope fullText to memory_recall handler, add opt-in and legacy tests Move fullText injection out of sanitizeMemoryForSerialization (shared helper used by all tools) and into the memory_recall execute handler, injected only when includeFullText=true. This keeps the opt-in contract clean and avoids L2 content bloating responses from other tools. Add two regression tests: - includeFullText=false: details.memories[].fullText is undefined - legacy memory (no smart metadata): fullText falls back to entry.text Co-Authored-By: Claude Sonnet 4.6 --- src/tools.ts | 35 +++++++++------- test/recall-text-cleanup.test.mjs | 66 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/tools.ts b/src/tools.ts index ac1a519..b22bd35 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -102,20 +102,16 @@ function deriveManualMemoryLayer(category: string): "durable" | "working" { } function sanitizeMemoryForSerialization(results: RetrievalResult[]) { - return results.map((r) => { - const metadata = parseSmartMetadata(r.entry.metadata, r.entry); - return { - id: r.entry.id, - text: r.entry.text, - fullText: metadata.l2_content || metadata.l1_overview || r.entry.text, - category: getDisplayCategoryTag(r.entry), - rawCategory: r.entry.category, - scope: r.entry.scope, - importance: r.entry.importance, - score: r.score, - sources: r.sources, - }; - }); + return results.map((r) => ({ + id: r.entry.id, + text: r.entry.text, + category: getDisplayCategoryTag(r.entry), + rawCategory: r.entry.category, + scope: r.entry.scope, + importance: r.entry.importance, + score: r.score, + sources: r.sources, + })); } const _warnedMissingAgentId = new Set(); @@ -618,6 +614,15 @@ export function registerMemoryRecallTool( }) .join("\n"); + const serializedMemories = sanitizeMemoryForSerialization(results); + if (includeFullText) { + for (let i = 0; i < results.length; i++) { + const metadata = parseSmartMetadata(results[i].entry.metadata, results[i].entry); + (serializedMemories[i] as Record).fullText = + metadata.l2_content || metadata.l1_overview || results[i].entry.text; + } + } + return { content: [ { @@ -627,7 +632,7 @@ export function registerMemoryRecallTool( ], details: { count: results.length, - memories: sanitizeMemoryForSerialization(results), + memories: serializedMemories, query, scopes: scopeFilter, retrievalMode: runtimeContext.retriever.getConfig().mode, diff --git a/test/recall-text-cleanup.test.mjs b/test/recall-text-cleanup.test.mjs index fa1d04a..513739e 100644 --- a/test/recall-text-cleanup.test.mjs +++ b/test/recall-text-cleanup.test.mjs @@ -479,6 +479,72 @@ describe("recall text cleanup", () => { assert.equal(resFull.details.memories[0].text, l0, "details.memories[0].text should still be L0 for compatibility"); }); + it("includeFullText=false does not expose fullText in details.memories", async () => { + const l0 = "short L0 abstract"; + const l2 = "Full L2 narrative that should not appear when includeFullText is false."; + + const results = [ + { + entry: { + id: "case-2", + text: l0, + category: "fact", + scope: "global", + importance: 0.85, + timestamp: Date.now(), + metadata: stringifySmartMetadata( + buildSmartMetadata( + { text: l0, category: "fact", importance: 0.85 }, + { + l0_abstract: l0, + l1_overview: "## Overview\n- some overview", + l2_content: l2, + memory_category: "cases", + fact_key: "cases:opt-in-check", + }, + ), + ), + }, + score: 0.9, + sources: { vector: { score: 0.9, rank: 1 } }, + }, + ]; + + const tool = createTool(registerMemoryRecallTool, makeRecallContext(results)); + const res = await tool.execute(null, { query: "opt-in check" }); + + assert.equal(res.details.memories[0].fullText, undefined, "fullText should be absent when includeFullText=false"); + assert.equal(res.details.memories[0].text, l0, "text should still carry L0"); + }); + + it("includeFullText=true falls back to entry.text for legacy memories without smart metadata", async () => { + const legacyText = "legacy memory with no smart metadata at all"; + + const results = [ + { + entry: { + id: "legacy-1", + text: legacyText, + category: "fact", + scope: "global", + importance: 0.6, + timestamp: Date.now(), + // no metadata field — simulates pre-smart-extraction records + }, + score: 0.75, + sources: { vector: { score: 0.75, rank: 1 } }, + }, + ]; + + const tool = createTool(registerMemoryRecallTool, makeRecallContext(results)); + const res = await tool.execute(null, { query: "legacy fallback", includeFullText: true }); + const lines = extractRenderedMemoryRecallLines(res.content[0].text); + + assert.equal(lines.length, 1); + assert.match(lines[0], /legacy memory with no smart metadata/, "should render entry.text as fallback for legacy memories"); + assert.equal(res.details.memories[0].fullText, legacyText, "details.memories[0].fullText should fall back to entry.text"); + }); + it("applies auto-recall item/char budgets before injecting context", async () => { MemoryRetriever.prototype.retrieve = async () => makeManyResults(5);