feat(FN-1984): enrich chat and heartbeat prompts with agent context

- Add agent memory support to instruction resolution and introduce/export buildAgentChatPrompt for identity-aware prompt assembly with optional project memory
- Wire ChatManager to AgentStore so chat sessions can enrich system prompts per agent and include recent conversation context in prompt payloads
- Update server chat wiring to provide an AgentStore for prompt enrichment and keep graceful fallback behavior when agent context is unavailable
- Enrich HeartbeatMonitor system prompts with resolved agent instructions/ratings while preserving base-prompt fallback paths
- Expand dashboard and engine tests to cover prompt enrichment, fallback behavior, conversation context assembly, and AgentStore route mocks
This commit is contained in:
Fusion
2026-04-17 02:19:54 -07:00
committed by gsxdsm
parent 0873e3f788
commit a5a65e86fd
11 changed files with 431 additions and 57 deletions

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Agent, AgentRating, AgentRatingSummary, AgentStore } from "@fusion/core";
import {
resolveAgentInstructions,
resolveAgentInstructionsWithRatings,
buildAgentChatPrompt,
buildSystemPromptWithInstructions,
} from "../agent-instructions.js";
@@ -84,6 +85,19 @@ describe("resolveAgentInstructions", () => {
expect(result).toBe("## Soul\n\nBe thorough and analytical.");
});
it("returns memory section when memory is set", async () => {
const agent = makeAgent({ memory: "Remember to keep CI green." });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("## Memory\n\nRemember to keep CI green.");
});
it("omits memory section when memory is empty", async () => {
const agent = makeAgent({ instructionsText: "Base instructions", memory: " " });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Base instructions");
expect(result).not.toContain("## Memory");
});
it("returns instructionsText when set", async () => {
const agent = makeAgent({ instructionsText: "Always write tests." });
const result = await resolveAgentInstructions(agent, testDir);
@@ -219,7 +233,7 @@ describe("resolveAgentInstructions", () => {
expect(result.length).toBe(10000 + "## Soul\n\n".length);
});
it("places soul section after instructionsText and before performance feedback", async () => {
it("places memory section after soul", async () => {
const filePath = join(testDir, "file-instructions.md");
await writeFile(filePath, "File-based instructions here.");
@@ -227,18 +241,20 @@ describe("resolveAgentInstructions", () => {
instructionsText: "Inline instructions.",
instructionsPath: "file-instructions.md",
soul: "Be methodical and detailed.",
memory: "Remember that this repository uses pnpm workspaces.",
});
const result = await resolveAgentInstructions(agent, testDir);
// Verify section order
const soulIndex = result.indexOf("## Soul");
const instructionsTextIndex = result.indexOf("Inline instructions.");
const instructionsFileIndex = result.indexOf("File-based instructions here.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
expect(instructionsTextIndex).toBeLessThan(soulIndex);
expect(instructionsFileIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(result.indexOf("## Soul") + 10); // Soul section is present
expect(soulIndex).toBeLessThan(memoryIndex);
expect(result).toContain("## Memory\n\nRemember that this repository uses pnpm workspaces.");
});
});
@@ -279,10 +295,11 @@ describe("resolveAgentInstructions with rating summary", () => {
expect(result).toContain(' - "Could communicate blockers sooner" (score: 4.0)');
});
it("places soul section before performance feedback and after instructions", async () => {
it("places soul and memory sections before performance feedback and after instructions", async () => {
const agent = makeAgent({
instructionsText: "Implement the feature.",
soul: "Be pragmatic and efficient.",
memory: "Past tasks with flaky tests needed retries.",
});
const summary = makeRatingSummary({
totalRatings: 3,
@@ -291,14 +308,16 @@ describe("resolveAgentInstructions with rating summary", () => {
const result = await resolveAgentInstructions(agent, testDir, summary);
// Verify section order: instructionsText → soul → Performance Feedback
// Verify section order: instructionsText → soul → memory → Performance Feedback
const instructionsIndex = result.indexOf("Implement the feature.");
const soulIndex = result.indexOf("## Soul");
const memoryIndex = result.indexOf("## Memory");
const feedbackIndex = result.indexOf("## Performance Feedback");
expect(instructionsIndex).toBeLessThan(soulIndex);
expect(soulIndex).toBeLessThan(feedbackIndex);
expect(result).toContain("## Soul");
expect(soulIndex).toBeLessThan(memoryIndex);
expect(memoryIndex).toBeLessThan(feedbackIndex);
expect(result).toContain("## Memory");
expect(result).toContain("## Performance Feedback");
});
@@ -465,6 +484,49 @@ describe("resolveAgentInstructionsWithRatings", () => {
});
});
describe("buildAgentChatPrompt", () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-chat-prompt-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it("builds an identity-aware prompt with soul, memory, instructions, and project memory", async () => {
await mkdir(join(testDir, ".fusion"), { recursive: true });
await writeFile(join(testDir, ".fusion", "memory.md"), "Project preference: avoid force pushes.");
const agent = makeAgent({
name: "Avery",
title: "Senior Engineer",
role: "reviewer",
instructionsText: "Always include focused tests.",
soul: "Be calm, direct, and empathetic.",
memory: "The team values short progress updates.",
});
const prompt = await buildAgentChatPrompt({
agent,
rootDir: testDir,
basePrompt: "You are a chat assistant.",
includeProjectMemory: true,
});
expect(prompt).toContain("You are a chat assistant.");
expect(prompt).toContain("## Custom Instructions");
expect(prompt).toContain(
"## Identity\n\nYou are Avery, Senior Engineer (agent ID: agent-test, role: reviewer).",
);
expect(prompt).toContain("Always include focused tests.");
expect(prompt).toContain("## Soul\n\nBe calm, direct, and empathetic.");
expect(prompt).toContain("## Memory\n\nThe team values short progress updates.");
expect(prompt).toContain("## Project Memory\n\nProject preference: avoid force pushes.");
});
});
describe("buildSystemPromptWithInstructions", () => {
it("returns base prompt when instructions are empty", () => {
const result = buildSystemPromptWithInstructions("Base prompt", "");

View File

@@ -1159,6 +1159,7 @@ describe("HeartbeatMonitor", () => {
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
@@ -1967,8 +1968,12 @@ describe("HeartbeatMonitor", () => {
});
describe("execution", () => {
it("creates session with correct system prompt and tools", async () => {
const store = createStoreWithAgentForExec();
it("creates session with enriched system prompt and expected tools", async () => {
const store = createStoreWithAgentForExec({
soul: "Act like a practical teammate who prioritizes clarity.",
memory: "Recent runs found flaky tests in integration suites.",
instructionsText: "Always log blockers with actionable next steps.",
});
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({
session: mockSession as any,
@@ -1981,7 +1986,12 @@ describe("HeartbeatMonitor", () => {
expect(mockedCreateKbAgent).toHaveBeenCalledOnce();
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
expect(callArgs.cwd).toBe("/tmp/test");
expect(callArgs.systemPrompt).toBe(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain(HEARTBEAT_SYSTEM_PROMPT);
expect(callArgs.systemPrompt).toContain("## Soul");
expect(callArgs.systemPrompt).toContain("Act like a practical teammate who prioritizes clarity.");
expect(callArgs.systemPrompt).toContain("## Memory");
expect(callArgs.systemPrompt).toContain("Recent runs found flaky tests in integration suites.");
expect(callArgs.systemPrompt).toContain("Always log blockers with actionable next steps.");
expect(callArgs.tools).toBe("readonly");
// Tools: task_create, task_log, task_document_write, task_document_read, list_agents, delegate_task, heartbeat_done
expect(callArgs.customTools).toHaveLength(7);
@@ -1995,6 +2005,26 @@ describe("HeartbeatMonitor", () => {
expect(callArgs.customTools![6]!.name).toBe("heartbeat_done");
});
it("falls back to the base heartbeat prompt when agent has no custom instructions", async () => {
const store = createStoreWithAgentForExec({
soul: undefined,
memory: undefined,
instructionsText: undefined,
instructionsPath: undefined,
});
const mockSession = createMockAgentSession();
mockedCreateKbAgent.mockResolvedValue({
session: mockSession as any,
});
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp/test" });
await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
const callArgs = mockedCreateKbAgent.mock.calls[0]![0];
expect(callArgs.systemPrompt).toBe(HEARTBEAT_SYSTEM_PROMPT);
});
it("includes document tools in heartbeat session", async () => {
const store = createStoreWithAgentForExec();
const mockSession = createMockAgentSession();
@@ -3883,6 +3913,7 @@ describe("executeHeartbeat — skill selection resolver contract (FN-1510/FN-151
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),
@@ -4069,6 +4100,7 @@ describe("executeHeartbeat — skill selection non-fatal (FN-1510/FN-1511)", ()
status: "completed" as const,
};
}),
getRatingSummary: vi.fn().mockResolvedValue(undefined),
endHeartbeatRun: vi.fn().mockResolvedValue(undefined),
getBudgetStatus: vi.fn().mockResolvedValue(createBudgetStatus()),
getCachedAgent: vi.fn().mockReturnValue(null),

View File

@@ -22,6 +22,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, taskCreateParams } from "./agent-tools.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { heartbeatLog } from "./logger.js";
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
@@ -936,10 +937,19 @@ export class HeartbeatMonitor {
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
let systemPrompt = HEARTBEAT_SYSTEM_PROMPT;
try {
const agentInstructions = await resolveAgentInstructionsWithRatings(agent, rootDir, this.store);
systemPrompt = buildSystemPromptWithInstructions(HEARTBEAT_SYSTEM_PROMPT, agentInstructions);
} catch (instructionError) {
const message = instructionError instanceof Error ? instructionError.message : String(instructionError);
heartbeatLog.warn(`Failed to enrich heartbeat system prompt for ${agentId}: ${message}`);
}
// Create agent session
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: HEARTBEAT_SYSTEM_PROMPT,
systemPrompt,
tools: "readonly",
customTools: heartbeatTools,
defaultProvider: agent.runtimeConfig?.modelProvider as string | undefined,

View File

@@ -1,10 +1,16 @@
import { readFile } from "node:fs/promises";
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
import type { Agent, AgentRatingSummary, AgentStore } from "@fusion/core";
import {
readProjectMemory,
type Agent,
type AgentRatingSummary,
type AgentStore,
} from "@fusion/core";
const MAX_INSTRUCTIONS_PATH_LENGTH = 500;
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
const MAX_SOUL_LENGTH = 10_000;
const MAX_MEMORY_LENGTH = 50_000;
function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string {
const trimmed = value.trim();
@@ -86,6 +92,14 @@ function formatSoulSection(soul: string, agentId: string): string {
return `## Soul\n\n${trimmed}`;
}
function formatMemorySection(memory: string, agentId: string): string {
const trimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId);
if (!trimmed) {
return "";
}
return `## Memory\n\n${trimmed}`;
}
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {
const lines: string[] = [
"## Performance Feedback",
@@ -179,7 +193,7 @@ export async function resolveAgentInstructions(
}
}
// Soul/personality section (after instructions, before performance feedback)
// Soul/personality section (after instructions, before memory/performance feedback)
if (agent.soul?.trim()) {
const soulSection = formatSoulSection(agent.soul, agent.id);
if (soulSection) {
@@ -187,6 +201,13 @@ export async function resolveAgentInstructions(
}
}
if (agent.memory?.trim()) {
const memorySection = formatMemorySection(agent.memory, agent.id);
if (memorySection) {
parts.push(memorySection);
}
}
if (ratingSummary && ratingSummary.totalRatings > 0) {
parts.push(formatPerformanceFeedbackSection(ratingSummary));
}
@@ -221,6 +242,42 @@ export async function resolveAgentInstructionsWithRatings(
}
}
export async function buildAgentChatPrompt(options: {
agent: Agent;
rootDir: string;
agentStore?: AgentStore;
basePrompt: string;
includeProjectMemory?: boolean;
}): Promise<string> {
const { agent, rootDir, agentStore, basePrompt, includeProjectMemory = false } = 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);
if (resolvedInstructions.trim()) {
instructionParts.push(resolvedInstructions);
}
if (includeProjectMemory) {
try {
const projectMemory = (await readProjectMemory(rootDir)).trim();
if (projectMemory) {
instructionParts.push(`## Project Memory\n\n${projectMemory}`);
}
} catch (error: unknown) {
// Graceful fallback for chat/heartbeat: if project memory cannot be read,
// continue with available identity + agent instructions.
const message = error instanceof Error ? error.message : String(error);
console.warn(`[agent-instructions] Failed to read project memory for agent ${agent.id}: ${message}`);
}
}
return buildSystemPromptWithInstructions(basePrompt, instructionParts.join("\n\n"));
}
/**
* Append a custom instructions block to a base system prompt.
* If instructions are empty, returns the base prompt unchanged.

View File

@@ -26,6 +26,12 @@ export {
type SkillDiagnostic,
} from "./skill-resolver.js";
export { AgentReflectionService, type AgentReflectionServiceOptions } from "./agent-reflection.js";
export {
buildAgentChatPrompt,
resolveAgentInstructionsWithRatings,
resolveAgentInstructions,
buildSystemPromptWithInstructions,
} from "./agent-instructions.js";
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
export { createLogger, type Logger } from "./logger.js";
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";