fix(FN-000): align qmd memory lifecycle
This commit is contained in:
@@ -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.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -2358,6 +2358,7 @@ export class TaskExecutor {
|
||||
agentPrompts: settings.agentPrompts,
|
||||
agentStore: this.options.agentStore,
|
||||
rootDir: this.rootDir,
|
||||
settings,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user