feat(FN-4393): complete Step 3 — branch memory inclusion by mode

Fusion-Task-Id: FN-4393
Fusion-Task-Lineage: 3615215d-9caa-4402-9258-a5a5de137dd6
This commit is contained in:
Fusion
2026-05-13 23:16:47 -07:00
committed by gsxdsm
parent 96b6d974e9
commit fbec3e4d2d
6 changed files with 121 additions and 24 deletions

View File

@@ -422,6 +422,26 @@ describe("resolveAgentInstructions with rating summary", () => {
expect(result).not.toContain("Should be trimmed");
});
it("renders index-mode memory instead of full body", async () => {
await mkdir(join(testDir, ".fusion", "agent-memory", "agent-test"), { recursive: true });
await writeFile(
join(testDir, ".fusion", "agent-memory", "agent-test", "MEMORY.md"),
"## Delegation\n\nUse concise asks\n",
"utf-8",
);
const result = await resolveAgentInstructions(makeAgent({ memory: "inline memory" }), testDir, undefined, "index");
expect(result).toContain("Memory is provided in index mode");
expect(result).toContain("## Agent Memory Index (use fn_memory_search / fn_memory_get to read)");
expect(result).not.toContain("inline memory");
});
it("omits memory section in off mode", async () => {
const result = await resolveAgentInstructions(makeAgent({ memory: "inline memory" }), testDir, undefined, "off");
expect(result).not.toContain("## Agent Memory");
expect(result).not.toContain("inline memory");
});
it("omits category breakdown when category averages are empty", async () => {
const result = await resolveAgentInstructions(
makeAgent({ instructionsText: "Do work" }),

View File

@@ -17,8 +17,8 @@
* - onTerminated: Called when a heartbeat run is terminated
*/
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage } from "@fusion/core";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog } from "@fusion/core";
import type { AgentStore, AgentHeartbeatRun, HeartbeatInvocationSource, AgentHeartbeatConfig, AgentBudgetStatus, Message, MessageStore, TaskStore, TaskDetail, AgentRole, Agent, InboxTask, RunMutationContext, Settings, AgentConfigRevision, ReflectionStore, ChatStore, ChatRoom, ChatRoomMessage, AgentMemoryInclusionMode } from "@fusion/core";
import { ApprovalRequestStore, buildExecutionMemoryInstructions, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, canAgentTakeImplementationTask, canAgentTakeImplementationTaskForExplicitRouting, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHash } from "node:crypto";
@@ -39,6 +39,16 @@ import type { AgentActionGateContext } from "./agent-action-gate.js";
import { buildSessionSkillContextSync } from "./session-skill-context.js";
import type { AgentReflectionService } from "./agent-reflection.js";
function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclusionMode): string {
if (mode === "full") return basePrompt;
const memoryPrimer = /\nYou may receive an Agent Memory section and a Project Memory section\.[\s\S]*?repository pitfalls\.\n/;
if (mode === "off") return basePrompt.replace(memoryPrimer, "\n");
return basePrompt.replace(
memoryPrimer,
"\nWhen an Agent Memory Index is provided instead of full memory, call fn_memory_search first for task-relevant context. Use fn_memory_get to open only relevant snippets.\n",
);
}
interface SelfImproveServiceLike {
shouldRunSelfImprove(agentId: string): Promise<boolean>;
getSelfImprovePrompt(agentId: string): Promise<string>;
@@ -1968,13 +1978,19 @@ export class HeartbeatMonitor {
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir, this.pluginRunner);
const baseHeartbeatSystemPrompt = isNoTaskRun
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
: HEARTBEAT_SYSTEM_PROMPT;
const resolvedMemoryMode = resolveAgentMemoryInclusionMode({
agent,
projectSettings: memorySettings,
});
const priorMemoryMode = agent.runtimeConfig?.lastAgentMemoryInclusionMode;
const baseHeartbeatSystemPrompt = adjustHeartbeatMemoryPrimer(
isNoTaskRun ? HEARTBEAT_NO_TASK_SYSTEM_PROMPT : HEARTBEAT_SYSTEM_PROMPT,
resolvedMemoryMode.mode,
);
let resolvedInstructionsForIdentity = "";
let workspaceMemoryForIdentity = "";
try {
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
resolvedInstructionsForIdentity = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store, resolvedMemoryMode.mode);
} catch (instructionError) {
const message = instructionError instanceof Error ? instructionError.message : String(instructionError);
heartbeatLog.warn(`Failed to resolve agent instructions for heartbeat ${agentId}: ${message}`);
@@ -1988,9 +2004,11 @@ export class HeartbeatMonitor {
}
let memoryInstructions = "";
if (memorySettings?.memoryEnabled !== false) {
if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) {
try {
memoryInstructions = buildExecutionMemoryInstructions(rootDir, memorySettings);
memoryInstructions = resolvedMemoryMode.mode === "index"
? "## Project Memory (Index Only)\n\nProject memory is available via fn_memory_search and fn_memory_get. Search first, then fetch only relevant excerpts."
: buildExecutionMemoryInstructions(rootDir, memorySettings);
} catch (memoryInstructionErr) {
const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr);
heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`);
@@ -2028,6 +2046,20 @@ export class HeartbeatMonitor {
const systemPromptFinal = collapsePromptLayers(heartbeatLayers);
if (priorMemoryMode !== resolvedMemoryMode.mode) {
const from = priorMemoryMode ? priorMemoryMode : "(initial)";
try {
await this.store.appendRunLog(agentId, run.id, {
timestamp: new Date().toISOString(),
taskId: taskId ?? run.taskId ?? "heartbeat",
type: "text",
text: `Agent memory inclusion mode: ${from}${resolvedMemoryMode.mode} (source: ${resolvedMemoryMode.source})`,
});
} catch (modeLogError) {
heartbeatLog.warn(`Failed to append memory-mode transition run log for ${agentId}: ${modeLogError instanceof Error ? modeLogError.message : String(modeLogError)}`);
}
}
// fn_heartbeat_done must be the last tool in the array (stable terminal signal)
heartbeatTools.push(heartbeatDoneTool);

View File

@@ -4,6 +4,7 @@ import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:pat
import {
readProjectMemory,
type Agent,
type AgentMemoryInclusionMode,
type AgentRatingSummary,
type AgentStore,
type PluginPromptSurface,
@@ -11,6 +12,7 @@ import {
import type { PluginRunner } from "./plugin-runner.js";
import { createLogger } from "./logger.js";
import { readAgentMemoryWorkspaceLongTerm } from "./agent-tools.js";
import { buildMemoryIndex } from "./agent-memory-index.js";
const log = createLogger("agent-instructions");
@@ -188,7 +190,31 @@ function memoryWorkspaceDisplayPath(agentId: string): string {
return `.fusion/agent-memory/${safeAgentId}/MEMORY.md`;
}
function formatMemorySection(memory: string, workspaceMemory: string, agentId: string): string {
async function formatMemorySection(
memory: string,
workspaceMemory: string,
agentId: string,
rootDir: string,
inclusionMode: AgentMemoryInclusionMode,
): Promise<string> {
if (inclusionMode === "off") {
return "";
}
if (inclusionMode === "index") {
const indexBody = await buildMemoryIndex({ rootDir, agentId });
if (!indexBody) {
return "";
}
return [
"## Agent Memory",
"",
"Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.",
"",
indexBody,
].join("\n");
}
const inlineTrimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId);
const workspaceTrimmed = trimAndClamp(workspaceMemory, MAX_MEMORY_LENGTH, "workspace memory", agentId);
if (!inlineTrimmed && !workspaceTrimmed) {
@@ -260,6 +286,7 @@ export async function resolveAgentInstructions(
agent: Agent | null | undefined,
rootDir: string,
ratingSummary?: AgentRatingSummary,
inclusionMode: AgentMemoryInclusionMode = "full",
): Promise<string> {
if (!agent) return "";
@@ -316,7 +343,13 @@ export async function resolveAgentInstructions(
}
const workspaceMemory = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id);
const memorySection = formatMemorySection(agent.memory ?? "", workspaceMemory, agent.id);
const memorySection = await formatMemorySection(
agent.memory ?? "",
workspaceMemory,
agent.id,
rootDir,
inclusionMode,
);
if (memorySection) {
parts.push(memorySection);
}
@@ -336,12 +369,13 @@ export async function resolveAgentInstructionsWithRatings(
agent: Agent | null | undefined,
rootDir: string,
agentStore: AgentStore | undefined,
inclusionMode: AgentMemoryInclusionMode = "full",
): Promise<string> {
if (!agent) {
return "";
}
const baseInstructions = await resolveAgentInstructions(agent, rootDir);
const baseInstructions = await resolveAgentInstructions(agent, rootDir, undefined, inclusionMode);
if (!agentStore || !agent.id) {
return baseInstructions;
@@ -349,7 +383,7 @@ export async function resolveAgentInstructionsWithRatings(
try {
const ratingSummary = await agentStore.getRatingSummary(agent.id);
return await resolveAgentInstructions(agent, rootDir, ratingSummary);
return await resolveAgentInstructions(agent, rootDir, ratingSummary, inclusionMode);
} catch {
return baseInstructions;
}

View File

@@ -5,7 +5,7 @@ const execAsync = promisify(exec);
import { delimiter, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
import { existsSync, realpathSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent } from "@fusion/core";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode } from "@fusion/core";
import {
ApprovalRequestStore,
buildExecutionMemoryInstructions,
@@ -15,6 +15,7 @@ import {
resolvePersistAgentThinkingLog,
resolveEffectiveAgentPermissionPolicy,
resolveProjectDefaultModel,
resolveAgentMemoryInclusionMode,
type RunCommandResult,
} from "@fusion/core";
import { findWorktreeUser } from "./merger.js";
@@ -2314,7 +2315,7 @@ export class TaskExecutor {
* in the AgentStore that have instructions configured.
* Returns an empty string if no instructions are found.
*/
private async resolveInstructionsForRole(role: string): Promise<string> {
private async resolveInstructionsForRole(role: string, settings?: Settings): Promise<string> {
if (!this.options.agentStore) return "";
try {
const agents = await this.options.agentStore.listAgents({ role: role as AgentCapability });
@@ -2322,11 +2323,13 @@ export class TaskExecutor {
if (agent.instructionsText || agent.instructionsPath) {
try {
const ratingSummary = await this.options.agentStore.getRatingSummary(agent.id);
return await resolveAgentInstructions(agent, this.rootDir, ratingSummary);
const mode = resolveAgentMemoryInclusionMode({ agent, projectSettings: settings }).mode;
return await resolveAgentInstructions(agent, this.rootDir, ratingSummary, mode);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
executorLog.warn(`${agent.id}: failed to load rating summary for instruction resolution, falling back to default instructions: ${msg}`);
return await resolveAgentInstructions(agent, this.rootDir);
const mode = resolveAgentMemoryInclusionMode({ agent, projectSettings: settings }).mode;
return await resolveAgentInstructions(agent, this.rootDir, undefined, mode);
}
}
}
@@ -3241,7 +3244,7 @@ export class TaskExecutor {
executorLog.log(`${task.id}: creating agent session (provider=${executorProvider ?? "default"}, model=${executorModelId ?? "default"}, resuming=${isResuming})`);
// Resolve per-agent custom instructions for the executor role
const executorInstructions = await this.resolveInstructionsForRole("executor");
const executorInstructions = await this.resolveInstructionsForRole("executor", settings);
// Build structured layers for cross-session prompt caching.
const executorPluginContributions = buildPluginPromptSection(
@@ -6411,7 +6414,7 @@ and show an appropriate message to the user.\`
attemptLabel: string,
): Promise<WorkflowStepOutcome> => {
// Workflow step agents inherit executor instructions
const stepInstructions = await this.resolveInstructionsForRole("executor");
const stepInstructions = await this.resolveInstructionsForRole("executor", settings);
const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions);
// Build skill selection context for workflow step session
@@ -8156,7 +8159,7 @@ and show an appropriate message to the user.\`
await this.options.agentStore.updateAgentState(agent.id, "active");
// Child agents inherit executor instructions
const childInstructions = await this.resolveInstructionsForRole("executor");
const childInstructions = await this.resolveInstructionsForRole("executor", settings);
const childBasePrompt = `You are a child agent spawned by a parent task executor.
Your role:
@@ -8373,9 +8376,12 @@ git log --oneline
// When enabled, agents consult and update project memory for durable project learnings.
// Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd)
const memoryEnabled = settings?.memoryEnabled !== false;
const memoryMode: AgentMemoryInclusionMode = settings?.agentMemoryInclusionMode ?? "full";
let memorySection = "";
if (memoryEnabled && rootDir) {
memorySection = "\n" + buildExecutionMemoryInstructions(rootDir, settings);
if (memoryEnabled && rootDir && memoryMode !== "off") {
memorySection = memoryMode === "index"
? "\n## Project Memory (Index Only)\n\nUse fn_memory_search first to find relevant memory, then fn_memory_get for specific excerpts.\n"
: "\n" + buildExecutionMemoryInstructions(rootDir, settings);
}
// Build steering comments section (last 10 comments only to avoid context bloat)

View File

@@ -9,7 +9,7 @@
*/
import type { TaskStore, TaskComment, AgentPromptsConfig, Settings } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt, resolvePersistAgentThinkingLog } from "@fusion/core";
import { buildReviewerMemoryInstructions, resolveAgentPrompt, resolvePersistAgentThinkingLog, resolveAgentMemoryInclusionMode } from "@fusion/core";
import { describeModel, promptWithFallback } from "./pi.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { createResolvedAgentSession, extractRuntimeHint } from "./agent-session-helpers.js";
@@ -387,7 +387,8 @@ export async function reviewStep(
const agents = await options.agentStore.listAgents({ role: "reviewer" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
reviewerInstructions = await resolveAgentInstructions(agent, options.rootDir);
const memoryMode = resolveAgentMemoryInclusionMode({ agent, projectSettings: options.settings }).mode;
reviewerInstructions = await resolveAgentInstructions(agent, options.rootDir, undefined, memoryMode);
break;
}
}

View File

@@ -11,6 +11,7 @@ import {
resolveAgentPrompt,
resolvePersistAgentThinkingLog,
sortTasksByPriorityThenAgeAndId,
resolveAgentMemoryInclusionMode,
} from "@fusion/core";
import type { ImageContent } from "@mariozechner/pi-ai";
import { Type, type Static } from "@mariozechner/pi-ai";
@@ -1008,10 +1009,12 @@ export class TriageProcessor {
// Resolve per-agent custom instructions for the triage role or assigned agent.
let triageInstructions = "";
if (assignedAgent) {
const memoryMode = resolveAgentMemoryInclusionMode({ agent: assignedAgent, projectSettings: settings }).mode;
triageInstructions = await resolveAgentInstructionsWithRatings(
assignedAgent,
this.rootDir,
this.options.agentStore,
memoryMode,
);
} else if (this.options.agentStore) {
try {
@@ -1019,7 +1022,8 @@ export class TriageProcessor {
for (const agent of agents) {
triageRuntimeHint ??= extractRuntimeHint(agent.runtimeConfig);
if (agent.instructionsText || agent.instructionsPath || agent.soul || agent.memory) {
triageInstructions = await resolveAgentInstructions(agent, this.rootDir);
const memoryMode = resolveAgentMemoryInclusionMode({ agent, projectSettings: settings }).mode;
triageInstructions = await resolveAgentInstructions(agent, this.rootDir, undefined, memoryMode);
break;
}
}