fix(FN-000): align qmd memory lifecycle

This commit is contained in:
gsxdsm
2026-04-17 09:41:05 -07:00
parent 9baa29fd08
commit 2a981fc4d1
26 changed files with 388 additions and 73 deletions

View File

@@ -88,14 +88,16 @@ describe("resolveAgentInstructions", () => {
it("returns memory section when memory is set", async () => {
const agent = makeAgent({ memory: "Remember to keep CI green." });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("## Memory\n\nRemember to keep CI green.");
expect(result).toContain("## Agent Memory");
expect(result).toContain("memory for this agent only");
expect(result).toContain("Remember to keep CI green.");
});
it("omits memory section when memory is empty", async () => {
const agent = makeAgent({ instructionsText: "Base instructions", memory: " " });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Base instructions");
expect(result).not.toContain("## Memory");
expect(result).not.toContain("## Agent Memory");
});
it("returns instructionsText when set", async () => {
@@ -249,12 +251,13 @@ describe("resolveAgentInstructions", () => {
const instructionsTextIndex = result.indexOf("Inline instructions.");
const instructionsFileIndex = result.indexOf("File-based instructions here.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
const memoryIndex = result.indexOf("## Agent Memory");
expect(instructionsTextIndex).toBeLessThan(soulIndex);
expect(instructionsFileIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(memoryIndex);
expect(result).toContain("## Memory\n\nRemember that this repository uses pnpm workspaces.");
expect(result).toContain("## Agent Memory");
expect(result).toContain("Remember that this repository uses pnpm workspaces.");
});
});
@@ -311,13 +314,13 @@ describe("resolveAgentInstructions with rating summary", () => {
// Verify section order: instructionsText → soul → memory → Performance Feedback
const instructionsIndex = result.indexOf("Implement the feature.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
const memoryIndex = result.indexOf("## Agent Memory");
const feedbackIndex = result.indexOf("## Performance Feedback");
expect(instructionsIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(memoryIndex);
expect(memoryIndex).toBeLessThan(feedbackIndex);
expect(result).toContain("## Memory");
expect(result).toContain("## Agent Memory");
expect(result).toContain("## Performance Feedback");
});
@@ -522,7 +525,8 @@ describe("buildAgentChatPrompt", () => {
);
expect(prompt).toContain("Always include focused tests.");
expect(prompt).toContain("## Soul\n\nBe calm, direct, and empathetic.");
expect(prompt).toContain("## Memory\n\nThe team values short progress updates.");
expect(prompt).toContain("## Agent Memory");
expect(prompt).toContain("The team values short progress updates.");
expect(prompt).toContain("## Project Memory\n\nProject preference: avoid force pushes.");
});
});

View File

@@ -1989,23 +1989,29 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("Act like a practical teammate who prioritizes clarity.");
expect(callArgs.systemPrompt).toContain("## Memory");
expect(callArgs.systemPrompt).toContain("## Agent Memory");
expect(callArgs.systemPrompt).toContain("Recent runs found flaky tests in integration suites.");
expect(callArgs.systemPrompt).toContain("Always log blockers with actionable next steps.");
expect(callArgs.systemPrompt).toContain("## Project Memory");
expect(callArgs.systemPrompt).toContain("memory_search");
expect(callArgs.tools).toBe("readonly");
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task, heartbeat_done
expect(callArgs.customTools).toHaveLength(7);
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task,
// memory_search, memory_get, memory_append, heartbeat_done
expect(callArgs.customTools).toHaveLength(10);
expect(callArgs.customTools![0]!.name).toBe("task_create");
expect(callArgs.customTools![1]!.name).toBe("task_log");
expect(callArgs.customTools![2]!.name).toBe("task_document_write");
expect(callArgs.customTools![3]!.name).toBe("task_document_read");
expect(callArgs.customTools![4]!.name).toBe("list_agents");
expect(callArgs.customTools![5]!.name).toBe("delegate_task");
expect(callArgs.customTools![6]!.name).toBe("memory_search");
expect(callArgs.customTools![7]!.name).toBe("memory_get");
expect(callArgs.customTools![8]!.name).toBe("memory_append");
// heartbeat_done is last (terminal tool)
expect(callArgs.customTools![6]!.name).toBe("heartbeat_done");
expect(callArgs.customTools![9]!.name).toBe("heartbeat_done");
});
it("falls back to the base heartbeat prompt when agent has no custom instructions", async () => {
it("includes memory instructions even when agent has no custom instructions", async () => {
const store = createStoreWithAgentForExec({
soul: undefined,
memory: undefined,
@@ -2022,7 +2028,30 @@ describe("HeartbeatMonitor", () => {
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
expect(callArgs.systemPrompt).toBe(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Project Memory");
});
it("omits memory tools and instructions when project memory is disabled", async () => {
const store = createStoreWithAgentForExec();
const taskStore = createMockTaskStore({
getSettings: vi.fn().mockResolvedValue({ memoryEnabled: false }),
} as Partial<TaskStore>);
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
const toolNames = callArgs.customTools!.map((tool: any) => tool.name);
expect(callArgs.systemPrompt).not.toContain("## Project Memory");
expect(toolNames).not.toContain("memory_search");
expect(toolNames).not.toContain("memory_get");
expect(toolNames).not.toContain("memory_append");
});
it("includes document tools in heartbeat session", async () => {

View File

@@ -17,10 +17,11 @@
* - onTerminated: Called when an unresponsive agent is terminated
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext } from "@fusion/core";
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, BlockedStateSnapshot, RunMutationContext, Settings } from "@fusion/core";
import { buildExecutionMemoryInstructions } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, taskCreateParams } from "./agent-tools.js";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { heartbeatLog } from "./logger.js";
@@ -139,6 +140,13 @@ You have readonly file access plus task_create, task_log, and task_document tool
**Task Documents:** Save important findings with task_document_write(key="...", content="...").
Documents persist across sessions and are visible in the dashboard's Documents tab.
## Memory Boundaries
You may receive an Agent Memory section and a Project Memory section.
- Agent Memory is specific to you, including imported and user-created agents such as CEO-style coordinator agents. Use it for your durable operating preferences and role context.
- Project Memory is the workspace memory system under .fusion/memory/ with long-term memory, daily notes, dreams, and qmd-backed retrieval.
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
## Processing Messages
When you are woken by an incoming message (source includes "wake-on-message"), you should:
@@ -159,6 +167,14 @@ const heartbeatDoneParams = Type.Object({
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
});
async function getHeartbeatMemorySettings(taskStore: TaskStore): Promise<Settings | undefined> {
const maybeGetSettings = (taskStore as { getSettings?: () => Promise<Settings> }).getSettings;
if (!maybeGetSettings) {
return undefined;
}
return maybeGetSettings.call(taskStore);
}
/**
* HeartbeatMonitor monitors agents via periodic polling.
* Detects missed heartbeats, auto-terminates unresponsive agents,
@@ -926,6 +942,14 @@ export class HeartbeatMonitor {
// Build tools with task creation tracking and run context for mutation correlation
// Pass messageStore for messaging tools (send_message, read_messages)
const heartbeatTools = this.createHeartbeatTools(agentId, taskStore, taskId, runContext, audit, this.messageStore);
let memorySettings: Settings | undefined;
try {
memorySettings = await getHeartbeatMemorySettings(taskStore);
heartbeatTools.push(...createMemoryTools(rootDir, memorySettings));
} catch (memorySettingsError) {
const message = memorySettingsError instanceof Error ? memorySettingsError.message : String(memorySettingsError);
heartbeatLog.warn(`Failed to configure heartbeat memory tools for ${agentId}: ${message}`);
}
heartbeatTools.push(heartbeatDoneTool);
agentLogger = new AgentLogger({
@@ -940,7 +964,13 @@ export class HeartbeatMonitor {
let systemPrompt = HEARTBEAT_SYSTEM_PROMPT;
try {
const agentInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
systemPrompt = buildSystemPromptWithInstructions(HEARTBEAT_SYSTEM_PROMPT, agentInstructions);
const memoryInstructions = memorySettings?.memoryEnabled === false
? ""
: buildExecutionMemoryInstructions(rootDir, memorySettings);
systemPrompt = buildSystemPromptWithInstructions(
HEARTBEAT_SYSTEM_PROMPT,
[agentInstructions, memoryInstructions].filter((part) => part.trim()).join("\n\n"),
);
} catch (instructionError) {
const message = instructionError instanceof Error ? instructionError.message : String(instructionError);
heartbeatLog.warn(`Failed to enrich heartbeat system prompt for ${agentId}: ${message}`);

View File

@@ -97,7 +97,13 @@ function formatMemorySection(memory: string, agentId: string): string {
if (!trimmed) {
return "";
}
return `## Memory\n\n${trimmed}`;
return [
"## Agent Memory",
"",
"This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.",
"",
trimmed,
].join("\n");
}
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {

View File

@@ -9,7 +9,7 @@
import { appendFile } from "node:fs/promises";
import type { AgentStore, AgentState, AgentCapability, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, searchProjectMemory } from "@fusion/core";
import { dailyMemoryPath, ensureOpenClawMemoryFiles, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, resolveMemoryBackend, scheduleQmdProjectMemoryRefresh, searchProjectMemory } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentReflectionService } from "./agent-reflection.js";
@@ -361,7 +361,7 @@ export function createMemoryGetTool(rootDir: string, settings?: MemoryToolSettin
};
}
export function createMemoryAppendTool(rootDir: string): ToolDefinition {
export function createMemoryAppendTool(rootDir: string, settings?: MemoryToolSettings): ToolDefinition {
return {
name: "memory_append",
label: "Append Memory",
@@ -378,6 +378,9 @@ export function createMemoryAppendTool(rootDir: string): ToolDefinition {
}
await appendFile(targetPath, `\n${content}\n`, "utf-8");
if (resolveMemoryBackend(settings).type === "qmd") {
scheduleQmdProjectMemoryRefresh(rootDir);
}
return {
content: [{ type: "text" as const, text: `Appended to ${params.layer} memory.` }],
details: { layer: params.layer },
@@ -395,7 +398,7 @@ export function createMemoryTools(rootDir: string, settings?: MemoryToolSettings
createMemoryGetTool(rootDir, settings),
];
if (getMemoryBackendCapabilities(settings).writable) {
tools.push(createMemoryAppendTool(rootDir));
tools.push(createMemoryAppendTool(rootDir, settings));
}
return tools;
}

View File

@@ -2358,6 +2358,7 @@ export class TaskExecutor {
agentPrompts: settings.agentPrompts,
agentStore: this.options.agentStore,
rootDir: this.rootDir,
settings,
},
);

View File

@@ -215,7 +215,7 @@ describe("promptWithFallback context recovery", () => {
expect(prompt).toHaveBeenCalledTimes(2);
expect(compact).not.toHaveBeenCalled();
expect(prompts[1]!.length).toBeLessThan(prompts[0]!.length);
expect(prompts[1]).toContain("Project memory compacted");
expect(prompts[1]).toContain("Memory compacted");
expect(prompts[1]).toContain("## Begin");
});

View File

@@ -190,12 +190,12 @@ function compactMarkdownMemorySection(sectionBody: string): string {
return [
compacted,
"",
`<!-- Project memory compacted from ${sectionBody.length} characters to avoid context overflow. Read .fusion/memory.md later only if essential. -->`,
`<!-- Memory compacted from ${sectionBody.length} characters to avoid context overflow. Use memory tools or the selected memory file later only if essential. -->`,
].join("\n").trim();
}
function compactPromptMemory(prompt: string): string | null {
const sectionPattern = /(^|\n)(## (?:Project Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
const sectionPattern = /(^|\n)(## (?:Project Memory|Agent Memory|Memory)\n\n)([\s\S]*?)(?=\n## [^#]|\n# [^#]|$)/g;
let changed = false;
const compactedPrompt = prompt.replace(sectionPattern, (match, prefix: string, heading: string, body: string) => {
const trimmedBody = body.trim();

View File

@@ -146,6 +146,39 @@ describe("reviewStep — spec review type", () => {
expect(opts.systemPrompt).toContain("Mission clarity");
});
it("injects read-only memory instructions and tools when project memory is enabled", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
);
await reviewStep(
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
undefined,
{ rootDir: "/tmp/project", settings: { memoryBackendType: "qmd" } as any },
);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.systemPrompt).toContain("## Project Memory");
expect(opts.systemPrompt).toContain("Do not update memory during review");
expect(opts.customTools?.map((tool: any) => tool.name)).toEqual(["memory_search", "memory_get"]);
});
it("omits reviewer memory tools and instructions when memory is disabled", async () => {
mockedCreateHaiAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
);
await reviewStep(
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
undefined,
{ rootDir: "/tmp/project", settings: { memoryEnabled: false } as any },
);
const opts = mockedCreateHaiAgent.mock.calls[0][0];
expect(opts.systemPrompt).not.toContain("## Project Memory");
expect(opts.customTools).toBeUndefined();
});
it("builds review request with spec-specific instructions", async () => {
let capturedPrompt = "";
mockedCreateHaiAgent.mockResolvedValue({

View File

@@ -8,8 +8,8 @@
* - Verdict + feedback is returned to the worker
*/
import type { TaskStore, TaskComment, AgentPromptsConfig } from "@fusion/core";
import { resolveAgentPrompt } from "@fusion/core";
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt } from "@fusion/core";
import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { buildSessionSkillContext } from "./session-skill-context.js";
import { AgentLogger } from "./agent-logger.js";
@@ -228,6 +228,8 @@ export interface ReviewOptions {
agentStore?: import("@fusion/core").AgentStore;
/** Project root directory for resolving relative instructionsPath files. */
rootDir?: string;
/** Project settings used for backend-aware memory tools and instructions. */
settings?: Settings;
}
/**
@@ -305,8 +307,12 @@ export async function reviewStep(
// Graceful fallback
}
}
const reviewerBasePrompt = resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT;
const memorySection = options.rootDir && options.settings?.memoryEnabled !== false
? "\n" + buildReviewerMemoryInstructions(options.rootDir, options.settings)
: "";
const reviewerSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT,
reviewerBasePrompt + memorySection,
reviewerInstructions,
);
@@ -326,14 +332,17 @@ export async function reviewStep(
}
// Spawn a reviewer agent with read-only tools
const memoryTools = options.rootDir && options.settings?.memoryEnabled !== false
? [
createMemorySearchTool(options.rootDir, options.settings),
createMemoryGetTool(options.rootDir, options.settings),
]
: undefined;
const { session } = await createKbAgent({
cwd,
systemPrompt: reviewerSystemPrompt,
tools: "readonly",
customTools: options.rootDir ? [
createMemorySearchTool(options.rootDir),
createMemoryGetTool(options.rootDir),
] : undefined,
customTools: memoryTools,
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
onThinking: agentLogger?.onThinking,
onToolStart: agentLogger?.onToolStart,