import { readFile, writeFile, mkdir, access } from "node:fs/promises"; import { constants as fsConstants } from "node:fs"; import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path"; import { readProjectMemory, type Agent, type AgentMemoryInclusionMode, type AgentRatingSummary, type AgentStore, type PluginPromptSurface, } from "@fusion/core"; import type { PluginRunner } from "./plugin-runner.js"; import { createLogger } from "./logger.js"; import { readAgentMemoryWorkspaceLongTerm } from "./agent-tools.js"; import { buildMemoryIndex } from "./agent-memory-index.js"; const log = createLogger("agent-instructions"); const MAX_INSTRUCTIONS_PATH_LENGTH = 500; export const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000; export const MAX_SOUL_LENGTH = 10_000; export const MAX_MEMORY_LENGTH = 50_000; function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string { const trimmed = value.trim(); if (!trimmed) { return ""; } if (trimmed.length <= maxLength) { return trimmed; } log.warn(`${label} exceeded max length for agent ${agentId}; truncating to ${maxLength} chars`); return trimmed.slice(0, maxLength); } function isPathTraversal(path: string): boolean { return path.split(/[\\/]+/).includes(".."); } function resolveValidatedMarkdownPath( rawPath: string, rootDir: string, agentId: string, fieldLabel: string, ): string | null { const trimmed = rawPath.trim(); if (!trimmed) { return null; } if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) { log.warn( `${fieldLabel} too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`, ); return null; } if (!trimmed.toLowerCase().endsWith(".md")) { log.warn(`${fieldLabel} must end in .md for agent ${agentId}: ${trimmed}`); return null; } if (isAbsolute(trimmed)) { log.warn(`${fieldLabel} must be project-relative for agent ${agentId}: ${trimmed}`); return null; } const normalized = normalize(trimmed); if (isPathTraversal(normalized)) { log.warn(`${fieldLabel} traversal is not allowed for agent ${agentId}: ${trimmed}`); return null; } const resolvedPath = resolve(rootDir, normalized); const rel = relative(rootDir, resolvedPath); if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) { log.warn(`${fieldLabel} escapes project root for agent ${agentId}: ${trimmed}`); return null; } return resolvedPath; } function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null { return resolveValidatedMarkdownPath(rawPath, rootDir, agentId, "instructionsPath"); } /** * Load a per-agent heartbeat procedure markdown file. Returns the file * contents (trimmed/clamped to MAX_INSTRUCTIONS_TEXT_LENGTH), or null if no * path is configured, the path is invalid, or the file is unreadable. Caller * substitutes a default constant on null. */ export async function resolveAgentHeartbeatProcedure( agent: Agent | null | undefined, rootDir: string, ): Promise { const rawPath = agent?.heartbeatProcedurePath?.trim(); if (!agent || !rawPath) { return null; } const filePath = resolveValidatedMarkdownPath(rawPath, rootDir, agent.id, "heartbeatProcedurePath"); if (!filePath) { return null; } try { const content = await readFile(filePath, "utf-8"); const normalized = trimAndClamp( content, MAX_INSTRUCTIONS_TEXT_LENGTH, "heartbeat procedure file content", agent.id, ); return normalized || null; } catch (err: unknown) { const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { log.warn(`Heartbeat procedure file not found for agent ${agent.id}: ${filePath}`); } else { log.warn(`Failed to read heartbeat procedure file for agent ${agent.id}: ${filePath} (${code})`); } return null; } } /** * Write the default heartbeat procedure file at `pathRel` (project-relative) * if it doesn't already exist. Idempotent: leaves an existing file untouched * so operators can edit the procedure without losing their changes on the * next upgrade run. Returns the absolute path that was ensured (or null if * the path is invalid). * * The default content is supplied by the caller — kept as a parameter rather * than imported from agent-heartbeat.ts to avoid a circular dependency * (agent-heartbeat.ts already imports from this module). */ export async function ensureDefaultHeartbeatProcedureFile( rootDir: string, procedurePathRel: string, defaultContent: string, ): Promise { // Reuse the same path validation as the per-agent loader so we never write // outside the project root. const filePath = resolveValidatedMarkdownPath(procedurePathRel, rootDir, "system", "heartbeatProcedurePath"); if (!filePath) { return null; } try { await access(filePath, fsConstants.F_OK); return filePath; // Already exists — preserve operator edits. } catch { // Falls through to write below. } try { await mkdir(dirname(filePath), { recursive: true }); await writeFile(filePath, defaultContent, "utf-8"); log.log(`Seeded default heartbeat procedure file at ${filePath}`); return filePath; } catch (err: unknown) { log.warn(`Failed to seed default heartbeat procedure file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`); return null; } } function getTrendLabel(trend: AgentRatingSummary["trend"]): string { switch (trend) { case "improving": return "📈 improving"; case "declining": return "📉 declining"; case "stable": return "➡️ stable"; case "insufficient-data": default: return "❓ insufficient-data"; } } function formatSoulSection(soul: string, agentId: string): string { const trimmed = trimAndClamp(soul, MAX_SOUL_LENGTH, "soul", agentId); if (!trimmed) { return ""; } return `## Soul\n\n${trimmed}`; } function memoryWorkspaceDisplayPath(agentId: string): string { const safeAgentId = agentId.trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "agent"; return `.fusion/agent-memory/${safeAgentId}/MEMORY.md`; } async function formatMemorySection( memory: string, workspaceMemory: string, agentId: string, rootDir: string, inclusionMode: AgentMemoryInclusionMode, ): Promise { if (inclusionMode === "off") { return ""; } if (inclusionMode === "index") { const indexBody = await buildMemoryIndex({ rootDir, agentId }); if (!indexBody) { return ""; } return [ "## Agent Memory", "", "Memory is provided in index mode. Use fn_memory_search first, then fn_memory_get for relevant files/lines.", "", indexBody, ].join("\n"); } const inlineTrimmed = trimAndClamp(memory, MAX_MEMORY_LENGTH, "memory", agentId); const workspaceTrimmed = trimAndClamp(workspaceMemory, MAX_MEMORY_LENGTH, "workspace memory", agentId); if (!inlineTrimmed && !workspaceTrimmed) { return ""; } const lines = [ "## Agent Memory", "", "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.", "", ]; if (inlineTrimmed) { lines.push(inlineTrimmed); } if (workspaceTrimmed) { if (!inlineTrimmed) { lines.push(`_Source: ${memoryWorkspaceDisplayPath(agentId)}_`, "", workspaceTrimmed); } else { lines.push("", "### Long-term Workspace Memory", "", workspaceTrimmed); } } return lines.join("\n"); } function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string { const lines: string[] = [ "## Performance Feedback", "", `- Average score: ${ratingSummary.averageScore.toFixed(1)}`, `- Trend: ${getTrendLabel(ratingSummary.trend)}`, ]; const categoryEntries = Object.entries(ratingSummary.categoryAverages); if (categoryEntries.length > 0) { lines.push("- Category breakdown:"); for (const [category, average] of categoryEntries.sort(([a], [b]) => a.localeCompare(b))) { lines.push(` - ${category}: ${average.toFixed(1)}`); } } const recentComments = ratingSummary.recentRatings .filter((rating) => typeof rating.comment === "string" && rating.comment.trim().length > 0) .slice(0, 3); if (recentComments.length > 0) { lines.push("- Recent feedback:"); for (const rating of recentComments) { lines.push(` - "${rating.comment?.trim()}" (score: ${rating.score.toFixed(1)})`); } } return lines.join("\n"); } /** * Resolve custom instructions for an agent by combining inline text and/or * file-based instructions. * * @param agent - The agent record (may contain instructionsText and instructionsPath) * @param rootDir - Project root directory for resolving relative paths * @returns Concatenated instructions string, or empty string if none */ export async function resolveAgentInstructions( agent: Agent | null | undefined, rootDir: string, ratingSummary?: AgentRatingSummary, inclusionMode: AgentMemoryInclusionMode = "full", ): Promise { if (!agent) return ""; const parts: string[] = []; // Inline instructions take first position if (agent.instructionsText?.trim()) { const inline = trimAndClamp( agent.instructionsText, MAX_INSTRUCTIONS_TEXT_LENGTH, "instructionsText", agent.id, ); if (inline) { parts.push(inline); } } // File-based instructions appended after inline text if (agent.instructionsPath?.trim()) { const filePath = resolveValidatedInstructionsPath(agent.instructionsPath, rootDir, agent.id); if (filePath) { try { const content = await readFile(filePath, "utf-8"); const normalizedContent = trimAndClamp( content, MAX_INSTRUCTIONS_TEXT_LENGTH, "instructions file content", agent.id, ); if (normalizedContent) { parts.push(normalizedContent); } } catch (err: unknown) { // Graceful fallback: file doesn't exist or is unreadable // Log a warning but don't throw — instructionsText is still used const code = (err as NodeJS.ErrnoException).code; if (code === "ENOENT") { log.warn(`Instructions file not found for agent ${agent.id}: ${filePath}`); } else { log.warn(`Failed to read instructions file for agent ${agent.id}: ${filePath} (${code})`); } } } } // Soul/personality section (after instructions, before memory/performance feedback) if (agent.soul?.trim()) { const soulSection = formatSoulSection(agent.soul, agent.id); if (soulSection) { parts.push(soulSection); } } const workspaceMemory = await readAgentMemoryWorkspaceLongTerm(rootDir, agent.id); const memorySection = await formatMemorySection( agent.memory ?? "", workspaceMemory, agent.id, rootDir, inclusionMode, ); if (memorySection) { parts.push(memorySection); } if (ratingSummary && ratingSummary.totalRatings > 0) { parts.push(formatPerformanceFeedbackSection(ratingSummary)); } return parts.join("\n\n"); } /** * Resolve agent instructions and include performance ratings when available. * Falls back gracefully to base instructions if ratings lookup fails. */ export async function resolveAgentInstructionsWithRatings( agent: Agent | null | undefined, rootDir: string, agentStore: AgentStore | undefined, inclusionMode: AgentMemoryInclusionMode = "full", ): Promise { if (!agent) { return ""; } const baseInstructions = await resolveAgentInstructions(agent, rootDir, undefined, inclusionMode); if (!agentStore || !agent.id) { return baseInstructions; } try { const ratingSummary = await agentStore.getRatingSummary(agent.id); return await resolveAgentInstructions(agent, rootDir, ratingSummary, inclusionMode); } catch { return baseInstructions; } } export async function buildAgentChatPrompt(options: { agent: Agent; rootDir: string; agentStore?: AgentStore; basePrompt: string; includeProjectMemory?: boolean; }): Promise { 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); log.warn(`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. * * @param basePrompt - The original system prompt * @param instructions - Resolved instructions string * @returns System prompt with instructions appended (if any) */ export function buildSystemPromptWithInstructions( basePrompt: string, instructions: string, ): string { if (!instructions.trim()) return basePrompt; return `${basePrompt}\n\n## Custom Instructions\n\n${instructions}`; } export function buildPluginPromptSection( surface: PluginPromptSurface, pluginRunner: PluginRunner | undefined, ): string { if (!pluginRunner) { return ""; } const contributions = pluginRunner.getPromptContributionsForSurface(surface); if (contributions.length === 0) { return ""; } const prependByPlugin = new Map(); const appendByPlugin = new Map(); for (const { pluginId, contribution } of contributions) { const target = contribution.position === "prepend" ? prependByPlugin : appendByPlugin; const existing = target.get(pluginId) ?? []; existing.push(contribution.content); target.set(pluginId, existing); } const toSections = (group: Map): string[] => { return Array.from(group.entries()).map(([pluginId, contents]) => { return `## Plugin: ${pluginId}\n\n${contents.join("\n\n")}`; }); }; const sections = [...toSections(prependByPlugin), ...toSections(appendByPlugin)]; log.log(`Applied ${contributions.length} prompt contributions for surface '${surface}'`); return sections.join("\n\n"); }