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:
197
packages/core/src/__tests__/agent-instructions.test.ts
Normal file
197
packages/core/src/__tests__/agent-instructions.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user