From 5a1853cac6621c73c9e0ac1536a5e7eb0d4e05b8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 11 Aug 2026 04:57:14 -0700 Subject: [PATCH] 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) --- .changeset/fn-8934-memory-pre-steering.md | 7 ++ docs/agents.md | 4 + docs/settings-reference.md | 2 + .../src/__tests__/memory-pre-steering.test.ts | 28 ++++++ .../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 + .../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 + .../app/components/AgentDetailView.css | 41 +++++++++ .../app/components/AgentDetailView.tsx | 56 +++++++++++- ...View.memory-consolidation-history.test.tsx | 58 +++++++++++++ .../__tests__/AgentDetailView.test-helpers.ts | 3 + ...gister-agent-memory-consolidations.test.ts | 87 +++++++++++++++++++ ...register-agent-reflection-rating-routes.ts | 36 ++++++++ .../memory-pre-steering-injection.test.ts | 50 +++++++++++ .../engine/src/__tests__/reviewer.test.ts | 18 ++++ packages/engine/src/__tests__/triage.test.ts | 18 +++- packages/engine/src/agent-heartbeat.ts | 14 ++- .../src/agents/agent-heartbeat-prompts.ts | 2 + .../engine/src/agents/agent-instructions.ts | 16 ++-- packages/engine/src/execution/reviewer.ts | 31 ++++--- .../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(-) create mode 100644 .changeset/fn-8934-memory-pre-steering.md create mode 100644 packages/core/src/__tests__/memory-pre-steering.test.ts create mode 100644 packages/core/src/memory/memory-pre-steering.ts create mode 100644 packages/dashboard/app/components/__tests__/AgentDetailView.memory-consolidation-history.test.tsx create mode 100644 packages/dashboard/src/routes/__tests__/register-agent-memory-consolidations.test.ts create mode 100644 packages/engine/src/__tests__/memory-pre-steering-injection.test.ts diff --git a/.changeset/fn-8934-memory-pre-steering.md b/.changeset/fn-8934-memory-pre-steering.md new file mode 100644 index 0000000000..de69c23215 --- /dev/null +++ b/.changeset/fn-8934-memory-pre-steering.md @@ -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. diff --git a/docs/agents.md b/docs/agents.md index 99d3e741c2..6df9df6c7c 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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`. 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. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 2967f22e51..64864083f2 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1945,3 +1945,5 @@ Settings → Authentication can hold multiple named credential accounts for each ### 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`. + +`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. diff --git a/packages/core/src/__tests__/memory-pre-steering.test.ts b/packages/core/src/__tests__/memory-pre-steering.test.ts new file mode 100644 index 0000000000..1e5d034e21 --- /dev/null +++ b/packages/core/src/__tests__/memory-pre-steering.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/__tests__/project-memory.test.ts b/packages/core/src/__tests__/project-memory.test.ts index a86f424d41..aeea1b2d37 100644 --- a/packages/core/src/__tests__/project-memory.test.ts +++ b/packages/core/src/__tests__/project-memory.test.ts @@ -59,6 +59,12 @@ describe("project-memory", () => { }); 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", () => { 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 ───────────────────────────────── describe("buildExecutionMemoryInstructions with backend settings", () => { diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index 1e00d27817..ca8101b670 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -1866,6 +1866,13 @@ export type { ProcessRunInput, } 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 { getDefaultMemoryScaffold, ensureMemoryFile, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d6d729ecfe..0c8979d57b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2056,6 +2056,13 @@ export type { ProcessRunInput, } 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 { getDefaultMemoryScaffold, ensureMemoryFile, diff --git a/packages/core/src/memory/index.ts b/packages/core/src/memory/index.ts index ec143b011f..eaa88cf698 100644 --- a/packages/core/src/memory/index.ts +++ b/packages/core/src/memory/index.ts @@ -10,3 +10,4 @@ export * from "./memory-insights.js"; export * from "./project-memory.js"; export * from "./recall/index.js"; export * from "./mcp/index.js"; +export * from "./memory-pre-steering.js"; diff --git a/packages/core/src/memory/memory-pre-steering.ts b/packages/core/src/memory/memory-pre-steering.ts new file mode 100644 index 0000000000..8089aac175 --- /dev/null +++ b/packages/core/src/memory/memory-pre-steering.ts @@ -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, + ); +} diff --git a/packages/core/src/memory/project-memory.ts b/packages/core/src/memory/project-memory.ts index e93b65217f..6f57028006 100644 --- a/packages/core/src/memory/project-memory.ts +++ b/packages/core/src/memory/project-memory.ts @@ -35,6 +35,8 @@ import { } from "./memory-backend.js"; import { appendRecallInstructionSection } from "./recall/recall-instructions.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 ───────────────────────────────────────────────── @@ -340,6 +342,7 @@ export function buildTriageMemoryInstructions( rootDir: string, settings?: MemorySettings, recallHits?: readonly RecallSearchHit[], + inclusionMode: AgentMemoryInclusionMode = "full", ): string { void rootDir; // Parameter kept for future use (e.g., checking file existence) const ctx = resolveMemoryInstructionContext(settings); @@ -354,6 +357,8 @@ export function buildTriageMemoryInstructions( return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has a memory system that stores durable project learnings. **Before writing the specification:** @@ -368,6 +373,8 @@ This project has a memory system that stores durable project learnings. return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has OpenClaw-style memory files: - \`.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 @@ -389,6 +396,8 @@ Do not read all memory directly by default. If memory is irrelevant, skip it. return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has a memory system that stores durable project learnings. **Before writing the specification:** @@ -427,6 +436,7 @@ export function buildExecutionMemoryInstructions( rootDir: string, settings?: MemorySettings, recallHits?: readonly RecallSearchHit[], + inclusionMode: AgentMemoryInclusionMode = "full", ): string { void rootDir; // Parameter kept for future use (e.g., checking file existence) const ctx = resolveMemoryInstructionContext(settings); @@ -441,6 +451,8 @@ export function buildExecutionMemoryInstructions( return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has a memory system that stores durable project learnings. **At the start of execution:** @@ -455,6 +467,8 @@ This project has a memory system that stores durable project learnings. return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has OpenClaw-style memory files: - \`.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 @@ -490,6 +504,8 @@ This project has OpenClaw-style memory files: return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has a memory system that stores durable project learnings accumulated from past task runs. **At the start of execution:** @@ -515,6 +531,7 @@ export function buildReviewerMemoryInstructions( rootDir: string, settings?: MemorySettings, recallHits?: readonly RecallSearchHit[], + inclusionMode: AgentMemoryInclusionMode = "full", ): string { void rootDir; const ctx = resolveMemoryInstructionContext(settings); @@ -525,6 +542,8 @@ export function buildReviewerMemoryInstructions( return appendRecallInstructionSection(` ## Project Memory +${buildMemoryPreSteeringNudge(inclusionMode)} + This project has a memory system that stores durable project learnings. **During review:** diff --git a/packages/dashboard/app/api/agents/agents.ts b/packages/dashboard/app/api/agents/agents.ts index 53f73c802e..ece168e68e 100644 --- a/packages/dashboard/app/api/agents/agents.ts +++ b/packages/dashboard/app/api/agents/agents.ts @@ -43,6 +43,18 @@ export function withAgentHeartbeatEnabled 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; +} + +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 { runId: string; createdAt: string; diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 5173c74acc..c2eca72e39 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -810,6 +810,7 @@ export { isAgentHeartbeatEnabled, fetchAgentHeartbeats, fetchAgentMemory, + fetchAgentMemoryConsolidations, fetchAgentMemoryFile, fetchAgentMemoryFiles, fetchAgentPromptSizes, @@ -840,6 +841,7 @@ export type { AgentDetail, AgentHeartbeatEvent, AgentHeartbeatRun, + MemoryConsolidationEvent, AgentPerformanceSummary, AgentPromptSizePoint, AgentReflection, diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 975bc4dee2..1d4a07c659 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -2416,3 +2416,44 @@ the tab into endless page scroll. .agent-memory-file-editor .cm-scroller { 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); } diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx index 7e026204aa..893081b68f 100644 --- a/packages/dashboard/app/components/AgentDetailView.tsx +++ b/packages/dashboard/app/components/AgentDetailView.tsx @@ -13,8 +13,8 @@ import { import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; 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 type { Agent } 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, MemoryConsolidationEvent } from "../api"; import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core"; import { AGENT_PERMISSIONS, @@ -2956,12 +2956,32 @@ function MemoryTab({ const [savingSelectedFile, setSavingSelectedFile] = useState(false); const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false); const [fileSwitchHint, setFileSwitchHint] = useState(""); + const [consolidations, setConsolidations] = useState([]); + const [consolidationsLoading, setConsolidationsLoading] = useState(false); + const [consolidationsError, setConsolidationsError] = useState(""); const justSavedTimeoutRef = useRef | null>(null); const selectedFileJustSavedTimeoutRef = useRef | null>(null); const isReadOnly = agent.state === "running"; + const isMemoryKeeper = agent.metadata?.builtInMemoryAgent === true; 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( () => memoryFiles.find((file) => file.path === selectedFilePath), [memoryFiles, selectedFilePath], @@ -3104,6 +3124,38 @@ function MemoryTab({

