FN-8934: add memory pre-steering across agent prompts

Add bounded memory-first guidance and consolidation observability across agent workflows.

- Apply mode-aware memory pre-steering to execution, triage, reviewer, heartbeat, and merger prompts.
- Export and test shared memory nudges while documenting memory inclusion settings.
- Expose agent memory consolidation history through dashboard API and detail UI.

Files changed:
 .changeset/fn-8934-memory-pre-steering.md          |  7 ++
 docs/agents.md                                     |  4 +
 docs/settings-reference.md                         |  2 +
 .../core/src/__tests__/memory-pre-steering.test.ts | 28 +++++++
 packages/core/src/__tests__/project-memory.test.ts | 22 ++++++
 packages/core/src/index.gate.ts                    |  7 ++
 packages/core/src/index.ts                         |  7 ++
 packages/core/src/memory/index.ts                  |  1 +
 packages/core/src/memory/memory-pre-steering.ts    | 41 ++++++++++
 packages/core/src/memory/project-memory.ts         | 19 +++++
 packages/dashboard/app/api/agents/agents.ts        | 12 +++
 packages/dashboard/app/api/legacy.ts               |  2 +
 .../dashboard/app/components/AgentDetailView.css   | 41 ++++++++++
 .../dashboard/app/components/AgentDetailView.tsx   | 56 +++++++++++++-
 ...etailView.memory-consolidation-history.test.tsx | 58 +++++++++++++++
 .../__tests__/AgentDetailView.test-helpers.ts      |  3 +
 .../register-agent-memory-consolidations.test.ts   | 87 ++++++++++++++++++++++
 .../register-agent-reflection-rating-routes.ts     | 36 +++++++++
 .../memory-pre-steering-injection.test.ts          | 50 +++++++++++++
 packages/engine/src/__tests__/reviewer.test.ts     | 18 +++++
 packages/engine/src/__tests__/triage.test.ts       | 18 ++++-
 packages/engine/src/agent-heartbeat.ts             | 14 +++-
 .../engine/src/agents/agent-heartbeat-prompts.ts   |  2 +
 packages/engine/src/agents/agent-instructions.ts   | 16 ++--
 packages/engine/src/execution/reviewer.ts          | 31 +++++---
 packages/engine/src/executor/execution-prompt.ts   |  6 +-
 packages/engine/src/merger.ts                      |  9 ++-
 packages/engine/src/triage.ts                      | 12 ++-
 28 files changed, 577 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-8934

Fusion-Task-Lineage: a062cc6b-1277-4d36-9926-b3ecba4945eb

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-11 04:57:14 -07:00
parent 25e292d0e6
commit 5a1853cac6
28 changed files with 577 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Steer every agent lane to search memory first and show Memory Keeper consolidation history.
category: feature
dev: Mode-aware pre-steering nudge across project-memory builders, heartbeat prompts, and agent instructions; Agent Detail reads memory:consolidation-* run-audit events from FN-8932.

View File

@@ -1745,3 +1745,7 @@ The workflow-native `memoryConsolidationEnabled` setting defaults to `true` and
Permanent agents carry one or more normalized role tags: `triage`, `executor`, `reviewer`, `merger`, `scheduler`, `engineer`, and `custom`. Upgrades preserve legacy singular roles, and every project receives four distinct heartbeat-disabled built-ins for planning, execution, review, and merge. Heartbeat enablement and `maxConcurrentRuns` are independent from `runtimeConfig.maxWorkflowSessions`. Permanent agents carry one or more normalized role tags: `triage`, `executor`, `reviewer`, `merger`, `scheduler`, `engineer`, and `custom`. Upgrades preserve legacy singular roles, and every project receives four distinct heartbeat-disabled built-ins for planning, execution, review, and merge. Heartbeat enablement and `maxConcurrentRuns` are independent from `runtimeConfig.maxWorkflowSessions`.
Workflow routing never changes `assignedAgentId`. An explicit task owner runs classified stages regardless of its tags, except for an exact reviewer-node override. Otherwise a column binding is considered, then an available role-tag pool is selected by fewest active workflow sessions, oldest creation time, and ID. A named unavailable principal holds work rather than falling back. Workflow routing never changes `assignedAgentId`. An explicit task owner runs classified stages regardless of its tags, except for an exact reviewer-node override. Otherwise a column binding is considered, then an available role-tag pool is selected by fewest active workflow sessions, oldest creation time, and ID. A named unavailable principal holds work rather than falling back.
### Memory-first steering and consolidation history
Agent instruction assembly uses the resolved `agentMemoryInclusionMode` across triage, execution, review, heartbeat, and chat lanes. `full` asks agents to query memory before re-reading raw sources, `index` keeps that direction terse, and `off` omits it. Operators can inspect the built-in Memory Keeper's compact consolidation audit history in **Agent Detail → Agent Memory**; it shows only completion outcomes and existing audit counts/reasons, never memory content.

View File

@@ -1945,3 +1945,5 @@ Settings → Authentication can hold multiple named credential accounts for each
### Knowledge graph artifact location ### Knowledge graph artifact location
`knowledgeGraphDir` is an optional project setting. Its default is `.fusion-knowledge/graph`; keep it outside `.fusion`, which is ignored. The directory is intentionally committable and is refreshed with `fn knowledge-graph build`. `knowledgeGraphDir` is an optional project setting. Its default is `.fusion-knowledge/graph`; keep it outside `.fusion`, which is ignored. The directory is intentionally committable and is refreshed with `fn knowledge-graph build`.
`agentMemoryInclusionMode` also controls memory-first pre-steering across triage, execution, review, heartbeat, and agent chat instruction assembly: `full` supplies detailed search-first guidance, `index` supplies a terse form, and `off` suppresses it.

View File

@@ -0,0 +1,28 @@
import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import {
buildMemoryPreSteeringNudge,
MAX_PRE_STEERING_FULL_BYTES,
MAX_PRE_STEERING_INDEX_BYTES,
MEMORY_PRE_STEERING_MARKER,
} from "../memory/memory-pre-steering.js";
describe("buildMemoryPreSteeringNudge", () => {
it("suppresses steering when memory inclusion is off", () => {
expect(buildMemoryPreSteeringNudge("off")).toBe("");
});
it("keeps index steering terse and byte-bounded", () => {
const nudge = buildMemoryPreSteeringNudge("index");
expect(nudge).toContain(MEMORY_PRE_STEERING_MARKER);
expect(nudge).toContain("fn_memory_search");
expect(Buffer.byteLength(nudge, "utf8")).toBeLessThanOrEqual(MAX_PRE_STEERING_INDEX_BYTES);
});
it("provides byte-bounded full search-first guidance", () => {
const nudge = buildMemoryPreSteeringNudge("full");
expect(nudge).toContain(MEMORY_PRE_STEERING_MARKER);
expect(nudge).toContain("fn_memory_search");
expect(Buffer.byteLength(nudge, "utf8")).toBeLessThanOrEqual(MAX_PRE_STEERING_FULL_BYTES);
});
});

