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`.
|
Permanent agents carry one or more normalized role tags: `triage`, `executor`, `reviewer`, `merger`, `scheduler`, `engineer`, and `custom`. Upgrades preserve legacy singular roles, and every project receives four distinct heartbeat-disabled built-ins for planning, execution, review, and merge. Heartbeat enablement and `maxConcurrentRuns` are independent from `runtimeConfig.maxWorkflowSessions`.
|
||||||
|
|
||||||
Workflow routing never changes `assignedAgentId`. An explicit task owner runs classified stages regardless of its tags, except for an exact reviewer-node override. Otherwise a column binding is considered, then an available role-tag pool is selected by fewest active workflow sessions, oldest creation time, and ID. A named unavailable principal holds work rather than falling back.
|
Workflow routing never changes `assignedAgentId`. An explicit task owner runs classified stages regardless of its tags, except for an exact reviewer-node override. Otherwise a column binding is considered, then an available role-tag pool is selected by fewest active workflow sessions, oldest creation time, and ID. A named unavailable principal holds work rather than falling back.
|
||||||
|
|
||||||
|
### Memory-first steering and consolidation history
|
||||||
|
|
||||||
|
Agent instruction assembly uses the resolved `agentMemoryInclusionMode` across triage, execution, review, heartbeat, and chat lanes. `full` asks agents to query memory before re-reading raw sources, `index` keeps that direction terse, and `off` omits it. Operators can inspect the built-in Memory Keeper's compact consolidation audit history in **Agent Detail → Agent Memory**; it shows only completion outcomes and existing audit counts/reasons, never memory content.
|
||||||
|
|||||||
@@ -1945,3 +1945,5 @@ Settings → Authentication can hold multiple named credential accounts for each
|
|||||||
### Knowledge graph artifact location
|
### Knowledge graph artifact location
|
||||||
|
|
||||||
`knowledgeGraphDir` is an optional project setting. Its default is `.fusion-knowledge/graph`; keep it outside `.fusion`, which is ignored. The directory is intentionally committable and is refreshed with `fn knowledge-graph build`.
|
`knowledgeGraphDir` is an optional project setting. Its default is `.fusion-knowledge/graph`; keep it outside `.fusion`, which is ignored. The directory is intentionally committable and is refreshed with `fn knowledge-graph build`.
|
||||||
|
|
||||||
|
`agentMemoryInclusionMode` also controls memory-first pre-steering across triage, execution, review, heartbeat, and agent chat instruction assembly: `full` supplies detailed search-first guidance, `index` supplies a terse form, and `off` suppresses it.
|
||||||
|
|||||||
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", () => {
|
describe("buildReviewerMemoryInstructions", () => {
|
||||||
|
it("injects full steering for its readable backend and honors inclusion mode", () => {
|
||||||
|
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" })).toContain("query memory before re-reading");
|
||||||
|
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" }, undefined, "index")).toContain("Memory-first:");
|
||||||
|
expect(buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" }, undefined, "off")).not.toContain("query memory before re-reading");
|
||||||
|
});
|
||||||
|
|
||||||
it("gives reviewers read-only project memory guidance", () => {
|
it("gives reviewers read-only project memory guidance", () => {
|
||||||
const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" });
|
const instructions = buildReviewerMemoryInstructions(testDir, { memoryBackendType: "qmd" });
|
||||||
|
|
||||||
@@ -588,6 +594,22 @@ describe("project-memory", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("pre-steering coverage for project-memory builders", () => {
|
||||||
|
const builders = [buildTriageMemoryInstructions, buildExecutionMemoryInstructions] as const;
|
||||||
|
const readableBackends = ["readonly", "file", "qmd"] as const;
|
||||||
|
|
||||||
|
it.each(builders.flatMap((builder) => readableBackends.map((memoryBackendType) => [builder.name, builder, memoryBackendType] as const)))("injects full steering into %s %s backend", (_name, builder, memoryBackendType) => {
|
||||||
|
expect(builder(testDir, { memoryBackendType })).toContain("query memory before re-reading");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the terse index nudge and suppresses it when off", () => {
|
||||||
|
for (const builder of builders) {
|
||||||
|
expect(builder(testDir, { memoryBackendType: "qmd" }, undefined, "index")).toContain("Memory-first:");
|
||||||
|
expect(builder(testDir, { memoryBackendType: "qmd" }, undefined, "off")).not.toContain("query memory before re-reading");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── Backend-aware buildExecutionMemoryInstructions ─────────────────────────────────
|
// ── Backend-aware buildExecutionMemoryInstructions ─────────────────────────────────
|
||||||
|
|
||||||
describe("buildExecutionMemoryInstructions with backend settings", () => {
|
describe("buildExecutionMemoryInstructions with backend settings", () => {
|
||||||
|
|||||||
@@ -1866,6 +1866,13 @@ export type {
|
|||||||
ProcessRunInput,
|
ProcessRunInput,
|
||||||
} from "./memory/memory-insights.js";
|
} from "./memory/memory-insights.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildMemoryPreSteeringNudge,
|
||||||
|
MEMORY_PRE_STEERING_MARKER,
|
||||||
|
MAX_PRE_STEERING_FULL_BYTES,
|
||||||
|
MAX_PRE_STEERING_INDEX_BYTES,
|
||||||
|
} from "./memory/memory-pre-steering.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getDefaultMemoryScaffold,
|
getDefaultMemoryScaffold,
|
||||||
ensureMemoryFile,
|
ensureMemoryFile,
|
||||||
|
|||||||
@@ -2056,6 +2056,13 @@ export type {
|
|||||||
ProcessRunInput,
|
ProcessRunInput,
|
||||||
} from "./memory/memory-insights.js";
|
} from "./memory/memory-insights.js";
|
||||||
|
|
||||||
|
export {
|
||||||
|
buildMemoryPreSteeringNudge,
|
||||||
|
MEMORY_PRE_STEERING_MARKER,
|
||||||
|
MAX_PRE_STEERING_FULL_BYTES,
|
||||||
|
MAX_PRE_STEERING_INDEX_BYTES,
|
||||||
|
} from "./memory/memory-pre-steering.js";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getDefaultMemoryScaffold,
|
getDefaultMemoryScaffold,
|
||||||
ensureMemoryFile,
|
ensureMemoryFile,
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from "./memory-insights.js";
|
|||||||
export * from "./project-memory.js";
|
export * from "./project-memory.js";
|
||||||
export * from "./recall/index.js";
|
export * from "./recall/index.js";
|
||||||
export * from "./mcp/index.js";
|
export * from "./mcp/index.js";
|
||||||
|
export * from "./memory-pre-steering.js";
|
||||||
|
|||||||
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";
|
} from "./memory-backend.js";
|
||||||
import { appendRecallInstructionSection } from "./recall/recall-instructions.js";
|
import { appendRecallInstructionSection } from "./recall/recall-instructions.js";
|
||||||
import type { RecallSearchHit } from "./recall/recall-types.js";
|
import type { RecallSearchHit } from "./recall/recall-types.js";
|
||||||
|
import { buildMemoryPreSteeringNudge } from "./memory-pre-steering.js";
|
||||||
|
import type { AgentMemoryInclusionMode } from "../types/settings/settings-scope.js";
|
||||||
|
|
||||||
// ── Default Scaffold ─────────────────────────────────────────────────
|
// ── Default Scaffold ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -340,6 +342,7 @@ export function buildTriageMemoryInstructions(
|
|||||||
rootDir: string,
|
rootDir: string,
|
||||||
settings?: MemorySettings,
|
settings?: MemorySettings,
|
||||||
recallHits?: readonly RecallSearchHit[],
|
recallHits?: readonly RecallSearchHit[],
|
||||||
|
inclusionMode: AgentMemoryInclusionMode = "full",
|
||||||
): string {
|
): string {
|
||||||
void rootDir; // Parameter kept for future use (e.g., checking file existence)
|
void rootDir; // Parameter kept for future use (e.g., checking file existence)
|
||||||
const ctx = resolveMemoryInstructionContext(settings);
|
const ctx = resolveMemoryInstructionContext(settings);
|
||||||
@@ -354,6 +357,8 @@ export function buildTriageMemoryInstructions(
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has a memory system that stores durable project learnings.
|
This project has a memory system that stores durable project learnings.
|
||||||
|
|
||||||
**Before writing the specification:**
|
**Before writing the specification:**
|
||||||
@@ -368,6 +373,8 @@ This project has a memory system that stores durable project learnings.
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has OpenClaw-style memory files:
|
This project has OpenClaw-style memory files:
|
||||||
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
|
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
|
||||||
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running context
|
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running context
|
||||||
@@ -389,6 +396,8 @@ Do not read all memory directly by default. If memory is irrelevant, skip it.
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has a memory system that stores durable project learnings.
|
This project has a memory system that stores durable project learnings.
|
||||||
|
|
||||||
**Before writing the specification:**
|
**Before writing the specification:**
|
||||||
@@ -427,6 +436,7 @@ export function buildExecutionMemoryInstructions(
|
|||||||
rootDir: string,
|
rootDir: string,
|
||||||
settings?: MemorySettings,
|
settings?: MemorySettings,
|
||||||
recallHits?: readonly RecallSearchHit[],
|
recallHits?: readonly RecallSearchHit[],
|
||||||
|
inclusionMode: AgentMemoryInclusionMode = "full",
|
||||||
): string {
|
): string {
|
||||||
void rootDir; // Parameter kept for future use (e.g., checking file existence)
|
void rootDir; // Parameter kept for future use (e.g., checking file existence)
|
||||||
const ctx = resolveMemoryInstructionContext(settings);
|
const ctx = resolveMemoryInstructionContext(settings);
|
||||||
@@ -441,6 +451,8 @@ export function buildExecutionMemoryInstructions(
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has a memory system that stores durable project learnings.
|
This project has a memory system that stores durable project learnings.
|
||||||
|
|
||||||
**At the start of execution:**
|
**At the start of execution:**
|
||||||
@@ -455,6 +467,8 @@ This project has a memory system that stores durable project learnings.
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has OpenClaw-style memory files:
|
This project has OpenClaw-style memory files:
|
||||||
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
|
- \`.fusion/memory/MEMORY.md\` — curated long-term memory for durable decisions, conventions, and pitfalls
|
||||||
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running observations and open loops
|
- \`.fusion/memory/YYYY-MM-DD.md\` — append-only daily notes for running observations and open loops
|
||||||
@@ -490,6 +504,8 @@ This project has OpenClaw-style memory files:
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has a memory system that stores durable project learnings accumulated from past task runs.
|
This project has a memory system that stores durable project learnings accumulated from past task runs.
|
||||||
|
|
||||||
**At the start of execution:**
|
**At the start of execution:**
|
||||||
@@ -515,6 +531,7 @@ export function buildReviewerMemoryInstructions(
|
|||||||
rootDir: string,
|
rootDir: string,
|
||||||
settings?: MemorySettings,
|
settings?: MemorySettings,
|
||||||
recallHits?: readonly RecallSearchHit[],
|
recallHits?: readonly RecallSearchHit[],
|
||||||
|
inclusionMode: AgentMemoryInclusionMode = "full",
|
||||||
): string {
|
): string {
|
||||||
void rootDir;
|
void rootDir;
|
||||||
const ctx = resolveMemoryInstructionContext(settings);
|
const ctx = resolveMemoryInstructionContext(settings);
|
||||||
@@ -525,6 +542,8 @@ export function buildReviewerMemoryInstructions(
|
|||||||
return appendRecallInstructionSection(`
|
return appendRecallInstructionSection(`
|
||||||
## Project Memory
|
## Project Memory
|
||||||
|
|
||||||
|
${buildMemoryPreSteeringNudge(inclusionMode)}
|
||||||
|
|
||||||
This project has a memory system that stores durable project learnings.
|
This project has a memory system that stores durable project learnings.
|
||||||
|
|
||||||
**During review:**
|
**During review:**
|
||||||
|
|||||||
@@ -43,6 +43,18 @@ export function withAgentHeartbeatEnabled<T extends Pick<Agent, "runtimeConfig">
|
|||||||
return { ...(agent.runtimeConfig ?? {}), enabled };
|
return { ...(agent.runtimeConfig ?? {}), enabled };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemoryConsolidationEvent {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
mutationType: "memory:consolidation-completed" | "memory:consolidation-skipped" | "memory:consolidation-failed";
|
||||||
|
runId: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchAgentMemoryConsolidations(agentId: string, limit = 50, projectId?: string): Promise<{ agentId: string; events: MemoryConsolidationEvent[] }> {
|
||||||
|
return api<{ agentId: string; events: MemoryConsolidationEvent[] }>(withProjectId(`/agents/${encodeURIComponent(agentId)}/memory-consolidations?limit=${Math.min(Math.max(Math.trunc(limit), 1), 100)}`, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentPromptSizePoint {
|
export interface AgentPromptSizePoint {
|
||||||
runId: string;
|
runId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -810,6 +810,7 @@ export {
|
|||||||
isAgentHeartbeatEnabled,
|
isAgentHeartbeatEnabled,
|
||||||
fetchAgentHeartbeats,
|
fetchAgentHeartbeats,
|
||||||
fetchAgentMemory,
|
fetchAgentMemory,
|
||||||
|
fetchAgentMemoryConsolidations,
|
||||||
fetchAgentMemoryFile,
|
fetchAgentMemoryFile,
|
||||||
fetchAgentMemoryFiles,
|
fetchAgentMemoryFiles,
|
||||||
fetchAgentPromptSizes,
|
fetchAgentPromptSizes,
|
||||||
@@ -840,6 +841,7 @@ export type {
|
|||||||
AgentDetail,
|
AgentDetail,
|
||||||
AgentHeartbeatEvent,
|
AgentHeartbeatEvent,
|
||||||
AgentHeartbeatRun,
|
AgentHeartbeatRun,
|
||||||
|
MemoryConsolidationEvent,
|
||||||
AgentPerformanceSummary,
|
AgentPerformanceSummary,
|
||||||
AgentPromptSizePoint,
|
AgentPromptSizePoint,
|
||||||
AgentReflection,
|
AgentReflection,
|
||||||
|
|||||||
@@ -2416,3 +2416,44 @@ the tab into endless page scroll.
|
|||||||
.agent-memory-file-editor .cm-scroller {
|
.agent-memory-file-editor .cm-scroller {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history {
|
||||||
|
margin-block: var(--space-md);
|
||||||
|
padding: var(--space-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history__heading,
|
||||||
|
.memory-consolidation-history__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history__heading h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history__list {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin: var(--space-sm) 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history__row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-consolidation-history__row--completed strong { color: var(--color-success); }
|
||||||
|
.memory-consolidation-history__row--skipped strong { color: var(--color-warning); }
|
||||||
|
.memory-consolidation-history__row--failed strong { color: var(--color-error); }
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo, SkillContent, AgentOnboardingSummary, AgentMailboxResponse, AgentPromptSizePoint } from "../api";
|
||||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, isAgentHeartbeatEnabled, withAgentHeartbeatEnabled, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, fetchAgentMemoryConsolidations, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, fetchSettings, fetchSettingsByScope, upgradeAgentHeartbeatProcedure, fetchSkillContent, uploadAgentAvatar, deleteAgentAvatar, fetchAgentMailbox, markMessageRead, fetchAgentPromptSizes } from "../api";
|
||||||
import type { Agent } from "../api";
|
import type { Agent, MemoryConsolidationEvent } from "../api";
|
||||||
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core";
|
import type { AgentLogEntry, Task, Message, ParticipantType, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermission, ThinkingLevel, Settings as CoreSettings } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
AGENT_PERMISSIONS,
|
AGENT_PERMISSIONS,
|
||||||
@@ -2956,12 +2956,32 @@ function MemoryTab({
|
|||||||
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
|
const [savingSelectedFile, setSavingSelectedFile] = useState(false);
|
||||||
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
|
const [selectedFileJustSaved, setSelectedFileJustSaved] = useState(false);
|
||||||
const [fileSwitchHint, setFileSwitchHint] = useState("");
|
const [fileSwitchHint, setFileSwitchHint] = useState("");
|
||||||
|
const [consolidations, setConsolidations] = useState<MemoryConsolidationEvent[]>([]);
|
||||||
|
const [consolidationsLoading, setConsolidationsLoading] = useState(false);
|
||||||
|
const [consolidationsError, setConsolidationsError] = useState("");
|
||||||
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const justSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const selectedFileJustSavedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const isReadOnly = agent.state === "running";
|
const isReadOnly = agent.state === "running";
|
||||||
|
const isMemoryKeeper = agent.metadata?.builtInMemoryAgent === true;
|
||||||
const hasInlineChanges = memory !== (agent.memory ?? "");
|
const hasInlineChanges = memory !== (agent.memory ?? "");
|
||||||
|
|
||||||
|
const loadConsolidations = useCallback(async () => {
|
||||||
|
if (!isMemoryKeeper) return;
|
||||||
|
setConsolidationsLoading(true);
|
||||||
|
setConsolidationsError("");
|
||||||
|
try {
|
||||||
|
const result = await fetchAgentMemoryConsolidations(agent.id, 50, projectId);
|
||||||
|
setConsolidations(result.events);
|
||||||
|
} catch (err) {
|
||||||
|
setConsolidationsError(getErrorMessage(err));
|
||||||
|
} finally {
|
||||||
|
setConsolidationsLoading(false);
|
||||||
|
}
|
||||||
|
}, [agent.id, isMemoryKeeper, projectId]);
|
||||||
|
|
||||||
|
useEffect(() => { void loadConsolidations(); }, [loadConsolidations]);
|
||||||
|
|
||||||
const selectedMemoryFile = useMemo(
|
const selectedMemoryFile = useMemo(
|
||||||
() => memoryFiles.find((file) => file.path === selectedFilePath),
|
() => memoryFiles.find((file) => file.path === selectedFilePath),
|
||||||
[memoryFiles, selectedFilePath],
|
[memoryFiles, selectedFilePath],
|
||||||
@@ -3104,6 +3124,38 @@ function MemoryTab({
|
|||||||
<p className="config-description">
|
<p className="config-description">
|
||||||
{t("agents.memoryDescription", "Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.")}
|
{t("agents.memoryDescription", "Store context that belongs to this agent only. Workspace memory, daily notes, dreams, and qmd search live in project settings under Project Memory.")}
|
||||||
</p>
|
</p>
|
||||||
|
{isMemoryKeeper && (
|
||||||
|
<section className="memory-consolidation-history" aria-label={t("agents.consolidationHistory", "Consolidation history")}>
|
||||||
|
{/*
|
||||||
|
FNXC:MemoryConsolidationHistory 2026-08-11-11:13:
|
||||||
|
FN-8934 keeps Memory Keeper history in its existing Memory tab and renders
|
||||||
|
only existing audit metadata, never memory content or synthesized audit prose.
|
||||||
|
*/}
|
||||||
|
<div className="memory-consolidation-history__heading">
|
||||||
|
<h4>{t("agents.consolidationHistory", "Consolidation history")}</h4>
|
||||||
|
<button className="btn btn-sm" onClick={() => void loadConsolidations()} disabled={consolidationsLoading}>
|
||||||
|
<RefreshCw size={14} />{t("common.refresh", "Refresh")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{consolidationsLoading ? <LoadingSpinner /> : consolidationsError ? (
|
||||||
|
<p className="config-hint">{t("agents.consolidationHistoryError", "Unable to load consolidation history: {{error}}", { error: consolidationsError })}</p>
|
||||||
|
) : consolidations.length === 0 ? (
|
||||||
|
<p className="config-hint">{t("agents.consolidationHistoryEmpty", "No consolidation activity yet.")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="memory-consolidation-history__list">
|
||||||
|
{consolidations.map((event) => {
|
||||||
|
const outcome = event.mutationType.endsWith("completed") ? t("agents.consolidationCompleted", "Completed") : event.mutationType.endsWith("skipped") ? t("agents.consolidationSkipped", "Skipped") : t("agents.consolidationFailed", "Failed");
|
||||||
|
const metadata = event.metadata ?? {};
|
||||||
|
const details = ["graphChanged", "parsedFiles", "recallCreated", "reason", "stage"].flatMap((key) => metadata[key] === undefined ? [] : [`${key}: ${String(metadata[key])}`]);
|
||||||
|
return <li key={event.id} className={`memory-consolidation-history__row memory-consolidation-history__row--${event.mutationType.split("-").at(-1)}`}>
|
||||||
|
<span>{relativeTime(event.timestamp, t)}</span><strong>{outcome}</strong>{details.length > 0 && <span>{details.join(" · ")}</span>}
|
||||||
|
</li>;
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{isReadOnly && (
|
{isReadOnly && (
|
||||||
<p className="config-hint config-hint--block-spacing">
|
<p className="config-hint config-hint--block-spacing">
|
||||||
{t("agents.memoryReadOnly", "Read-only while this agent is running.")}
|
{t("agents.memoryReadOnly", "Read-only while this agent is running.")}
|
||||||
|
|||||||
@@ -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 mockUpdateAgentSoul = vi.fn<ApiModule["updateAgentSoul"]>();
|
||||||
export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>();
|
export const mockUpdateAgentMemory = vi.fn<ApiModule["updateAgentMemory"]>();
|
||||||
export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>();
|
export const mockFetchAgentMemoryFiles = vi.fn<ApiModule["fetchAgentMemoryFiles"]>();
|
||||||
|
export const mockFetchAgentMemoryConsolidations = vi.fn<ApiModule["fetchAgentMemoryConsolidations"]>();
|
||||||
export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>();
|
export const mockFetchAgentMemoryFile = vi.fn<ApiModule["fetchAgentMemoryFile"]>();
|
||||||
export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>();
|
export const mockSaveAgentMemoryFile = vi.fn<ApiModule["saveAgentMemoryFile"]>();
|
||||||
export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>();
|
export const mockFetchWorkspaceFileContent = vi.fn<ApiModule["fetchWorkspaceFileContent"]>();
|
||||||
@@ -72,6 +73,7 @@ vi.mock("../../api", () => ({
|
|||||||
updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args),
|
updateAgentSoul: (...args: Parameters<ApiModule["updateAgentSoul"]>) => mockUpdateAgentSoul(...args),
|
||||||
updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args),
|
updateAgentMemory: (...args: Parameters<ApiModule["updateAgentMemory"]>) => mockUpdateAgentMemory(...args),
|
||||||
fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args),
|
fetchAgentMemoryFiles: (...args: Parameters<ApiModule["fetchAgentMemoryFiles"]>) => mockFetchAgentMemoryFiles(...args),
|
||||||
|
fetchAgentMemoryConsolidations: (...args: Parameters<ApiModule["fetchAgentMemoryConsolidations"]>) => mockFetchAgentMemoryConsolidations(...args),
|
||||||
fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args),
|
fetchAgentMemoryFile: (...args: Parameters<ApiModule["fetchAgentMemoryFile"]>) => mockFetchAgentMemoryFile(...args),
|
||||||
saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...args),
|
saveAgentMemoryFile: (...args: Parameters<ApiModule["saveAgentMemoryFile"]>) => mockSaveAgentMemoryFile(...args),
|
||||||
fetchAgentTasks: (...args: Parameters<ApiModule["fetchAgentTasks"]>) => mockFetchAgentTasks(...args),
|
fetchAgentTasks: (...args: Parameters<ApiModule["fetchAgentTasks"]>) => mockFetchAgentTasks(...args),
|
||||||
@@ -346,6 +348,7 @@ export function setupAgentDetailMocks() {
|
|||||||
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
mockFetchWorkspaceFileContent.mockResolvedValue({ content: "", mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
mockSaveWorkspaceFileContent.mockResolvedValue({ success: true, mtime: "2024-01-01T00:00:00.000Z", size: 0 });
|
||||||
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
mockUpdateAgentInstructions.mockResolvedValue({} as any);
|
||||||
|
mockFetchAgentMemoryConsolidations.mockResolvedValue({ agentId: "agent-001", events: [] });
|
||||||
mockFetchAgentMemoryFiles.mockResolvedValue({
|
mockFetchAgentMemoryFiles.mockResolvedValue({
|
||||||
files: [
|
files: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {
|
export function registerAgentReflectionRatingRoutes(ctx: ApiRoutesContext): void {
|
||||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MemoryConsolidationHistory 2026-08-11-11:13:
|
||||||
|
FN-8934 exposes only the compact, existing FN-8932 consolidation audit rows.
|
||||||
|
The route intentionally passes through structured metadata without inventing memory prose.
|
||||||
|
*/
|
||||||
|
router.get("/agents/:id/memory-consolidations", async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { store: scopedStore } = await getProjectContext(req);
|
||||||
|
const { AgentStore } = await import("@fusion/core");
|
||||||
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), asyncLayer: scopedStore.getAsyncLayer() ?? undefined });
|
||||||
|
await agentStore.init();
|
||||||
|
const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
||||||
|
if (!agentId) throw badRequest("Agent id is required");
|
||||||
|
if (!await agentStore.getAgent(agentId)) throw notFound("Agent not found");
|
||||||
|
const rawLimit = Number.parseInt(String(req.query.limit ?? "50"), 10);
|
||||||
|
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), 100) : 50;
|
||||||
|
const consolidationTypes = [
|
||||||
|
"memory:consolidation-completed",
|
||||||
|
"memory:consolidation-skipped",
|
||||||
|
"memory:consolidation-failed",
|
||||||
|
] as const;
|
||||||
|
// Filter in the store query: its SQL limit applies before returned rows can be merged.
|
||||||
|
const rows = await Promise.all(consolidationTypes.map((mutationType) => (
|
||||||
|
scopedStore.getRunAuditEventsAsync({ agentId, mutationType, limit: 100 })
|
||||||
|
)));
|
||||||
|
const events = rows.flat()
|
||||||
|
.sort((left, right) => right.timestamp.localeCompare(left.timestamp))
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ id, timestamp, mutationType, runId, metadata }) => ({ id, timestamp, mutationType, runId, metadata }));
|
||||||
|
res.json({ agentId, events });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof ApiError) throw err;
|
||||||
|
rethrowAsApiError(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/agents/:id/reflections/latest
|
* GET /api/agents/:id/reflections/latest
|
||||||
* Fetch the most recent reflection for an agent.
|
* Fetch the most recent reflection for an agent.
|
||||||
|
|||||||
@@ -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"]);
|
expect(opts.customTools?.map((tool: any) => tool.name)).toEqual(["fn_web_fetch", "fn_memory_search", "fn_memory_get"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["off", "index", "full"] as const)("uses assigned reviewer %s memory inclusion mode", async (mode) => {
|
||||||
|
mockedCreateFnAgent.mockResolvedValue(createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."));
|
||||||
|
await reviewStep(
|
||||||
|
"/tmp/worktree", "FN-050", 0, "Spec Review", "spec", "# Task: KB-050",
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
rootDir: "/tmp/project",
|
||||||
|
agentId: "reviewer-1",
|
||||||
|
agentStore: { getAgent: vi.fn().mockResolvedValue({ id: "reviewer-1", runtimeConfig: { agentMemoryInclusionMode: mode } }) } as any,
|
||||||
|
settings: { memoryBackendType: "qmd" } as any,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const prompt = mockedCreateFnAgent.mock.calls[0][0].systemPrompt;
|
||||||
|
expect(prompt.includes("query memory before re-reading")).toBe(mode !== "off");
|
||||||
|
if (mode === "index") expect(prompt).toContain("fn_memory_search");
|
||||||
|
});
|
||||||
|
|
||||||
it("omits reviewer memory tools and instructions when memory is disabled", async () => {
|
it("omits reviewer memory tools and instructions when memory is disabled", async () => {
|
||||||
mockedCreateFnAgent.mockResolvedValue(
|
mockedCreateFnAgent.mockResolvedValue(
|
||||||
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
|
createMockSession("### Verdict: APPROVE\n### Summary\nGood spec."),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import type { TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
|
import type { Agent, TaskStore, Task, TaskDetail, Settings } from "@fusion/core";
|
||||||
import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
|
import { applyOriginalDescription, builtinSeamPrompt, buildBootstrapPrompt, computePlanApprovalFingerprint, MAX_TASK_LIST_TEXT_CHARS, renderTriagePolicyPlaceholders, resolveAgentPrompt } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
TriageProcessor,
|
TriageProcessor,
|
||||||
@@ -571,6 +571,22 @@ describe("buildSpecificationPrompt", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("memoryEnabled setting", () => {
|
describe("memoryEnabled setting", () => {
|
||||||
|
it.each(["off", "index", "full"] as const)("uses assigned-agent %s memory inclusion mode", (mode) => {
|
||||||
|
const prompt = buildSpecificationPrompt(
|
||||||
|
baseTask,
|
||||||
|
".fusion/tasks/KB-001/PROMPT.md",
|
||||||
|
{ memoryEnabled: true } as Settings,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ id: "planner", runtimeConfig: { agentMemoryInclusionMode: mode } } as Agent,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prompt.includes("query memory before re-reading")).toBe(mode !== "off");
|
||||||
|
if (mode === "index") expect(prompt).toContain("fn_memory_search");
|
||||||
|
});
|
||||||
|
|
||||||
it("includes memory instructions when memoryEnabled: true", () => {
|
it("includes memory instructions when memoryEnabled: true", () => {
|
||||||
const settings: Settings = {
|
const settings: Settings = {
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { AutoClaimSnapshotManager, resolveFreshAutoClaimCandidates, type AutoCla
|
|||||||
import {
|
import {
|
||||||
ApprovalRequestStore,
|
ApprovalRequestStore,
|
||||||
buildExecutionMemoryInstructions,
|
buildExecutionMemoryInstructions,
|
||||||
|
buildMemoryPreSteeringNudge,
|
||||||
isEphemeralAgent,
|
isEphemeralAgent,
|
||||||
hasAgentIdentity,
|
hasAgentIdentity,
|
||||||
resolveEffectiveAgentPermissionPolicy,
|
resolveEffectiveAgentPermissionPolicy,
|
||||||
@@ -135,13 +136,18 @@ import { accumulateSessionTokenUsage, captureSessionTokenBaseline } from "./exec
|
|||||||
|
|
||||||
const promptSizeLog = createLogger("prompt-size");
|
const promptSizeLog = createLogger("prompt-size");
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MemoryPreSteering 2026-08-11-11:13:
|
||||||
|
FN-8934 treats the heartbeat primer as an injection surface: off removes both
|
||||||
|
memory boundaries and their nudge, while index replaces them with the bounded terse form.
|
||||||
|
*/
|
||||||
function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclusionMode): string {
|
function adjustHeartbeatMemoryPrimer(basePrompt: string, mode: AgentMemoryInclusionMode): string {
|
||||||
if (mode === "full") return basePrompt;
|
if (mode === "full") return basePrompt;
|
||||||
const memoryPrimer = /\nYou may receive an Agent Memory section and a Project Memory section\.[\s\S]*?- Project Memory examples:[^\n]*\n/;
|
const memoryPrimer = /\nYou may receive an Agent Memory section and a Project Memory section\.[\s\S]*?- Project Memory examples:[^\n]*\n(?:- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get\n)?/;
|
||||||
if (mode === "off") return basePrompt.replace(memoryPrimer, "\n");
|
if (mode === "off") return basePrompt.replace(memoryPrimer, "\n");
|
||||||
return basePrompt.replace(
|
return basePrompt.replace(
|
||||||
memoryPrimer,
|
memoryPrimer,
|
||||||
"\nWhen an Agent Memory Index is provided instead of full memory, call fn_memory_search first for task-relevant context. Use fn_memory_get to open only relevant snippets.\n",
|
`\n${buildMemoryPreSteeringNudge("index")}\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2840,8 +2846,8 @@ export class HeartbeatMonitor {
|
|||||||
if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) {
|
if (resolvedMemoryMode.mode !== "off" && memorySettings?.memoryEnabled !== false) {
|
||||||
try {
|
try {
|
||||||
memoryInstructions = resolvedMemoryMode.mode === "index"
|
memoryInstructions = resolvedMemoryMode.mode === "index"
|
||||||
? "## Project Memory (Index Only)\n\nProject memory is available via fn_memory_search and fn_memory_get. Search first, then fetch only relevant excerpts."
|
? `## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}`
|
||||||
: buildExecutionMemoryInstructions(rootDir, memorySettings);
|
: buildExecutionMemoryInstructions(rootDir, memorySettings, undefined, resolvedMemoryMode.mode);
|
||||||
} catch (memoryInstructionErr) {
|
} catch (memoryInstructionErr) {
|
||||||
const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr);
|
const message = memoryInstructionErr instanceof Error ? memoryInstructionErr.message : String(memoryInstructionErr);
|
||||||
heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`);
|
heartbeatLog.warn(`Failed to resolve project memory instructions for heartbeat ${agentId}: ${message}`);
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ You may receive an Agent Memory section and a Project Memory section.
|
|||||||
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
|
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
|
||||||
- Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style.
|
- Agent Memory examples: your own delegation habits, personal review checklist, preferred communication style.
|
||||||
- Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know.
|
- Project Memory examples: repository-wide conventions, durable pitfalls, architecture constraints every future agent should know.
|
||||||
|
- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get.
|
||||||
|
|
||||||
## Processing Messages
|
## Processing Messages
|
||||||
|
|
||||||
@@ -202,6 +203,7 @@ You may receive an Agent Memory section and a Project Memory section.
|
|||||||
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
|
- Keep these separate: do not copy personal agent operating notes into Project Memory unless they are genuinely useful to every future agent in this workspace.
|
||||||
- Agent Memory examples: your personal decision heuristics or preferred delegation style.
|
- Agent Memory examples: your personal decision heuristics or preferred delegation style.
|
||||||
- Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls.
|
- Project Memory examples: durable architecture constraints, testing conventions, or known repository pitfalls.
|
||||||
|
- Memory-first: query memory before re-reading raw sources; search with fn_memory_search, then open relevant excerpts with fn_memory_get.
|
||||||
|
|
||||||
## Processing Messages
|
## Processing Messages
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { constants as fsConstants } from "node:fs";
|
|||||||
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
|
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
|
||||||
import {
|
import {
|
||||||
readProjectMemory,
|
readProjectMemory,
|
||||||
|
buildMemoryPreSteeringNudge,
|
||||||
type Agent,
|
type Agent,
|
||||||
type AgentMemoryInclusionMode,
|
type AgentMemoryInclusionMode,
|
||||||
type AgentRatingSummary,
|
type AgentRatingSummary,
|
||||||
@@ -203,15 +204,12 @@ async function formatMemorySection(
|
|||||||
|
|
||||||
if (inclusionMode === "index") {
|
if (inclusionMode === "index") {
|
||||||
const indexBody = await buildMemoryIndex({ rootDir, agentId });
|
const indexBody = await buildMemoryIndex({ rootDir, agentId });
|
||||||
if (!indexBody) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
"## Agent Memory",
|
"## Agent Memory",
|
||||||
"",
|
"",
|
||||||
"Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.",
|
"Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.",
|
||||||
"",
|
buildMemoryPreSteeringNudge("index"),
|
||||||
indexBody,
|
...(indexBody ? ["", indexBody] : []),
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +224,7 @@ async function formatMemorySection(
|
|||||||
"",
|
"",
|
||||||
"This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.",
|
"This is memory for this agent only. Keep it separate from workspace Project Memory; use it for durable preferences, operating habits, and context that should follow this agent across tasks.",
|
||||||
"Additional daily/dream files under .fusion/agent-memory/{agentId}/ are searchable with fn_memory_search.",
|
"Additional daily/dream files under .fusion/agent-memory/{agentId}/ are searchable with fn_memory_search.",
|
||||||
|
buildMemoryPreSteeringNudge("full"),
|
||||||
"",
|
"",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -395,20 +394,21 @@ export async function buildAgentChatPrompt(options: {
|
|||||||
agentStore?: AgentStore;
|
agentStore?: AgentStore;
|
||||||
basePrompt: string;
|
basePrompt: string;
|
||||||
includeProjectMemory?: boolean;
|
includeProjectMemory?: boolean;
|
||||||
|
inclusionMode?: AgentMemoryInclusionMode;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const { agent, rootDir, agentStore, basePrompt, includeProjectMemory = false } = options;
|
const { agent, rootDir, agentStore, basePrompt, includeProjectMemory = false, inclusionMode = "full" } = options;
|
||||||
|
|
||||||
const titleSuffix = agent.title?.trim() ? `, ${agent.title.trim()}` : "";
|
const titleSuffix = agent.title?.trim() ? `, ${agent.title.trim()}` : "";
|
||||||
const identitySection = `## Identity\n\nYou are ${agent.name}${titleSuffix} (agent ID: ${agent.id}, role: ${agent.role}).`;
|
const identitySection = `## Identity\n\nYou are ${agent.name}${titleSuffix} (agent ID: ${agent.id}, role: ${agent.role}).`;
|
||||||
|
|
||||||
const instructionParts = [identitySection];
|
const instructionParts = [identitySection];
|
||||||
|
|
||||||
const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore);
|
const resolvedInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, agentStore, inclusionMode);
|
||||||
if (resolvedInstructions.trim()) {
|
if (resolvedInstructions.trim()) {
|
||||||
instructionParts.push(resolvedInstructions);
|
instructionParts.push(resolvedInstructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (includeProjectMemory) {
|
if (includeProjectMemory && inclusionMode !== "off") {
|
||||||
try {
|
try {
|
||||||
const projectMemory = (await readProjectMemory(rootDir)).trim();
|
const projectMemory = (await readProjectMemory(rootDir)).trim();
|
||||||
if (projectMemory) {
|
if (projectMemory) {
|
||||||
|
|||||||
@@ -340,6 +340,18 @@ export async function reviewStep(
|
|||||||
const validatorFallbackProvider = validatorFallback.provider;
|
const validatorFallbackProvider = validatorFallback.provider;
|
||||||
const validatorFallbackModelId = validatorFallback.modelId;
|
const validatorFallbackModelId = validatorFallback.modelId;
|
||||||
|
|
||||||
|
// Resolve the routed reviewer once so both its instructions and project-memory policy
|
||||||
|
// honor the same per-agent override.
|
||||||
|
const assignedAgentId = options.agentId ?? options.task?.assignedAgentId ?? null;
|
||||||
|
const agentStore = options.agentStore;
|
||||||
|
const memoryAgent =
|
||||||
|
options.rootDir
|
||||||
|
&& agentStore
|
||||||
|
&& assignedAgentId
|
||||||
|
&& typeof (agentStore as { getAgent?: unknown }).getAgent === "function"
|
||||||
|
? await agentStore.getAgent(assignedAgentId).catch(() => null)
|
||||||
|
: null;
|
||||||
|
|
||||||
let reviewerInstructions = "";
|
let reviewerInstructions = "";
|
||||||
if (options.agentStore && options.rootDir) {
|
if (options.agentStore && options.rootDir) {
|
||||||
try {
|
try {
|
||||||
@@ -375,8 +387,14 @@ export async function reviewStep(
|
|||||||
// FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node;
|
// FN-6235: built-in reviewer policy is sourced from the resolved workflow IR review node;
|
||||||
// explicit reviewer role overrides still win, and the built-in default keeps this fail-soft.
|
// explicit reviewer role overrides still win, and the built-in default keeps this fail-soft.
|
||||||
const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer");
|
const reviewerBasePrompt = userReviewerPrompt || workflowReviewerPrompt || resolveAgentPrompt("reviewer");
|
||||||
const memorySection = options.rootDir && effectiveSettings?.memoryEnabled !== false
|
/*
|
||||||
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings)
|
FNXC:MemoryPreSteering 2026-08-11-11:30:
|
||||||
|
FN-8934 requires project-memory guidance to use the routed reviewer's mode as well as
|
||||||
|
its instruction section, so an explicit off override suppresses every memory nudge.
|
||||||
|
*/
|
||||||
|
const reviewerMemoryMode = resolveAgentMemoryInclusionMode({ agent: memoryAgent, globalSettings: effectiveSettings }).mode;
|
||||||
|
const memorySection = options.rootDir && effectiveSettings?.memoryEnabled !== false && reviewerMemoryMode !== "off"
|
||||||
|
? buildReviewerMemoryInstructions(options.rootDir, effectiveSettings, undefined, reviewerMemoryMode)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const reviewerPluginContributions = await buildPluginPromptSection(
|
const reviewerPluginContributions = await buildPluginPromptSection(
|
||||||
@@ -411,15 +429,6 @@ export async function reviewStep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A routed reviewer principal owns its memory/runtime identity for this session.
|
// A routed reviewer principal owns its memory/runtime identity for this session.
|
||||||
const assignedAgentId = options.agentId ?? options.task?.assignedAgentId ?? null;
|
|
||||||
const agentStore = options.agentStore;
|
|
||||||
const memoryAgent =
|
|
||||||
options.rootDir
|
|
||||||
&& agentStore
|
|
||||||
&& assignedAgentId
|
|
||||||
&& typeof (agentStore as { getAgent?: unknown }).getAgent === "function"
|
|
||||||
? await agentStore.getAgent(assignedAgentId).catch(() => null)
|
|
||||||
: null;
|
|
||||||
const memoryTools = options.rootDir && effectiveSettings?.memoryEnabled !== false
|
const memoryTools = options.rootDir && effectiveSettings?.memoryEnabled !== false
|
||||||
? [
|
? [
|
||||||
createMemorySearchTool(options.rootDir, effectiveSettings, memoryAgent ? {
|
createMemorySearchTool(options.rootDir, effectiveSettings, memoryAgent ? {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
TaskDetail,
|
TaskDetail,
|
||||||
WorkflowFieldDefinition,
|
WorkflowFieldDefinition,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import { buildExecutionMemoryInstructions, type WorkspaceConfig } from "@fusion/core";
|
import { buildExecutionMemoryInstructions, buildMemoryPreSteeringNudge, type WorkspaceConfig } from "@fusion/core";
|
||||||
import { executorLog } from "../logger.js";
|
import { executorLog } from "../logger.js";
|
||||||
import type { PluginRunner } from "../plugins/plugin-runner.js";
|
import type { PluginRunner } from "../plugins/plugin-runner.js";
|
||||||
import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js";
|
import { parseReviewLevelFromPrompt } from "./prompt-derived-eligibility.js";
|
||||||
@@ -163,8 +163,8 @@ git log --oneline
|
|||||||
let memorySection = "";
|
let memorySection = "";
|
||||||
if (memoryEnabled && rootDir && memoryMode !== "off") {
|
if (memoryEnabled && rootDir && memoryMode !== "off") {
|
||||||
memorySection = memoryMode === "index"
|
memorySection = memoryMode === "index"
|
||||||
? "\n## Project Memory (Index Only)\n\nUse fn_memory_search first to find relevant memory, then fn_memory_get for specific excerpts.\n"
|
? `\n## Project Memory (Index Only)\n\n${buildMemoryPreSteeringNudge("index")}\n`
|
||||||
: "\n" + buildExecutionMemoryInstructions(rootDir, settings);
|
: "\n" + buildExecutionMemoryInstructions(rootDir, settings, undefined, memoryMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build steering comments section (last 10 comments only to avoid context bloat)
|
// Build steering comments section (last 10 comments only to avoid context bloat)
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import {
|
|||||||
resolveTitleSummarizerSettingsModel,
|
resolveTitleSummarizerSettingsModel,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
resolvePersistAgentThinkingLog,
|
resolvePersistAgentThinkingLog,
|
||||||
|
resolveAgentMemoryInclusionMode,
|
||||||
summarizeCommitBody,
|
summarizeCommitBody,
|
||||||
summarizeCommitSubject,
|
summarizeCommitSubject,
|
||||||
summarizeMergeCommit,
|
summarizeMergeCommit,
|
||||||
@@ -10962,7 +10963,13 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
|||||||
const agents = await options.agentStore.listAgents({ role: "merger" });
|
const agents = await options.agentStore.listAgents({ role: "merger" });
|
||||||
for (const agent of agents) {
|
for (const agent of agents) {
|
||||||
if (agent.instructionsText || agent.instructionsPath) {
|
if (agent.instructionsText || agent.instructionsPath) {
|
||||||
mergerInstructions = await resolveAgentInstructions(agent, rootDir);
|
/*
|
||||||
|
FNXC:MemoryPreSteering 2026-08-11-11:54:
|
||||||
|
FN-8934 requires merger prompts to honor the selected principal's memory mode.
|
||||||
|
Passing the resolved mode prevents off/index settings from silently receiving full-memory steering.
|
||||||
|
*/
|
||||||
|
const memoryMode = resolveAgentMemoryInclusionMode({ agent, globalSettings: settings }).mode;
|
||||||
|
mergerInstructions = await resolveAgentInstructions(agent, rootDir, undefined, memoryMode);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3273,6 +3273,7 @@ export class TriageProcessor {
|
|||||||
originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined,
|
originalDescription: typeof originalDescriptionDocument?.content === "string" ? originalDescriptionDocument.content : undefined,
|
||||||
planReviewFeedbackHistory,
|
planReviewFeedbackHistory,
|
||||||
},
|
},
|
||||||
|
assignedAgent,
|
||||||
);
|
);
|
||||||
/*
|
/*
|
||||||
FNXC:TriagePlanningTimeout 2026-08-10-18:32:
|
FNXC:TriagePlanningTimeout 2026-08-10-18:32:
|
||||||
@@ -5537,6 +5538,7 @@ export function buildSpecificationPrompt(
|
|||||||
existingPrompt?: string,
|
existingPrompt?: string,
|
||||||
feedback?: string,
|
feedback?: string,
|
||||||
planningContext?: { plan?: string; originalDescription?: string; planReviewFeedbackHistory?: string[] },
|
planningContext?: { plan?: string; originalDescription?: string; planReviewFeedbackHistory?: string[] },
|
||||||
|
memoryAgent?: Agent | null,
|
||||||
): string {
|
): string {
|
||||||
const hasFeedback = Boolean(feedback?.trim());
|
const hasFeedback = Boolean(feedback?.trim());
|
||||||
const planDocument = planningContext?.plan?.trim();
|
const planDocument = planningContext?.plan?.trim();
|
||||||
@@ -5574,9 +5576,15 @@ When writing PROMPT.md, add this as an explicit requirement under completion doc
|
|||||||
// When enabled, agents consult project memory for durable project learnings.
|
// When enabled, agents consult project memory for durable project learnings.
|
||||||
// Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd)
|
// Backend-aware: instructions branch based on memoryBackendType (file, readonly, qmd)
|
||||||
const memoryEnabled = settings?.memoryEnabled !== false;
|
const memoryEnabled = settings?.memoryEnabled !== false;
|
||||||
|
const memoryMode = resolveAgentMemoryInclusionMode({ agent: memoryAgent, globalSettings: settings }).mode;
|
||||||
let memorySection = "";
|
let memorySection = "";
|
||||||
if (memoryEnabled) {
|
/*
|
||||||
memorySection = "\n\n" + buildTriageMemoryInstructions("", settings);
|
FNXC:MemoryPreSteering 2026-08-11-11:13:
|
||||||
|
FN-8934 closes triage's mode gap: project-memory instructions must follow the
|
||||||
|
same off/index/full policy as assigned-agent instructions, not always inject full memory.
|
||||||
|
*/
|
||||||
|
if (memoryEnabled && memoryMode !== "off") {
|
||||||
|
memorySection = "\n\n" + buildTriageMemoryInstructions("", settings, undefined, memoryMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
let taskDefinitionLanguageSection = "";
|
let taskDefinitionLanguageSection = "";
|
||||||
|
|||||||
Reference in New Issue
Block a user