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:
gsxdsm
2026-04-08 00:44:33 -07:00
parent 92e6aa2b49
commit 07697b2f5b
19 changed files with 942 additions and 169 deletions

View File

@@ -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"]);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
/**

View File

@@ -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;

View File

@@ -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

View File

@@ -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)" };

View File

@@ -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>

View File

@@ -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 = () => {

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>;
};
}

View File

@@ -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", () => {

View File

@@ -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 });

View File

@@ -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 {

View File

@@ -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})`,
);
}
}
}
}

View File

@@ -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", () => {

View File

@@ -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}`);