feat(FN-3718): add agent workspace memory reader and include workspace memo
Merged branch lands two features: FN-3718 wires workspace memory into the agent instruction pipeline — adding a reader helper, injecting workspace memory into agent instructions and identity snapshots, and documenting the resolution order — and FN-3716 adds planning mode priority controls for task r Fusion-Task-Id: FN-3718
This commit is contained in:
8
.changeset/FN-3718-agent-memory-lookup.md
Normal file
8
.changeset/FN-3718-agent-memory-lookup.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix agent memory lookup: the system prompt's "## Agent Memory" section and the
|
||||||
|
heartbeat Identity Snapshot now read from the on-disk agent-memory workspace
|
||||||
|
(`.fusion/agent-memory/{agentId}/MEMORY.md`) when the inline `agent.memory`
|
||||||
|
field is empty, matching the documented contract.
|
||||||
@@ -285,7 +285,11 @@ Runtime behavior:
|
|||||||
- `.fusion/agent-memory/{agentId}/DREAMS.md` (synthesized patterns)
|
- `.fusion/agent-memory/{agentId}/DREAMS.md` (synthesized patterns)
|
||||||
- `.fusion/agent-memory/{agentId}/YYYY-MM-DD.md` (daily notes)
|
- `.fusion/agent-memory/{agentId}/YYYY-MM-DD.md` (daily notes)
|
||||||
- `fn_memory_get` is intentionally bounded to those same files only.
|
- `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.
|
- Agent memory resolution order is:
|
||||||
|
1. Inline `agent.memory` (highest priority)
|
||||||
|
2. `.fusion/agent-memory/{agentId}/MEMORY.md` (fallback when inline is empty, and supplemental long-term section when inline is present)
|
||||||
|
3. Additional `.fusion/agent-memory/{agentId}/DREAMS.md` and daily files surfaced via `fn_memory_search`/`fn_memory_get`
|
||||||
|
- Empty inline `agent.memory` does **not** disable search/read of existing workspace files once the agent-memory workspace exists.
|
||||||
|
|
||||||
This layered behavior is shared by heartbeat agents and task-scoped sessions that inherit agent identity.
|
This layered behavior is shared by heartbeat agents and task-scoped sessions that inherit agent identity.
|
||||||
|
|
||||||
@@ -648,10 +652,10 @@ Heartbeat runs are composed from multiple prompt layers so each wake has full id
|
|||||||
- Inline instructions (`instructionsText`)
|
- Inline instructions (`instructionsText`)
|
||||||
- File-backed instructions (`instructionsPath`)
|
- File-backed instructions (`instructionsPath`)
|
||||||
- Soul/personality (`soul`)
|
- Soul/personality (`soul`)
|
||||||
- Agent memory (`memory`)
|
- Agent memory resolved from inline `agent.memory` first, then `.fusion/agent-memory/{agentId}/MEMORY.md` as fallback/supplement
|
||||||
- Optional project memory guidance (when memory is enabled)
|
- Optional project memory guidance (when memory is enabled)
|
||||||
3. **Execution prompt framing**
|
3. **Execution prompt framing**
|
||||||
- `Identity Snapshot` block (agent ID/role + loaded soul/instructions/memory preview)
|
- `Identity Snapshot` block (agent ID/role + loaded soul/instructions/memory preview; `memory: loaded` when either inline memory or workspace `MEMORY.md` is present)
|
||||||
- `Wake Delta` block (source, trigger detail, wake reason, assignment/comments/messages)
|
- `Wake Delta` block (source, trigger detail, wake reason, assignment/comments/messages)
|
||||||
- Heartbeat procedure block (task-scoped or no-task variant, plus optional per-agent procedure override file)
|
- Heartbeat procedure block (task-scoped or no-task variant, plus optional per-agent procedure override file)
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,60 @@ describe("resolveAgentInstructions", () => {
|
|||||||
expect(result).not.toContain("## Agent Memory");
|
expect(result).not.toContain("## Agent Memory");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses workspace MEMORY.md when inline memory is empty", async () => {
|
||||||
|
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
|
||||||
|
"\nworkspace memory content\n",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveAgentInstructions(makeAgent({ memory: "" }), testDir);
|
||||||
|
expect(result).toContain("## Agent Memory");
|
||||||
|
expect(result).toContain("workspace memory content");
|
||||||
|
expect(result).toContain("_Source: .fusion/agent-memory/agent-test/MEMORY.md_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both inline and workspace memory when both exist", async () => {
|
||||||
|
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
|
||||||
|
"workspace memory content",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveAgentInstructions(makeAgent({ memory: "inline memory content" }), testDir);
|
||||||
|
expect(result).toContain("inline memory content");
|
||||||
|
expect(result).toContain("### Long-term Workspace Memory");
|
||||||
|
expect(result).toContain("workspace memory content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads workspace memory using sanitized agent id", async () => {
|
||||||
|
const weirdId = "Agent X/1";
|
||||||
|
await mkdir(join(testDir, ".fusion", "agent-memory", "Agent-X-1"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(testDir, ".fusion", "agent-memory", "Agent-X-1", "MEMORY.md"),
|
||||||
|
"sanitized workspace memory",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveAgentInstructions(makeAgent({ id: weirdId, memory: "" }), testDir);
|
||||||
|
expect(result).toContain("sanitized workspace memory");
|
||||||
|
expect(result).toContain("_Source: .fusion/agent-memory/Agent-X-1/MEMORY.md_");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps oversized workspace memory", async () => {
|
||||||
|
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
|
||||||
|
"x".repeat(60000),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await resolveAgentInstructions(makeAgent({ memory: "" }), testDir);
|
||||||
|
expect(result).toContain("x".repeat(50000));
|
||||||
|
});
|
||||||
|
|
||||||
it("returns instructionsText when set", async () => {
|
it("returns instructionsText when set", async () => {
|
||||||
const agent = makeAgent({ instructionsText: "Always write tests." });
|
const agent = makeAgent({ instructionsText: "Always write tests." });
|
||||||
const result = await resolveAgentInstructions(agent, testDir);
|
const result = await resolveAgentInstructions(agent, testDir);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
createReadMessagesTool,
|
createReadMessagesTool,
|
||||||
createResearchTools,
|
createResearchTools,
|
||||||
qmdAgentMemoryCollectionName,
|
qmdAgentMemoryCollectionName,
|
||||||
|
readAgentMemoryWorkspaceLongTerm,
|
||||||
sendMessageParams,
|
sendMessageParams,
|
||||||
readMessagesParams,
|
readMessagesParams,
|
||||||
} from "../agent-tools.js";
|
} from "../agent-tools.js";
|
||||||
@@ -418,6 +419,45 @@ describe("createMemoryTools", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("readAgentMemoryWorkspaceLongTerm returns empty string when MEMORY.md is missing", async () => {
|
||||||
|
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "ceo-agent")).resolves.toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readAgentMemoryWorkspaceLongTerm returns trimmed MEMORY.md contents", async () => {
|
||||||
|
const tools = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
|
||||||
|
agentMemory: {
|
||||||
|
agentId: "ceo-agent",
|
||||||
|
agentName: "CEO",
|
||||||
|
memory: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const appendTool = tools.find((tool) => tool.name === "fn_memory_append")!;
|
||||||
|
await (appendTool as any).execute("call-1", {
|
||||||
|
scope: "agent",
|
||||||
|
layer: "long-term",
|
||||||
|
content: " durable memory content ",
|
||||||
|
}, undefined, undefined, undefined);
|
||||||
|
|
||||||
|
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "ceo-agent")).resolves.toContain("durable memory content");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("readAgentMemoryWorkspaceLongTerm reads sanitized agent ids", async () => {
|
||||||
|
const tools = createMemoryTools(tempDir, { memoryBackendType: "file" }, {
|
||||||
|
agentMemory: {
|
||||||
|
agentId: "Agent X/1",
|
||||||
|
agentName: "CEO",
|
||||||
|
memory: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const appendTool = tools.find((tool) => tool.name === "fn_memory_append")!;
|
||||||
|
await (appendTool as any).execute("call-1", {
|
||||||
|
scope: "agent",
|
||||||
|
layer: "long-term",
|
||||||
|
content: "- sanitized id memory",
|
||||||
|
}, undefined, undefined, undefined);
|
||||||
|
|
||||||
|
await expect(readAgentMemoryWorkspaceLongTerm(tempDir, "Agent X/1")).resolves.toContain("sanitized id memory");
|
||||||
|
});
|
||||||
|
|
||||||
it("logs a warning and continues when agent memory directory read fails", async () => {
|
it("logs a warning and continues when agent memory directory read fails", async () => {
|
||||||
readdirMock.mockRejectedValueOnce(new Error("EACCES"));
|
readdirMock.mockRejectedValueOnce(new Error("EACCES"));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { appendFileSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import {
|
import {
|
||||||
@@ -1881,6 +1881,41 @@ describe("executeHeartbeat", () => {
|
|||||||
expect(callArgs.customTools![13]!.name).toBe("fn_heartbeat_done");
|
expect(callArgs.customTools![13]!.name).toBe("fn_heartbeat_done");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads workspace memory into system prompt and identity snapshot when inline memory is empty", async () => {
|
||||||
|
const rootDir = mkdtempSync(join(tmpdir(), "heartbeat-workspace-memory-"));
|
||||||
|
mkdirSync(join(rootDir, ".fusion", "agent-memory", "agent-001"), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(rootDir, ".fusion", "agent-memory", "agent-001", "MEMORY.md"),
|
||||||
|
"workspace memory for heartbeat",
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const store = createStoreWithAgentForExec({
|
||||||
|
memory: "",
|
||||||
|
instructionsText: undefined,
|
||||||
|
instructionsPath: undefined,
|
||||||
|
});
|
||||||
|
const mockSession = createMockAgentSession();
|
||||||
|
mockedCreateFnAgent.mockResolvedValue({
|
||||||
|
session: mockSession as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir });
|
||||||
|
|
||||||
|
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||||
|
|
||||||
|
const callArgs = mockedCreateFnAgent.mock.calls[0]![0];
|
||||||
|
expect(callArgs.systemPrompt).toContain("## Agent Memory");
|
||||||
|
expect(callArgs.systemPrompt).toContain("workspace memory for heartbeat");
|
||||||
|
|
||||||
|
const executionPrompt = mockSession.prompt.mock.calls.at(-1)?.[0] as string;
|
||||||
|
expect(executionPrompt).toMatch(/- memory: loaded \(\d+ chars, sha256:[a-f0-9]{8}, source: workspace\)/);
|
||||||
|
} finally {
|
||||||
|
rmSync(rootDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("includes memory instructions even when agent has no custom instructions", async () => {
|
it("includes memory instructions even when agent has no custom instructions", async () => {
|
||||||
const store = createStoreWithAgentForExec({
|
const store = createStoreWithAgentForExec({
|
||||||
soul: undefined,
|
soul: undefined,
|
||||||
|
|||||||
@@ -637,7 +637,7 @@ describe("HeartbeatMonitor observability — prompt persistence + run-scoped log
|
|||||||
expect(exec).toContain("## Identity Snapshot");
|
expect(exec).toContain("## Identity Snapshot");
|
||||||
expect(exec).toContain("- agentId: agent-001");
|
expect(exec).toContain("- agentId: agent-001");
|
||||||
expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
|
expect(exec).toMatch(/- soul: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
|
||||||
expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}\)/);
|
expect(exec).toMatch(/- memory: loaded \(\d+ chars, sha256:[0-9a-f]{8}(, source: (inline|workspace))?\)/);
|
||||||
// Snapshot must NOT contain full preview content (that lives in the system prompt)
|
// Snapshot must NOT contain full preview content (that lives in the system prompt)
|
||||||
expect(exec).not.toContain("I keep momentum across stalled tasks.");
|
expect(exec).not.toContain("I keep momentum across stalled tasks.");
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgen
|
|||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, taskCreateParams } from "./agent-tools.js";
|
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createGetAgentConfigTool, createUpdateAgentConfigTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, createReadEvaluationsTool, createUpdateIdentityTool, createReflectOnPerformanceTool, readAgentMemoryWorkspaceLongTerm, taskCreateParams } from "./agent-tools.js";
|
||||||
import { AgentLogger } from "./agent-logger.js";
|
import { AgentLogger } from "./agent-logger.js";
|
||||||
import {
|
import {
|
||||||
resolveAgentInstructionsWithRatings,
|
resolveAgentInstructionsWithRatings,
|
||||||
@@ -490,16 +490,21 @@ function shortContentHash(value: string): string {
|
|||||||
function buildIdentitySnapshot(args: {
|
function buildIdentitySnapshot(args: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
resolvedInstructions: string;
|
resolvedInstructions: string;
|
||||||
|
workspaceMemory: string;
|
||||||
}): string {
|
}): string {
|
||||||
const { agent, resolvedInstructions } = args;
|
const { agent, resolvedInstructions, workspaceMemory } = args;
|
||||||
|
|
||||||
const soulTrimmed = typeof agent.soul === "string" ? agent.soul.trim() : "";
|
const soulTrimmed = typeof agent.soul === "string" ? agent.soul.trim() : "";
|
||||||
const instrTrimmed = resolvedInstructions.trim();
|
const instrTrimmed = resolvedInstructions.trim();
|
||||||
const memTrimmed = typeof agent.memory === "string" ? agent.memory.trim() : "";
|
const inlineMemoryTrimmed = typeof agent.memory === "string" ? agent.memory.trim() : "";
|
||||||
|
const workspaceMemoryTrimmed = workspaceMemory.trim();
|
||||||
|
const memorySource = inlineMemoryTrimmed ? "inline" : workspaceMemoryTrimmed ? "workspace" : null;
|
||||||
|
const memTrimmed = inlineMemoryTrimmed || workspaceMemoryTrimmed;
|
||||||
|
|
||||||
const formatField = (trimmed: string): string => {
|
const formatField = (trimmed: string, source?: "inline" | "workspace"): string => {
|
||||||
if (!trimmed) return "absent";
|
if (!trimmed) return "absent";
|
||||||
return `loaded (${trimmed.length} chars, sha256:${shortContentHash(trimmed)})`;
|
const sourceLabel = source ? `, source: ${source}` : "";
|
||||||
|
return `loaded (${trimmed.length} chars, sha256:${shortContentHash(trimmed)}${sourceLabel})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -512,7 +517,7 @@ function buildIdentitySnapshot(args: {
|
|||||||
`- role: ${agent.role}`,
|
`- role: ${agent.role}`,
|
||||||
`- soul: ${formatField(soulTrimmed)}`,
|
`- soul: ${formatField(soulTrimmed)}`,
|
||||||
`- instructions: ${formatField(instrTrimmed)}`,
|
`- instructions: ${formatField(instrTrimmed)}`,
|
||||||
`- memory: ${formatField(memTrimmed)}`,
|
`- memory: ${formatField(memTrimmed, memorySource ?? undefined)}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1721,6 +1726,7 @@ export class HeartbeatMonitor {
|
|||||||
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
|
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
|
||||||
: HEARTBEAT_SYSTEM_PROMPT;
|
: HEARTBEAT_SYSTEM_PROMPT;
|
||||||
let resolvedInstructionsForIdentity = "";
|
let resolvedInstructionsForIdentity = "";
|
||||||
|
let workspaceMemoryForIdentity = "";
|
||||||
try {
|
try {
|
||||||
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
|
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
|
||||||
} catch (instructionError) {
|
} catch (instructionError) {
|
||||||
@@ -1728,6 +1734,13 @@ export class HeartbeatMonitor {
|
|||||||
heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`);
|
heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
workspaceMemoryForIdentity = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id);
|
||||||
|
} catch (memoryReadErr) {
|
||||||
|
const message = memoryReadErr instanceof Error ? memoryReadErr.message : String(memoryReadErr);
|
||||||
|
heartbeatLog.warn(`Failed to resolve workspace memory for heartbeat ${agentId}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
let memoryInstructions = "";
|
let memoryInstructions = "";
|
||||||
if (memorySettings?.memoryEnabled !== false) {
|
if (memorySettings?.memoryEnabled !== false) {
|
||||||
try {
|
try {
|
||||||
@@ -1895,7 +1908,11 @@ export class HeartbeatMonitor {
|
|||||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||||
"",
|
"",
|
||||||
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }),
|
buildIdentitySnapshot({
|
||||||
|
agent,
|
||||||
|
resolvedInstructions: resolvedInstructionsForIdentity,
|
||||||
|
workspaceMemory: workspaceMemoryForIdentity,
|
||||||
|
}),
|
||||||
"",
|
"",
|
||||||
"## Wake Delta",
|
"## Wake Delta",
|
||||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||||
@@ -2003,7 +2020,11 @@ export class HeartbeatMonitor {
|
|||||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||||
`Assigned task: ${taskId} — ${taskTitle}`,
|
`Assigned task: ${taskId} — ${taskTitle}`,
|
||||||
"",
|
"",
|
||||||
buildIdentitySnapshot({ agent, resolvedInstructions: resolvedInstructionsForIdentity }),
|
buildIdentitySnapshot({
|
||||||
|
agent,
|
||||||
|
resolvedInstructions: resolvedInstructionsForIdentity,
|
||||||
|
workspaceMemory: workspaceMemoryForIdentity,
|
||||||
|
}),
|
||||||
"",
|
"",
|
||||||
"## Wake Delta",
|
"## Wake Delta",
|
||||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { PluginRunner } from "./plugin-runner.js";
|
import type { PluginRunner } from "./plugin-runner.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
|
import { readAgentMemoryWorkspaceLongTerm } from "./agent-tools.js";
|
||||||
|
|
||||||
const log = createLogger("agent-instructions");
|
const log = createLogger("agent-instructions");
|
||||||
|
|
||||||
@@ -182,18 +183,39 @@ function formatSoulSection(soul: string, agentId: string): string {
|
|||||||
return `## Soul\n\n${trimmed}`;
|
return `## Soul\n\n${trimmed}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMemorySection(memory: string, agentId: string): string {
|
function memoryWorkspaceDisplayPath(agentId: string): string {
|
||||||
const trimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId);
|
const safeAgentId = agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
||||||
if (!trimmed) {
|
return `.fusion/agent-memory/${safeAgentId}/MEMORY.md`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemorySection(memory: string, workspaceMemory: string, agentId: string): string {
|
||||||
|
const inlineTrimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId);
|
||||||
|
const workspaceTrimmed = trimAndClamp(workspaceMemory, MAX_MEMORY_LENGTH, "workspace memory", agentId);
|
||||||
|
if (!inlineTrimmed && !workspaceTrimmed) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
return [
|
|
||||||
|
const lines = [
|
||||||
"## Agent Memory",
|
"## 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.",
|
"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.",
|
||||||
|
"Additional daily/dream files under .fusion/agent-memory/{agentId}/ are searchable with fn_memory_search.",
|
||||||
"",
|
"",
|
||||||
trimmed,
|
];
|
||||||
].join("\n");
|
|
||||||
|
if (inlineTrimmed) {
|
||||||
|
lines.push(inlineTrimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaceTrimmed) {
|
||||||
|
if (!inlineTrimmed) {
|
||||||
|
lines.push(`_Source: ${memoryWorkspaceDisplayPath(agentId)}_`, "", workspaceTrimmed);
|
||||||
|
} else {
|
||||||
|
lines.push("", "### Long-term Workspace Memory", "", workspaceTrimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {
|
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {
|
||||||
@@ -293,11 +315,10 @@ export async function resolveAgentInstructions(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (agent.memory?.trim()) {
|
const workspaceMemory = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id);
|
||||||
const memorySection = formatMemorySection(agent.memory, agent.id);
|
const memorySection = formatMemorySection(agent.memory ?? "", workspaceMemory, agent.id);
|
||||||
if (memorySection) {
|
if (memorySection) {
|
||||||
parts.push(memorySection);
|
parts.push(memorySection);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ratingSummary && ratingSummary.totalRatings > 0) {
|
if (ratingSummary && ratingSummary.totalRatings > 0) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { ResearchStepRunner } from "./research-step-runner.js";
|
|||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||||
import type { AgentReflectionService } from "./agent-reflection.js";
|
import type { AgentReflectionService } from "./agent-reflection.js";
|
||||||
import { MAX_INSTRUCTIONS_TEXT_LENGTH, MAX_MEMORY_LENGTH, MAX_SOUL_LENGTH } from "./agent-instructions.js";
|
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
|
|
||||||
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
// ── Tool parameter schemas (canonical definitions) ────────────────────────
|
||||||
@@ -194,6 +193,10 @@ type MemorySearchHit = {
|
|||||||
|
|
||||||
const log = createLogger("agent-tools");
|
const log = createLogger("agent-tools");
|
||||||
|
|
||||||
|
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||||
|
const MAX_MEMORY_LENGTH = 50_000;
|
||||||
|
const MAX_SOUL_LENGTH = 10_000;
|
||||||
|
|
||||||
const AGENT_MEMORY_ROOT = ".fusion/agent-memory";
|
const AGENT_MEMORY_ROOT = ".fusion/agent-memory";
|
||||||
const AGENT_MEMORY_FILENAME = "MEMORY.md";
|
const AGENT_MEMORY_FILENAME = "MEMORY.md";
|
||||||
const AGENT_DREAMS_FILENAME = "DREAMS.md";
|
const AGENT_DREAMS_FILENAME = "DREAMS.md";
|
||||||
@@ -201,11 +204,11 @@ const agentQmdRefreshState = new Map<string, { lastStartedAt: number; inFlight?:
|
|||||||
const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
const AGENT_QMD_REFRESH_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
const DAILY_AGENT_MEMORY_RE = /^\d{4}-\d{2}-\d{2}\.md$/;
|
||||||
|
|
||||||
function sanitizeAgentMemoryId(agentId: string): string {
|
export function sanitizeAgentMemoryId(agentId: string): string {
|
||||||
return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
return agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent";
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentMemoryDisplayPath(agentId: string): string {
|
export function agentMemoryDisplayPath(agentId: string): string {
|
||||||
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`;
|
return `${AGENT_MEMORY_ROOT}/${sanitizeAgentMemoryId(agentId)}/${AGENT_MEMORY_FILENAME}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +220,7 @@ function agentMemoryDirectory(rootDir: string, agentId: string): string {
|
|||||||
return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId));
|
return join(rootDir, AGENT_MEMORY_ROOT, sanitizeAgentMemoryId(agentId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function agentMemoryFilePath(rootDir: string, agentId: string): string {
|
export function agentMemoryFilePath(rootDir: string, agentId: string): string {
|
||||||
return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME);
|
return join(agentMemoryDirectory(rootDir, agentId), AGENT_MEMORY_FILENAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +232,26 @@ function agentDailyFilePath(rootDir: string, agentId: string, date = new Date())
|
|||||||
return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`);
|
return join(agentMemoryDirectory(rootDir, agentId), `${date.toISOString().slice(0, 10)}.md`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readAgentMemoryWorkspaceLongTerm(rootDir: string, agentId: string): Promise<string> {
|
||||||
|
const safeRoot = typeof rootDir === "string" ? rootDir.trim() : "";
|
||||||
|
const safeAgentId = typeof agentId === "string" ? agentId.trim() : "";
|
||||||
|
if (!safeRoot || !safeAgentId) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = agentMemoryFilePath(safeRoot, safeAgentId);
|
||||||
|
try {
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
if (!fileStat.isFile()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
return typeof content === "string" ? content.trim() : "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string {
|
export function qmdAgentMemoryCollectionName(rootDir: string, agentId: string): string {
|
||||||
const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12);
|
const hash = createHash("sha1").update(`${rootDir}:${agentId}`).digest("hex").slice(0, 12);
|
||||||
return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`;
|
return `fusion-agent-memory-${sanitizeAgentMemoryId(agentId).toLowerCase()}-${hash}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user