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