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 f34c86345c
commit 0f0ddc02fb
14 changed files with 775 additions and 13 deletions

View File

@@ -0,0 +1,10 @@
---
"@gsxdsm/fusion": minor
---
Add per-agent custom instructions support. Each agent can now have `instructionsText` (inline markdown) and/or `instructionsPath` (path to a .md file) that are appended to the agent's system prompt at execution time. This enables customizing agent behavior (coding style, project conventions, review criteria) without modifying built-in system prompts.
- New fields on Agent type: `instructionsPath` and `instructionsText`
- New API endpoint: `PATCH /api/agents/:id/instructions`
- Dashboard: Custom Instructions section in agent Config tab
- Executor, triage, reviewer, and merger all resolve per-agent instructions at session creation

View File

@@ -315,6 +315,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
aiMergeTask(store, cwd, taskId, { aiMergeTask(store, cwd, taskId, {
pool, pool,
usageLimitPauser, usageLimitPauser,
agentStore,
onAgentText: (delta) => process.stdout.write(delta), onAgentText: (delta) => process.stdout.write(delta),
onSession: (session) => { activeMergeSession = session; }, onSession: (session) => { activeMergeSession = session; },
}); });
@@ -580,6 +581,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const triage = new TriageProcessor(store, cwd, { const triage = new TriageProcessor(store, cwd, {
semaphore, semaphore,
usageLimitPauser, usageLimitPauser,
agentStore,
onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`), onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`),
onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`), onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`),
onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`), onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { AgentStore } from "../agent-store.js";
describe("AgentStore — instructions fields", () => {
let testDir: string;
let store: AgentStore;
beforeEach(async () => {
testDir = await mkdtemp(join(tmpdir(), "agent-instructions-test-"));
store = new AgentStore({ rootDir: testDir });
await store.init();
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
it("creates an agent with instructionsText", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Always use TypeScript strict mode.",
});
expect(agent.instructionsText).toBe("Always use TypeScript strict mode.");
expect(agent.instructionsPath).toBeUndefined();
});
it("creates an agent with instructionsPath", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/custom.md",
});
expect(agent.instructionsPath).toBe(".fusion/agents/custom.md");
expect(agent.instructionsText).toBeUndefined();
});
it("creates an agent with both instructionsText and instructionsPath", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "reviewer",
instructionsText: "Check for security issues.",
instructionsPath: ".fusion/agents/reviewer.md",
});
expect(agent.instructionsText).toBe("Check for security issues.");
expect(agent.instructionsPath).toBe(".fusion/agents/reviewer.md");
});
it("creates an agent without instructions (default)", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
expect(agent.instructionsText).toBeUndefined();
expect(agent.instructionsPath).toBeUndefined();
});
it("persists instructionsText through roundtrip", async () => {
const created = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Always write tests.",
});
const loaded = await store.getAgent(created.id);
expect(loaded).not.toBeNull();
expect(loaded!.instructionsText).toBe("Always write tests.");
});
it("persists instructionsPath through roundtrip", async () => {
const created = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/instructions.md",
});
const loaded = await store.getAgent(created.id);
expect(loaded).not.toBeNull();
expect(loaded!.instructionsPath).toBe(".fusion/agents/instructions.md");
});
it("updates instructionsText on an existing agent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "Use functional programming patterns.",
});
expect(updated.instructionsText).toBe("Use functional programming patterns.");
});
it("updates instructionsPath on an existing agent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
});
const updated = await store.updateAgent(agent.id, {
instructionsPath: ".fusion/agents/new-instructions.md",
});
expect(updated.instructionsPath).toBe(".fusion/agents/new-instructions.md");
});
it("clears instructionsText by updating to empty string", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Some instructions",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "",
});
// Empty string should be persisted as-is (the engine resolver treats empty as no-op)
expect(updated.instructionsText).toBe("");
});
it("clears instructionsPath by updating to empty string", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsPath: ".fusion/agents/old.md",
});
const updated = await store.updateAgent(agent.id, {
instructionsPath: "",
});
expect(updated.instructionsPath).toBe("");
});
it("updates both instructions fields simultaneously", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "merger",
instructionsText: "Old text",
instructionsPath: "old.md",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "New text",
instructionsPath: ".fusion/agents/new.md",
});
expect(updated.instructionsText).toBe("New text");
expect(updated.instructionsPath).toBe(".fusion/agents/new.md");
// Verify persistence
const loaded = await store.getAgent(agent.id);
expect(loaded!.instructionsText).toBe("New text");
expect(loaded!.instructionsPath).toBe(".fusion/agents/new.md");
});
it("preserves other fields when updating instructions", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
title: "My Executor",
instructionsText: "Initial",
});
const updated = await store.updateAgent(agent.id, {
instructionsText: "Updated",
});
expect(updated.name).toBe("test-agent");
expect(updated.role).toBe("executor");
expect(updated.title).toBe("My Executor");
expect(updated.instructionsText).toBe("Updated");
});
it("roundtrips instructions through getCachedAgent", async () => {
const agent = await store.createAgent({
name: "test-agent",
role: "executor",
instructionsText: "Cached instructions",
instructionsPath: ".fusion/cached.md",
});
const cached = store.getCachedAgent(agent.id);
expect(cached).not.toBeNull();
expect(cached!.instructionsText).toBe("Cached instructions");
expect(cached!.instructionsPath).toBe(".fusion/cached.md");
});
});

View File

@@ -76,9 +76,9 @@ interface AgentData {
totalInputTokens?: number; totalInputTokens?: number;
totalOutputTokens?: number; totalOutputTokens?: number;
lastError?: string; lastError?: string;
instructionsPath?: string;
instructionsText?: string;
} }
/** Per-agent write lock for serialization */
interface AgentLock { interface AgentLock {
promise: Promise<unknown>; promise: Promise<unknown>;
} }
@@ -136,6 +136,8 @@ export class AgentStore extends EventEmitter {
...(input.reportsTo && { reportsTo: input.reportsTo }), ...(input.reportsTo && { reportsTo: input.reportsTo }),
...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }), ...(input.runtimeConfig && { runtimeConfig: input.runtimeConfig }),
...(input.permissions && { permissions: input.permissions }), ...(input.permissions && { permissions: input.permissions }),
...(input.instructionsPath && { instructionsPath: input.instructionsPath }),
...(input.instructionsText && { instructionsText: input.instructionsText }),
}; };
await this.writeAgent(agent); await this.writeAgent(agent);
@@ -214,6 +216,8 @@ export class AgentStore extends EventEmitter {
...(updates.lastError !== undefined && { lastError: updates.lastError }), ...(updates.lastError !== undefined && { lastError: updates.lastError }),
...(updates.totalInputTokens !== undefined && { totalInputTokens: updates.totalInputTokens }), ...(updates.totalInputTokens !== undefined && { totalInputTokens: updates.totalInputTokens }),
...(updates.totalOutputTokens !== undefined && { totalOutputTokens: updates.totalOutputTokens }), ...(updates.totalOutputTokens !== undefined && { totalOutputTokens: updates.totalOutputTokens }),
...(updates.instructionsPath !== undefined && { instructionsPath: updates.instructionsPath }),
...(updates.instructionsText !== undefined && { instructionsText: updates.instructionsText }),
}; };
await this.writeAgent(updated); await this.writeAgent(updated);
@@ -767,6 +771,8 @@ export class AgentStore extends EventEmitter {
totalInputTokens: data.totalInputTokens, totalInputTokens: data.totalInputTokens,
totalOutputTokens: data.totalOutputTokens, totalOutputTokens: data.totalOutputTokens,
lastError: data.lastError, lastError: data.lastError,
instructionsPath: data.instructionsPath,
instructionsText: data.instructionsText,
}; };
} }
@@ -791,6 +797,8 @@ export class AgentStore extends EventEmitter {
totalInputTokens: agent.totalInputTokens, totalInputTokens: agent.totalInputTokens,
totalOutputTokens: agent.totalOutputTokens, totalOutputTokens: agent.totalOutputTokens,
lastError: agent.lastError, lastError: agent.lastError,
instructionsPath: agent.instructionsPath,
instructionsText: agent.instructionsText,
}; };
// Write atomically using temp file // Write atomically using temp file

View File

@@ -1499,6 +1499,11 @@ export interface Agent {
totalOutputTokens?: number; totalOutputTokens?: number;
/** Last error message */ /** Last error message */
lastError?: string; lastError?: string;
/** Path to a markdown file containing custom instructions (resolved relative to project root).
* Must end in `.md`, no `..` traversal. Max 500 chars. */
instructionsPath?: string;
/** Inline custom instructions appended to the agent's system prompt at execution time. Max 50,000 chars. */
instructionsText?: string;
} }
/** Per-agent heartbeat configuration, stored in agent.runtimeConfig */ /** Per-agent heartbeat configuration, stored in agent.runtimeConfig */
@@ -1531,6 +1536,8 @@ export interface AgentCreateInput {
reportsTo?: string; reportsTo?: string;
runtimeConfig?: Record<string, unknown>; runtimeConfig?: Record<string, unknown>;
permissions?: Record<string, boolean>; permissions?: Record<string, boolean>;
instructionsPath?: string;
instructionsText?: string;
} }
/** Input for updating an existing agent */ /** Input for updating an existing agent */
@@ -1547,6 +1554,8 @@ export interface AgentUpdateInput {
lastError?: string; lastError?: string;
totalInputTokens?: number; totalInputTokens?: number;
totalOutputTokens?: number; totalOutputTokens?: number;
instructionsPath?: string;
instructionsText?: string;
} }
/** Per-task session persistence for an agent */ /** Per-task session persistence for an agent */

View File

@@ -1790,6 +1790,18 @@ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectI
}); });
} }
/** Update agent custom instructions */
export function updateAgentInstructions(
agentId: string,
instructions: { instructionsPath?: string; instructionsText?: string },
projectId?: string,
): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/instructions`, projectId), {
method: "PATCH",
body: JSON.stringify(instructions),
});
}
/** Update an agent's state */ /** Update an agent's state */
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> { export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), { return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight ChevronDown, ChevronRight
} from "lucide-react"; } from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api"; import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun } from "../api"; import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions } from "../api";
import type { Agent } from "../api"; import type { Agent } from "../api";
import type { AgentLogEntry } from "@fusion/core"; import type { AgentLogEntry } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer"; import { AgentLogViewer } from "./AgentLogViewer";
@@ -1179,8 +1179,14 @@ function ConfigTab({
}); });
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
const [errors, setErrors] = useState<ValidationErrors>({}); const [errors, setErrors] = useState<ValidationErrors>({});
const [justSaved, setJustSaved] = useState(false); const [justSaved, setJustSaved] = useState(false);
const [justSavedInstructions, setJustSavedInstructions] = useState(false);
// Custom instructions state
const [instructionsText, setInstructionsText] = useState(agent.instructionsText ?? "");
const [instructionsPath, setInstructionsPath] = useState(agent.instructionsPath ?? "");
/** Detect whether any local value differs from the persisted metadata */ /** Detect whether any local value differs from the persisted metadata */
const hasChanges = (() => { const hasChanges = (() => {
@@ -1201,6 +1207,14 @@ function ConfigTab({
return false; return false;
})(); })();
const hasInstructionsChanges = (() => {
const currentText = instructionsText ?? "";
const persistedText = agent.instructionsText ?? "";
const currentPath = instructionsPath?.trim() ?? "";
const persistedPath = agent.instructionsPath?.trim() ?? "";
return currentText !== persistedText || currentPath !== persistedPath;
})();
const handleFieldChange = (key: string, value: string) => { const handleFieldChange = (key: string, value: string) => {
setFormValues((prev) => ({ ...prev, [key]: value })); setFormValues((prev) => ({ ...prev, [key]: value }));
setJustSaved(false); setJustSaved(false);
@@ -1291,6 +1305,28 @@ function ConfigTab({
} }
}; };
const handleSaveInstructions = async () => {
setIsSavingInstructions(true);
try {
await updateAgentInstructions(
agent.id,
{
instructionsText: instructionsText || undefined,
instructionsPath: instructionsPath.trim() || undefined,
},
projectId,
);
addToast("Instructions saved", "success");
setJustSavedInstructions(true);
setTimeout(() => setJustSavedInstructions(false), 3000);
await onSaved();
} catch (err: any) {
addToast(`Failed to save instructions: ${err.message}`, "error");
} finally {
setIsSavingInstructions(false);
}
};
return ( return (
<div className="config-tab"> <div className="config-tab">
<div className="config-section"> <div className="config-section">
@@ -1445,6 +1481,74 @@ function ConfigTab({
)} )}
</div> </div>
</div> </div>
<div className="config-section">
<h3>Custom Instructions</h3>
<p className="config-description">
Append custom instructions to this agent&apos;s system prompt at execution time. Use this to customize behavior, coding style, or project conventions without modifying built-in prompts.
</p>
<div className="config-fields">
<div className="config-field">
<label htmlFor="instructions-text">Inline Instructions</label>
<textarea
id="instructions-text"
className="input"
rows={10}
placeholder="Enter custom instructions to append to this agent's system prompt..."
value={instructionsText}
onChange={(e) => {
setInstructionsText(e.target.value);
setJustSavedInstructions(false);
}}
style={{ fontFamily: "monospace", fontSize: "0.875rem", resize: "vertical" }}
/>
<span className="config-hint">Markdown formatting supported. Max 50,000 characters.</span>
</div>
<div className="config-field">
<label htmlFor="instructions-path">Instructions File Path</label>
<input
id="instructions-path"
type="text"
className="input"
placeholder="e.g., .fusion/agents/my-agent-instructions.md"
value={instructionsPath}
onChange={(e) => {
setInstructionsPath(e.target.value);
setJustSavedInstructions(false);
}}
/>
<span className="config-hint">Path to a .md file (relative to project root). Contents are read and appended at execution time.</span>
</div>
</div>
<div className="config-actions">
<button
className="btn btn--primary"
disabled={!hasInstructionsChanges || isSavingInstructions}
onClick={() => void handleSaveInstructions()}
>
{isSavingInstructions ? (
<>
<Loader2 size={16} className="animate-spin" />
Saving…
</>
) : (
<>
<CheckCircle size={16} />
Save Instructions
</>
)}
</button>
{!hasInstructionsChanges && justSavedInstructions && (
<span className="config-saved-indicator">
<CheckCircle size={14} />
Instructions saved
</span>
)}
</div>
</div>
</div> </div>
); );
} }

