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:
10
.changeset/per-agent-custom-instructions.md
Normal file
10
.changeset/per-agent-custom-instructions.md
Normal 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
|
||||
@@ -315,6 +315,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
pool,
|
||||
usageLimitPauser,
|
||||
agentStore,
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
onSession: (session) => { activeMergeSession = session; },
|
||||
});
|
||||
@@ -580,6 +581,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
const triage = new TriageProcessor(store, cwd, {
|
||||
semaphore,
|
||||
usageLimitPauser,
|
||||
agentStore,
|
||||
onSpecifyStart: (t) => console.log(`[engine] Specifying ${t.id}...`),
|
||||
onSpecifyComplete: (t) => console.log(`[engine] ✓ ${t.id} → todo`),
|
||||
onSpecifyError: (t, e) => console.log(`[engine] ✗ ${t.id}: ${e.message}`),
|
||||
|
||||
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 */
|
||||
|
||||
@@ -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 */
|
||||
export function updateAgentState(agentId: string, state: AgentState, projectId?: string): Promise<Agent> {
|
||||
return api<Agent>(withProjectId(`/agents/${encodeURIComponent(agentId)}/state`, projectId), {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
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 { AgentLogEntry } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -1179,8 +1179,14 @@ function ConfigTab({
|
||||
});
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSavingInstructions, setIsSavingInstructions] = useState(false);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
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 */
|
||||
const hasChanges = (() => {
|
||||
@@ -1201,6 +1207,14 @@ function ConfigTab({
|
||||
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) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: value }));
|
||||
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 (
|
||||
<div className="config-tab">
|
||||
<div className="config-section">
|
||||
@@ -1445,6 +1481,74 @@ function ConfigTab({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-section">
|
||||
<h3>Custom Instructions</h3>
|
||||
<p className="config-description">
|
||||
Append custom instructions to this agent'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
* Update agent state.
|
||||
|
||||
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