feat(FN-1050): add per-agent custom instructions support

- Add instructionsPath and instructionsText fields to Agent type and AgentStore
- Create agent-instructions resolver module in engine with priority-based resolution
- Wire custom instructions into executor, triage, reviewer, and merger agents
- Add PATCH /agents/:id/instructions API endpoint with file and text support
- Add instructions editor UI to dashboard agent detail config tab
- Add comprehensive tests for instructions resolver and AgentStore integration
- Add changeset for published package bump
This commit is contained in:
gsxdsm
2026-04-07 15:17:08 -07:00
parent 637556343a
commit 335a20e1bd
14 changed files with 775 additions and 13 deletions

View File

@@ -0,0 +1,161 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { Agent } from "@fusion/core";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "../agent-instructions.js";
function makeAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-test",
name: "test-agent",
role: "executor",
state: "idle",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
...overrides,
} as Agent;
}
describe("resolveAgentInstructions", () => {
let testDir: string;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-instr-resolve-"));
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it("returns empty string for null agent", async () => {
const result = await resolveAgentInstructions(null, testDir);
expect(result).toBe("");
});
it("returns empty string for undefined agent", async () => {
const result = await resolveAgentInstructions(undefined, testDir);
expect(result).toBe("");
});
it("returns empty string for agent with no instructions", async () => {
const agent = makeAgent();
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("");
});
it("returns empty string for agent with empty instructions fields", async () => {
const agent = makeAgent({ instructionsText: "", instructionsPath: "" });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("");
});
it("returns instructionsText when set", async () => {
const agent = makeAgent({ instructionsText: "Always write tests." });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Always write tests.");
});
it("returns file contents when instructionsPath is set", async () => {
const filePath = join(testDir, "instructions.md");
await writeFile(filePath, "# Custom Instructions\nUse strict TypeScript.");
const agent = makeAgent({ instructionsPath: "instructions.md" });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("# Custom Instructions\nUse strict TypeScript.");
});
it("returns file contents when instructionsPath is absolute", async () => {
const filePath = join(testDir, "absolute-instructions.md");
await writeFile(filePath, "Absolute path instructions.");
const agent = makeAgent({ instructionsPath: filePath });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Absolute path instructions.");
});
it("concatenates instructionsText and file contents with double newline", async () => {
const filePath = join(testDir, "extra.md");
await writeFile(filePath, "Extra instructions from file.");
const agent = makeAgent({
instructionsText: "Inline instructions.",
instructionsPath: "extra.md",
});
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Inline instructions.\n\nExtra instructions from file.");
});
it("gracefully handles missing instructionsPath file", async () => {
const agent = makeAgent({
instructionsText: "Fallback text.",
instructionsPath: "nonexistent.md",
});
const result = await resolveAgentInstructions(agent, testDir);
// Should return fallback text even when file is missing
expect(result).toBe("Fallback text.");
});
it("gracefully handles unreadable file", async () => {
const agent = makeAgent({
instructionsPath: "unreadable.md",
});
const result = await resolveAgentInstructions(agent, testDir);
// Should return empty string when only path is provided but file doesn't exist
expect(result).toBe("");
});
it("trims whitespace from instructionsText", async () => {
const agent = makeAgent({ instructionsText: " padded text " });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("padded text");
});
it("trims whitespace from file contents", async () => {
const filePath = join(testDir, "padded.md");
await writeFile(filePath, " padded file content ");
const agent = makeAgent({ instructionsPath: "padded.md" });
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("padded file content");
});
it("ignores empty file contents", async () => {
const filePath = join(testDir, "empty.md");
await writeFile(filePath, " ");
const agent = makeAgent({
instructionsText: "Text only.",
instructionsPath: "empty.md",
});
const result = await resolveAgentInstructions(agent, testDir);
expect(result).toBe("Text only.");
});
});
describe("buildSystemPromptWithInstructions", () => {
it("returns base prompt when instructions are empty", () => {
const result = buildSystemPromptWithInstructions("Base prompt", "");
expect(result).toBe("Base prompt");
});
it("returns base prompt when instructions are whitespace only", () => {
const result = buildSystemPromptWithInstructions("Base prompt", " ");
expect(result).toBe("Base prompt");
});
it("appends instructions block to base prompt", () => {
const result = buildSystemPromptWithInstructions(
"Base prompt",
"Use strict TypeScript.",
);
expect(result).toBe(
"Base prompt\n\n## Custom Instructions\n\nUse strict TypeScript.",
);
});
});