View File

@@ -59,6 +59,12 @@ describe("project-memory", () => {
}); });
describe("buildReviewerMemoryInstructions", () => { describe("buildReviewerMemoryInstructions", () => {
it("injects full steering for its readable backend and honors inclusion mode", () => {
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" })).toContain("query memory before re-reading");
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" }, undefined, "index")).toContain("Memory-first:");
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" }, undefined, "off")).not.toContain("query memory before re-reading");
});
it("gives reviewers read-only project memory guidance", () => { it("gives reviewers read-only project memory guidance", () => {
const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" }); const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" });
@@ -588,6 +594,22 @@ describe("project-memory", () => {
}); });
}); });
describe("pre-steering coverage for project-memory builders", () => {
const builders = [buildTriageMemoryInstructions, buildExecutionMemoryInstructions] as const;
const readableBackends = ["readonly", "file", "qmd"] as const;
it.each(builders.flatMap((builder) => readableBackends.map((memoryBackendType) => [builder.name, builder, memoryBackendType] as const)))("injects full steering into %s %s backend", (_name, builder, memoryBackendType) => {
expect(builder(testDir, { memoryBackendType })).toContain("query memory before re-reading");
});
it("uses the terse index nudge and suppresses it when off", () => {
for (const builder of builders) {
expect(builder(testDir, { memoryBackendType: "qmd" }, undefined, "index")).toContain("Memory-first:");
expect(builder(testDir, { memoryBackendType: "qmd" }, undefined, "off")).not.toContain("query memory before re-reading");
}
});
});
// ── Backend-aware buildExecutionMemoryInstructions ───────────────────────────────── // ── Backend-aware buildExecutionMemoryInstructions ─────────────────────────────────
describe("buildExecutionMemoryInstructions with backend settings", () => { describe("buildExecutionMemoryInstructions with backend settings", () => {

View File

@@ -1866,6 +1866,13 @@ export type {
ProcessRunInput, ProcessRunInput,
} from "./memory/memory-insights.js"; } from "./memory/memory-insights.js";
export {
buildMemoryPreSteeringNudge,
MEMORY_PRE_STEERING_MARKER,
MAX_PRE_STEERING_FULL_BYTES,
MAX_PRE_STEERING_INDEX_BYTES,
} from "./memory/memory-pre-steering.js";
export { export {
getDefaultMemoryScaffold, getDefaultMemoryScaffold,
ensureMemoryFile, ensureMemoryFile,

View File

@@ -2056,6 +2056,13 @@ export type {
ProcessRunInput, ProcessRunInput,
} from "./memory/memory-insights.js"; } from "./memory/memory-insights.js";
export {
buildMemoryPreSteeringNudge,
MEMORY_PRE_STEERING_MARKER,
MAX_PRE_STEERING_FULL_BYTES,
MAX_PRE_STEERING_INDEX_BYTES,
} from "./memory/memory-pre-steering.js";
export { export {
getDefaultMemoryScaffold, getDefaultMemoryScaffold,
ensureMemoryFile, ensureMemoryFile,

View File

@@ -10,3 +10,4 @@ export * from "./memory-insights.js";
export * from "./project-memory.js"; export * from "./project-memory.js";
export * from "./recall/index.js"; export * from "./recall/index.js";
export * from "./mcp/index.js"; export * from "./mcp/index.js";
export * from "./memory-pre-steering.js";

View File

@@ -0,0 +1,41 @@
import type { AgentMemoryInclusionMode } from "../types/settings/settings-scope.js";
/** A stable assertion marker shared by every memory instruction surface. */
export const MEMORY_PRE_STEERING_MARKER = "query memory before re-reading";
/** Prompt-footprint cap for the detailed memory-first instruction. */
export const MAX_PRE_STEERING_FULL_BYTES = 360;
/** Prompt-footprint cap for the terse index-mode instruction. */
export const MAX_PRE_STEERING_INDEX_BYTES = 180;
function clampUtf8(input: string, maxBytes: number): string {
if (Buffer.byteLength(input, "utf8") <= maxBytes) return input;
let output = "";
for (const character of input) {
if (Buffer.byteLength(output + character, "utf8") > maxBytes) break;
output += character;
}
return output;
}
/*
FNXC:MemoryPreSteering 2026-08-11-11:13:
FN-8934 prevents a single-lane memory prompt from silently leaving other agent
lanes to re-discover raw sources. Off must emit no nudge, while explicit UTF-8
budgets keep index and full steering from growing prompt footprints. This only
steers retrieval order; extraction, recall persistence, and MCP remain unchanged.
*/
export function buildMemoryPreSteeringNudge(mode: AgentMemoryInclusionMode): string {
if (mode === "off") return "";
if (mode === "index") {
return clampUtf8(
"Memory-first: query memory before re-reading raw sources. Use fn_memory_search, then fn_memory_get only for relevant excerpts.",
MAX_PRE_STEERING_INDEX_BYTES,
);
}
return clampUtf8(
`Memory-first:\n- query memory before re-reading raw sources or re-deriving context.\n- Prefer fn_memory_search, then fn_memory_get for relevant excerpts.\n- Treat durable conventions, decisions, and pitfalls as higher priority than re-scanning; skip memory when irrelevant.`,
MAX_PRE_STEERING_FULL_BYTES,
);
}

View File

@@ -35,6 +35,8 @@ import {
} from "./memory-backend.js"; } from "./memory-backend.js";
import { appendRecallInstructionSection } from "./recall/recall-instructions.js"; import { appendRecallInstructionSection } from "./recall/recall-instructions.js";
import type { RecallSearchHit } from "./recall/recall-types.js"; import type { RecallSearchHit } from "./recall/recall-types.js";
import { buildMemoryPreSteeringNudge } from "./memory-pre-steering.js";
import type { AgentMemoryInclusionMode } from "../types/settings/settings-scope.js";
// ── Default Scaffold ───────────────────────────────────────────────── // ── Default Scaffold ─────────────────────────────────────────────────
@@ -340,6 +342,7 @@ export function buildTriageMemoryInstructions(
rootDir: string, rootDir: string,
settings?: MemorySettings, settings?: MemorySettings,
recallHits?: readonly RecallSearchHit[], recallHits?: readonly RecallSearchHit[],
inclusionMode: AgentMemoryInclusionMode = "full",
): string { ): string {
void rootDir; // Parameter kept for future use (e.g., checking file existence) void rootDir; // Parameter kept for future use (e.g., checking file existence)
const ctx = resolveMemoryInstructionContext(settings); const ctx = resolveMemoryInstructionContext(settings);
@@ -354,6 +357,8 @@ export function buildTriageMemoryInstructions(
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has a memory system that stores durable project learnings. This project has a memory system that stores durable project learnings.
**Before writing the specification:** **Before writing the specification:**
@@ -368,6 +373,8 @@ This project has a memory system that stores durable project learnings.
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has OpenClaw-style memory files: This project has OpenClaw-style memory files:
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls - \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running context - \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running context
@@ -389,6 +396,8 @@ Do not read all memory directly by default. If memory is irrelevant, skip it.
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has a memory system that stores durable project learnings. This project has a memory system that stores durable project learnings.
**Before writing the specification:** **Before writing the specification:**
@@ -427,6 +436,7 @@ export function buildExecutionMemoryInstructions(
rootDir: string, rootDir: string,
settings?: MemorySettings, settings?: MemorySettings,
recallHits?: readonly RecallSearchHit[], recallHits?: readonly RecallSearchHit[],
inclusionMode: AgentMemoryInclusionMode = "full",
): string { ): string {
void rootDir; // Parameter kept for future use (e.g., checking file existence) void rootDir; // Parameter kept for future use (e.g., checking file existence)
const ctx = resolveMemoryInstructionContext(settings); const ctx = resolveMemoryInstructionContext(settings);
@@ -441,6 +451,8 @@ export function buildExecutionMemoryInstructions(
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has a memory system that stores durable project learnings. This project has a memory system that stores durable project learnings.
**At the start of execution:** **At the start of execution:**
@@ -455,6 +467,8 @@ This project has a memory system that stores durable project learnings.
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has OpenClaw-style memory files: This project has OpenClaw-style memory files:
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls - \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running observations and open loops - \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running observations and open loops
@@ -490,6 +504,8 @@ This project has OpenClaw-style memory files:
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has a memory system that stores durable project learnings accumulated from past task runs. This project has a memory system that stores durable project learnings accumulated from past task runs.
**At the start of execution:** **At the start of execution:**
@@ -515,6 +531,7 @@ export function buildReviewerMemoryInstructions(
rootDir: string, rootDir: string,
settings?: MemorySettings, settings?: MemorySettings,
recallHits?: readonly RecallSearchHit[], recallHits?: readonly RecallSearchHit[],
inclusionMode: AgentMemoryInclusionMode = "full",
): string { ): string {
void rootDir; void rootDir;
const ctx = resolveMemoryInstructionContext(settings); const ctx = resolveMemoryInstructionContext(settings);
@@ -525,6 +542,8 @@ export function buildReviewerMemoryInstructions(
return appendRecallInstructionSection(` return appendRecallInstructionSection(`
## Project Memory ## Project Memory
${buildMemoryPreSteeringNudge(inclusionMode)}
This project has a memory system that stores durable project learnings. This project has a memory system that stores durable project learnings.
**During review:** **During review:**

View File

@@ -43,6 +43,18 @@ export function withAgentHeartbeatEnabled<T extends Pick<Agent, "runtimeConfig">
return { ...(agent.runtimeConfig ?? {}), enabled }; return { ...(agent.runtimeConfig ?? {}), enabled };
} }
export interface MemoryConsolidationEvent {
id: string;
timestamp: string;
mutationType: "memory:consolidation-completed" | "memory:consolidation-skipped" | "memory:consolidation-failed";
runId: string;
metadata?: Record<string, unknown>;
}
export function fetchAgentMemoryConsolidations(agentId: string, limit = 50, projectId?: string): Promise<{ agentId: string; events: MemoryConsolidationEvent[] }> {
return api<{ agentId: string; events: MemoryConsolidationEvent[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory-consolidations?limit=${Math.min(Math.max(Math.trunc(limit), 1), 100)}`, projectId));
}
export interface AgentPromptSizePoint { export interface AgentPromptSizePoint {
runId: string; runId: string;
createdAt: string; createdAt: string;

View File

@@ -810,6 +810,7 @@ export {
isAgentHeartbeatEnabled, isAgentHeartbeatEnabled,
fetchAgentHeartbeats, fetchAgentHeartbeats,
fetchAgentMemory, fetchAgentMemory,
fetchAgentMemoryConsolidations,
fetchAgentMemoryFile, fetchAgentMemoryFile,
fetchAgentMemoryFiles, fetchAgentMemoryFiles,
fetchAgentPromptSizes, fetchAgentPromptSizes,
@@ -840,6 +841,7 @@ export type {
AgentDetail, AgentDetail,
AgentHeartbeatEvent, AgentHeartbeatEvent,
AgentHeartbeatRun, AgentHeartbeatRun,
MemoryConsolidationEvent,
AgentPerformanceSummary, AgentPerformanceSummary,
AgentPromptSizePoint, AgentPromptSizePoint,
AgentReflection, AgentReflection,

View File

@@ -2416,3 +2416,44 @@ the tab into endless page scroll.
.agent-memory-file-editor .cm-scroller { .agent-memory-file-editor .cm-scroller {
overflow: auto; overflow: auto;
} }
.memory-consolidation-history {
margin-block: var(--space-md);
padding: var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface);
}
.memory-consolidation-history__heading,
.memory-consolidation-history__row {
display: flex;
align-items: center;
gap: var(--space-sm);
justify-content: space-between;
}
.memory-consolidation-history__heading h4 {
margin: 0;
color: var(--text-primary);
}
.memory-consolidation-history__list {
display: grid;
gap: var(--space-sm);
margin: var(--space-sm) 0 0;
padding: 0;
list-style: none;
}
.memory-consolidation-history__row {
flex-wrap: wrap;
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-muted);
}
.memory-consolidation-history__row--completed strong { color: var(--color-success); }
.memory-consolidation-history__row--skipped strong { color: var(--color-warning); }
.memory-consolidation-history__row--failed strong { color: var(--color-error); }

View File

@@ -13,8 +13,8 @@ import {
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api"; import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, fetchAgentMemoryConsolidations, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
import type { Agent } from "../api"; import type { Agent, MemoryConsolidationEvent } from "../api";
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core";
import { import {
AGENT_PERMISSIONS, AGENT_PERMISSIONS,
@@ -2956,12 +2956,32 @@ function MemoryTab({
const [savingSelectedFile, setSavingSelectedFile] = useState(false); const [savingSelectedFile, setSavingSelectedFile] = useState(false);
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false); const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
const [fileSwitchHint, setFileSwitchHint] = useState(""); const [fileSwitchHint, setFileSwitchHint] = useState("");
const [consolidations, setConsolidations] = useState<MemoryConsolidationEvent[]>([]);
const [consolidationsLoading, setConsolidationsLoading] = useState(false);
const [consolidationsError, setConsolidationsError] = useState("");
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isReadOnly = agent.state === "running"; const isReadOnly = agent.state === "running";
const isMemoryKeeper = agent.metadata?.builtInMemoryAgent === true;
const hasInlineChanges = memory !== (agent.memory ?? ""); const hasInlineChanges = memory !== (agent.memory ?? "");
const loadConsolidations = useCallback(async () => {
if (!isMemoryKeeper) return;
setConsolidationsLoading(true);
setConsolidationsError("");
try {
const result = await fetchAgentMemoryConsolidations(agent.id, 50, projectId);
setConsolidations(result.events);
} catch (err) {
setConsolidationsError(getErrorMessage(err));
} finally {
setConsolidationsLoading(false);
}
}, [agent.id, isMemoryKeeper, projectId]);
useEffect(() => { void loadConsolidations(); }, [loadConsolidations]);
const selectedMemoryFile = useMemo( const selectedMemoryFile = useMemo(
() => memoryFiles.find((file) => file.path === selectedFilePath), () => memoryFiles.find((file) => file.path === selectedFilePath),
[memoryFiles, selectedFilePath], [memoryFiles, selectedFilePath],
@@ -3104,6 +3124,38 @@ function MemoryTab({
<p className="config-description"> <p className="config-description">
{t("agents.memoryDescription", "Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.")} {t("agents.memoryDescription", "Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.")}
</p> </p>
{isMemoryKeeper && (
<section className="memory-consolidation-history" aria-label={t("agents.consolidationHistory", "Consolidation history")}>
{/*
FNXC:MemoryConsolidationHistory 2026-08-11-11:13:
FN-8934 keeps Memory Keeper history in its existing Memory tab and renders
only existing audit metadata, never memory content or synthesized audit prose.
*/}
<div className="memory-consolidation-history__heading">
<h4>{t("agents.consolidationHistory", "Consolidation history")}</h4>
<button className="btn btn-sm" onClick={() => void loadConsolidations()} disabled={consolidationsLoading}>
<RefreshCw size={14} />{t("common.refresh", "Refresh")}
</button>
</div>
{consolidationsLoading ? <LoadingSpinner /> : consolidationsError ? (
<p className="config-hint">{t("agents.consolidationHistoryError", "Unable to load consolidation history: {{error}}", { error: consolidationsError })}</p>
) : consolidations.length === 0 ? (
<p className="config-hint">{t("agents.consolidationHistoryEmpty", "No consolidation activity yet.")}</p>
) : (
<ul className="memory-consolidation-history__list">
{consolidations.map((event) => {
const outcome = event.mutationType.endsWith("completed") ? t("agents.consolidationCompleted", "Completed") : event.mutationType.endsWith("skipped") ? t("agents.consolidationSkipped", "Skipped") : t("agents.consolidationFailed", "Failed");
const metadata = event.metadata ?? {};
const details = ["graphChanged", "parsedFiles", "recallCreated", "reason", "stage"].flatMap((key) => metadata[key] === undefined ? [] : [`${key}: ${String(metadata[key])}`]);
return <li key={event.id} className={`memory-consolidation-history__row memory-consolidation-history__row--${event.mutationType.split("-").at(-1)}`}>
<span>{relativeTime(event.timestamp, t)}</span><strong>{outcome}</strong>{details.length > 0 && <span>{details.join(" · ")}</span>}
</li>;
})}
</ul>
)}
</section>
)}
{isReadOnly && ( {isReadOnly && (
<p className="config-hint config-hint--block-spacing"> <p className="config-hint config-hint--block-spacing">
{t("agents.memoryReadOnly", "Read-only while this agent is running.")} {t("agents.memoryReadOnly", "Read-only while this agent is running.")}

View File

@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import {
createMockAgent,
mockFetchAgent,
mockFetchAgentMemoryConsolidations,
setupAgentDetailMocks,
} from "./AgentDetailView.test-helpers";
import { AgentDetailView } from "../AgentDetailView";
describe("AgentDetailView Memory Keeper consolidation history", () => {
beforeEach(() => setupAgentDetailMocks());
async function openMemoryTab() {
await waitFor(() => expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument());
fireEvent.click(screen.getByText("Agent Memory"));
}
it("renders the empty history for the built-in Memory Keeper", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: { builtInMemoryAgent: true } }));
mockFetchAgentMemoryConsolidations.mockResolvedValue({ agentId: "agent-001", events: [] });
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openMemoryTab();
expect(await screen.findByText("No consolidation activity yet.")).toBeInTheDocument();
});
it("renders completed, skipped, and failed audit metadata", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: { builtInMemoryAgent: true } }));
mockFetchAgentMemoryConsolidations.mockResolvedValue({
agentId: "agent-001",
events: [
{ id: "completed", timestamp: "2026-08-11T10:00:00.000Z", mutationType: "memory:consolidation-completed", runId: "run-1", metadata: { parsedFiles: 2, recallCreated: 1 } },
{ id: "skipped", timestamp: "2026-08-11T11:00:00.000Z", mutationType: "memory:consolidation-skipped", runId: "run-2", metadata: { reason: "disabled" } },
{ id: "failed", timestamp: "2026-08-11T12:00:00.000Z", mutationType: "memory:consolidation-failed", runId: "run-3", metadata: { stage: "graph" } },
],
});
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openMemoryTab();
expect(await screen.findByText("Completed")).toBeInTheDocument();
expect(screen.getByText("Skipped")).toBeInTheDocument();
expect(screen.getByText("Failed")).toBeInTheDocument();
expect(screen.getByText(/parsedFiles: 2/)).toBeInTheDocument();
expect(screen.getByText(/reason: disabled/)).toBeInTheDocument();
expect(screen.getByText(/stage: graph/)).toBeInTheDocument();
});
it("does not show the section for an unrelated agent", async () => {
mockFetchAgent.mockResolvedValue(createMockAgent({ metadata: {} }));
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
await openMemoryTab();
expect(screen.queryByLabelText("Consolidation history")).not.toBeInTheDocument();
expect(mockFetchAgentMemoryConsolidations).not.toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,7 @@ export const mockUpdateAgentInstructions = vi.fn<ApiModule["updateAgentInstructi
export const mockUpdateAgentSoul = vi.fn<ApiModule["updateAgentSoul"]>(); export const mockUpdateAgentSoul = vi.fn<ApiModule["updateAgentSoul"]>();
export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>(); export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>();
export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>(); export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>();
export const mockFetchAgentMemoryConsolidations = vi.fn<ApiModule["fetchAgentMemoryConsolidations"]>();
export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>(); export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>();
export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>(); export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>();
export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>(); export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>();
@@ -72,6 +73,7 @@ vi.mock("../../api", () => ({
updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args), updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args),
updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args), updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args),
fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args), fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args),
fetchAgentMemoryConsolidations: (...args: Parameters<ApiModule["fetchAgentMemoryConsolidations"]>) => mockFetchAgentMemoryConsolidations(...args),
fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args), fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args),
saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...args), saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...args),
fetchAgentTasks: (...args: Parameters<ApiModule["fetchAgentTasks"]>) => mockFetchAgentTasks(...args), fetchAgentTasks: (...args: Parameters<ApiModule["fetchAgentTasks"]>) => mockFetchAgentTasks(...args),
@@ -346,6 +348,7 @@ export function setupAgentDetailMocks() {
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 }); mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 }); mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
mockUpdateAgentInstructions.mockResolvedValue({} as any); mockUpdateAgentInstructions.mockResolvedValue({} as any);
mockFetchAgentMemoryConsolidations.mockResolvedValue({ agentId: "agent-001", events: [] });
mockFetchAgentMemoryFiles.mockResolvedValue({ mockFetchAgentMemoryFiles.mockResolvedValue({
files: [ files: [
{ {

View File

@@ -0,0 +1,87 @@
// @vitest-environment node
import express from "express";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { request } from "../../test-request.js";
const getAgent = vi.fn();
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
class MockAgentStore {
async init() {}
getAgent = getAgent;
}
return { ...actual, AgentStore: MockAgentStore };
});
function app(events: unknown[]) {
const store = {
getRootDir: vi.fn().mockReturnValue("/fake/project"),
getFusionDir: vi.fn().mockReturnValue("/fake/project/.fusion"),
getAsyncLayer: vi.fn().mockReturnValue(undefined),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn(),
getPluginStore: vi.fn().mockReturnValue({ init: vi.fn().mockResolvedValue(undefined), listPlugins: vi.fn().mockResolvedValue([]) }),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
getRunAuditEventsAsync: vi.fn().mockImplementation(async ({ mutationType }: { mutationType?: string }) => events.filter((event: any) => event.mutationType === mutationType)),
} as unknown as TaskStore;
const server = express();
server.use(express.json());
server.use("/api", createApiRoutes(store));
return { server, store };
}
const completed = { id: "audit-completed", timestamp: "2026-08-11T10:00:00.000Z", mutationType: "memory:consolidation-completed", runId: "run-1", metadata: { parsedFiles: 2, recallCreated: 1 } };
const skipped = { id: "audit-skipped", timestamp: "2026-08-11T11:00:00.000Z", mutationType: "memory:consolidation-skipped", runId: "run-2", metadata: { reason: "disabled" } };
const failed = { id: "audit-failed", timestamp: "2026-08-11T12:00:00.000Z", mutationType: "memory:consolidation-failed", runId: "run-3", metadata: { stage: "graph" } };
describe("GET /api/agents/:id/memory-consolidations", () => {
beforeEach(() => {
getAgent.mockReset();
getAgent.mockResolvedValue({ id: "memory-keeper" });
});
it("merges each consolidation mutation query newest-first", async () => {
const { server, store } = app([completed, skipped, failed, { id: "noise", timestamp: "2026-08-11T13:00:00.000Z", mutationType: "agent:heartbeat", runId: "run-noise", metadata: { text: "never expose" } }]);
const response = await request(server, "GET", "/api/agents/memory-keeper/memory-consolidations");
expect(response.status).toBe(200);
expect(response.body.events.map((event: typeof completed) => event.id)).toEqual(["audit-failed", "audit-skipped", "audit-completed"]);
expect((store.getRunAuditEventsAsync as any).mock.calls.map(([filter]: any[]) => filter.mutationType).sort()).toEqual([
"memory:consolidation-completed", "memory:consolidation-failed", "memory:consolidation-skipped",
]);
});
it("returns an empty history", async () => {
const { server } = app([]);
const response = await request(server, "GET", "/api/agents/memory-keeper/memory-consolidations");
expect(response.status).toBe(200);
expect(response.body).toEqual({ agentId: "memory-keeper", events: [] });
});
it("returns 404 for an unknown agent", async () => {
getAgent.mockResolvedValueOnce(null);
const { server } = app([]);
const response = await request(server, "GET", "/api/agents/missing/memory-consolidations");
expect(response.status).toBe(404);
});
it("caps the merged response limit", async () => {
const events = Array.from({ length: 101 }, (_, index) => ({
...completed,
id: `audit-${index}`,
timestamp: `2026-08-11T10:${String(index % 60).padStart(2, "0")}:00.000Z`,
}));
const { server } = app(events);
const response = await request(server, "GET", "/api/agents/memory-keeper/memory-consolidations?limit=999");
expect(response.status).toBe(200);
expect(response.body.events).toHaveLength(100);
});
});

View File

@@ -21,6 +21,42 @@ export function __setAgentReflectionServiceForTests(service: EngineAgentReflecti
export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void { export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx; const { router, getProjectContext, rethrowAsApiError } = ctx;
/*
FNXC:MemoryConsolidationHistory 2026-08-11-11:13:
FN-8934 exposes only the compact, existing FN-8932 consolidation audit rows.
The route intentionally passes through structured metadata without inventing memory prose.
*/
router.get("/agents/:id/memory-consolidations", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined });
await agentStore.init();
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
if (!agentId) throw badRequest("Agent id is required");
if (!await agentStore.getAgent(agentId)) throw notFound("Agent not found");
const rawLimit = Number.parseInt(String(req.query.limit ?? "50"), 10);
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 100) : 50;
const consolidationTypes = [
"memory:consolidation-completed",
"memory:consolidation-skipped",
"memory:consolidation-failed",
] as const;
// Filter in the store query: its SQL limit applies before returned rows can be merged.
const rows = await Promise.all(consolidationTypes.map((mutationType) => (
scopedStore.getRunAuditEventsAsync({ agentId, mutationType, limit: 100 })
)));
const events = rows.flat()
.sort((left, right) => right.timestamp.localeCompare(left.timestamp))
.slice(0, limit)
.map(({ id, timestamp, mutationType, runId, metadata }) => ({ id, timestamp, mutationType, runId, metadata }));
res.json({ agentId, events });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
/** /**
* GET /api/agents/:id/reflections/latest * GET /api/agents/:id/reflections/latest
* Fetch the most recent reflection for an agent. * Fetch the most recent reflection for an agent.

View File

@@ -0,0 +1,50 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { MEMORY_PRE_STEERING_MARKER } from "@fusion/core";
import { buildAgentChatPrompt, resolveAgentInstructions } from "../agents/agent-instructions.js";
import { HEARTBEAT_NO_TASK_SYSTEM_PROMPT, HEARTBEAT_SYSTEM_PROMPT, renderHeartbeatNoTaskSystemPrompt } from "../agents/agent-heartbeat-prompts.js";
import { buildExecutionPrompt } from "../executor.js";
const agent = {
id: "memory-agent",
name: "Memory Agent",
role: "executor",
roles: ["executor"],
state: "idle",
memory: "Remember the repository convention.",
runtimeConfig: {},
} as any;
const task = { id: "FN-8934", title: "Memory steering", description: "Test task", steps: [], dependencies: [] } as any;
function source(relativePath: string): string {
return readFileSync(resolve(process.cwd(), relativePath.replace("packages/engine/", "")), "utf8");
}
describe("memory pre-steering injection inventory", () => {
it("injects full/index agent instructions and suppresses them off", async () => {
await expect(resolveAgentInstructions(agent, process.cwd(), undefined, "full")).resolves.toContain(MEMORY_PRE_STEERING_MARKER);
await expect(resolveAgentInstructions(agent, process.cwd(), undefined, "index")).resolves.toContain(MEMORY_PRE_STEERING_MARKER);
await expect(resolveAgentInstructions(agent, process.cwd(), undefined, "off")).resolves.not.toContain(MEMORY_PRE_STEERING_MARKER);
});
it("carries the nudge through chat and execution prompt assembly", async () => {
await expect(buildAgentChatPrompt({ agent, rootDir: process.cwd(), basePrompt: "Base" })).resolves.toContain(MEMORY_PRE_STEERING_MARKER);
expect(buildExecutionPrompt(task, process.cwd(), { agentMemoryInclusionMode: "full" } as any)).toContain(MEMORY_PRE_STEERING_MARKER);
expect(buildExecutionPrompt(task, process.cwd(), { agentMemoryInclusionMode: "index" } as any)).toContain(MEMORY_PRE_STEERING_MARKER);
expect(buildExecutionPrompt(task, process.cwd(), { agentMemoryInclusionMode: "off" } as any)).not.toContain(MEMORY_PRE_STEERING_MARKER);
});
it("keeps both static heartbeat variants and patrol rendering memory-first", () => {
expect(HEARTBEAT_SYSTEM_PROMPT).toContain(MEMORY_PRE_STEERING_MARKER);
expect(HEARTBEAT_NO_TASK_SYSTEM_PROMPT).toContain(MEMORY_PRE_STEERING_MARKER);
expect(renderHeartbeatNoTaskSystemPrompt({ plannerHeartbeatPatrolEnabled: false })).toContain(MEMORY_PRE_STEERING_MARKER);
});
it("threads mode-aware steering through heartbeat, triage, and reviewer assemblers", () => {
expect(source("packages/engine/src/agent-heartbeat.ts")).toContain('buildMemoryPreSteeringNudge("index")');
expect(source("packages/engine/src/triage.ts")).toContain('buildTriageMemoryInstructions("", settings, undefined, memoryMode)');
expect(source("packages/engine/src/execution/reviewer.ts")).toContain('buildReviewerMemoryInstructions(options.rootDir, effectiveSettings, undefined, reviewerMemoryMode)');
expect(source("packages/engine/src/merger.ts")).toContain('resolveAgentInstructions(agent, rootDir, undefined, memoryMode)');
});
});

View File

@@ -535,6 +535,24 @@ describe("reviewStep — spec review type", () => {
expect(opts.customTools?.map((tool: any) => tool.name)).toEqual(["fn_web_fetch", "fn_memory_search", "fn_memory_get"]); expect(opts.customTools?.map((tool: any) => tool.name)).toEqual(["fn_web_fetch", "fn_memory_search", "fn_memory_get"]);
}); });
it.each(["off", "index", "full"] as const)("uses assigned reviewer %s memory inclusion mode", async (mode) => {
mockedCreateFnAgent.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",
agentId: "reviewer-1",
agentStore: { getAgent: vi.fn().mockResolvedValue({ id: "reviewer-1", runtimeConfig: { agentMemoryInclusionMode: mode } }) } as any,
settings: { memoryBackendType: "qmd" } as any,
},
);
const prompt = mockedCreateFnAgent.mock.calls[0][0].systemPrompt;
expect(prompt.includes("query memory before re-reading")).toBe(mode !== "off");
if (mode === "index") expect(prompt).toContain("fn_memory_search");
});
it("omits reviewer memory tools and instructions when memory is disabled", async () => { it("omits reviewer memory tools and instructions when memory is disabled", async () => {
mockedCreateFnAgent.mockResolvedValue( mockedCreateFnAgent.mockResolvedValue(
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."), createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core"; import type { Agent, TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core"; import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
import { import {
TriageProcessor, TriageProcessor,
@@ -571,6 +571,22 @@ describe("buildSpecificationPrompt", () => {
}); });
describe("memoryEnabled setting", () => { describe("memoryEnabled setting", () => {
it.each(["off", "index", "full"] as const)("uses assigned-agent %s memory inclusion mode", (mode) => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
{ memoryEnabled: true } as Settings,
undefined,
undefined,
undefined,
undefined,
{ id: "planner", runtimeConfig: { agentMemoryInclusionMode: mode } } as Agent,
);
expect(prompt.includes("query memory before re-reading")).toBe(mode !== "off");
if (mode === "index") expect(prompt).toContain("fn_memory_search");
});
it("includes memory instructions when memoryEnabled: true", () => { it("includes memory instructions when memoryEnabled: true", () => {
const settings: Settings = { const settings: Settings = {
maxConcurrent: 2, maxConcurrent: 2,

View File

@@ -22,6 +22,7 @@ import { AutoClaimSnapshotManager, resolveFreshAutoClaimCandidates, type AutoCla
import { import {
ApprovalRequestStore, ApprovalRequestStore,
buildExecutionMemoryInstructions, buildExecutionMemoryInstructions,
buildMemoryPreSteeringNudge,
isEphemeralAgent, isEphemeralAgent,
hasAgentIdentity, hasAgentIdentity,
resolveEffectiveAgentPermissionPolicy, resolveEffectiveAgentPermissionPolicy,
@@ -135,13 +136,18 @@ import { accumulateSessionTokenUsage, captureSessionTokenBaseline } from "./exec
const promptSizeLog = createLogger("prompt-size"); const promptSizeLog = createLogger("prompt-size");
/*
FNXC:MemoryPreSteering 2026-08-11-11:13:
FN-8934 treats the heartbeat primer as an injection surface: off removes both
memory boundaries and their nudge, while index replaces them with the bounded terse form.
*/
function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclusionMode): string { function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclusionMode): string {
if (mode === "full") return basePrompt; if (mode === "full") return basePrompt;
const memoryPrimer = /\nYou may receive an Agent Memory section and a Project Memory section\.[\s\S]*?- Project Memory examples:[^\n]*\n/; const memoryPrimer = /\nYou may receive an Agent Memory section and a Project Memory section\.[\s\S]*?- Project Memory examples:[^\n]*\n(?:- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get\n)?/;
if (mode === "off") return basePrompt.replace(memoryPrimer, "\n"); if (mode === "off") return basePrompt.replace(memoryPrimer, "\n");
return basePrompt.replace( return basePrompt.replace(
memoryPrimer, 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", `\n${buildMemoryPreSteeringNudge("index")}\n`,
); );
} }
@@ -2840,8 +2846,8 @@ export class HeartbeatMonitor {
if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) { if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) {
try { try {
memoryInstructions = resolvedMemoryMode.mode === "index" 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." ? `## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}`
: buildExecutionMemoryInstructions(rootDir, memorySettings); : buildExecutionMemoryInstructions(rootDir, memorySettings, undefined, resolvedMemoryMode.mode);
} catch (memoryInstructionErr) { } catch (memoryInstructionErr) {
const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr); const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr);
heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`); heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`);

View File

@@ -99,6 +99,7 @@ You may receive an Agent Memory section and a Project Memory section.
- 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. - 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.
- Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style. - Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style.
- Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know. - Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know.
- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get.
## Processing Messages ## Processing Messages
@@ -202,6 +203,7 @@ You may receive an Agent Memory section and a Project Memory section.
- 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. - 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.
- Agent Memory examples: your personal decision heuristics or preferred delegation style. - Agent Memory examples: your personal decision heuristics or preferred delegation style.
- Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls. - Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls.
- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get.
## Processing Messages ## Processing Messages

View File

@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path"; import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
import { import {
readProjectMemory, readProjectMemory,
buildMemoryPreSteeringNudge,
type Agent, type Agent,
type AgentMemoryInclusionMode, type AgentMemoryInclusionMode,
type AgentRatingSummary, type AgentRatingSummary,
@@ -203,15 +204,12 @@ async function formatMemorySection(
if (inclusionMode === "index") { if (inclusionMode === "index") {
const indexBody = await buildMemoryIndex({ rootDir, agentId }); const indexBody = await buildMemoryIndex({ rootDir, agentId });
if (!indexBody) {
return "";
}
return [ return [
"## Agent Memory", "## Agent Memory",
"", "",
"Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.", "Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.",
"", buildMemoryPreSteeringNudge("index"),
indexBody, ...(indexBody ? ["", indexBody] : []),
].join("\n"); ].join("\n");
} }
@@ -226,6 +224,7 @@ async function formatMemorySection(
"", "",
"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.", "Additional daily/dream files under .fusion/agent-memory/{agentId}/ are searchable with fn_memory_search.",
buildMemoryPreSteeringNudge("full"),
"", "",
]; ];
@@ -395,20 +394,21 @@ export async function buildAgentChatPrompt(options: {
agentStore?: AgentStore; agentStore?: AgentStore;
basePrompt: string; basePrompt: string;
includeProjectMemory?: boolean; includeProjectMemory?: boolean;
inclusionMode?: AgentMemoryInclusionMode;
}): Promise<string> { }): Promise<string> {
const { agent, rootDir, agentStore, basePrompt, includeProjectMemory = false } = options; const { agent, rootDir, agentStore, basePrompt, includeProjectMemory = false, inclusionMode = "full" } = options;
const titleSuffix = agent.title?.trim() ? `, ${agent.title.trim()}` : ""; const titleSuffix = agent.title?.trim() ? `, ${agent.title.trim()}` : "";
const identitySection = `## Identity\n\nYou are ${agent.name}${titleSuffix} (agent ID: ${agent.id}, role: ${agent.role}).`; const identitySection = `## Identity\n\nYou are ${agent.name}${titleSuffix} (agent ID: ${agent.id}, role: ${agent.role}).`;
const instructionParts = [identitySection]; const instructionParts = [identitySection];
const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore); const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore, inclusionMode);
if (resolvedInstructions.trim()) { if (resolvedInstructions.trim()) {
instructionParts.push(resolvedInstructions); instructionParts.push(resolvedInstructions);
} }
if (includeProjectMemory) { if (includeProjectMemory && inclusionMode !== "off") {
try { try {
const projectMemory = (await readProjectMemory(rootDir)).trim(); const projectMemory = (await readProjectMemory(rootDir)).trim();
if (projectMemory) { if (projectMemory) {

View File

@@ -340,6 +340,18 @@ export async function reviewStep(
const validatorFallbackProvider = validatorFallback.provider; const validatorFallbackProvider = validatorFallback.provider;
const validatorFallbackModelId = validatorFallback.modelId; const validatorFallbackModelId = validatorFallback.modelId;
// Resolve the routed reviewer once so both its instructions and project-memory policy
// honor the same per-agent override.
const assignedAgentId = options.agentId ?? options.task?.assignedAgentId ?? null;
const agentStore = options.agentStore;
const memoryAgent =
options.rootDir
&& agentStore
&& assignedAgentId
&& typeof (agentStore as { getAgent?: unknown }).getAgent === "function"
? await agentStore.getAgent(assignedAgentId).catch(() => null)
: null;
let reviewerInstructions = ""; let reviewerInstructions = "";
if (options.agentStore && options.rootDir) { if (options.agentStore && options.rootDir) {
try { try {
@@ -375,8 +387,14 @@ export async function reviewStep(
// FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node; // FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node;
// explicit reviewer role overrides still win, and the built-in default keeps this fail-soft. // explicit reviewer role overrides still win, and the built-in default keeps this fail-soft.
const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer"); const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer");
const memorySection = options.rootDir && effectiveSettings?.memoryEnabled !== false /*
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings) FNXC:MemoryPreSteering 2026-08-11-11:30:
FN-8934 requires project-memory guidance to use the routed reviewer's mode as well as
its instruction section, so an explicit off override suppresses every memory nudge.
*/
const reviewerMemoryMode = resolveAgentMemoryInclusionMode({ agent: memoryAgent, globalSettings: effectiveSettings }).mode;
const memorySection = options.rootDir && effectiveSettings?.memoryEnabled !== false && reviewerMemoryMode !== "off"
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings, undefined, reviewerMemoryMode)
: ""; : "";
const reviewerPluginContributions = await buildPluginPromptSection( const reviewerPluginContributions = await buildPluginPromptSection(
@@ -411,15 +429,6 @@ export async function reviewStep(
} }
// A routed reviewer principal owns its memory/runtime identity for this session. // A routed reviewer principal owns its memory/runtime identity for this session.
const assignedAgentId = options.agentId ?? options.task?.assignedAgentId ?? null;
const agentStore = options.agentStore;
const memoryAgent =
options.rootDir
&& agentStore
&& assignedAgentId
&& typeof (agentStore as { getAgent?: unknown }).getAgent === "function"
? await agentStore.getAgent(assignedAgentId).catch(() => null)
: null;
const memoryTools = options.rootDir && effectiveSettings?.memoryEnabled !== false const memoryTools = options.rootDir && effectiveSettings?.memoryEnabled !== false
? [ ? [
createMemorySearchTool(options.rootDir, effectiveSettings, memoryAgent ? { createMemorySearchTool(options.rootDir, effectiveSettings, memoryAgent ? {

View File

@@ -9,7 +9,7 @@ import type {
TaskDetail, TaskDetail,
WorkflowFieldDefinition, WorkflowFieldDefinition,
} from "@fusion/core"; } from "@fusion/core";
import { buildExecutionMemoryInstructions, type WorkspaceConfig } from "@fusion/core"; import { buildExecutionMemoryInstructions, buildMemoryPreSteeringNudge, type WorkspaceConfig } from "@fusion/core";
import { executorLog } from "../logger.js"; import { executorLog } from "../logger.js";
import type { PluginRunner } from "../plugins/plugin-runner.js"; import type { PluginRunner } from "../plugins/plugin-runner.js";
import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js"; import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js";
@@ -163,8 +163,8 @@ git log --oneline
let memorySection = ""; let memorySection = "";
if (memoryEnabled && rootDir && memoryMode !== "off") { if (memoryEnabled && rootDir && memoryMode !== "off") {
memorySection = memoryMode === "index" 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## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}\n`
: "\n" + buildExecutionMemoryInstructions(rootDir, settings); : "\n" + buildExecutionMemoryInstructions(rootDir, settings, undefined, memoryMode);
} }
// Build steering comments section (last 10 comments only to avoid context bloat) // Build steering comments section (last 10 comments only to avoid context bloat)

View File

@@ -85,6 +85,7 @@ import {
resolveTitleSummarizerSettingsModel, resolveTitleSummarizerSettingsModel,
resolveAgentPrompt, resolveAgentPrompt,
resolvePersistAgentThinkingLog, resolvePersistAgentThinkingLog,
resolveAgentMemoryInclusionMode,
summarizeCommitBody, summarizeCommitBody,
summarizeCommitSubject, summarizeCommitSubject,
summarizeMergeCommit, summarizeMergeCommit,
@@ -10962,7 +10963,13 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
const agents = await options.agentStore.listAgents({ role: "merger" }); const agents = await options.agentStore.listAgents({ role: "merger" });
for (const agent of agents) { for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) { if (agent.instructionsText || agent.instructionsPath) {
mergerInstructions = await resolveAgentInstructions(agent, rootDir); /*
FNXC:MemoryPreSteering 2026-08-11-11:54:
FN-8934 requires merger prompts to honor the selected principal's memory mode.
Passing the resolved mode prevents off/index settings from silently receiving full-memory steering.
*/
const memoryMode = resolveAgentMemoryInclusionMode({ agent, globalSettings: settings }).mode;
mergerInstructions = await resolveAgentInstructions(agent, rootDir, undefined, memoryMode);
break; break;
} }
} }

View File

@@ -3273,6 +3273,7 @@ export class TriageProcessor {
originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined, originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined,
planReviewFeedbackHistory, planReviewFeedbackHistory,
}, },
assignedAgent,
); );
/* /*
FNXC:TriagePlanningTimeout 2026-08-10-18:32: FNXC:TriagePlanningTimeout 2026-08-10-18:32:
@@ -5537,6 +5538,7 @@ export function buildSpecificationPrompt(
existingPrompt?: string, existingPrompt?: string,
feedback?: string, feedback?: string,
planningContext?: { plan?: string; originalDescription?: string; planReviewFeedbackHistory?: string[] }, planningContext?: { plan?: string; originalDescription?: string; planReviewFeedbackHistory?: string[] },
memoryAgent?: Agent | null,
): string { ): string {
const hasFeedback = Boolean(feedback?.trim()); const hasFeedback = Boolean(feedback?.trim());
const planDocument = planningContext?.plan?.trim(); const planDocument = planningContext?.plan?.trim();
@@ -5574,9 +5576,15 @@ When writing PROMPT.md, add this as an explicit requirement under completion doc
// When enabled, agents consult project memory for durable project learnings. // When enabled, agents consult project memory for durable project learnings.
// Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd) // Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd)
const memoryEnabled = settings?.memoryEnabled !== false; const memoryEnabled = settings?.memoryEnabled !== false;
const memoryMode = resolveAgentMemoryInclusionMode({ agent: memoryAgent, globalSettings: settings }).mode;
let memorySection = ""; let memorySection = "";
if (memoryEnabled) { /*
memorySection = "\n\n" + buildTriageMemoryInstructions("", settings); FNXC:MemoryPreSteering 2026-08-11-11:13:
FN-8934 closes triage's mode gap: project-memory instructions must follow the
same off/index/full policy as assigned-agent instructions, not always inject full memory.
*/
if (memoryEnabled && memoryMode !== "off") {
memorySection = "\n\n" + buildTriageMemoryInstructions("", settings, undefined, memoryMode);
} }
let taskDefinitionLanguageSection = ""; let taskDefinitionLanguageSection = "";