Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 27 additions & 7 deletions app/api/agents/[id]/task/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,35 @@ interface RouteContext {

function getRuntimeAgent(id: string) {
const displayAgent = findAgentByLookup(id)
if (!displayAgent) {
if (!/^bot-\d+$/.test(id)) return null
return getOrCreateAgent({
id,
name: id,
model: "claude/runtime-delegated",
})
}
return getOrCreateAgent({
id: displayAgent?.id ?? id,
name: displayAgent?.name ?? id,
model: displayAgent?.model ?? "claude/runtime-delegated",
district: displayAgent?.district,
cpu: displayAgent?.cpu,
memory: displayAgent?.memory,
autoRestart: displayAgent?.autoRestart,
id: displayAgent.id,
name: displayAgent.name,
model: displayAgent.model,
district: displayAgent.district,
cpu: displayAgent.cpu,
memory: displayAgent.memory,
autoRestart: displayAgent.autoRestart,
})
}

export async function GET(_req: Request, context: RouteContext) {
const { id } = await context.params
const agentId = decodeURIComponent(id)
const agent = getRuntimeAgent(agentId)
if (!agent) {
return NextResponse.json(
{ ok: false, error: "agent_not_found" },
{ status: 404, headers: { "Cache-Control": "no-store" } },
)
}

return NextResponse.json(
{ ok: true, tasks: listAgentTaskRecords(agent.id) },
Expand All @@ -36,6 +50,12 @@ export async function POST(req: Request, context: RouteContext) {
const agentId = decodeURIComponent(id)
const body = await req.json().catch(() => ({}))
const agent = getRuntimeAgent(agentId)
if (!agent) {
return NextResponse.json(
{ ok: false, error: "agent_not_found" },
{ status: 404, headers: { "Cache-Control": "no-store" } },
)
}
const rateLimit = checkRateLimit(agentId)

if (!rateLimit.allowed) {
Expand Down
4 changes: 2 additions & 2 deletions lib/agent-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface CapabilityCount {
export type AgentRegistryChangeAction = "registered" | "updated" | "deregistered"

const DISTRICTS: DistrictId[] = ["data-center", "comm-hub", "processing", "defense", "research"]
const STATUSES: AgentStatus[] = ["active", "idle", "working", "error", "offline"]
const STATUSES: AgentStatus[] = ["active", "idle", "running", "working", "error", "offline", "stopped"]

interface AgentRegistryState {
agents: Map<string, AgentCapabilityManifest>
Expand Down Expand Up @@ -276,4 +276,4 @@ export function deregisterAgent(agentId: string): AgentCapabilityManifest | null

export function resetAgentRegistryForTests(): void {
registry.agents.clear()
}
}
134 changes: 134 additions & 0 deletions lib/agent-runtime/__tests__/agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { Agent } from "@/lib/agent-runtime/agent"
import type { AgentConfig, Task } from "@/lib/agent-runtime/types"
import { POST } from "@/app/api/agents/[id]/task/route"

const jest = vi

function createConfig(id: string, overrides: Partial<AgentConfig> = {}): AgentConfig {
return {
id,
name: `Agent ${id}`,
model: "claude/test",
heartbeatIntervalMs: 1_000,
offlineAfterMs: 60_000,
...overrides,
}
}

function createTask(id = "task-1"): Task {
return {
id,
title: "Test task",
payload: { ok: true },
createdAt: new Date(Date.now()).toISOString(),
}
}

describe("Agent SDK lifecycle", () => {
beforeEach(() => {
jest.useFakeTimers()
jest.setSystemTime(new Date("2026-06-30T00:00:00.000Z"))
})

afterEach(() => {
jest.runOnlyPendingTimers()
jest.useRealTimers()
vi.restoreAllMocks()
})

it("start() transitions agent status from idle to running and starts the heartbeat", async () => {
const agent = new Agent(createConfig("agent-start"))

expect(agent.getStatus()).toBe("idle")

await agent.start()
const firstHeartbeat = agent.getMetrics().lastHeartbeat

expect(agent.getStatus()).toBe("running")
expect(firstHeartbeat).toBe("2026-06-30T00:00:00.000Z")

jest.advanceTimersByTime(1_000)

expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:01.000Z")
})

it("stop() transitions to stopped and clears the heartbeat interval", async () => {
const agent = new Agent(createConfig("agent-stop"))
await agent.start()

jest.advanceTimersByTime(1_000)
await agent.stop()
const stoppedHeartbeat = agent.getMetrics().lastHeartbeat

expect(agent.getStatus()).toBe("stopped")

jest.advanceTimersByTime(5_000)

expect(agent.getMetrics().lastHeartbeat).toBe(stoppedHeartbeat)
})

it("restart() calls stop() then start()", async () => {
const agent = new Agent(createConfig("agent-restart"))
const calls: string[] = []
const stopSpy = vi.spyOn(agent, "stop").mockImplementation(async () => { calls.push("stop") })
const startSpy = vi.spyOn(agent, "start").mockImplementation(async () => { calls.push("start") })

await agent.restart()

expect(stopSpy).toHaveBeenCalledTimes(1)
expect(startSpy).toHaveBeenCalledTimes(1)
expect(calls).toEqual(["stop", "start"])
})

it("executeTask() sets status to working, calls the executor, and returns to running", async () => {
const agent = new Agent(createConfig("agent-task"))
await agent.start()
const task = createTask()
const handler = vi.fn(() => {
expect(agent.getStatus()).toBe("working")
return "executor completed"
})
agent.onTask(handler)

const result = await agent.executeTask(task)

expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: task.id }), agent)
expect(result).toMatchObject({ taskId: task.id, agentId: agent.id, status: "completed", summary: "executor completed" })
expect(agent.getStatus()).toBe("running")
})

it("executeTask() on a stopped agent throws an error", async () => {
const agent = new Agent(createConfig("agent-stopped-task"))
await agent.start()
await agent.stop()

await expect(agent.executeTask(createTask())).rejects.toThrow("Cannot execute task on a stopped agent")
})

it("heartbeat updates lastSeen timestamp every interval", async () => {
const agent = new Agent(createConfig("agent-heartbeat", { heartbeatIntervalMs: 2_500 }))
await agent.start()

expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:00.000Z")

jest.advanceTimersByTime(2_500)
expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:02.500Z")

jest.advanceTimersByTime(2_500)
expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:05.000Z")
})

it("POST /api/agents/[id]/task returns 404 for unknown agent IDs", async () => {
const response = await POST(
new Request("http://localhost/api/agents/not-a-real-agent/task", {
method: "POST",
body: JSON.stringify(createTask("missing-agent-task")),
}),
{ params: Promise.resolve({ id: "not-a-real-agent" }) },
)

await expect(response.json()).resolves.toMatchObject({ ok: false, error: "agent_not_found" })
expect(response.status).toBe(404)
})
})
8 changes: 4 additions & 4 deletions lib/agent-runtime/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export class Agent implements AgentRuntimeContext {
async start(): Promise<void> {
this.startedAtMs = this.startedAtMs ?? Date.now()
this.stoppedAtMs = null
this.status = "active"
this.status = "running"
this.recordHeartbeat()
this.heartbeatTimer ??= setInterval(() => this.recordHeartbeat(), this.config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS)
publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status })
Expand All @@ -91,7 +91,7 @@ export class Agent implements AgentRuntimeContext {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
this.stoppedAtMs = Date.now()
this.status = "offline"
this.status = "stopped"
this.recordHeartbeat()
publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status })
}
Expand All @@ -110,7 +110,7 @@ export class Agent implements AgentRuntimeContext {
}

async executeTask(taskInput: Task): Promise<TaskResult> {
if (this.status === "offline") await this.start()
if (this.status === "stopped" || this.status === "offline") throw new Error("Cannot execute task on a stopped agent")
const task = normalizeTask(taskInput)
const startedAt = isoNow()
const startedMs = Date.now()
Expand All @@ -136,7 +136,7 @@ export class Agent implements AgentRuntimeContext {
}
this.metrics.tasksCompleted += 1
this.taskDurations.push(durationMs)
this.status = "idle"
this.status = "running"
this.recordHeartbeat()
writeTaskRecord(this.id, { task, result, status: "completed", updatedAt: completedAt })
publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.summary, durationMs } })
Expand Down
2 changes: 1 addition & 1 deletion lib/agents/agent-health-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export const HEARTBEAT_INTERVAL_MS = 15_000
export const OFFLINE_AFTER_MS = 30_000
export const ALERT_AFTER_MS = 5 * 60_000

const VALID_AGENT_STATUSES: AgentStatus[] = ["active", "idle", "working", "error", "offline"]
const VALID_AGENT_STATUSES: AgentStatus[] = ["active", "idle", "running", "working", "error", "offline", "stopped"]

export interface AgentHeartbeatInput {
status?: unknown
Expand Down
4 changes: 2 additions & 2 deletions lib/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type AgentStatus = "active" | "idle" | "working" | "error" | "offline"
export type AgentStatus = "active" | "idle" | "running" | "working" | "error" | "offline" | "stopped"

export type DistrictId = "data-center" | "comm-hub" | "processing" | "defense" | "research"

Expand Down Expand Up @@ -105,4 +105,4 @@ export interface AgentTask {
createdAt: number
startedAt?: number
completedAt?: number
}
}
Loading