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:
@@ -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 () => {
|
||||
|
||||
@@ -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[]) => {
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
Reference in New Issue
Block a user