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

View File

@@ -1499,6 +1499,11 @@ export interface Agent {
totalOutputTokens?: number;
/** Last error message */
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 */
@@ -1531,6 +1536,8 @@ export interface AgentCreateInput {
reportsTo?: string;
runtimeConfig?: Record<string, unknown>;
permissions?: Record<string, boolean>;
instructionsPath?: string;
instructionsText?: string;
}
/** Input for updating an existing agent */
@@ -1547,6 +1554,8 @@ export interface AgentUpdateInput {
lastError?: string;
totalInputTokens?: number;
totalOutputTokens?: number;
instructionsPath?: string;
instructionsText?: string;
}
/** Per-task session persistence for an agent */