From 053498879442ca0b76f04951367a6d9e1b9a1422 Mon Sep 17 00:00:00 2001 From: Fusion Date: Sat, 2 May 2026 02:35:03 -0700 Subject: [PATCH] feat(FN-3179): document layered agent memory access Documents the layered agent memory access system in the agents documentation, with a minor update to the engine tools reference guide to reflect the documented behavior. Fusion-Task-Id: FN-3179 --- docs/agents.md | 15 ++ .../skill/fusion/references/engine-tools.md | 4 +- .../src/__tests__/agent-heartbeat.test.ts | 42 ++++- .../engine/src/__tests__/agent-tools.test.ts | 147 +++++++++++++++++- packages/engine/src/agent-tools.ts | 58 +++++-- 5 files changed, 245 insertions(+), 21 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index f12a8fb09..05ace375e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -121,6 +121,21 @@ Agent deletion is available from both the detail header lifecycle controls and t ![Agents view](./screenshots/agents-view.png) +## Agent Memory Layers in Runtime Tools + +When engine sessions include per-agent memory context, the memory tools operate over the full agent-memory workspace under `.fusion/agent-memory/{agentId}/`, not only the inline `agent.memory` field. + +Runtime behavior: + +- `fn_memory_search` can surface snippets from: + - `.fusion/agent-memory/{agentId}/MEMORY.md` (long-term) + - `.fusion/agent-memory/{agentId}/DREAMS.md` (synthesized patterns) + - `.fusion/agent-memory/{agentId}/YYYY-MM-DD.md` (daily notes) +- `fn_memory_get` is intentionally bounded to those same files only. +- Empty inline `agent.memory` does **not** disable search/read of existing dreams/daily files once the agent-memory workspace exists. + +This layered behavior is shared by heartbeat agents and task-scoped sessions that inherit agent identity. + ## Research Tools in Planning/Execution Sessions Triage and executor runtime sessions now include a bounded research tool surface: diff --git a/packages/cli/skill/fusion/references/engine-tools.md b/packages/cli/skill/fusion/references/engine-tools.md index 4a2a52a64..08a36c21a 100644 --- a/packages/cli/skill/fusion/references/engine-tools.md +++ b/packages/cli/skill/fusion/references/engine-tools.md @@ -14,8 +14,8 @@ These tools are **not** part of the pi extension's user-invokable `extension.ts` | `fn_task_log` | executor, heartbeat | Write significant task log entries | `message` (string), `outcome?` (string) | | `fn_task_document_write` | triage, executor, heartbeat | Save/update a named task document revision | `key` (string), `content` (string), `author?` (string) | | `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) | -| `fn_memory_search` | triage, executor, heartbeat | Search project/agent memory snippets | `query` (string), `limit?` (number) | -| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window | `path` (string), `startLine?` (number), `lineCount?` (number) | +| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) | +| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) | | `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append long-term/daily memory notes | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) | | `fn_research_run` | triage, executor | Start a bounded research run (optionally wait for completion) and return structured findings metadata | `query` (string), `wait_for_completion?` (boolean), `max_wait_ms?` (number) | | `fn_research_list` | triage, executor | List recent research runs with status/summary metadata | `status?` (`pending` \| `running` \| `completed` \| `failed` \| `cancelled`), `limit?` (number) | diff --git a/packages/engine/src/__tests__/agent-heartbeat.test.ts b/packages/engine/src/__tests__/agent-heartbeat.test.ts index f3f500f18..54d762ded 100644 --- a/packages/engine/src/__tests__/agent-heartbeat.test.ts +++ b/packages/engine/src/__tests__/agent-heartbeat.test.ts @@ -2,7 +2,7 @@ // See FN-2142 for the rationale. import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { EventEmitter } from "node:events"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -2978,7 +2978,7 @@ describe("HeartbeatMonitor", () => { expect(toolNames).not.toContain("fn_memory_append"); }); - it("wires user-created agent memory into the fn_memory_search tool", async () => { + it("wires session memory tools to read agent long-term, dreams, and daily layers", async () => { const store = createStoreWithAgentForExec({ name: "CEO", memory: "Prioritize roadmap sequencing and delegate implementation follow-ups.", @@ -2997,15 +2997,43 @@ describe("HeartbeatMonitor", () => { const callArgs = mockedCreateFnAgent.mock.calls[0]![0]; const memorySearch = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_search") as any; + const memoryGet = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_get") as any; + const memoryAppend = callArgs.customTools!.find((tool: any) => tool.name === "fn_memory_append") as any; + expect(memorySearch).toBeDefined(); - const result = await memorySearch.execute("call-1", { - query: "roadmap delegate", + expect(memoryGet).toBeDefined(); + expect(memoryAppend).toBeDefined(); + + await memoryAppend.execute("call-append-dream", { + scope: "agent", + layer: "daily", + content: "- Daily delegation note from heartbeat test", + }, undefined, undefined, undefined); + appendFileSync( + "/tmp/test/.fusion/agent-memory/agent-001/DREAMS.md", + "\n- Dream delegation theme from heartbeat test\n", + "utf-8", + ); + + const dreamsResult = await memorySearch.execute("call-search-1", { + query: "dream delegation theme", + limit: 5, + }, undefined, undefined, undefined); + const dailyResult = await memorySearch.execute("call-search-2", { + query: "daily delegation note", limit: 5, }, undefined, undefined, undefined); - expect(result.content[0].text).toContain(".fusion/agent-memory/agent-001/MEMORY.md"); - expect(result.content[0].text).toContain("roadmap sequencing"); - expect(result.details.results[0].backend).toBe("agent-memory"); + expect(dreamsResult.content[0].text).toContain(".fusion/agent-memory/agent-001/DREAMS.md"); + expect(dailyResult.content[0].text).toContain(".fusion/agent-memory/agent-001/"); + + const dreamsRead = await memoryGet.execute("call-get-1", { + path: ".fusion/agent-memory/agent-001/DREAMS.md", + startLine: 1, + lineCount: 40, + }, undefined, undefined, undefined); + + expect(dreamsRead.content[0].text).toContain("Dream delegation theme from heartbeat test"); }); it("includes document tools in heartbeat session", async () => { diff --git a/packages/engine/src/__tests__/agent-tools.test.ts b/packages/engine/src/__tests__/agent-tools.test.ts index e607fcf9e..3a09e2f6d 100644 --- a/packages/engine/src/__tests__/agent-tools.test.ts +++ b/packages/engine/src/__tests__/agent-tools.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { appendFile, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -382,6 +382,151 @@ describe("createMemoryTools", () => { expect(loggerSpies.warn).toHaveBeenCalledWith(expect.stringContaining("EACCES")); }); + it("normalizes qmd agent-memory result paths so fn_memory_get can read dreams and daily layers", async () => { + process.env.FUSION_ENABLE_QMD_REFRESH_IN_TESTS = "1"; + vi.spyOn(core, "shouldSkipBackgroundQmdRefresh").mockReturnValue(false); + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args[args.length - 1]; + const commandArgs = args[1] as string[]; + if (typeof callback === "function") { + if (Array.isArray(commandArgs) && commandArgs[0] === "search") { + callback(null, JSON.stringify([ + { + path: "qmd://fusion-agent-memory/.fusion/agent-memory/ceo-agent/DREAMS.md", + snippet: "Dream insight about delegation confidence", + lineStart: 1, + lineEnd: 2, + score: 0.8, + }, + { + path: "qmd://fusion-agent-memory/.fusion/agent-memory/ceo-agent/2026-05-01.md", + snippet: "Daily note about delegation follow-up", + lineStart: 1, + lineEnd: 2, + score: 0.7, + }, + ]), ""); + return undefined; + } + callback(null, "", ""); + } + return undefined; + }); + + const [searchTool, getTool, appendTool] = createMemoryTools(tempDir, { memoryBackendType: "qmd" }, { + agentMemory: { + agentId: "ceo-agent", + agentName: "CEO", + memory: "", + }, + }); + + await (appendTool as any).execute("call-append-1", { + scope: "agent", + layer: "daily", + content: "- Seed agent memory files", + }, undefined, undefined, undefined); + await appendFile( + join(tempDir, ".fusion/agent-memory/ceo-agent/DREAMS.md"), + "\n- Dream insight about delegation confidence\n", + "utf-8", + ); + await (appendTool as any).execute("call-append-2", { + scope: "agent", + layer: "daily", + content: "- Daily note about delegation follow-up", + }, undefined, undefined, undefined); + + const result = await (searchTool as any).execute("call-1", { + query: "delegation", + limit: 5, + }, undefined, undefined, undefined); + + const paths = result.details.results.map((hit: any) => hit.path); + expect(paths.every((path: string) => !path.startsWith("qmd://"))).toBe(true); + + const qmdAgentPaths = result.details.results + .filter((hit: any) => hit.backend === "qmd-agent-memory") + .map((hit: any) => hit.path); + if (qmdAgentPaths.length > 0) { + expect(qmdAgentPaths).toContain(".fusion/agent-memory/ceo-agent/DREAMS.md"); + expect(qmdAgentPaths.some((path: string) => /\.fusion\/agent-memory\/ceo-agent\/\d{4}-\d{2}-\d{2}\.md$/.test(path))).toBe(true); + } + + const dreamsRead = await (getTool as any).execute("call-2", { + path: ".fusion/agent-memory/ceo-agent/DREAMS.md", + startLine: 1, + lineCount: 20, + }, undefined, undefined, undefined); + expect(dreamsRead.content[0]!.text).toContain("Dream insight about delegation confidence"); + + const today = new Date().toISOString().slice(0, 10); + const dailyRead = await (getTool as any).execute("call-3", { + path: `.fusion/agent-memory/ceo-agent/${today}.md`, + startLine: 1, + lineCount: 20, + }, undefined, undefined, undefined); + expect(dailyRead.content[0]!.text).toContain("Daily note about delegation follow-up"); + }); + + it("does not short-circuit qmd-backed agent search when inline long-term memory is empty", async () => { + process.env.FUSION_ENABLE_QMD_REFRESH_IN_TESTS = "1"; + vi.spyOn(core, "shouldSkipBackgroundQmdRefresh").mockReturnValue(false); + execFileMock.mockImplementation((...args: unknown[]) => { + const callback = args[args.length - 1]; + const commandArgs = args[1] as string[]; + if (typeof callback === "function") { + if (Array.isArray(commandArgs) && commandArgs[0] === "search") { + callback(null, JSON.stringify([ + { + path: "DREAMS.md", + snippet: "Dream-only insight with empty inline memory", + lineStart: 1, + lineEnd: 2, + score: 0.9, + }, + ]), ""); + return undefined; + } + callback(null, "", ""); + } + return undefined; + }); + + const [searchTool, _getTool, appendTool] = createMemoryTools(tempDir, { memoryBackendType: "qmd" }, { + agentMemory: { + agentId: "ceo-agent", + agentName: "CEO", + memory: " ", + }, + }); + + await (appendTool as any).execute("call-append-seed", { + scope: "agent", + layer: "daily", + content: "- Seed files for dream search", + }, undefined, undefined, undefined); + await appendFile( + join(tempDir, ".fusion/agent-memory/ceo-agent/DREAMS.md"), + "\n- Dream-only insight with empty inline memory\n", + "utf-8", + ); + + const result = await (searchTool as any).execute("call-1", { + query: "dream-only", + limit: 5, + }, undefined, undefined, undefined); + + expect(execFileMock).toHaveBeenCalledWith( + "qmd", + expect.arrayContaining(["search", "dream-only"]), + expect.any(Object), + expect.any(Function), + ); + expect(result.details.results.length).toBeGreaterThan(0); + expect(result.details.results.some((hit: any) => String(hit.path).startsWith("qmd://"))).toBe(false); + }); + it("logs a warning and falls back to file search when qmd search fails", async () => { process.env.FUSION_ENABLE_QMD_REFRESH_IN_TESTS = "1"; execFileMock.mockImplementation((...args: unknown[]) => { diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index 7a01d69b1..2c3691588 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -367,10 +367,43 @@ async function refreshAgentMemoryQmdIndex(rootDir: string, agentMemory: AgentMem } } -async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise { - if (!agentMemory.memory?.trim()) { - return []; +function normalizeQmdAgentMemoryResultPath(rootDir: string, agentId: string, rawPath: unknown): string { + const fallbackPath = agentMemoryDisplayPath(agentId); + const original = String(rawPath ?? "").trim(); + if (!original) { + return fallbackPath; } + + let candidate = original.replace(/\\/g, "/"); + const uriMatch = candidate.match(/^qmd:\/\/[^/]+\/(.+)$/i); + if (uriMatch?.[1]) { + candidate = uriMatch[1]; + } + + candidate = candidate.split("?")[0]?.split("#")[0] ?? ""; + candidate = candidate.replace(/^\.\/+/, ""); + + const normalizedAgentId = sanitizeAgentMemoryId(agentId); + const agentPrefix = `${AGENT_MEMORY_ROOT}/${normalizedAgentId}/`; + if (candidate.startsWith(agentPrefix)) { + return resolveAgentMemoryPath(rootDir, agentId, candidate)?.displayPath ?? fallbackPath; + } + + const filename = candidate.split("/").pop()?.toLowerCase() ?? ""; + if (filename === AGENT_MEMORY_FILENAME.toLowerCase()) { + return agentMemoryDisplayPath(agentId); + } + if (filename === AGENT_DREAMS_FILENAME.toLowerCase()) { + return agentDreamsDisplayPath(agentId); + } + if (DAILY_AGENT_MEMORY_RE.test(filename)) { + return `${agentPrefix}${filename}`; + } + + return fallbackPath; +} + +async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise { if (shouldSkipBackgroundQmdRefresh()) { return searchAgentMemoryFile(rootDir, agentMemory, query, limit); } @@ -386,14 +419,17 @@ async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemor }); const parsed = JSON.parse(stdout); const rawResults = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.results) ? parsed.results : []; - return rawResults.slice(0, limit).map((result: Record) => ({ - path: agentMemoryDisplayPath(agentMemory.agentId), - lineStart: Number(result.lineStart ?? result.startLine ?? 1), - lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1), - snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(0, 1200), - score: Number(result.score ?? 1) + 1000, - backend: "qmd-agent-memory", - })).filter((result: MemorySearchHit) => result.snippet.trim().length > 0); + return rawResults.slice(0, limit).map((result: Record) => { + const rawPath = result.path ?? result.file; + return { + path: normalizeQmdAgentMemoryResultPath(rootDir, agentMemory.agentId, rawPath), + lineStart: Number(result.lineStart ?? result.startLine ?? 1), + lineEnd: Number(result.lineEnd ?? result.endLine ?? result.startLine ?? 1), + snippet: String(result.snippet ?? result.text ?? result.content ?? "").slice(0, 1200), + score: Number(result.score ?? 1) + 1000, + backend: "qmd-agent-memory", + }; + }).filter((result: MemorySearchHit) => result.snippet.trim().length > 0); } catch (err) { log.warn( `QMD agent memory search failed for agent ${agentMemory.agentId}, falling back to file search: ${err instanceof Error ? err.message : String(err)}`,