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:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { ChatManager, __setCreateKbAgent, __resetChatState } from "../chat.js";
|
||||
import { ChatManager, __setBuildAgentChatPrompt, __setCreateKbAgent, __resetChatState } from "../chat.js";
|
||||
|
||||
// ── Mock Setup ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,6 +27,15 @@ const mockChatStore = {
|
||||
updateSession: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAgentStore = {
|
||||
init: vi.fn(),
|
||||
getAgent: vi.fn(),
|
||||
};
|
||||
|
||||
function createChatManager(): ChatManager {
|
||||
return new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any);
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ChatManager.sendMessage", () => {
|
||||
@@ -46,6 +55,26 @@ describe("ChatManager.sendMessage", () => {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
});
|
||||
mockChatStore.getMessages.mockReturnValue([]);
|
||||
|
||||
mockAgentStore.init.mockResolvedValue(undefined);
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
soul: "Be calm and precise.",
|
||||
memory: "Remember to keep test coverage high.",
|
||||
instructionsText: "Keep replies focused.",
|
||||
});
|
||||
|
||||
__setBuildAgentChatPrompt(async ({ agent, basePrompt }: any) => {
|
||||
return [
|
||||
basePrompt,
|
||||
`## Soul\n\n${agent.soul ?? ""}`,
|
||||
`## Memory\n\n${agent.memory ?? ""}`,
|
||||
`## Instructions\n\n${agent.instructionsText ?? ""}`,
|
||||
].join("\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -78,10 +107,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
});
|
||||
|
||||
// Arrange
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
// Act
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
@@ -114,10 +140,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
@@ -148,10 +171,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
@@ -179,10 +199,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
@@ -216,10 +233,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
@@ -243,10 +257,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "User message");
|
||||
|
||||
@@ -264,6 +275,118 @@ describe("ChatManager.sendMessage", () => {
|
||||
expect(calls[1][1].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("passes enriched system prompt with agent soul when agent context is available", async () => {
|
||||
let createOptions: any;
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(mockAgentStore.init).toHaveBeenCalledTimes(1);
|
||||
expect(mockAgentStore.getAgent).toHaveBeenCalledWith("agent-001");
|
||||
expect(createOptions.systemPrompt).toContain("Be calm and precise.");
|
||||
});
|
||||
|
||||
it("passes enriched system prompt with agent memory when agent context is available", async () => {
|
||||
mockAgentStore.getAgent.mockResolvedValue({
|
||||
id: "agent-001",
|
||||
name: "Avery",
|
||||
role: "executor",
|
||||
soul: "Be concise.",
|
||||
memory: "Remember repo conventions from prior tasks.",
|
||||
instructionsText: "Focus on correctness.",
|
||||
});
|
||||
|
||||
let createOptions: any;
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createOptions.systemPrompt).toContain("Remember repo conventions from prior tasks.");
|
||||
});
|
||||
|
||||
it("falls back to generic chat system prompt when agent lookup returns null", async () => {
|
||||
mockAgentStore.getAgent.mockResolvedValue(null);
|
||||
|
||||
let createOptions: any;
|
||||
__setCreateKbAgent(async (options: any) => {
|
||||
createOptions = options;
|
||||
return {
|
||||
session: {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Hello");
|
||||
|
||||
expect(createOptions.systemPrompt).toContain("You are a helpful AI assistant integrated into the fn task board system.");
|
||||
expect(createOptions.systemPrompt).not.toContain("## Soul");
|
||||
});
|
||||
|
||||
it("includes previous user and assistant messages in the prompt context", async () => {
|
||||
const promptSpy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockChatStore.getMessages.mockReturnValue([
|
||||
{ role: "user", content: "Earlier user question" },
|
||||
{ role: "assistant", content: "Earlier assistant answer" },
|
||||
{ role: "system", content: "System note should be filtered" },
|
||||
{ role: "user", content: "Current question" },
|
||||
]);
|
||||
|
||||
__setCreateKbAgent(async () => {
|
||||
return {
|
||||
session: {
|
||||
prompt: promptSpy,
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [{ role: "assistant", content: "Done" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = createChatManager();
|
||||
await chatManager.sendMessage("chat-001", "Current question");
|
||||
|
||||
expect(promptSpy).toHaveBeenCalledTimes(1);
|
||||
const promptArgument = promptSpy.mock.calls[0]?.[0];
|
||||
expect(promptArgument).toContain("## Previous Conversation");
|
||||
expect(promptArgument).toContain("[User]: Earlier user question");
|
||||
expect(promptArgument).toContain("[Assistant]: Earlier assistant answer");
|
||||
expect(promptArgument).not.toContain("System note should be filtered");
|
||||
expect(promptArgument).toContain("## Current Message");
|
||||
expect(promptArgument).toContain("Current question");
|
||||
});
|
||||
|
||||
it("generates title when session has no title", async () => {
|
||||
mockSummarizeTitle.mockResolvedValue("Short Title");
|
||||
|
||||
@@ -279,10 +402,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "This is a long message that needs to be summarized");
|
||||
|
||||
@@ -316,10 +436,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
const longMessage = "A".repeat(300);
|
||||
await chatManager.sendMessage("chat-001", longMessage);
|
||||
@@ -354,10 +471,7 @@ describe("ChatManager.sendMessage", () => {
|
||||
};
|
||||
});
|
||||
|
||||
const chatManager = new ChatManager(
|
||||
mockChatStore as any,
|
||||
"/tmp/test",
|
||||
);
|
||||
const chatManager = createChatManager();
|
||||
|
||||
await chatManager.sendMessage("chat-001", "This is a long message");
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { request, get } from "../test-request.js";
|
||||
const mockInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockClose = vi.fn().mockResolvedValue(undefined);
|
||||
const mockGetNode = vi.fn();
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
@@ -18,6 +20,10 @@ vi.mock("@fusion/core", () => {
|
||||
ChatStore: class MockChatStore {
|
||||
init = vi.fn().mockResolvedValue(undefined);
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockAgentStoreInit;
|
||||
getAgent = mockAgentStoreGetAgent;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ const mockUpdateSettingsSyncState = vi.fn();
|
||||
const mockApplyRemoteSettings = vi.fn();
|
||||
const mockGetSettingsForSync = vi.fn();
|
||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
@@ -47,6 +49,10 @@ vi.mock("@fusion/core", () => {
|
||||
ChatStore: class MockChatStore {
|
||||
init = mockChatStoreInit;
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockAgentStoreInit;
|
||||
getAgent = mockAgentStoreGetAgent;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const mockCheckNodeHealth = vi.fn();
|
||||
const mockIsDiscoveryActive = vi.fn().mockReturnValue(false);
|
||||
const mockGetDiscoveryConfig = vi.fn().mockReturnValue(null);
|
||||
const mockChatStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreInit = vi.fn().mockResolvedValue(undefined);
|
||||
const mockAgentStoreGetAgent = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock("@fusion/core", () => {
|
||||
return {
|
||||
@@ -33,6 +35,10 @@ vi.mock("@fusion/core", () => {
|
||||
ChatStore: class MockChatStore {
|
||||
init = mockChatStoreInit;
|
||||
},
|
||||
AgentStore: class MockAgentStore {
|
||||
init = mockAgentStoreInit;
|
||||
getAgent = mockAgentStoreGetAgent;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
AgentStore,
|
||||
ChatStore,
|
||||
ChatSession,
|
||||
ChatSessionCreateInput,
|
||||
@@ -27,10 +29,12 @@ import { SessionEventBuffer } from "./sse-buffer.js";
|
||||
type AgentResult = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let buildAgentChatPromptFn: any;
|
||||
|
||||
// Initialize the import (this runs in actual server, mocked in tests)
|
||||
async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
if (!createKbAgent || !buildAgentChatPromptFn) {
|
||||
try {
|
||||
// Use dynamic import with variable to prevent static analysis
|
||||
const engineModule = "@fusion/engine";
|
||||
@@ -38,11 +42,17 @@ async function initEngine() {
|
||||
if (!createKbAgent) {
|
||||
createKbAgent = engine.createKbAgent;
|
||||
}
|
||||
if (!buildAgentChatPromptFn) {
|
||||
buildAgentChatPromptFn = engine.buildAgentChatPrompt;
|
||||
}
|
||||
} catch {
|
||||
// Allow failure in test environments - agent functionality will be stubbed
|
||||
if (!createKbAgent) {
|
||||
createKbAgent = undefined;
|
||||
}
|
||||
if (!buildAgentChatPromptFn) {
|
||||
buildAgentChatPromptFn = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,9 +265,12 @@ export function getRateLimitResetTime(ip: string): Date | null {
|
||||
* Creates sessions, sends messages, and streams AI responses via SSE.
|
||||
*/
|
||||
export class ChatManager {
|
||||
private agentStoreReady?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private chatStore: ChatStore,
|
||||
private rootDir: string,
|
||||
private agentStore?: AgentStore,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -357,10 +370,60 @@ export class ChatManager {
|
||||
throw new Error("AI agent not available");
|
||||
}
|
||||
|
||||
let systemPrompt = CHAT_SYSTEM_PROMPT;
|
||||
let agent: Agent | null = null;
|
||||
|
||||
if (this.agentStore && session.agentId) {
|
||||
try {
|
||||
this.agentStoreReady ??= this.agentStore.init();
|
||||
await this.agentStoreReady;
|
||||
agent = await this.agentStore.getAgent(session.agentId);
|
||||
} catch (agentLoadError) {
|
||||
const message = agentLoadError instanceof Error ? agentLoadError.message : String(agentLoadError);
|
||||
console.warn(`[chat] Failed to load agent context for ${session.agentId}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (agent && buildAgentChatPromptFn) {
|
||||
try {
|
||||
systemPrompt = await buildAgentChatPromptFn({
|
||||
agent,
|
||||
rootDir: this.rootDir,
|
||||
agentStore: this.agentStore,
|
||||
basePrompt: CHAT_SYSTEM_PROMPT,
|
||||
includeProjectMemory: true,
|
||||
});
|
||||
} catch (promptBuildError) {
|
||||
const message = promptBuildError instanceof Error ? promptBuildError.message : String(promptBuildError);
|
||||
console.warn(`[chat] Failed to build enriched system prompt for ${agent.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const allMessages = this.chatStore.getMessages(sessionId, { limit: 10000 }) ?? [];
|
||||
const previousMessages = allMessages.slice(-51, -1);
|
||||
const conversationMessages = previousMessages.filter(
|
||||
(message) => message.role === "user" || message.role === "assistant",
|
||||
);
|
||||
|
||||
const promptContent = conversationMessages.length > 0
|
||||
? [
|
||||
"## Previous Conversation",
|
||||
"",
|
||||
...conversationMessages.map((message) => {
|
||||
const speaker = message.role === "user" ? "User" : "Assistant";
|
||||
return `[${speaker}]: ${message.content}`;
|
||||
}),
|
||||
"",
|
||||
"## Current Message",
|
||||
"",
|
||||
content,
|
||||
].join("\n")
|
||||
: content;
|
||||
|
||||
// Create AI agent session
|
||||
agentResult = await createKbAgent({
|
||||
cwd: this.rootDir,
|
||||
systemPrompt: CHAT_SYSTEM_PROMPT,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(effectiveModelProvider && effectiveModelId
|
||||
? {
|
||||
@@ -385,7 +448,7 @@ export class ChatManager {
|
||||
});
|
||||
|
||||
// Send user message and get response
|
||||
await agentResult.session.prompt(content);
|
||||
await agentResult.session.prompt(promptContent);
|
||||
|
||||
// Extract response text from agent state
|
||||
let responseText = "";
|
||||
@@ -452,10 +515,19 @@ export function __setCreateKbAgent(mock: typeof createKbAgent): void {
|
||||
createKbAgent = mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock buildAgentChatPrompt function. Used for testing only.
|
||||
*/
|
||||
export function __setBuildAgentChatPrompt(mock: typeof buildAgentChatPromptFn): void {
|
||||
buildAgentChatPromptFn = mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all chat state. Used for testing only.
|
||||
*/
|
||||
export function __resetChatState(): void {
|
||||
chatStreamManager.reset();
|
||||
rateLimits.clear();
|
||||
engineReady = undefined;
|
||||
buildAgentChatPromptFn = undefined;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Task, TaskStore, MergeResult, AutomationStore, RoutineStore, CentralCore, MessageStore } from "@fusion/core";
|
||||
import { ChatStore } from "@fusion/core";
|
||||
import { AgentStore, ChatStore } from "@fusion/core";
|
||||
import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
@@ -603,8 +603,11 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// Create ChatStore for chat session management
|
||||
const chatStore = options?.chatStore ?? new ChatStore(store.getFusionDir(), store.getDatabase());
|
||||
|
||||
// Create AgentStore for chat prompt enrichment (lazy-initialized inside ChatManager)
|
||||
const chatAgentStore = new AgentStore({ rootDir: store.getFusionDir() });
|
||||
|
||||
// Create ChatManager for AI chat message handling
|
||||
const chatManager = options?.chatManager ?? new ChatManager(chatStore, store.getRootDir());
|
||||
const chatManager = options?.chatManager ?? new ChatManager(chatStore, store.getRootDir(), chatAgentStore);
|
||||
|
||||
const runAiSessionCleanup = (maxAgeMs: number, source: "initial" | "scheduled") => {
|
||||
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
|
||||
|
||||
@@ -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", "");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user