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, {
|
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}`),
|
||||||
|
|||||||
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;
|
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
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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), {
|
||||||
|
|||||||
@@ -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'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
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 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user