{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.")}

+ {isMemoryKeeper && ( +
+ {/* + 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. + */} +
+

{t("agents.consolidationHistory", "Consolidation history")}

+ +
+ {consolidationsLoading ? : consolidationsError ? ( +

{t("agents.consolidationHistoryError", "Unable to load consolidation history: {{error}}", { error: consolidationsError })}

+ ) : consolidations.length === 0 ? ( +

{t("agents.consolidationHistoryEmpty", "No consolidation activity yet.")}

+ ) : ( +
    + {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
  • + {relativeTime(event.timestamp, t)}{outcome}{details.length > 0 && {details.join(" · ")}} +
  • ; + })} +
+ )} +
+ )} + {isReadOnly && (

{t("agents.memoryReadOnly", "Read-only while this agent is running.")} diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.memory-consolidation-history.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.memory-consolidation-history.test.tsx new file mode 100644 index 0000000000..02b399f304 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.memory-consolidation-history.test.tsx @@ -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(); + + 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(); + + 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(); + + await openMemoryTab(); + expect(screen.queryByLabelText("Consolidation history")).not.toBeInTheDocument(); + expect(mockFetchAgentMemoryConsolidations).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts b/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts index bf454dd939..8a88c87b2d 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.test-helpers.ts @@ -24,6 +24,7 @@ export const mockUpdateAgentInstructions = vi.fn(); export const mockUpdateAgentMemory = vi.fn(); export const mockFetchAgentMemoryFiles = vi.fn(); +export const mockFetchAgentMemoryConsolidations = vi.fn(); export const mockFetchAgentMemoryFile = vi.fn(); export const mockSaveAgentMemoryFile = vi.fn(); export const mockFetchWorkspaceFileContent = vi.fn(); @@ -72,6 +73,7 @@ vi.mock("../../api", () => ({ updateAgentSoul: (...args: Parameters) => mockUpdateAgentSoul(...args), updateAgentMemory: (...args: Parameters) => mockUpdateAgentMemory(...args), fetchAgentMemoryFiles: (...args: Parameters) => mockFetchAgentMemoryFiles(...args), + fetchAgentMemoryConsolidations: (...args: Parameters) => mockFetchAgentMemoryConsolidations(...args), fetchAgentMemoryFile: (...args: Parameters) => mockFetchAgentMemoryFile(...args), saveAgentMemoryFile: (...args: Parameters) => mockSaveAgentMemoryFile(...args), fetchAgentTasks: (...args: Parameters) => mockFetchAgentTasks(...args), @@ -346,6 +348,7 @@ export function setupAgentDetailMocks() { 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 }); mockUpdateAgentInstructions.mockResolvedValue({} as any); + mockFetchAgentMemoryConsolidations.mockResolvedValue({ agentId: "agent-001", events: [] }); mockFetchAgentMemoryFiles.mockResolvedValue({ files: [ { diff --git a/packages/dashboard/src/routes/__tests__/register-agent-memory-consolidations.test.ts b/packages/dashboard/src/routes/__tests__/register-agent-memory-consolidations.test.ts new file mode 100644 index 0000000000..8be1a79d4b --- /dev/null +++ b/packages/dashboard/src/routes/__tests__/register-agent-memory-consolidations.test.ts @@ -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("@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); + }); +}); diff --git a/packages/dashboard/src/routes/register-agent-reflection-rating-routes.ts b/packages/dashboard/src/routes/register-agent-reflection-rating-routes.ts index f06b23edae..4900f0e890 100644 --- a/packages/dashboard/src/routes/register-agent-reflection-rating-routes.ts +++ b/packages/dashboard/src/routes/register-agent-reflection-rating-routes.ts @@ -21,6 +21,42 @@ export function __setAgentReflectionServiceForTests(service: EngineAgentReflecti export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void { 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 * Fetch the most recent reflection for an agent. diff --git a/packages/engine/src/__tests__/memory-pre-steering-injection.test.ts b/packages/engine/src/__tests__/memory-pre-steering-injection.test.ts new file mode 100644 index 0000000000..120f8018ad --- /dev/null +++ b/packages/engine/src/__tests__/memory-pre-steering-injection.test.ts @@ -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)'); + }); +}); diff --git a/packages/engine/src/__tests__/reviewer.test.ts b/packages/engine/src/__tests__/reviewer.test.ts index 2245934917..c1aa97f6e7 100644 --- a/packages/engine/src/__tests__/reviewer.test.ts +++ b/packages/engine/src/__tests__/reviewer.test.ts @@ -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"]); }); + 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 () => { mockedCreateFnAgent.mockResolvedValue( createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."), diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 149fcbcdd1..6c341653a2 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -1,5 +1,5 @@ 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 { TriageProcessor, @@ -571,6 +571,22 @@ describe("buildSpecificationPrompt", () => { }); 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", () => { const settings: Settings = { maxConcurrent: 2, diff --git a/packages/engine/src/agent-heartbeat.ts b/packages/engine/src/agent-heartbeat.ts index b70be8dd94..f67fef7980 100644 --- a/packages/engine/src/agent-heartbeat.ts +++ b/packages/engine/src/agent-heartbeat.ts @@ -22,6 +22,7 @@ import { AutoClaimSnapshotManager, resolveFreshAutoClaimCandidates, type AutoCla import { ApprovalRequestStore, buildExecutionMemoryInstructions, + buildMemoryPreSteeringNudge, isEphemeralAgent, hasAgentIdentity, resolveEffectiveAgentPermissionPolicy, @@ -135,13 +136,18 @@ import { accumulateSessionTokenUsage, captureSessionTokenBaseline } from "./exec 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 { 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"); 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", + `\n${buildMemoryPreSteeringNudge("index")}\n`, ); } @@ -2840,8 +2846,8 @@ export class HeartbeatMonitor { if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) { try { 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); + ? `## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}` + : buildExecutionMemoryInstructions(rootDir, memorySettings, undefined, resolvedMemoryMode.mode); } catch (memoryInstructionErr) { const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr); heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`); diff --git a/packages/engine/src/agents/agent-heartbeat-prompts.ts b/packages/engine/src/agents/agent-heartbeat-prompts.ts index 12f13edee6..d6d0998836 100644 --- a/packages/engine/src/agents/agent-heartbeat-prompts.ts +++ b/packages/engine/src/agents/agent-heartbeat-prompts.ts @@ -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. - 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. +- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get. ## 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. - Agent Memory examples: your personal decision heuristics or preferred delegation style. - 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 diff --git a/packages/engine/src/agents/agent-instructions.ts b/packages/engine/src/agents/agent-instructions.ts index 53229e2151..247237bdc0 100644 --- a/packages/engine/src/agents/agent-instructions.ts +++ b/packages/engine/src/agents/agent-instructions.ts @@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs"; import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path"; import { readProjectMemory, + buildMemoryPreSteeringNudge, type Agent, type AgentMemoryInclusionMode, type AgentRatingSummary, @@ -203,15 +204,12 @@ async function formatMemorySection( 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, + buildMemoryPreSteeringNudge("index"), + ...(indexBody ? ["", indexBody] : []), ].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.", "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; basePrompt: string; includeProjectMemory?: boolean; + inclusionMode?: AgentMemoryInclusionMode; }): Promise { - 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 identitySection = `## Identity\n\nYou are ${agent.name}${titleSuffix} (agent ID: ${agent.id}, role: ${agent.role}).`; const instructionParts = [identitySection]; - const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore); + const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore, inclusionMode); if (resolvedInstructions.trim()) { instructionParts.push(resolvedInstructions); } - if (includeProjectMemory) { + if (includeProjectMemory && inclusionMode !== "off") { try { const projectMemory = (await readProjectMemory(rootDir)).trim(); if (projectMemory) { diff --git a/packages/engine/src/execution/reviewer.ts b/packages/engine/src/execution/reviewer.ts index c4ce4bfa34..85c8ee93d6 100644 --- a/packages/engine/src/execution/reviewer.ts +++ b/packages/engine/src/execution/reviewer.ts @@ -340,6 +340,18 @@ export async function reviewStep( const validatorFallbackProvider = validatorFallback.provider; 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 = ""; if (options.agentStore && options.rootDir) { try { @@ -375,8 +387,14 @@ export async function reviewStep( // 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. 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( @@ -411,15 +429,6 @@ export async function reviewStep( } // 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 ? [ createMemorySearchTool(options.rootDir, effectiveSettings, memoryAgent ? { diff --git a/packages/engine/src/executor/execution-prompt.ts b/packages/engine/src/executor/execution-prompt.ts index 573c366c99..9c105bbd7c 100644 --- a/packages/engine/src/executor/execution-prompt.ts +++ b/packages/engine/src/executor/execution-prompt.ts @@ -9,7 +9,7 @@ import type { TaskDetail, WorkflowFieldDefinition, } from "@fusion/core"; -import { buildExecutionMemoryInstructions, type WorkspaceConfig } from "@fusion/core"; +import { buildExecutionMemoryInstructions, buildMemoryPreSteeringNudge, type WorkspaceConfig } from "@fusion/core"; import { executorLog } from "../logger.js"; import type { PluginRunner } from "../plugins/plugin-runner.js"; import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js"; @@ -163,8 +163,8 @@ git log --oneline let memorySection = ""; 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); + ? `\n## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}\n` + : "\n" + buildExecutionMemoryInstructions(rootDir, settings, undefined, memoryMode); } // Build steering comments section (last 10 comments only to avoid context bloat) diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts index e14f349715..4213c456d0 100644 --- a/packages/engine/src/merger.ts +++ b/packages/engine/src/merger.ts @@ -85,6 +85,7 @@ import { resolveTitleSummarizerSettingsModel, resolveAgentPrompt, resolvePersistAgentThinkingLog, + resolveAgentMemoryInclusionMode, summarizeCommitBody, summarizeCommitSubject, summarizeMergeCommit, @@ -10962,7 +10963,13 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo const agents = await options.agentStore.listAgents({ role: "merger" }); for (const agent of agents) { 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; } } diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index b7a98451bd..35ded21a12 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -3273,6 +3273,7 @@ export class TriageProcessor { originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined, planReviewFeedbackHistory, }, + assignedAgent, ); /* FNXC:TriagePlanningTimeout 2026-08-10-18:32: @@ -5537,6 +5538,7 @@ export function buildSpecificationPrompt( existingPrompt?: string, feedback?: string, planningContext?: { plan?: string; originalDescription?: string; planReviewFeedbackHistory?: string[] }, + memoryAgent?: Agent | null, ): string { const hasFeedback = Boolean(feedback?.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. // Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd) const memoryEnabled = settings?.memoryEnabled !== false; + const memoryMode = resolveAgentMemoryInclusionMode({ agent: memoryAgent, globalSettings: settings }).mode; 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 = "";