feat(FN-1085): align agent routing and runtime contracts
- Harden core AgentStore lifecycle behavior and heartbeat runtime integration paths - Align dashboard agent APIs, server routes, and agent UI flows with the updated contract - Tighten CLI agent/message command routing and validate payload handling semantics - Expand test coverage across core, dashboard, engine, and CLI for route, heartbeat, and instruction regressions
This commit is contained in:
@@ -22,6 +22,15 @@ const runNodeAdd = vi.fn();
|
||||
const runNodeRemove = vi.fn();
|
||||
const runNodeShow = vi.fn();
|
||||
const runNodeHealth = vi.fn();
|
||||
const runAgentStop = vi.fn();
|
||||
const runAgentStart = vi.fn();
|
||||
const runAgentMailbox = vi.fn();
|
||||
const runAgentImport = vi.fn();
|
||||
const runMessageInbox = vi.fn();
|
||||
const runMessageOutbox = vi.fn();
|
||||
const runMessageSend = vi.fn();
|
||||
const runMessageRead = vi.fn();
|
||||
const runMessageDelete = vi.fn();
|
||||
|
||||
vi.mock("../commands/dashboard.js", () => ({
|
||||
runDashboard: vi.fn(),
|
||||
@@ -94,6 +103,24 @@ vi.mock("../commands/node.js", () => ({
|
||||
runNodeHealth,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/agent.js", () => ({
|
||||
runAgentStop,
|
||||
runAgentStart,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/agent-import.js", () => ({
|
||||
runAgentImport,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/message.js", () => ({
|
||||
runMessageInbox,
|
||||
runMessageOutbox,
|
||||
runMessageSend,
|
||||
runMessageRead,
|
||||
runMessageDelete,
|
||||
runAgentMailbox,
|
||||
}));
|
||||
|
||||
describe("bin", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
@@ -173,6 +200,20 @@ describe("bin", () => {
|
||||
expect(runGitFetch).toHaveBeenCalledWith("origin", "demo");
|
||||
});
|
||||
|
||||
it("passes projectName through to agent import handler", async () => {
|
||||
await runBin(["agent", "import", "./agents.sh", "--dry-run", "--skip-existing", "--project", "demo"]);
|
||||
expect(runAgentImport).toHaveBeenCalledWith("./agents.sh", {
|
||||
dryRun: true,
|
||||
skipExisting: true,
|
||||
project: "demo",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses multi-word message send content and project flag", async () => {
|
||||
await runBin(["message", "send", "agent-123", "Hello", "from", "CLI", "--project", "demo"]);
|
||||
expect(runMessageSend).toHaveBeenCalledWith("agent-123", "Hello from CLI", "demo");
|
||||
});
|
||||
|
||||
it("routes project subcommands and aliases", async () => {
|
||||
await runBin(["project", "list"]);
|
||||
await runBin(["project", "ls"]);
|
||||
|
||||
@@ -864,7 +864,7 @@ async function main() {
|
||||
}
|
||||
case "send": {
|
||||
const toId = args[2];
|
||||
const content = args[3];
|
||||
const content = args.slice(3).join(" ").trim();
|
||||
if (!toId || !content) {
|
||||
console.error("Usage: fn message send <agent-id> <content>");
|
||||
process.exit(1);
|
||||
|
||||
@@ -175,6 +175,43 @@ describe("AgentStore", () => {
|
||||
expect(updated.metadata).toEqual({ preserved: true }); // preserved
|
||||
});
|
||||
|
||||
it("allows clearing optional fields via explicit undefined", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Clearable",
|
||||
role: "executor",
|
||||
title: "Worker",
|
||||
instructionsText: "Initial instructions",
|
||||
});
|
||||
|
||||
const withTransientState = await store.updateAgent(created.id, {
|
||||
pauseReason: "manual",
|
||||
lastError: "oops",
|
||||
});
|
||||
expect(withTransientState.pauseReason).toBe("manual");
|
||||
expect(withTransientState.lastError).toBe("oops");
|
||||
|
||||
const cleared = await store.updateAgent(created.id, {
|
||||
title: undefined,
|
||||
instructionsText: undefined,
|
||||
pauseReason: undefined,
|
||||
lastError: undefined,
|
||||
});
|
||||
|
||||
expect(cleared.title).toBeUndefined();
|
||||
expect(cleared.instructionsText).toBeUndefined();
|
||||
expect(cleared.pauseReason).toBeUndefined();
|
||||
expect(cleared.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects whitespace-only names", async () => {
|
||||
const created = await store.createAgent({
|
||||
name: "Rename Me",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
await expect(store.updateAgent(created.id, { name: " " })).rejects.toThrow("Agent name cannot be empty");
|
||||
});
|
||||
|
||||
it("throws for non-existent agent ID", async () => {
|
||||
await expect(
|
||||
store.updateAgent("agent-missing", { name: "Nope" })
|
||||
@@ -555,7 +592,10 @@ describe("AgentStore", () => {
|
||||
await s.recordHeartbeat(agent.id, "missed");
|
||||
await s.updateAgentState(agent.id, "active");
|
||||
await s.assignTask(agent.id, "KB-999");
|
||||
await s.updateAgent(agent.id, { lastError: "something broke" });
|
||||
await s.updateAgent(agent.id, {
|
||||
pauseReason: "manual",
|
||||
lastError: "something broke",
|
||||
});
|
||||
await s.updateAgentState(agent.id, "terminated");
|
||||
return agent;
|
||||
}
|
||||
@@ -567,6 +607,24 @@ describe("AgentStore", () => {
|
||||
expect(reset.state).toBe("idle");
|
||||
});
|
||||
|
||||
it("can reset directly from running", async () => {
|
||||
const agent = await store.createAgent({ name: "RunningReset", role: "executor" });
|
||||
await store.recordHeartbeat(agent.id, "ok");
|
||||
await store.updateAgentState(agent.id, "active");
|
||||
await store.updateAgentState(agent.id, "running");
|
||||
await store.assignTask(agent.id, "KB-123");
|
||||
await store.updateAgent(agent.id, {
|
||||
pauseReason: "stalled",
|
||||
lastError: "runner failed",
|
||||
});
|
||||
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
expect(reset.state).toBe("idle");
|
||||
expect(reset.taskId).toBeUndefined();
|
||||
expect(reset.pauseReason).toBeUndefined();
|
||||
expect(reset.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears lastError", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsError");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
@@ -574,6 +632,13 @@ describe("AgentStore", () => {
|
||||
expect(reset.lastError).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears pauseReason", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsPause");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
|
||||
expect(reset.pauseReason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears taskId", async () => {
|
||||
const agent = await createTerminatedAgent(store, "ResetClearsTask");
|
||||
const reset = await store.resetAgent(agent.id);
|
||||
|
||||
@@ -207,23 +207,28 @@ export class AgentStore extends EventEmitter {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
const nextName = "name" in updates && typeof updates.name === "string" ? updates.name.trim() : undefined;
|
||||
if (nextName !== undefined && !nextName) {
|
||||
throw new Error("Agent name cannot be empty");
|
||||
}
|
||||
|
||||
const updated: Agent = {
|
||||
...agent,
|
||||
name: updates.name?.trim() ?? agent.name,
|
||||
name: nextName ?? agent.name,
|
||||
role: updates.role ?? agent.role,
|
||||
metadata: updates.metadata !== undefined ? updates.metadata : agent.metadata,
|
||||
updatedAt: new Date().toISOString(),
|
||||
...(updates.title !== undefined && { title: updates.title }),
|
||||
...(updates.icon !== undefined && { icon: updates.icon }),
|
||||
...(updates.reportsTo !== undefined && { reportsTo: updates.reportsTo }),
|
||||
...(updates.runtimeConfig !== undefined && { runtimeConfig: updates.runtimeConfig }),
|
||||
...(updates.pauseReason !== undefined && { pauseReason: updates.pauseReason }),
|
||||
...(updates.permissions !== undefined && { permissions: updates.permissions }),
|
||||
...(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 }),
|
||||
...("title" in updates && { title: updates.title }),
|
||||
...("icon" in updates && { icon: updates.icon }),
|
||||
...("reportsTo" in updates && { reportsTo: updates.reportsTo }),
|
||||
...("runtimeConfig" in updates && { runtimeConfig: updates.runtimeConfig }),
|
||||
...("pauseReason" in updates && { pauseReason: updates.pauseReason }),
|
||||
...("permissions" in updates && { permissions: updates.permissions }),
|
||||
...("lastError" in updates && { lastError: updates.lastError }),
|
||||
...("totalInputTokens" in updates && { totalInputTokens: updates.totalInputTokens }),
|
||||
...("totalOutputTokens" in updates && { totalOutputTokens: updates.totalOutputTokens }),
|
||||
...("instructionsPath" in updates && { instructionsPath: updates.instructionsPath }),
|
||||
...("instructionsText" in updates && { instructionsText: updates.instructionsText }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
@@ -310,33 +315,45 @@ export class AgentStore extends EventEmitter {
|
||||
|
||||
/**
|
||||
* Reset an agent from any state back to "idle".
|
||||
* Clears lastError, taskId, and ends any active heartbeat run.
|
||||
* Uses updateAgentState internally for proper validation and event emission.
|
||||
* Clears transient execution state (taskId, lastError, pauseReason)
|
||||
* and ends any active heartbeat run.
|
||||
* @param agentId - The agent ID
|
||||
* @returns The reset agent
|
||||
* @throws Error if agent not found or transition is invalid
|
||||
*/
|
||||
async resetAgent(agentId: string): Promise<Agent> {
|
||||
let agent = await this.getAgent(agentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${agentId} not found`);
|
||||
}
|
||||
|
||||
// End any active heartbeat run before transitioning
|
||||
const activeRun = await this.getActiveHeartbeatRun(agentId);
|
||||
if (activeRun) {
|
||||
await this.endHeartbeatRun(activeRun.id, "terminated");
|
||||
}
|
||||
|
||||
// Transition state via updateAgentState (validates transition, emits events)
|
||||
const agent = await this.updateAgentState(agentId, "idle");
|
||||
// Normalize to terminated first when idle is not directly reachable.
|
||||
if (agent.state !== "idle" && agent.state !== "terminated") {
|
||||
agent = await this.updateAgentState(agentId, "terminated");
|
||||
}
|
||||
|
||||
// Clear taskId and lastError on top of the state transition
|
||||
const reset: Agent = {
|
||||
...agent,
|
||||
taskId: undefined,
|
||||
lastError: undefined,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (agent.state !== "idle") {
|
||||
agent = await this.updateAgentState(agentId, "idle");
|
||||
}
|
||||
|
||||
await this.writeAgent(reset);
|
||||
if (agent.taskId !== undefined) {
|
||||
agent = await this.assignTask(agentId, undefined);
|
||||
}
|
||||
|
||||
return reset;
|
||||
if (agent.lastError !== undefined || agent.pauseReason !== undefined) {
|
||||
agent = await this.updateAgent(agentId, {
|
||||
lastError: undefined,
|
||||
pauseReason: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return agent;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -907,6 +907,8 @@ describe("refineTask", () => {
|
||||
|
||||
import {
|
||||
startAgentRun,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
fetchGitStatus,
|
||||
fetchGitCommits,
|
||||
fetchCommitDiff,
|
||||
@@ -922,6 +924,71 @@ import {
|
||||
pushBranch,
|
||||
} from "./api";
|
||||
|
||||
describe("agent API wrappers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("creates agents with full create payload and project scope", async () => {
|
||||
const createdAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "idle" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, createdAgent, 201));
|
||||
|
||||
await createAgent({
|
||||
name: "reviewer",
|
||||
role: "reviewer",
|
||||
title: "Review Agent",
|
||||
icon: "🔍",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
|
||||
permissions: { read: true, write: false },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Prioritize security and edge cases.",
|
||||
}, "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents?projectId=proj_123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "reviewer",
|
||||
role: "reviewer",
|
||||
title: "Review Agent",
|
||||
icon: "🔍",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
|
||||
permissions: { read: true, write: false },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Prioritize security and edge cases.",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("updates agents with runtime + instruction fields", async () => {
|
||||
const updatedAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "active" };
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, updatedAgent));
|
||||
|
||||
await updateAgent("agent-001", {
|
||||
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Handle migrations cautiously.",
|
||||
pauseReason: "maintenance",
|
||||
reportsTo: undefined,
|
||||
}, "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001?projectId=proj_123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
|
||||
instructionsPath: ".fusion/agents/reviewer.md",
|
||||
instructionsText: "Handle migrations cautiously.",
|
||||
pauseReason: "maintenance",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startAgentRun", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
|
||||
@@ -1175,6 +1175,9 @@ function ConfigTab({
|
||||
if (rc.heartbeatTimeoutMs !== undefined && rc.heartbeatTimeoutMs !== null) {
|
||||
initial.heartbeatTimeoutMs = String(rc.heartbeatTimeoutMs);
|
||||
}
|
||||
if (rc.maxConcurrentRuns !== undefined && rc.maxConcurrentRuns !== null) {
|
||||
initial.maxConcurrentRuns = String(rc.maxConcurrentRuns);
|
||||
}
|
||||
if (rc.messageResponseMode === "immediate" || rc.messageResponseMode === "on-heartbeat") {
|
||||
initial.messageResponseMode = rc.messageResponseMode;
|
||||
}
|
||||
@@ -1202,7 +1205,7 @@ function ConfigTab({
|
||||
}
|
||||
// Check heartbeat values
|
||||
const rc = agent.runtimeConfig ?? {};
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "messageResponseMode"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns", "messageResponseMode"] as const) {
|
||||
const current = heartbeatValues[key]?.trim() ?? "";
|
||||
const persisted = rc[key] !== undefined && rc[key] !== null ? String(rc[key]) : "";
|
||||
if (current !== persisted) return true;
|
||||
@@ -1251,6 +1254,7 @@ function ConfigTab({
|
||||
for (const [key, config] of Object.entries({
|
||||
heartbeatIntervalMs: { label: "Heartbeat Interval", min: 1000 },
|
||||
heartbeatTimeoutMs: { label: "Heartbeat Timeout", min: 5000 },
|
||||
maxConcurrentRuns: { label: "Max Concurrent Runs", min: 1 },
|
||||
})) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) continue;
|
||||
@@ -1289,7 +1293,7 @@ function ConfigTab({
|
||||
|
||||
// Build the runtimeConfig payload — only include non-empty values
|
||||
const newRuntimeConfig: Record<string, unknown> = { ...agent.runtimeConfig };
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs"] as const) {
|
||||
for (const key of ["heartbeatIntervalMs", "heartbeatTimeoutMs", "maxConcurrentRuns"] as const) {
|
||||
const raw = heartbeatValues[key]?.trim();
|
||||
if (!raw) {
|
||||
delete newRuntimeConfig[key];
|
||||
@@ -1420,6 +1424,24 @@ function ConfigTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-maxConcurrentRuns">Max Concurrent Runs</label>
|
||||
<input
|
||||
id="hb-maxConcurrentRuns"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className={cn("input", !!errors.maxConcurrentRuns && "input--error")}
|
||||
placeholder="1"
|
||||
value={heartbeatValues.maxConcurrentRuns ?? ""}
|
||||
onChange={(e) => handleHeartbeatFieldChange("maxConcurrentRuns", e.target.value)}
|
||||
/>
|
||||
{errors.maxConcurrentRuns ? (
|
||||
<span className="config-error">{errors.maxConcurrentRuns}</span>
|
||||
) : (
|
||||
<span className="config-hint">Maximum simultaneous heartbeat runs for this agent. Leave empty for system default (1).</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="hb-messageResponseMode">Message Response Mode</label>
|
||||
<select
|
||||
|
||||
@@ -165,6 +165,25 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
void loadAgents();
|
||||
};
|
||||
|
||||
es.addEventListener("agent:created", refresh);
|
||||
es.addEventListener("agent:updated", refresh);
|
||||
es.addEventListener("agent:deleted", refresh);
|
||||
es.addEventListener("agent:stateChanged", refresh);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
};
|
||||
}, [projectId, loadAgents]);
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -253,8 +272,11 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
}
|
||||
const lastHeartbeat = new Date(agent.lastHeartbeatAt).getTime();
|
||||
const elapsed = Date.now() - lastHeartbeat;
|
||||
const timeoutMs = 60000; // 60 second timeout
|
||||
if (elapsed > timeoutMs) {
|
||||
const runtimeConfig = agent.runtimeConfig as Record<string, unknown> | undefined;
|
||||
const configuredTimeout = typeof runtimeConfig?.heartbeatTimeoutMs === "number"
|
||||
? runtimeConfig.heartbeatTimeoutMs
|
||||
: 60000;
|
||||
if (elapsed > configuredTimeout) {
|
||||
return { label: "Unresponsive", icon: <Activity size={14} />, color: "var(--state-error-text)" };
|
||||
}
|
||||
return { label: "Healthy", icon: <Heart size={14} />, color: "var(--state-active-text)" };
|
||||
|
||||
@@ -78,6 +78,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
const [title, setTitle] = useState("");
|
||||
const [icon, setIcon] = useState("");
|
||||
const [role, setRole] = useState<AgentCapability>("custom");
|
||||
const [reportsTo, setReportsTo] = useState("");
|
||||
const [instructionsPath, setInstructionsPath] = useState("");
|
||||
const [instructionsText, setInstructionsText] = useState("");
|
||||
const [runtimeConfig, setRuntimeConfig] = useState<RuntimeConfig>({
|
||||
model: "",
|
||||
thinkingLevel: "off",
|
||||
@@ -175,6 +178,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
setTitle("");
|
||||
setIcon("");
|
||||
setRole("custom");
|
||||
setReportsTo("");
|
||||
setInstructionsPath("");
|
||||
setInstructionsText("");
|
||||
setRuntimeConfig({ model: "", thinkingLevel: "off", maxTurns: 10 });
|
||||
setSelectedPresetId(null);
|
||||
setError(null);
|
||||
@@ -196,6 +202,9 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
role,
|
||||
...(title.trim() ? { title: title.trim() } : {}),
|
||||
...(icon.trim() ? { icon: icon.trim() } : {}),
|
||||
...(reportsTo.trim() ? { reportsTo: reportsTo.trim() } : {}),
|
||||
...(instructionsPath.trim() ? { instructionsPath: instructionsPath.trim() } : {}),
|
||||
...(instructionsText.trim() ? { instructionsText: instructionsText.trim() } : {}),
|
||||
...(Object.keys(runtimeCfg).length > 0 ? { runtimeConfig: runtimeCfg } : {}),
|
||||
}, projectId);
|
||||
handleClose();
|
||||
@@ -303,6 +312,39 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-reports-to">Reports To <span className="agent-dialog-optional">(optional agent ID)</span></label>
|
||||
<input
|
||||
id="agent-reports-to"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. agent-1234abcd"
|
||||
value={reportsTo}
|
||||
onChange={e => setReportsTo(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-path">Instructions Path <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<input
|
||||
id="agent-instructions-path"
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="e.g. .fusion/agents/reviewer.md"
|
||||
value={instructionsPath}
|
||||
onChange={e => setInstructionsPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="agent-dialog-field">
|
||||
<label htmlFor="agent-instructions-text">Inline Instructions <span className="agent-dialog-optional">(optional)</span></label>
|
||||
<textarea
|
||||
id="agent-instructions-text"
|
||||
className="input"
|
||||
rows={4}
|
||||
placeholder="Add custom behavior instructions..."
|
||||
value={instructionsText}
|
||||
onChange={e => setInstructionsText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* AI-assisted generation */}
|
||||
<div className="agent-dialog-ai-generate">
|
||||
<button
|
||||
@@ -394,6 +436,24 @@ export function NewAgentDialog({ isOpen, onClose, onCreated, projectId }: NewAge
|
||||
<span className="agent-dialog-summary-row-label">Role</span>
|
||||
<span>{selectedRole?.icon} {selectedRole?.label}</span>
|
||||
</div>
|
||||
{reportsTo.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Reports To</span>
|
||||
<span>{reportsTo.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsPath.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Instructions File</span>
|
||||
<span>{instructionsPath.trim()}</span>
|
||||
</div>
|
||||
)}
|
||||
{instructionsText.trim() && (
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Inline Instructions</span>
|
||||
<span>{instructionsText.trim().length} chars</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="agent-dialog-summary-row">
|
||||
<span className="agent-dialog-summary-row-label">Model</span>
|
||||
<span>
|
||||
|
||||
@@ -35,8 +35,7 @@ export function useAgents(projectId?: string) {
|
||||
|
||||
// SSE subscription for agent events
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const query = `?projectId=${encodeURIComponent(projectId)}`;
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/events${query}`);
|
||||
|
||||
const refresh = () => {
|
||||
|
||||
@@ -262,7 +262,6 @@ describe("Agent runs routes (without HeartbeatMonitor)", () => {
|
||||
describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
let store: MockStore;
|
||||
let app: ReturnType<typeof import("../server.js").createServer>;
|
||||
let mockStartRun: ReturnType<typeof vi.fn>;
|
||||
let mockExecuteHeartbeat: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -271,14 +270,12 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
mockListAgents.mockResolvedValue([]);
|
||||
mockGetActiveHeartbeatRun.mockResolvedValue(null);
|
||||
|
||||
mockStartRun = vi.fn();
|
||||
mockExecuteHeartbeat = vi.fn();
|
||||
|
||||
store = new MockStore();
|
||||
const { createServer } = await import("../server.js");
|
||||
app = createServer(store as any, {
|
||||
heartbeatMonitor: {
|
||||
startRun: mockStartRun,
|
||||
executeHeartbeat: mockExecuteHeartbeat,
|
||||
},
|
||||
});
|
||||
@@ -289,9 +286,8 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
it("delegates to heartbeatMonitor.startRun when available", async () => {
|
||||
it("delegates to heartbeatMonitor.executeHeartbeat when available", async () => {
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand", triggerDetail: "Triggered from dashboard" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue({ ...mockRun, status: "completed" });
|
||||
|
||||
const response = await request(
|
||||
@@ -303,26 +299,20 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
// executeHeartbeat should be called fire-and-forget
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from dashboard",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes custom source and triggerDetail to heartbeatMonitor", async () => {
|
||||
const mockRun = createMockRun();
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
await request(
|
||||
@@ -333,9 +323,11 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
|
||||
expect(mockStartRun).toHaveBeenCalledWith("agent-001", {
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
taskId: undefined,
|
||||
contextSnapshot: {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "Scheduled run",
|
||||
@@ -349,7 +341,6 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
const mockEvent = { id: "evt-001", agentId: "agent-001", status: "ok", timestamp: "2026-01-01T00:00:00.000Z" };
|
||||
mockRecordHeartbeat.mockResolvedValue(mockEvent);
|
||||
const mockRun = createMockRun({ invocationSource: "on_demand" });
|
||||
mockStartRun.mockResolvedValue(mockRun);
|
||||
mockExecuteHeartbeat.mockResolvedValue(mockRun);
|
||||
|
||||
const response = await request(
|
||||
@@ -361,8 +352,15 @@ describe("Agent runs routes (with HeartbeatMonitor)", () => {
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockStartRun).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalled();
|
||||
expect(mockExecuteHeartbeat).toHaveBeenCalledWith({
|
||||
agentId: "agent-001",
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
// Response should include both event and run
|
||||
expect((response.body as any).event).toBeDefined();
|
||||
expect((response.body as any).run).toBeDefined();
|
||||
|
||||
@@ -7876,6 +7876,131 @@ describe("POST /workflow-step-templates/:id/create", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Agent create/update routes", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agents-fields-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Initial Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildAgentApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("POST /api/agents accepts all AgentCreateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body).toMatchObject({
|
||||
name: "Full Agent",
|
||||
role: "reviewer",
|
||||
metadata: { team: "qa" },
|
||||
title: "QA Reviewer",
|
||||
icon: "🧪",
|
||||
reportsTo: agentId,
|
||||
runtimeConfig: { heartbeatIntervalMs: 60000 },
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCH /api/agents/:id accepts all AgentUpdateInput fields", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"PATCH",
|
||||
`/api/agents/${agentId}`,
|
||||
JSON.stringify({
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({
|
||||
id: agentId,
|
||||
name: "Updated Agent",
|
||||
role: "engineer",
|
||||
metadata: { area: "infra" },
|
||||
title: "Infra Engineer",
|
||||
icon: "⚙️",
|
||||
reportsTo: "agent-parent",
|
||||
runtimeConfig: { heartbeatTimeoutMs: 120000 },
|
||||
pauseReason: "manual",
|
||||
permissions: { deploy: true },
|
||||
totalInputTokens: 42,
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
`/api/agents/${agentId}/state`,
|
||||
JSON.stringify({ state: "terminated" }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Invalid state transition");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/agents/:id/runs", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
@@ -6893,14 +6893,69 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
function validateAgentInstructionsPayload(
|
||||
res: Response,
|
||||
instructionsPath: unknown,
|
||||
instructionsText: unknown,
|
||||
): boolean {
|
||||
if (instructionsPath !== undefined && instructionsPath !== null && instructionsPath !== "") {
|
||||
if (typeof instructionsPath !== "string") {
|
||||
res.status(400).json({ error: "instructionsPath must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.length > 500) {
|
||||
res.status(400).json({ error: "instructionsPath must be at most 500 characters" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsPath.includes("..")) {
|
||||
res.status(400).json({ error: "instructionsPath must not contain parent directory traversal (..)" });
|
||||
return false;
|
||||
}
|
||||
const isAbsoluteUnix = instructionsPath.startsWith("/");
|
||||
const isAbsoluteWindows = /^[A-Za-z]:[\\/]/.test(instructionsPath);
|
||||
if (isAbsoluteUnix || isAbsoluteWindows) {
|
||||
res.status(400).json({ error: "instructionsPath must be a project-relative path" });
|
||||
return false;
|
||||
}
|
||||
if (!instructionsPath.endsWith(".md")) {
|
||||
res.status(400).json({ error: "instructionsPath must end in .md" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (instructionsText !== undefined && instructionsText !== null && instructionsText !== "") {
|
||||
if (typeof instructionsText !== "string") {
|
||||
res.status(400).json({ error: "instructionsText must be a string" });
|
||||
return false;
|
||||
}
|
||||
if (instructionsText.length > 50000) {
|
||||
res.status(400).json({ error: "instructionsText must be at most 50,000 characters" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agents
|
||||
* Create a new agent.
|
||||
* Body: { name: string, role: string, metadata?: object }
|
||||
*/
|
||||
router.post("/agents", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata } = req.body;
|
||||
const {
|
||||
name,
|
||||
role,
|
||||
metadata,
|
||||
title,
|
||||
icon,
|
||||
reportsTo,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath,
|
||||
instructionsText,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
res.status(400).json({ error: "name is required" });
|
||||
return;
|
||||
@@ -6909,16 +6964,58 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
res.status(400).json({ error: "role is required" });
|
||||
return;
|
||||
}
|
||||
if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
if (title !== undefined && title !== null && typeof title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
if (icon !== undefined && icon !== null && typeof icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
if (reportsTo !== undefined && reportsTo !== null && typeof reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
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.createAgent({ name, role: role as import("@fusion/core").AgentCapability, metadata });
|
||||
const agent = await agentStore.createAgent({
|
||||
name,
|
||||
role: role as import("@fusion/core").AgentCapability,
|
||||
metadata,
|
||||
title: title ?? undefined,
|
||||
icon: icon ?? undefined,
|
||||
reportsTo: reportsTo ?? undefined,
|
||||
runtimeConfig,
|
||||
permissions,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.status(201).json(agent);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
if (err.message?.includes("required") || err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -7079,18 +7176,119 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
router.patch("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
const { name, role, metadata, runtimeConfig } = req.body;
|
||||
const body = req.body ?? {};
|
||||
const updates: import("@fusion/core").AgentUpdateInput = {};
|
||||
|
||||
if ("name" in body) {
|
||||
if (body.name !== null && typeof body.name !== "string") {
|
||||
res.status(400).json({ error: "name must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.name = body.name ?? undefined;
|
||||
}
|
||||
|
||||
if ("role" in body) {
|
||||
if (body.role !== null && typeof body.role !== "string") {
|
||||
res.status(400).json({ error: "role must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.role = body.role ?? undefined;
|
||||
}
|
||||
|
||||
if ("metadata" in body) {
|
||||
if (body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) {
|
||||
res.status(400).json({ error: "metadata must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.metadata = body.metadata ?? undefined;
|
||||
}
|
||||
|
||||
if ("title" in body) {
|
||||
if (body.title !== null && typeof body.title !== "string") {
|
||||
res.status(400).json({ error: "title must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.title = body.title ?? undefined;
|
||||
}
|
||||
|
||||
if ("icon" in body) {
|
||||
if (body.icon !== null && typeof body.icon !== "string") {
|
||||
res.status(400).json({ error: "icon must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.icon = body.icon ?? undefined;
|
||||
}
|
||||
|
||||
if ("reportsTo" in body) {
|
||||
if (body.reportsTo !== null && typeof body.reportsTo !== "string") {
|
||||
res.status(400).json({ error: "reportsTo must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.reportsTo = body.reportsTo ?? undefined;
|
||||
}
|
||||
|
||||
if ("pauseReason" in body) {
|
||||
if (body.pauseReason !== null && typeof body.pauseReason !== "string") {
|
||||
res.status(400).json({ error: "pauseReason must be a string" });
|
||||
return;
|
||||
}
|
||||
updates.pauseReason = body.pauseReason ?? undefined;
|
||||
}
|
||||
|
||||
if ("runtimeConfig" in body) {
|
||||
if (body.runtimeConfig !== null && (typeof body.runtimeConfig !== "object" || Array.isArray(body.runtimeConfig))) {
|
||||
res.status(400).json({ error: "runtimeConfig must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.runtimeConfig = body.runtimeConfig ?? undefined;
|
||||
}
|
||||
|
||||
if ("permissions" in body) {
|
||||
if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) {
|
||||
res.status(400).json({ error: "permissions must be an object" });
|
||||
return;
|
||||
}
|
||||
updates.permissions = body.permissions ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalInputTokens" in body) {
|
||||
if (body.totalInputTokens !== null && typeof body.totalInputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalInputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalInputTokens = body.totalInputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if ("totalOutputTokens" in body) {
|
||||
if (body.totalOutputTokens !== null && typeof body.totalOutputTokens !== "number") {
|
||||
res.status(400).json({ error: "totalOutputTokens must be a number" });
|
||||
return;
|
||||
}
|
||||
updates.totalOutputTokens = body.totalOutputTokens ?? undefined;
|
||||
}
|
||||
|
||||
if (!validateAgentInstructionsPayload(res, body.instructionsPath, body.instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if ("instructionsPath" in body) {
|
||||
updates.instructionsPath = body.instructionsPath ?? undefined;
|
||||
}
|
||||
if ("instructionsText" in body) {
|
||||
updates.instructionsText = body.instructionsText ?? undefined;
|
||||
}
|
||||
|
||||
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, { name, role, metadata, runtimeConfig });
|
||||
const agent = await agentStore.updateAgent(req.params.id, updates);
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("cannot be empty")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
@@ -7104,38 +7302,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
*/
|
||||
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 { instructionsPath, instructionsText } = req.body ?? {};
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
@@ -7143,7 +7312,10 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const agent = await agentStore.updateAgent(req.params.id, { instructionsPath, instructionsText });
|
||||
const agent = await agentStore.updateAgent(req.params.id, {
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
});
|
||||
res.json(agent);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
@@ -7177,7 +7349,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("not found")) {
|
||||
res.status(404).json({ error: err.message });
|
||||
} else if (err.message?.includes("Invalid state transition") || err.message?.includes("Cannot transition from terminated")) {
|
||||
} else if (/invalid state transition/i.test(err.message ?? "")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -7316,7 +7488,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Body: { status?: "ok"|"missed"|"recovered", triggerExecution?: boolean }
|
||||
*
|
||||
* When triggerExecution is true AND HeartbeatMonitor is available,
|
||||
* also starts a heartbeat run after recording the heartbeat event.
|
||||
* also executes a heartbeat run after recording the heartbeat event.
|
||||
*/
|
||||
router.post("/agents/:id/heartbeat", async (req, res) => {
|
||||
try {
|
||||
@@ -7332,18 +7504,14 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
// Optionally trigger execution
|
||||
let run: import("@fusion/core").AgentHeartbeatRun | undefined;
|
||||
if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) {
|
||||
run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
});
|
||||
|
||||
// Fire-and-forget execution
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot: {
|
||||
wakeReason: "on_demand",
|
||||
triggerDetail: "Triggered from heartbeat",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7407,11 +7575,9 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
* Manually start a heartbeat run for an agent.
|
||||
* Body: { source?: HeartbeatInvocationSource, triggerDetail?: string, taskId?: string }
|
||||
*
|
||||
* When HeartbeatMonitor is available, delegates to startRun() which enriches
|
||||
* the run with execution context, transitions the agent to "running", and
|
||||
* fires the onRunStarted event. The route returns the run immediately with
|
||||
* "active" status while execution continues in the background via
|
||||
* executeHeartbeat() fire-and-forget.
|
||||
* When HeartbeatMonitor is available, delegates to executeHeartbeat() with
|
||||
* a structured wake context snapshot. This ensures a single authoritative run
|
||||
* record is created and fully completed without duplicate startRun calls.
|
||||
*
|
||||
* Returns 409 Conflict if the agent already has an active run.
|
||||
*/
|
||||
@@ -7443,21 +7609,13 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Delegate to HeartbeatMonitor for enriched run creation
|
||||
const run = await heartbeatMonitor.startRun(req.params.id, {
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
// Fire-and-forget execution in the background
|
||||
void heartbeatMonitor.executeHeartbeat({
|
||||
// Execute heartbeat end-to-end (single run record, no duplicate startRun call)
|
||||
const run = await heartbeatMonitor.executeHeartbeat({
|
||||
agentId: req.params.id,
|
||||
source: invocationSource,
|
||||
triggerDetail: trigger,
|
||||
taskId,
|
||||
}).catch((err: any) => {
|
||||
console.error(`[heartbeat] Background execution failed for ${req.params.id}:`, err.message);
|
||||
contextSnapshot,
|
||||
});
|
||||
|
||||
res.status(201).json(run);
|
||||
@@ -7479,7 +7637,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
// Enrich with invocation source, trigger detail, and context snapshot
|
||||
(run as any).invocationSource = invocationSource;
|
||||
(run as any).triggerDetail = triggerDetail;
|
||||
(run as any).triggerDetail = trigger;
|
||||
(run as any).contextSnapshot = contextSnapshot;
|
||||
|
||||
await agentStore.saveRun(run);
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface ServerOptions {
|
||||
/** Optional HeartbeatMonitor for triggering agent execution runs */
|
||||
heartbeatMonitor?: {
|
||||
startRun(agentId: string, options?: { source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
executeHeartbeat(options: { agentId: string; source: import("@fusion/core").HeartbeatInvocationSource; triggerDetail?: string; taskId?: string; contextSnapshot?: Record<string, unknown> }): Promise<import("@fusion/core").AgentHeartbeatRun>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,13 +66,13 @@ describe("resolveAgentInstructions", () => {
|
||||
expect(result).toBe("# Custom Instructions\nUse strict TypeScript.");
|
||||
});
|
||||
|
||||
it("returns file contents when instructionsPath is absolute", async () => {
|
||||
it("ignores absolute instructionsPath for safety", async () => {
|
||||
const filePath = join(testDir, "absolute-instructions.md");
|
||||
await writeFile(filePath, "Absolute path instructions.");
|
||||
|
||||
const agent = makeAgent({ instructionsPath: filePath });
|
||||
const agent = makeAgent({ instructionsPath: filePath, instructionsText: "Inline fallback." });
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result).toBe("Absolute path instructions.");
|
||||
expect(result).toBe("Inline fallback.");
|
||||
});
|
||||
|
||||
it("concatenates instructionsText and file contents with double newline", async () => {
|
||||
@@ -136,6 +136,47 @@ describe("resolveAgentInstructions", () => {
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result).toBe("Text only.");
|
||||
});
|
||||
|
||||
it("rejects path traversal in instructionsPath", async () => {
|
||||
const agent = makeAgent({
|
||||
instructionsText: "Safe inline.",
|
||||
instructionsPath: "../secrets.md",
|
||||
});
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result).toBe("Safe inline.");
|
||||
});
|
||||
|
||||
it("rejects non-markdown instruction files", async () => {
|
||||
const txtPath = join(testDir, "instructions.txt");
|
||||
await writeFile(txtPath, "should not be read");
|
||||
|
||||
const agent = makeAgent({
|
||||
instructionsText: "Inline only.",
|
||||
instructionsPath: "instructions.txt",
|
||||
});
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result).toBe("Inline only.");
|
||||
});
|
||||
|
||||
it("truncates oversized inline instructions", async () => {
|
||||
const oversized = "x".repeat(50010);
|
||||
const agent = makeAgent({ instructionsText: oversized });
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result.length).toBe(50000);
|
||||
});
|
||||
|
||||
it("truncates oversized instructions files", async () => {
|
||||
const filePath = join(testDir, "large.md");
|
||||
await writeFile(filePath, "y".repeat(50020));
|
||||
|
||||
const agent = makeAgent({ instructionsPath: "large.md" });
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
|
||||
expect(result.length).toBe(50000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSystemPromptWithInstructions", () => {
|
||||
|
||||
@@ -1070,6 +1070,7 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(result.status).toBe("completed");
|
||||
expect(result.resultJson).toEqual({ reason: "invalid_state", state: "terminated" });
|
||||
expect(mockedCreateKbAgent).not.toHaveBeenCalled();
|
||||
expect(store.updateAgentState).not.toHaveBeenCalledWith("agent-001", "active");
|
||||
});
|
||||
|
||||
it("completes as failed when agent not found in store", async () => {
|
||||
@@ -1231,6 +1232,33 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(callArgs.defaultProvider).toBeUndefined();
|
||||
expect(callArgs.defaultModelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("persists contextSnapshot on run records", async () => {
|
||||
const store = createStoreWithAgentForExec();
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateKbAgent.mockResolvedValue({
|
||||
session: mockSession as any,
|
||||
});
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
|
||||
const result = await monitor.executeHeartbeat({
|
||||
agentId: "agent-001",
|
||||
source: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
contextSnapshot: {
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
taskId: "FN-001",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.contextSnapshot).toEqual({
|
||||
wakeReason: "assignment",
|
||||
triggerDetail: "task-assigned",
|
||||
taskId: "FN-001",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("heartbeat_done tool", () => {
|
||||
@@ -1901,6 +1929,21 @@ describe("HeartbeatTriggerScheduler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps configured interval to a minimum of 1000ms", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 10 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(callback).toHaveBeenCalledOnce();
|
||||
expect(callback).toHaveBeenCalledWith("agent-001", "timer", {
|
||||
wakeReason: "timer",
|
||||
triggerDetail: "scheduled",
|
||||
intervalMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it("fires multiple times for multiple intervals", async () => {
|
||||
scheduler.registerAgent("agent-001", { heartbeatIntervalMs: 5000 });
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ export interface HeartbeatExecutionOptions {
|
||||
triggerDetail?: string;
|
||||
/** Optional task ID override (uses agent.taskId if not set) */
|
||||
taskId?: string;
|
||||
/** Optional structured context persisted on the run record */
|
||||
contextSnapshot?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Session interface for disposing agent resources */
|
||||
@@ -297,6 +299,8 @@ export class HeartbeatMonitor {
|
||||
resultJson?: Record<string, unknown>;
|
||||
stdoutExcerpt?: string;
|
||||
stderrExcerpt?: string;
|
||||
/** When true, preserve current agent state instead of forcing a terminal transition. */
|
||||
skipStateTransition?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
// Load and update the run
|
||||
@@ -345,18 +349,20 @@ export class HeartbeatMonitor {
|
||||
}
|
||||
|
||||
// Transition agent state based on result
|
||||
try {
|
||||
if (result.status === "failed") {
|
||||
await this.store.updateAgentState(agentId, "error");
|
||||
await this.store.updateAgent(agentId, { lastError: result.stderrExcerpt ?? "Run failed" });
|
||||
} else if (result.status === "terminated") {
|
||||
await this.store.updateAgentState(agentId, "terminated");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
if (!result.skipStateTransition) {
|
||||
try {
|
||||
if (result.status === "failed") {
|
||||
await this.store.updateAgentState(agentId, "error");
|
||||
await this.store.updateAgent(agentId, { lastError: result.stderrExcerpt ?? "Run failed" });
|
||||
} else if (result.status === "terminated") {
|
||||
await this.store.updateAgentState(agentId, "terminated");
|
||||
} else {
|
||||
// Completed successfully - back to active
|
||||
await this.store.updateAgentState(agentId, "active");
|
||||
}
|
||||
} catch {
|
||||
// State transition may fail if already in target state
|
||||
}
|
||||
} catch {
|
||||
// State transition may fail if already in target state
|
||||
}
|
||||
|
||||
// End the heartbeat run tracking
|
||||
@@ -478,7 +484,7 @@ export class HeartbeatMonitor {
|
||||
* @throws Error if taskStore or rootDir are not configured
|
||||
*/
|
||||
async executeHeartbeat(options: HeartbeatExecutionOptions): Promise<AgentHeartbeatRun> {
|
||||
const { agentId, source, triggerDetail, taskId: explicitTaskId } = options;
|
||||
const { agentId, source, triggerDetail, taskId: explicitTaskId, contextSnapshot } = options;
|
||||
|
||||
// Validate execution dependencies
|
||||
if (!this.taskStore || !this.rootDir) {
|
||||
@@ -492,7 +498,7 @@ export class HeartbeatMonitor {
|
||||
heartbeatLog.log(`Executing heartbeat for ${agentId} (source=${source})`);
|
||||
|
||||
// Start run
|
||||
const run = await this.startRun(agentId, { source, triggerDetail });
|
||||
const run = await this.startRun(agentId, { source, triggerDetail, contextSnapshot });
|
||||
|
||||
try {
|
||||
// Resolve agent
|
||||
@@ -524,6 +530,7 @@ export class HeartbeatMonitor {
|
||||
await this.completeRun(agentId, run.id, {
|
||||
status: "completed",
|
||||
resultJson: { reason: "invalid_state", state: agent.state },
|
||||
skipStateTransition: true,
|
||||
});
|
||||
return (await this.store.getRunDetail(agentId, run.id))!;
|
||||
}
|
||||
@@ -932,19 +939,19 @@ export class HeartbeatTriggerScheduler {
|
||||
}
|
||||
|
||||
// Skip if no interval configured
|
||||
const intervalMs = config.heartbeatIntervalMs;
|
||||
if (!intervalMs || typeof intervalMs !== "number" || intervalMs <= 0) {
|
||||
const rawIntervalMs = config.heartbeatIntervalMs;
|
||||
if (!rawIntervalMs || typeof rawIntervalMs !== "number" || !Number.isFinite(rawIntervalMs) || rawIntervalMs <= 0) {
|
||||
heartbeatLog.log(`Skipping timer registration for ${agentId} (no interval)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Math.round(rawIntervalMs));
|
||||
|
||||
// Clear existing timer if re-registering
|
||||
this.unregisterAgent(agentId);
|
||||
|
||||
const maxConcurrent = config.maxConcurrentRuns ?? 1;
|
||||
|
||||
const handle = setInterval(() => {
|
||||
void this.onTimerTick(agentId, intervalMs, maxConcurrent);
|
||||
void this.onTimerTick(agentId, intervalMs);
|
||||
}, intervalMs);
|
||||
|
||||
this.timers.set(agentId, { intervalMs, handle });
|
||||
@@ -1021,7 +1028,7 @@ export class HeartbeatTriggerScheduler {
|
||||
* Handle a timer tick for an agent.
|
||||
* Checks for active runs before invoking the callback.
|
||||
*/
|
||||
private async onTimerTick(agentId: string, intervalMs: number, maxConcurrent: number): Promise<void> {
|
||||
private async onTimerTick(agentId: string, intervalMs: number): Promise<void> {
|
||||
if (!this.running) return;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,68 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join, isAbsolute } from "node:path";
|
||||
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
|
||||
import type { Agent } from "@fusion/core";
|
||||
|
||||
const MAX_INSTRUCTIONS_PATH_LENGTH = 500;
|
||||
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||
|
||||
function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
console.warn(
|
||||
`[agent-instructions] ${label} exceeded max length for agent ${agentId}; truncating to ${maxLength} chars`,
|
||||
);
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function isPathTraversal(path: string): boolean {
|
||||
return path.split(/[\\/]+/).includes("..");
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
|
||||
console.warn(
|
||||
`[agent-instructions] instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trimmed.toLowerCase().endsWith(".md")) {
|
||||
console.warn(`[agent-instructions] instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolute(trimmed)) {
|
||||
console.warn(`[agent-instructions] instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalize(trimmed);
|
||||
if (isPathTraversal(normalized)) {
|
||||
console.warn(`[agent-instructions] instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(rootDir, normalized);
|
||||
const rel = relative(rootDir, resolvedPath);
|
||||
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
||||
console.warn(`[agent-instructions] instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve custom instructions for an agent by combining inline text and/or
|
||||
* file-based instructions.
|
||||
@@ -20,32 +81,46 @@ export async function resolveAgentInstructions(
|
||||
|
||||
// Inline instructions take first position
|
||||
if (agent.instructionsText?.trim()) {
|
||||
parts.push(agent.instructionsText.trim());
|
||||
const inline = trimAndClamp(
|
||||
agent.instructionsText,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"instructionsText",
|
||||
agent.id,
|
||||
);
|
||||
if (inline) {
|
||||
parts.push(inline);
|
||||
}
|
||||
}
|
||||
|
||||
// File-based instructions appended after inline text
|
||||
if (agent.instructionsPath?.trim()) {
|
||||
const filePath = isAbsolute(agent.instructionsPath)
|
||||
? agent.instructionsPath
|
||||
: join(rootDir, agent.instructionsPath);
|
||||
const filePath = resolveValidatedInstructionsPath(agent.instructionsPath, rootDir, agent.id);
|
||||
|
||||
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})`,
|
||||
if (filePath) {
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const normalizedContent = trimAndClamp(
|
||||
content,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"instructions file content",
|
||||
agent.id,
|
||||
);
|
||||
if (normalizedContent) {
|
||||
parts.push(normalizedContent);
|
||||
}
|
||||
} 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})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,6 +352,40 @@ describe("InProcessRuntime", () => {
|
||||
expect(scheduler!.getRegisteredAgents().length).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes assignment triggers through executeHeartbeat", async () => {
|
||||
await runtime.start();
|
||||
|
||||
const monitor = runtime.getHeartbeatMonitor();
|
||||
expect(monitor).toBeDefined();
|
||||
const executeSpy = vi
|
||||
.spyOn(monitor!, "executeHeartbeat")
|
||||
.mockResolvedValue({ id: "run-test" } as any);
|
||||
|
||||
const store = (runtime as any).agentStore;
|
||||
expect(store).toBeDefined();
|
||||
|
||||
const agent = await store.createAgent({
|
||||
name: "Assignable",
|
||||
role: "executor",
|
||||
});
|
||||
|
||||
await store.assignTask(agent.id, "FN-001");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(executeSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: agent.id,
|
||||
source: "assignment",
|
||||
taskId: "FN-001",
|
||||
contextSnapshot: expect.objectContaining({
|
||||
taskId: "FN-001",
|
||||
wakeReason: "assignment",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration", () => {
|
||||
|
||||
@@ -234,14 +234,13 @@ export class InProcessRuntime
|
||||
async (agentId, source, context: WakeContext) => {
|
||||
if (!this.heartbeatMonitor) return;
|
||||
|
||||
// Convert WakeContext to WakeupOptions
|
||||
const options = {
|
||||
await this.heartbeatMonitor.executeHeartbeat({
|
||||
agentId,
|
||||
source,
|
||||
triggerDetail: context.triggerDetail,
|
||||
taskId: typeof context.taskId === "string" ? context.taskId : undefined,
|
||||
contextSnapshot: { ...context },
|
||||
};
|
||||
|
||||
await this.heartbeatMonitor.startRun(agentId, options);
|
||||
});
|
||||
},
|
||||
);
|
||||
this.triggerScheduler.start();
|
||||
@@ -469,7 +468,7 @@ export class InProcessRuntime
|
||||
async executeHeartbeat(
|
||||
agentId: string,
|
||||
source: HeartbeatInvocationSource,
|
||||
options?: { taskId?: string; triggerDetail?: string }
|
||||
options?: { taskId?: string; triggerDetail?: string; contextSnapshot?: Record<string, unknown> }
|
||||
): Promise<AgentHeartbeatRun | null> {
|
||||
if (this.status !== "active") {
|
||||
throw new Error(`Cannot execute heartbeat: runtime status is ${this.status}`);
|
||||
|
||||
Reference in New Issue
Block a user