View File

@@ -7058,6 +7058,63 @@ Output ONLY the prompt text (no markdown, no explanations).`;
} }
}); });
/**
* PATCH /api/agents/:id/instructions
* Update agent custom instructions.
* Body: { instructionsPath?: string, instructionsText?: string }
*/
router.patch("/agents/:id/instructions", async (req, res) => {
try {
const { instructionsPath, instructionsText } = req.body;
// Validate instructionsPath if provided
if (instructionsPath !== undefined && instructionsPath !== "") {
if (typeof instructionsPath !== "string") {
res.status(400).json({ error: "instructionsPath must be a string" });
return;
}
if (instructionsPath.length > 500) {
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
return;
}
if (instructionsPath.includes("..")) {
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
return;
}
if (!instructionsPath.endsWith(".md")) {
res.status(400).json({ error: "instructionsPath must end in .md" });
return;
}
}
// Validate instructionsText if provided
if (instructionsText !== undefined && instructionsText !== "") {
if (typeof instructionsText !== "string") {
res.status(400).json({ error: "instructionsText must be a string" });
return;
}
if (instructionsText.length > 50000) {
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
return;
}
}
const scopedStore = await getScopedStore(req);
const { AgentStore } = await import("@fusion/core");
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
await agentStore.init();
const agent = await agentStore.updateAgent(req.params.id, { instructionsPath, instructionsText });
res.json(agent);
} catch (err: any) {
if (err.message?.includes("not found")) {
res.status(404).json({ error: err.message });
} else {
res.status(500).json({ error: err.message });
}
}
});
/** /**
* POST /api/agents/:id/state * POST /api/agents/:id/state
* Update agent state. * Update agent state.

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 type { StuckTaskDetector, StuckTaskEvent } from "./stuck-task-detector.js";
import { isContextLimitError } from "./context-limit-detector.js"; import { isContextLimitError } from "./context-limit-detector.js";
import { StepSessionExecutor, type StepSessionExecutorOptions, type StepResult } from "./step-session-executor.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"; import { createTaskCreateTool as sharedCreateTaskCreateTool, createTaskLogTool as sharedCreateTaskLogTool, taskCreateParams, taskLogParams } from "./agent-tools.js";
// Re-export for backward compatibility (tests import from executor.ts) // 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 * 3. Otherwise, create a fresh worktree via `git worktree add` and run the
* `worktreeInitCommand` if configured. * `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 { private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
if (task.dependencies.length === 0) return 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})`); 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({ let { session, sessionFile } = await createKbAgent({
cwd: worktreePath, cwd: worktreePath,
systemPrompt: getExecutorSystemPrompt(settings), systemPrompt: executorSystemPrompt,
tools: "coding", tools: "coding",
customTools, customTools,
onText: agentLogger.onText, onText: agentLogger.onText,
@@ -1181,7 +1210,7 @@ export class TaskExecutor {
const { session: retrySession, sessionFile: retrySessionFile } = await createKbAgent({ const { session: retrySession, sessionFile: retrySessionFile } = await createKbAgent({
cwd: worktreePath, cwd: worktreePath,
systemPrompt: getExecutorSystemPrompt(settings), systemPrompt: executorSystemPrompt,
tools: "coding", tools: "coding",
customTools, customTools,
onText: agentLogger.onText, onText: agentLogger.onText,
@@ -1720,6 +1749,8 @@ export class TaskExecutor {
store, store,
taskId, taskId,
agentPrompts: settings.agentPrompts, 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 stepModelId = workflowStep.modelId || settings.defaultModelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); 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({ const { session } = await createKbAgent({
cwd: worktreePath, cwd: worktreePath,
systemPrompt, systemPrompt: stepSystemPrompt,
tools: toolMode, tools: toolMode,
defaultProvider: stepProvider, defaultProvider: stepProvider,
defaultModelId: stepModelId, defaultModelId: stepModelId,
@@ -2873,10 +2908,15 @@ If issues are found that need attention, describe them clearly.`;
// Transition agent to active state // Transition agent to active state
await this.options.agentStore.updateAgentState(agent.id, "active"); 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 // Create child agent session
const { session: childSession } = await createKbAgent({ const { session: childSession } = await createKbAgent({
cwd: childWorktreePath, 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", tools: "coding",
defaultProvider: settings.defaultProvider, defaultProvider: settings.defaultProvider,
defaultModelId: settings.defaultModelId, defaultModelId: settings.defaultModelId,

View File

@@ -8,6 +8,7 @@ import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js"; import { mergerLog } from "./logger.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js"; import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
import { withRateLimitRetry } from "./rate-limit-retry.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 { ToolDefinition } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
@@ -597,6 +598,8 @@ export interface MergerOptions {
* caller (e.g. dashboard.ts) to track and externally dispose the session * caller (e.g. dashboard.ts) to track and externally dispose the session
* when a global pause is triggered. */ * when a global pause is triggered. */
onSession?: (session: { dispose: () => void }) => void; 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) // 8. Run post-merge workflow steps (failures logged but do not block completion)
try { try {
await runPostMergeWorkflowSteps(store, taskId, rootDir, settings); await runPostMergeWorkflowSteps(store, taskId, rootDir, settings, options);
} catch (err: any) { } catch (err: any) {
mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`); mergerLog.error(`${taskId}: post-merge workflow steps error: ${err.message}`);
// Non-fatal — task still moves to done // Non-fatal — task still moves to done
@@ -1331,9 +1334,29 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
: undefined, : 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({ const { session } = await createKbAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt: buildMergeSystemPrompt(includeTaskId, settings.agentPrompts), systemPrompt: mergerSystemPrompt,
tools: "coding", tools: "coding",
customTools: [reportBuildFailureTool], customTools: [reportBuildFailureTool],
onText: agentLogger.onText, onText: agentLogger.onText,
@@ -1489,6 +1512,7 @@ async function runPostMergeWorkflowSteps(
taskId: string, taskId: string,
rootDir: string, rootDir: string,
settings: Settings, settings: Settings,
mergeOptions: MergerOptions = {},
): Promise<void> { ): Promise<void> {
const task = await store.getTask(taskId); const task = await store.getTask(taskId);
if (!task.enabledWorkflowSteps?.length) return; if (!task.enabledWorkflowSteps?.length) return;
@@ -1547,7 +1571,7 @@ async function runPostMergeWorkflowSteps(
try { try {
const result = stepMode === "script" const result = stepMode === "script"
? await executePostMergeScriptStep(store, taskId, ws, rootDir, settings) ? 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(); const completedAt = new Date().toISOString();
if (result.success) { if (result.success) {
@@ -1640,6 +1664,7 @@ async function executePostMergePromptStep(
workflowStep: WorkflowStep, workflowStep: WorkflowStep,
rootDir: string, rootDir: string,
settings: Settings, settings: Settings,
mergeOptions: MergerOptions = {},
): Promise<{ success: boolean; output?: string; error?: string }> { ): Promise<{ success: boolean; output?: string; error?: string }> {
const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly"; const toolMode: "coding" | "readonly" = workflowStep.toolMode || "readonly";
const systemPrompt = `You are a post-merge workflow step agent executing: ${workflowStep.name} 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 stepModelId = workflowStep.modelId || settings.defaultModelId;
const useOverride = !!(workflowStep.modelProvider && workflowStep.modelId); 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({ const { session } = await createKbAgent({
cwd: rootDir, cwd: rootDir,
systemPrompt, systemPrompt: postMergeSystemPrompt,
tools: toolMode, tools: toolMode,
defaultProvider: stepProvider, defaultProvider: stepProvider,
defaultModelId: stepModelId, defaultModelId: stepModelId,

View File

@@ -14,6 +14,7 @@ import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { reviewerLog } from "./logger.js"; import { reviewerLog } from "./logger.js";
import { checkSessionError } from "./usage-limit-detector.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. export const REVIEWER_SYSTEM_PROMPT = `You are an independent code and plan reviewer.
@@ -198,6 +199,10 @@ export interface ReviewOptions {
userComments?: TaskComment[]; userComments?: TaskComment[];
/** Agent prompt configuration for resolving custom reviewer prompts. */ /** Agent prompt configuration for resolving custom reviewer prompts. */
agentPrompts?: AgentPromptsConfig; 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.validatorFallbackModelId
: options.fallbackModelId; : 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 // Spawn a reviewer agent with read-only tools
const { session } = await createKbAgent({ const { session } = await createKbAgent({
cwd, cwd,
systemPrompt: resolveAgentPrompt("reviewer", options.agentPrompts) || REVIEWER_SYSTEM_PROMPT, systemPrompt: reviewerSystemPrompt,
tools: "readonly", tools: "readonly",
onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta), onText: agentLogger ? agentLogger.onText : (delta) => options.onText?.(delta),
onThinking: agentLogger?.onThinking, onThinking: agentLogger?.onThinking,

View File

@@ -16,6 +16,7 @@ import { createKbAgent, describeModel, promptWithFallback } from "./pi.js";
import { reviewStep, type ReviewVerdict } from "./reviewer.js"; import { reviewStep, type ReviewVerdict } from "./reviewer.js";
import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js"; import { PRIORITY_SPECIFY, type AgentSemaphore } from "./concurrency.js";
import { AgentLogger } from "./agent-logger.js"; import { AgentLogger } from "./agent-logger.js";
import { resolveAgentInstructions, buildSystemPromptWithInstructions } from "./agent-instructions.js";
import { triageLog, reviewerLog } from "./logger.js"; import { triageLog, reviewerLog } from "./logger.js";
import { import {
isUsageLimitError, isUsageLimitError,
@@ -236,6 +237,8 @@ export interface TriageProcessorOptions {
onSpecifyComplete?: (task: Task) => void; onSpecifyComplete?: (task: Task) => void;
onSpecifyError?: (task: Task, error: Error) => void; onSpecifyError?: (task: Task, error: Error) => void;
onAgentText?: (taskId: string, delta: string) => 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({ const { session } = await createKbAgent({
cwd: this.rootDir, cwd: this.rootDir,
systemPrompt: resolveAgentPrompt("triage", settings.agentPrompts) || TRIAGE_SYSTEM_PROMPT, systemPrompt: triageSystemPrompt,
tools: "coding", tools: "coding",
customTools, customTools,
onText: agentLogger.onText, onText: agentLogger.onText,
@@ -1049,6 +1072,8 @@ export class TriageProcessor {
store, store,
taskId, taskId,
userComments: currentUserComments.length > 0 ? currentUserComments : undefined, userComments: currentUserComments.length > 0 ? currentUserComments : undefined,
agentStore: this.options.agentStore,
rootDir,
}, },
); );