From fcf1756b7924bf00f2344e05bc3d7ccced834a1d Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Thu, 5 Feb 2026 17:56:04 -0600 Subject: [PATCH] feat: add agent self-reporting protocol with registry, heartbeat, and discovery (closes #52) - Agent Registry Service: registration, heartbeat, capability discovery, stale detection - REST API: POST /register, POST /:id/heartbeat, DELETE /:id, GET /stats, GET /capabilities/:cap - Persistent storage: .veritas-kanban/agent-registry.json - Auto-offline: agents without heartbeat for 5min marked offline - AGENTS.md template: docs/AGENTS-TEMPLATE.md with full integration guide --- docs/AGENTS-TEMPLATE.md | 126 +++++++ server/src/routes/agent-registry.ts | 166 +++++++++ server/src/routes/v1/index.ts | 2 + server/src/services/agent-registry-service.ts | 334 ++++++++++++++++++ 4 files changed, 628 insertions(+) create mode 100644 docs/AGENTS-TEMPLATE.md create mode 100644 server/src/routes/agent-registry.ts create mode 100644 server/src/services/agent-registry-service.ts diff --git a/docs/AGENTS-TEMPLATE.md b/docs/AGENTS-TEMPLATE.md new file mode 100644 index 00000000..b8f7e5fc --- /dev/null +++ b/docs/AGENTS-TEMPLATE.md @@ -0,0 +1,126 @@ +# AGENTS.md Template — Veritas Kanban Self-Reporting Protocol + +Use this template for agents that integrate with Veritas Kanban. Copy it into your agent's workspace and fill in the sections. + +--- + +# AGENTS.md + +## Identity + +- **Agent ID:** `my-agent-id` _(unique, lowercase, dashes)_ +- **Name:** My Agent +- **Model:** anthropic/claude-sonnet-4-5 +- **Provider:** anthropic +- **Version:** 1.0.0 + +## Capabilities + +List what this agent can do. Used for task routing. + +- `code` — Write, review, and refactor code +- `research` — Deep web research and analysis +- `review` — Code review and PR feedback +- `deploy` — CI/CD and deployment operations +- `documentation` — Write and maintain docs + +## Registration + +On startup, register with Veritas Kanban: + +```bash +curl -X POST http://localhost:3001/api/agents/register \ + -H 'Content-Type: application/json' \ + -d '{ + "id": "my-agent-id", + "name": "My Agent", + "model": "anthropic/claude-sonnet-4-5", + "provider": "anthropic", + "capabilities": [ + {"name": "code", "description": "Write and review code"}, + {"name": "research", "description": "Deep research and analysis"} + ], + "version": "1.0.0" + }' +``` + +## Heartbeat + +Send periodic heartbeats to stay registered (every 2-3 minutes): + +```bash +curl -X POST http://localhost:3001/api/agents/register/my-agent-id/heartbeat \ + -H 'Content-Type: application/json' \ + -d '{ + "status": "busy", + "currentTaskId": "task_20260205_abc123", + "currentTaskTitle": "Implement feature X" + }' +``` + +### Status Values + +| Status | Meaning | +|--------|---------| +| `online` | Agent is available for work | +| `busy` | Agent is actively working on a task | +| `idle` | Agent is running but not doing anything | +| `offline` | Agent hasn't sent a heartbeat in 5+ minutes (auto-set) | + +## Deregistration + +On shutdown, deregister cleanly: + +```bash +curl -X DELETE http://localhost:3001/api/agents/register/my-agent-id +``` + +## Discovery + +### List all agents +```bash +curl http://localhost:3001/api/agents/register +``` + +### Filter by status +```bash +curl http://localhost:3001/api/agents/register?status=online +``` + +### Filter by capability +```bash +curl http://localhost:3001/api/agents/register?capability=code +``` + +### Find agents for a capability +```bash +curl http://localhost:3001/api/agents/register/capabilities/research +``` + +### Registry stats +```bash +curl http://localhost:3001/api/agents/register/stats +``` + +## Task Integration + +When picking up a task: +1. Send heartbeat with `status: "busy"` and `currentTaskId` +2. Use existing task APIs: `POST /api/agents/:taskId/start` +3. Report tokens: `POST /api/agents/:taskId/tokens` +4. Complete: `POST /api/agents/:taskId/complete` +5. Send heartbeat with `status: "idle"` and clear task + +## Multi-Agent Coordination + +The registry enables agents to discover each other: + +```bash +# Find who can help with code review +curl http://localhost:3001/api/agents/register/capabilities/review + +# Check if a specific agent is available +curl http://localhost:3001/api/agents/register/codex-1 +``` + +This is the foundation for multi-agent task assignment (#29) and @mention notifications (#30). diff --git a/server/src/routes/agent-registry.ts b/server/src/routes/agent-registry.ts new file mode 100644 index 00000000..7c205e96 --- /dev/null +++ b/server/src/routes/agent-registry.ts @@ -0,0 +1,166 @@ +/** + * Agent Registry API Routes + * + * POST /api/agents/register — Register or update an agent + * POST /api/agents/register/:id/heartbeat — Send heartbeat + * DELETE /api/agents/register/:id — Deregister an agent + * GET /api/agents/register — List all registered agents + * GET /api/agents/register/:id — Get specific agent + * GET /api/agents/register/stats — Get registry statistics + * GET /api/agents/register/capabilities/:capability — Find agents by capability + */ + +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { getAgentRegistryService } from '../services/agent-registry-service.js'; +import { asyncHandler } from '../middleware/async-handler.js'; +import { NotFoundError, ValidationError } from '../middleware/error-handler.js'; + +const router: RouterType = Router(); + +// ─── Validation Schemas ────────────────────────────────────────── + +const capabilitySchema = z.object({ + name: z.string().min(1).max(50), + description: z.string().max(200).optional(), +}); + +const registerSchema = z.object({ + id: z.string().min(1).max(50), + name: z.string().min(1).max(100), + model: z.string().max(100).optional(), + provider: z.string().max(50).optional(), + capabilities: z.array(capabilitySchema).optional(), + version: z.string().max(50).optional(), + metadata: z.record(z.unknown()).optional(), + sessionKey: z.string().max(200).optional(), +}); + +const heartbeatSchema = z.object({ + status: z.enum(['online', 'busy', 'idle']).optional(), + currentTaskId: z.string().max(100).optional().nullable(), + currentTaskTitle: z.string().max(200).optional().nullable(), + metadata: z.record(z.unknown()).optional(), +}); + +// ─── Routes ────────────────────────────────────────────────────── + +/** + * GET /api/agents/register/stats + * Get registry statistics (must be before /:id to avoid collision) + */ +router.get( + '/stats', + asyncHandler(async (_req, res) => { + const registry = getAgentRegistryService(); + res.json(registry.stats()); + }) +); + +/** + * GET /api/agents/register/capabilities/:capability + * Find agents that have a specific capability + */ +router.get( + '/capabilities/:capability', + asyncHandler(async (req, res) => { + const registry = getAgentRegistryService(); + const agents = registry.findByCapability(req.params.capability as string); + res.json(agents); + }) +); + +/** + * POST /api/agents/register + * Register or update an agent + */ +router.post( + '/', + asyncHandler(async (req, res) => { + const parsed = registerSchema.safeParse(req.body); + if (!parsed.success) { + throw new ValidationError('Invalid registration', parsed.error.errors); + } + + const registry = getAgentRegistryService(); + const agent = registry.register(parsed.data); + res.status(201).json(agent); + }) +); + +/** + * GET /api/agents/register + * List all registered agents, with optional filters + */ +router.get( + '/', + asyncHandler(async (req, res) => { + const registry = getAgentRegistryService(); + const agents = registry.list({ + status: req.query.status as string | undefined, + capability: req.query.capability as string | undefined, + }); + res.json(agents); + }) +); + +/** + * GET /api/agents/register/:id + * Get a specific agent + */ +router.get( + '/:id', + asyncHandler(async (req, res) => { + const registry = getAgentRegistryService(); + const agent = registry.get(req.params.id as string); + if (!agent) { + throw new NotFoundError('Agent not found'); + } + res.json(agent); + }) +); + +/** + * POST /api/agents/register/:id/heartbeat + * Send heartbeat for an agent + */ +router.post( + '/:id/heartbeat', + asyncHandler(async (req, res) => { + const parsed = heartbeatSchema.safeParse(req.body); + if (!parsed.success) { + throw new ValidationError('Invalid heartbeat', parsed.error.errors); + } + + const registry = getAgentRegistryService(); + const agent = registry.heartbeat(req.params.id as string, { + ...parsed.data, + currentTaskId: parsed.data.currentTaskId ?? undefined, + currentTaskTitle: parsed.data.currentTaskTitle ?? undefined, + }); + + if (!agent) { + throw new NotFoundError('Agent not registered. Call POST /api/agents/register first.'); + } + + res.json(agent); + }) +); + +/** + * DELETE /api/agents/register/:id + * Deregister an agent + */ +router.delete( + '/:id', + asyncHandler(async (req, res) => { + const registry = getAgentRegistryService(); + const removed = registry.deregister(req.params.id as string); + if (!removed) { + throw new NotFoundError('Agent not found'); + } + res.json({ removed: true }); + }) +); + +export { router as agentRegistryRoutes }; diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 5fed0d08..f59f8d01 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -50,6 +50,7 @@ import { analyticsRoutes } from '../analytics.js'; import tracesRoutes from '../traces.js'; import { settingsRoutes } from '../settings.js'; import { agentStatusRoutes } from '../agent-status.js'; +import { agentRegistryRoutes } from '../agent-registry.js'; import { statusHistoryRoutes } from '../status-history.js'; import digestRoutes from '../digest.js'; import auditRoutes from '../audit.js'; @@ -118,6 +119,7 @@ v1Router.use('/traces', tracesRoutes); v1Router.use('/settings', settingsRoutes); v1Router.use('/settings/transition-hooks', transitionHooksRoutes); v1Router.use('/agent/status', agentStatusRoutes); +v1Router.use('/agents/register', agentRegistryRoutes); v1Router.use('/status-history', statusHistoryRoutes); v1Router.use('/digest', digestRoutes); v1Router.use('/audit', auditRoutes); diff --git a/server/src/services/agent-registry-service.ts b/server/src/services/agent-registry-service.ts new file mode 100644 index 00000000..01766297 --- /dev/null +++ b/server/src/services/agent-registry-service.ts @@ -0,0 +1,334 @@ +/** + * Agent Registry Service + * + * Manages agent registration, heartbeat tracking, and capability discovery. + * Agents register themselves with name, model, capabilities, and metadata. + * The registry tracks liveness via heartbeats and exposes discovery APIs. + * + * Storage: File-based JSON in .veritas-kanban/agent-registry.json + */ + +import path from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from '../storage/fs-helpers.js'; +import { createLogger } from '../lib/logger.js'; + +const log = createLogger('agent-registry'); + +// ─── Types ─────────────────────────────────────────────────────── + +export interface AgentCapability { + /** Capability name (e.g., "code", "research", "deploy", "review") */ + name: string; + /** Optional description */ + description?: string; +} + +export interface RegisteredAgent { + /** Unique agent identifier (e.g., "veritas", "codex-1", "sonnet-research") */ + id: string; + /** Human-readable display name */ + name: string; + /** Model identifier (e.g., "claude-opus-4-6", "gpt-5.2-codex") */ + model?: string; + /** Provider (e.g., "anthropic", "openai-codex") */ + provider?: string; + /** Agent capabilities */ + capabilities: AgentCapability[]; + /** Agent version or build info */ + version?: string; + /** Freeform metadata */ + metadata?: Record; + /** Current status */ + status: 'online' | 'busy' | 'idle' | 'offline'; + /** ISO timestamp of registration */ + registeredAt: string; + /** ISO timestamp of last heartbeat */ + lastHeartbeat: string; + /** Current task ID (if working on something) */ + currentTaskId?: string; + /** Current task title */ + currentTaskTitle?: string; + /** Session key (for OpenClaw/orchestrator integration) */ + sessionKey?: string; +} + +export interface AgentRegistration { + id: string; + name: string; + model?: string; + provider?: string; + capabilities?: AgentCapability[]; + version?: string; + metadata?: Record; + sessionKey?: string; +} + +export interface AgentHeartbeat { + status?: 'online' | 'busy' | 'idle'; + currentTaskId?: string; + currentTaskTitle?: string; + metadata?: Record; +} + +export interface AgentRegistryData { + agents: Record; + lastUpdated: string; +} + +// ─── Configuration ─────────────────────────────────────────────── + +/** How long before an agent is considered offline (no heartbeat) */ +const HEARTBEAT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes + +/** How often to check for stale agents */ +const STALE_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute + +// ─── Service ───────────────────────────────────────────────────── + +class AgentRegistryService { + private agents: Map = new Map(); + private dataDir: string; + private filePath: string; + private staleCheckInterval: ReturnType | null = null; + + constructor() { + this.dataDir = process.env.VERITAS_DATA_DIR || path.join(process.cwd(), '..', '.veritas-kanban'); + this.filePath = path.join(this.dataDir, 'agent-registry.json'); + this.load(); + this.startStaleCheck(); + } + + /** + * Register or update an agent in the registry. + */ + register(registration: AgentRegistration): RegisteredAgent { + const existing = this.agents.get(registration.id); + const now = new Date().toISOString(); + + const agent: RegisteredAgent = { + id: registration.id, + name: registration.name, + model: registration.model ?? existing?.model, + provider: registration.provider ?? existing?.provider, + capabilities: registration.capabilities ?? existing?.capabilities ?? [], + version: registration.version ?? existing?.version, + metadata: registration.metadata ?? existing?.metadata, + sessionKey: registration.sessionKey ?? existing?.sessionKey, + status: 'online', + registeredAt: existing?.registeredAt ?? now, + lastHeartbeat: now, + currentTaskId: existing?.currentTaskId, + currentTaskTitle: existing?.currentTaskTitle, + }; + + this.agents.set(registration.id, agent); + this.persist(); + + log.info( + { agentId: agent.id, model: agent.model, capabilities: agent.capabilities.length }, + `Agent registered: ${agent.name}` + ); + + return agent; + } + + /** + * Process a heartbeat from an agent. + */ + heartbeat(agentId: string, update?: AgentHeartbeat): RegisteredAgent | null { + const agent = this.agents.get(agentId); + if (!agent) { + return null; + } + + agent.lastHeartbeat = new Date().toISOString(); + if (update?.status) agent.status = update.status; + if (update?.currentTaskId !== undefined) agent.currentTaskId = update.currentTaskId || undefined; + if (update?.currentTaskTitle !== undefined) agent.currentTaskTitle = update.currentTaskTitle || undefined; + if (update?.metadata) agent.metadata = { ...agent.metadata, ...update.metadata }; + + this.agents.set(agentId, agent); + this.persist(); + + return agent; + } + + /** + * Deregister an agent. + */ + deregister(agentId: string): boolean { + const existed = this.agents.delete(agentId); + if (existed) { + this.persist(); + log.info({ agentId }, `Agent deregistered: ${agentId}`); + } + return existed; + } + + /** + * Get a specific agent by ID. + */ + get(agentId: string): RegisteredAgent | null { + return this.agents.get(agentId) ?? null; + } + + /** + * List all registered agents, optionally filtered. + */ + list(filters?: { + status?: string; + capability?: string; + }): RegisteredAgent[] { + let agents = Array.from(this.agents.values()); + + if (filters?.status) { + agents = agents.filter((a) => a.status === filters.status); + } + + if (filters?.capability) { + const cap = filters.capability.toLowerCase(); + agents = agents.filter((a) => + a.capabilities.some((c) => c.name.toLowerCase() === cap) + ); + } + + return agents; + } + + /** + * Find agents that have a specific capability. + */ + findByCapability(capability: string): RegisteredAgent[] { + const cap = capability.toLowerCase(); + return Array.from(this.agents.values()).filter((a) => + a.status !== 'offline' && a.capabilities.some((c) => c.name.toLowerCase() === cap) + ); + } + + /** + * Get registry statistics. + */ + stats(): { + total: number; + online: number; + busy: number; + idle: number; + offline: number; + capabilities: string[]; + } { + const agents = Array.from(this.agents.values()); + const allCaps = new Set(); + for (const agent of agents) { + for (const cap of agent.capabilities) { + allCaps.add(cap.name); + } + } + + return { + total: agents.length, + online: agents.filter((a) => a.status === 'online').length, + busy: agents.filter((a) => a.status === 'busy').length, + idle: agents.filter((a) => a.status === 'idle').length, + offline: agents.filter((a) => a.status === 'offline').length, + capabilities: Array.from(allCaps).sort(), + }; + } + + /** + * Mark stale agents as offline. + */ + private checkStaleAgents(): void { + const now = Date.now(); + let changed = false; + + for (const agent of this.agents.values()) { + if (agent.status === 'offline') continue; + + const lastBeat = new Date(agent.lastHeartbeat).getTime(); + if (now - lastBeat > HEARTBEAT_TIMEOUT_MS) { + agent.status = 'offline'; + changed = true; + log.info( + { agentId: agent.id, lastHeartbeat: agent.lastHeartbeat }, + `Agent marked offline (heartbeat timeout): ${agent.id}` + ); + } + } + + if (changed) { + this.persist(); + } + } + + private startStaleCheck(): void { + this.staleCheckInterval = setInterval(() => this.checkStaleAgents(), STALE_CHECK_INTERVAL_MS); + } + + /** + * Load registry from disk. + */ + private load(): void { + try { + if (existsSync(this.filePath)) { + const raw = readFileSync(this.filePath, 'utf-8'); + const data = JSON.parse(raw) as AgentRegistryData; + if (data.agents) { + for (const [id, agent] of Object.entries(data.agents)) { + this.agents.set(id, agent); + } + log.info({ count: this.agents.size }, 'Agent registry loaded from disk'); + } + } + } catch (err) { + log.warn({ err }, 'Could not load agent registry, starting fresh'); + } + } + + /** + * Persist registry to disk. + */ + private persist(): void { + try { + const dir = path.dirname(this.filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const data: AgentRegistryData = { + agents: Object.fromEntries(this.agents), + lastUpdated: new Date().toISOString(), + }; + + writeFileSync(this.filePath, JSON.stringify(data, null, 2), 'utf-8'); + } catch (err) { + log.warn({ err }, 'Failed to persist agent registry'); + } + } + + /** + * Clean up resources. + */ + dispose(): void { + if (this.staleCheckInterval) { + clearInterval(this.staleCheckInterval); + this.staleCheckInterval = null; + } + } +} + +// Singleton +let instance: AgentRegistryService | null = null; + +export function getAgentRegistryService(): AgentRegistryService { + if (!instance) { + instance = new AgentRegistryService(); + } + return instance; +} + +export function disposeAgentRegistryService(): void { + if (instance) { + instance.dispose(); + instance = null; + } +}