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

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