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
This commit is contained in:
Fusion
2026-05-02 02:35:03 -07:00
committed by gsxdsm
parent 623772629f
commit 6b75b3ac8e
5 changed files with 245 additions and 21 deletions

View File

@@ -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 () => {

View File

@@ -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[]) => {

View File

@@ -367,10 +367,43 @@ async function refreshAgentMemoryQmdIndex(rootDir: string, agentMemory: AgentMem
}
}
async function searchAgentMemoryWithQmd(rootDir: string, agentMemory: AgentMemoryContext, query: string, limit: number): Promise<MemorySearchHit[]> {
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<MemorySearchHit[]> {
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<string, unknown>) => ({
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<string, unknown>) => {
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)}`,