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
This commit is contained in:
Brad Groux 2026-02-05 17:56:04 -06:00
parent 931d437b67
commit fcf1756b79
4 changed files with 628 additions and 0 deletions

126
docs/AGENTS-TEMPLATE.md Normal file
View file

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

View file

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

View file

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

View file

@ -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<string, unknown>;
/** 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<string, unknown>;
sessionKey?: string;
}
export interface AgentHeartbeat {
status?: 'online' | 'busy' | 'idle';
currentTaskId?: string;
currentTaskTitle?: string;
metadata?: Record<string, unknown>;
}
export interface AgentRegistryData {
agents: Record<string, RegisteredAgent>;
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<string, RegisteredAgent> = new Map();
private dataDir: string;
private filePath: string;
private staleCheckInterval: ReturnType<typeof setInterval> | 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<string>();
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;
}
}