View File

@@ -0,0 +1,70 @@
import { readFile } from "node:fs/promises";
import { join, isAbsolute } from "node:path";
import type { Agent } from "@fusion/core";
/**
* 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,
): Promise<string> {
if (!agent) return "";
const parts: string[] = [];
// Inline instructions take first position
if (agent.instructionsText?.trim()) {
parts.push(agent.instructionsText.trim());
}
// File-based instructions appended after inline text
if (agent.instructionsPath?.trim()) {
const filePath = isAbsolute(agent.instructionsPath)
? agent.instructionsPath
: join(rootDir, agent.instructionsPath);
try {
const content = await readFile(filePath, "utf-8");
if (content.trim()) {
parts.push(content.trim());
}
} 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") {
console.warn(
`[agent-instructions] Instructions file not found for agent ${agent.id}: ${filePath}`,
);
} else {
console.warn(
`[agent-instructions] Failed to read instructions file for agent ${agent.id}: ${filePath} (${code})`,
);
}
}
}
return parts.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}`;
}

View File

@@ -22,6 +22,7 @@ import { computeRecoveryDecision, formatDelay, MAX_RECOVERY_RETRIES } from "./re
import type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js";
import { isContextLimitError } from "./context-limit-detector.js";
import { StepSessionExecutor, type StepSessionExecutorOptions, type StepResult } from "./step-session-executor.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { createTaskCreateTool as sharedCreateTaskCreateTool, createTaskLogTool as sharedCreateTaskLogTool, taskCreateParams, taskLogParams } from "./agent-tools.js";
// Re-export for backward compatibility (tests import from executor.ts)
@@ -511,6 +512,27 @@ export class TaskExecutor {
* 3. Otherwise, create a fresh worktree via `git worktree add` and run the
* `worktreeInitCommand` if configured.
*/
/**
* Resolve custom instructions for a given agent role by looking up agents
* in the AgentStore that have instructions configured.
* Returns an empty string if no instructions are found.
*/
private async resolveInstructionsForRole(role: string): Promise<string> {
if (!this.options.agentStore) return "";
try {
const agents = await this.options.agentStore.listAgents({ role: role as AgentCapability });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
return await resolveAgentInstructions(agent, this.rootDir);
}
}
} catch {
// Graceful fallback — no instructions if lookup fails
}
return "";
}
private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
if (task.dependencies.length === 0) return null;
@@ -992,9 +1014,16 @@ export class TaskExecutor {
executorLog.log(`${task.id}: creating agent session (provider=${executorProvider ?? "default"}, model=${executorModelId ?? "default"}, resuming=${isResuming})`);
// Resolve per-agent custom instructions for the executor role
const executorInstructions = await this.resolveInstructionsForRole("executor");
const executorSystemPrompt = buildSystemPromptWithInstructions(
getExecutorSystemPrompt(settings),
executorInstructions,
);
let { session, sessionFile } = await createKbAgent({
cwd: worktreePath,
systemPrompt: getExecutorSystemPrompt(settings),
systemPrompt: executorSystemPrompt,
tools: "coding",
customTools,
onText: agentLogger.onText,
@@ -1181,7 +1210,7 @@ export class TaskExecutor {
const { session: retrySession, sessionFile: retrySessionFile } = await createKbAgent({
cwd: worktreePath,
systemPrompt: getExecutorSystemPrompt(settings),
systemPrompt: executorSystemPrompt,
tools: "coding",
customTools,
onText: agentLogger.onText,
@@ -1720,6 +1749,8 @@ export class TaskExecutor {
store,
taskId,
agentPrompts: settings.agentPrompts,
agentStore: this.options.agentStore,
rootDir: this.rootDir,
},
);
@@ -2145,9 +2176,13 @@ If issues are found that need attention, describe them clearly.`;
const stepModelId = workflowStep.modelId || settings.defaultModelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
// Workflow step agents inherit executor instructions
const stepInstructions = await this.resolveInstructionsForRole("executor");
const stepSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, stepInstructions);
const { session } = await createKbAgent({
cwd: worktreePath,
systemPrompt,
systemPrompt: stepSystemPrompt,
tools: toolMode,
defaultProvider: stepProvider,
defaultModelId: stepModelId,
@@ -2873,10 +2908,15 @@ If issues are found that need attention, describe them clearly.`;
// Transition agent to active state
await this.options.agentStore.updateAgentState(agent.id, "active");
// Child agents inherit executor instructions
const childInstructions = await this.resolveInstructionsForRole("executor");
const childBasePrompt = `You are a child agent spawned by a parent task executor. Your job is to complete the following delegated task. Work autonomously and thoroughly. Report your findings and results.\n\nParent task: ${taskId}\nChild agent: ${agent.id} (${name})`;
const childSystemPrompt = buildSystemPromptWithInstructions(childBasePrompt, childInstructions);
// Create child agent session
const { session: childSession } = await createKbAgent({
cwd: childWorktreePath,
systemPrompt: `You are a child agent spawned by a parent task executor. Your job is to complete the following delegated task. Work autonomously and thoroughly. Report your findings and results.\n\nParent task: ${taskId}\nChild agent: ${agent.id} (${name})`,
systemPrompt: childSystemPrompt,
tools: "coding",
defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId,

View File

@@ -8,6 +8,7 @@ import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
@@ -597,6 +598,8 @@ export interface MergerOptions {
* caller (e.g. dashboard.ts) to track and externally dispose the session
* when a global pause is triggered. */
onSession?: (session: { dispose: () => void }) => void;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
}
/**
@@ -927,7 +930,7 @@ export async function aiMergeTask(
// 8. Run post-merge workflow steps (failures logged but do not block completion)
try {
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings);
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings, options);
} catch (err: any) {
mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`);
// Non-fatal — task still moves to done
@@ -1331,9 +1334,29 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
: undefined,
});
// Resolve per-agent custom instructions for the merger role
let mergerInstructions = "";
if (options.agentStore) {
try {
const agents = await options.agentStore.listAgents({ role: "merger" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
mergerInstructions = await resolveAgentInstructions(agent, rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const mergerSystemPrompt = buildSystemPromptWithInstructions(
buildMergeSystemPrompt(includeTaskId, settings.agentPrompts),
mergerInstructions,
);
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId, settings.agentPrompts),
systemPrompt: mergerSystemPrompt,
tools: "coding",
customTools: [reportBuildFailureTool],
onText: agentLogger.onText,
@@ -1489,6 +1512,7 @@ async function runPostMergeWorkflowSteps(
taskId: string,
rootDir: string,
settings: Settings,
mergeOptions: MergerOptions = {},
): Promise<void> {
const task = await store.getTask(taskId);
if (!task.enabledWorkflowSteps?.length) return;
@@ -1547,7 +1571,7 @@ async function runPostMergeWorkflowSteps(
try {
const result = stepMode === "script"
? await executePostMergeScriptStep(store, taskId, ws, rootDir, settings)
: await executePostMergePromptStep(store, taskId, ws, rootDir, settings);
: await executePostMergePromptStep(store, taskId, ws, rootDir, settings, mergeOptions);
const completedAt = new Date().toISOString();
if (result.success) {
@@ -1640,6 +1664,7 @@ async function executePostMergePromptStep(
workflowStep: WorkflowStep,
rootDir: string,
settings: Settings,
mergeOptions: MergerOptions = {},
): Promise<{ success: boolean; output?: string; error?: string }> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name}
@@ -1667,9 +1692,26 @@ If issues are found that need attention, describe them clearly.`;
const stepModelId = workflowStep.modelId || settings.defaultModelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId);
// Post-merge step agents inherit merger instructions
let postMergeInstructions = "";
if (mergeOptions.agentStore) {
try {
const agents = await mergeOptions.agentStore.listAgents({ role: "merger" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
postMergeInstructions = await resolveAgentInstructions(agent, rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const postMergeSystemPrompt = buildSystemPromptWithInstructions(systemPrompt, postMergeInstructions);
const { session } = await createKbAgent({
cwd: rootDir,
systemPrompt,
systemPrompt: postMergeSystemPrompt,
tools: toolMode,
defaultProvider: stepProvider,
defaultModelId: stepModelId,

View File

@@ -14,6 +14,7 @@ import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js";
import { checkSessionError } from "./usage-limit-detector.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
@@ -198,6 +199,10 @@ export interface ReviewOptions {
userComments?: TaskComment[];
/** Agent prompt configuration for resolving custom reviewer prompts. */
agentPrompts?: AgentPromptsConfig;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
/** Project root directory for resolving relative instructionsPath files. */
rootDir?: string;
}
/**
@@ -245,10 +250,30 @@ export async function reviewStep(
? options.validatorFallbackModelId
: options.fallbackModelId;
// Resolve per-agent custom instructions for the reviewer role
let reviewerInstructions = "";
if (options.agentStore && options.rootDir) {
try {
const agents = await options.agentStore.listAgents({ role: "reviewer" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
reviewerInstructions = await resolveAgentInstructions(agent, options.rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const reviewerSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT,
reviewerInstructions,
);
// Spawn a reviewer agent with read-only tools
const { session } = await createKbAgent({
cwd,
systemPrompt: resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT,
systemPrompt: reviewerSystemPrompt,
tools: "readonly",
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
onThinking: agentLogger?.onThinking,

View File

@@ -16,6 +16,7 @@ import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { triageLog, reviewerLog } from "./logger.js";
import {
isUsageLimitError,
@@ -236,6 +237,8 @@ export interface TriageProcessorOptions {
onSpecifyComplete?: (task: Task) => void;
onSpecifyError?: (task: Task, error: Error) => void;
onAgentText?: (taskId: string, delta: string) => void;
/** AgentStore for resolving per-agent custom instructions. */
agentStore?: import("@fusion/core").AgentStore;
}
/**
@@ -492,9 +495,29 @@ export class TriageProcessor {
),
];
// Resolve per-agent custom instructions for the triage role
let triageInstructions = "";
if (this.options.agentStore) {
try {
const agents = await this.options.agentStore.listAgents({ role: "triage" });
for (const agent of agents) {
if (agent.instructionsText || agent.instructionsPath) {
triageInstructions = await resolveAgentInstructions(agent, this.rootDir);
break;
}
}
} catch {
// Graceful fallback
}
}
const triageSystemPrompt = buildSystemPromptWithInstructions(
resolveAgentPrompt("triage", settings.agentPrompts) || TRIAGE_SYSTEM_PROMPT,
triageInstructions,
);
const { session } = await createKbAgent({
cwd: this.rootDir,
systemPrompt: resolveAgentPrompt("triage", settings.agentPrompts) || TRIAGE_SYSTEM_PROMPT,
systemPrompt: triageSystemPrompt,
tools: "coding",
customTools,
onText: agentLogger.onText,
@@ -1049,6 +1072,8 @@ export class TriageProcessor {
store,
taskId,
userComments: currentUserComments.length > 0 ? currentUserComments : undefined,
agentStore: this.options.agentStore,
rootDir,
},
);