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:
7
.changeset/fn-8934-memory-pre-steering.md
Normal file
7
.changeset/fn-8934-memory-pre-steering.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
28
packages/core/src/__tests__/memory-pre-steering.test.ts
Normal file
28
packages/core/src/__tests__/memory-pre-steering.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
41
packages/core/src/memory/memory-pre-steering.ts
Normal file
41
packages/core/src/memory/memory-pre-steering.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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:**
|
||||
|
||||
@@ -43,6 +43,18 @@ export function withAgentHeartbeatEnabled<T extends Pick<Agent, "runtimeConfig">
|
||||
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 {
|
||||
runId: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<MemoryConsolidationEvent[]>([]);
|
||||
const [consolidationsLoading, setConsolidationsLoading] = useState(false);
|
||||
const [consolidationsError, setConsolidationsError] = useState("");
|
||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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({
|
||||
<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.")}
|
||||
</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 && (
|
||||
<p className="config-hint config-hint--block-spacing">
|
||||
{t("agents.memoryReadOnly", "Read-only while this agent is running.")}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export const mockUpdateAgentInstructions = vi.fn<ApiModule["updateAgentInstructi
|
||||
export const mockUpdateAgentSoul = vi.fn<ApiModule["updateAgentSoul"]>();
|
||||
export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>();
|
||||
export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>();
|
||||
export const mockFetchAgentMemoryConsolidations = vi.fn<ApiModule["fetchAgentMemoryConsolidations"]>();
|
||||
export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>();
|
||||
export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>();
|
||||
export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>();
|
||||
@@ -72,6 +73,7 @@ vi.mock("../../api", () => ({
|
||||
updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args),
|
||||
updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args),
|
||||
fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args),
|
||||
fetchAgentMemoryConsolidations: (...args: Parameters<ApiModule["fetchAgentMemoryConsolidations"]>) => mockFetchAgentMemoryConsolidations(...args),
|
||||
fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args),
|
||||
saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...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 });
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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."),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<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 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) {
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
Reference in New Issue
Block a user