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