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:
@@ -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" }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user