diff --git a/src/app/api/frameworks/route.ts b/src/app/api/frameworks/route.ts new file mode 100644 index 0000000..27c72db --- /dev/null +++ b/src/app/api/frameworks/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireRole } from '@/lib/auth' +import { listAdapters } from '@/lib/adapters' +import { + listFrameworks, + getFrameworkInfo, + getTemplatesForFramework, + UNIVERSAL_TEMPLATES, +} from '@/lib/framework-templates' + +/** + * GET /api/frameworks — List all supported frameworks with connection info and templates. + * + * Query params: + * ?framework=langgraph — Get details for a specific framework + * ?templates=true — Include available templates in response + */ +export async function GET(request: NextRequest) { + const auth = requireRole(request, 'viewer') + if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + + const { searchParams: n } = new URL(request.url) + const frameworkFilter = n.get('framework') + const includeTemplates = n.get('templates') === 'true' + + // Single framework detail + if (frameworkFilter) { + const info = getFrameworkInfo(frameworkFilter) + if (!info) { + return NextResponse.json( + { error: `Unknown framework: ${frameworkFilter}. Available: ${listAdapters().join(', ')}` }, + { status: 404 } + ) + } + + const response: Record = { framework: info } + if (includeTemplates) { + response.templates = getTemplatesForFramework(frameworkFilter) + } + return NextResponse.json(response) + } + + // List all frameworks + const frameworks = listFrameworks() + const response: Record = { frameworks } + + if (includeTemplates) { + response.templates = UNIVERSAL_TEMPLATES + } + + return NextResponse.json(response) +} + +export const dynamic = 'force-dynamic' diff --git a/src/lib/__tests__/framework-templates.test.ts b/src/lib/__tests__/framework-templates.test.ts new file mode 100644 index 0000000..1a5e8ec --- /dev/null +++ b/src/lib/__tests__/framework-templates.test.ts @@ -0,0 +1,146 @@ +/** + * Framework Templates Test Suite + * + * Tests the framework-agnostic template registry, ensuring: + * - All adapters have corresponding framework info + * - Universal templates map correctly to framework-specific configs + * - Template resolution works for all framework/template combinations + */ + +import { describe, it, expect } from 'vitest' +import { + FRAMEWORK_REGISTRY, + UNIVERSAL_TEMPLATES, + listFrameworks, + getFrameworkInfo, + getTemplatesForFramework, + getUniversalTemplate, + resolveTemplateConfig, +} from '../framework-templates' +import { listAdapters } from '../adapters' +import { AGENT_TEMPLATES } from '../agent-templates' + +describe('Framework Registry', () => { + it('has an entry for every registered adapter', () => { + const adapters = listAdapters() + for (const adapter of adapters) { + expect(FRAMEWORK_REGISTRY[adapter]).toBeDefined() + expect(FRAMEWORK_REGISTRY[adapter].id).toBe(adapter) + } + }) + + it('every framework has required connection config', () => { + for (const fw of listFrameworks()) { + expect(fw.connection).toBeDefined() + expect(fw.connection.connectionMode).toMatch(/^(webhook|polling|websocket)$/) + expect(fw.connection.heartbeatInterval).toBeGreaterThan(0) + expect(fw.connection.setupHints.length).toBeGreaterThan(0) + expect(fw.connection.exampleSnippet.length).toBeGreaterThan(0) + } + }) + + it('every framework has a label and description', () => { + for (const fw of listFrameworks()) { + expect(fw.label).toBeTruthy() + expect(fw.description).toBeTruthy() + } + }) + + it('getFrameworkInfo returns correct framework', () => { + const info = getFrameworkInfo('langgraph') + expect(info?.id).toBe('langgraph') + expect(info?.label).toBe('LangGraph') + }) + + it('getFrameworkInfo returns undefined for unknown', () => { + expect(getFrameworkInfo('nonexistent')).toBeUndefined() + }) +}) + +describe('Universal Templates', () => { + it('has at least 5 template archetypes', () => { + expect(UNIVERSAL_TEMPLATES.length).toBeGreaterThanOrEqual(5) + }) + + it('every template has required fields', () => { + for (const tpl of UNIVERSAL_TEMPLATES) { + expect(tpl.type).toBeTruthy() + expect(tpl.label).toBeTruthy() + expect(tpl.description).toBeTruthy() + expect(tpl.emoji).toBeTruthy() + expect(tpl.frameworks.length).toBeGreaterThan(0) + expect(tpl.capabilities.length).toBeGreaterThan(0) + } + }) + + it('every template supports at least "generic" framework', () => { + for (const tpl of UNIVERSAL_TEMPLATES) { + expect(tpl.frameworks).toContain('generic') + } + }) + + it('templates with openclawTemplateType reference valid OpenClaw templates', () => { + for (const tpl of UNIVERSAL_TEMPLATES) { + if (tpl.openclawTemplateType) { + const ocTemplate = AGENT_TEMPLATES.find(t => t.type === tpl.openclawTemplateType) + expect(ocTemplate).toBeDefined() + } + } + }) + + it('getUniversalTemplate returns correct template', () => { + const tpl = getUniversalTemplate('developer') + expect(tpl?.type).toBe('developer') + expect(tpl?.label).toBe('Developer') + }) + + it('getUniversalTemplate returns undefined for unknown', () => { + expect(getUniversalTemplate('nonexistent')).toBeUndefined() + }) +}) + +describe('Template-Framework Resolution', () => { + it('getTemplatesForFramework returns templates for known frameworks', () => { + for (const fw of listAdapters()) { + const templates = getTemplatesForFramework(fw) + expect(templates.length).toBeGreaterThan(0) + } + }) + + it('getTemplatesForFramework returns empty for unknown framework', () => { + expect(getTemplatesForFramework('nonexistent')).toEqual([]) + }) + + it('resolveTemplateConfig returns OpenClaw template for openclaw framework', () => { + const result = resolveTemplateConfig('developer', 'openclaw') + expect(result).toBeDefined() + expect(result?.template).toBeDefined() + expect(result?.template?.type).toBe('developer') + expect(result?.universal.type).toBe('developer') + }) + + it('resolveTemplateConfig returns universal-only for non-openclaw frameworks', () => { + const result = resolveTemplateConfig('developer', 'langgraph') + expect(result).toBeDefined() + expect(result?.template).toBeUndefined() + expect(result?.universal.type).toBe('developer') + }) + + it('resolveTemplateConfig returns undefined for unknown template', () => { + expect(resolveTemplateConfig('nonexistent', 'generic')).toBeUndefined() + }) + + it('resolveTemplateConfig returns undefined for unsupported framework', () => { + expect(resolveTemplateConfig('developer', 'nonexistent')).toBeUndefined() + }) + + it('all universal templates resolve for all their declared frameworks', () => { + for (const tpl of UNIVERSAL_TEMPLATES) { + for (const fw of tpl.frameworks) { + const result = resolveTemplateConfig(tpl.type, fw) + expect(result).toBeDefined() + expect(result?.universal.type).toBe(tpl.type) + } + } + }) +}) diff --git a/src/lib/adapters/__tests__/adapter-api.test.ts b/src/lib/adapters/__tests__/adapter-api.test.ts new file mode 100644 index 0000000..1c8e778 --- /dev/null +++ b/src/lib/adapters/__tests__/adapter-api.test.ts @@ -0,0 +1,200 @@ +/** + * Adapter API Route Integration Tests + * + * Tests the POST /api/adapters dispatcher against all frameworks. + * Simulates what an external agent would do to connect to Mission Control. + * + * This is the "Feynman test" — timing how long it takes a stranger's + * agent to connect via the HTTP API. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { getAdapter, listAdapters } from '../index' + +// These tests verify the API contract from the external agent's perspective. +// They don't hit the HTTP layer (that's E2E) but verify the adapter dispatch +// logic matches what the API route does. + +const mockBroadcast = vi.fn() +vi.mock('@/lib/event-bus', () => ({ + eventBus: { broadcast: (...args: unknown[]) => mockBroadcast(...args) }, +})) + +const mockQuery = vi.fn() +vi.mock('../adapter', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + queryPendingAssignments: (...args: unknown[]) => mockQuery(...args), + } +}) + +// Simulate what POST /api/adapters does internally +async function simulateAdapterAction( + framework: string, + action: string, + payload: Record +): Promise<{ ok?: boolean; assignments?: unknown[]; error?: string }> { + let adapter + try { + adapter = getAdapter(framework) + } catch { + return { error: `Unknown framework: ${framework}` } + } + + switch (action) { + case 'register': { + const { agentId, name, metadata } = payload + if (!agentId || !name) return { error: 'payload.agentId and payload.name required' } + await adapter.register({ + agentId: agentId as string, + name: name as string, + framework, + metadata: metadata as Record, + }) + return { ok: true } + } + case 'heartbeat': { + const { agentId, status, metrics } = payload + if (!agentId) return { error: 'payload.agentId required' } + await adapter.heartbeat({ + agentId: agentId as string, + status: (status as string) || 'online', + metrics: metrics as Record, + }) + return { ok: true } + } + case 'report': { + const { taskId, agentId, progress, status, output } = payload + if (!taskId || !agentId) return { error: 'payload.taskId and payload.agentId required' } + await adapter.reportTask({ + taskId: taskId as string, + agentId: agentId as string, + progress: (progress as number) ?? 0, + status: (status as string) || 'in_progress', + output, + }) + return { ok: true } + } + case 'assignments': { + const { agentId } = payload + if (!agentId) return { error: 'payload.agentId required' } + const assignments = await adapter.getAssignments(agentId as string) + return { assignments } + } + case 'disconnect': { + const { agentId } = payload + if (!agentId) return { error: 'payload.agentId required' } + await adapter.disconnect(agentId as string) + return { ok: true } + } + default: + return { error: `Unknown action: ${action}` } + } +} + +describe('Adapter API dispatch', () => { + beforeEach(() => { + mockBroadcast.mockClear() + mockQuery.mockClear() + }) + + // Full lifecycle for every framework + describe.each(listAdapters())('Full agent lifecycle: %s', (framework) => { + it('register → heartbeat → report → assignments → disconnect', async () => { + mockQuery.mockResolvedValue([{ taskId: '1', description: 'Do stuff', priority: 1 }]) + + // 1. Register + const reg = await simulateAdapterAction(framework, 'register', { + agentId: `${framework}-agent-1`, + name: `${framework} Test Agent`, + metadata: { version: '2.0' }, + }) + expect(reg.ok).toBe(true) + + // 2. Heartbeat + const hb = await simulateAdapterAction(framework, 'heartbeat', { + agentId: `${framework}-agent-1`, + status: 'busy', + metrics: { tasksInProgress: 1 }, + }) + expect(hb.ok).toBe(true) + + // 3. Report task progress + const rpt = await simulateAdapterAction(framework, 'report', { + taskId: 'task-abc', + agentId: `${framework}-agent-1`, + progress: 50, + status: 'in_progress', + output: { log: 'halfway done' }, + }) + expect(rpt.ok).toBe(true) + + // 4. Get assignments + const asgn = await simulateAdapterAction(framework, 'assignments', { + agentId: `${framework}-agent-1`, + }) + expect(asgn.assignments).toHaveLength(1) + + // 5. Disconnect + const disc = await simulateAdapterAction(framework, 'disconnect', { + agentId: `${framework}-agent-1`, + }) + expect(disc.ok).toBe(true) + + // Verify event sequence + const eventTypes = mockBroadcast.mock.calls.map(c => c[0]) + expect(eventTypes).toEqual([ + 'agent.created', + 'agent.status_changed', + 'task.updated', + 'agent.status_changed', + ]) + }) + }) + + // Validation checks + describe('input validation', () => { + it('rejects unknown framework', async () => { + const result = await simulateAdapterAction('totally-fake', 'register', { + agentId: 'x', name: 'X', + }) + expect(result.error).toContain('Unknown framework') + }) + + it('rejects unknown action', async () => { + const result = await simulateAdapterAction('generic', 'explode', {}) + expect(result.error).toContain('Unknown action') + }) + + it('rejects register without agentId', async () => { + const result = await simulateAdapterAction('generic', 'register', { name: 'No ID' }) + expect(result.error).toContain('agentId') + }) + + it('rejects register without name', async () => { + const result = await simulateAdapterAction('generic', 'register', { agentId: 'no-name' }) + expect(result.error).toContain('name') + }) + + it('rejects heartbeat without agentId', async () => { + const result = await simulateAdapterAction('generic', 'heartbeat', {}) + expect(result.error).toContain('agentId') + }) + + it('rejects report without taskId', async () => { + const result = await simulateAdapterAction('generic', 'report', { agentId: 'x' }) + expect(result.error).toContain('taskId') + }) + + it('rejects assignments without agentId', async () => { + const result = await simulateAdapterAction('generic', 'assignments', {}) + expect(result.error).toContain('agentId') + }) + + it('rejects disconnect without agentId', async () => { + const result = await simulateAdapterAction('generic', 'disconnect', {}) + expect(result.error).toContain('agentId') + }) + }) +}) diff --git a/src/lib/adapters/__tests__/adapter-compliance.test.ts b/src/lib/adapters/__tests__/adapter-compliance.test.ts new file mode 100644 index 0000000..ff183fa --- /dev/null +++ b/src/lib/adapters/__tests__/adapter-compliance.test.ts @@ -0,0 +1,406 @@ +/** + * Adapter Compliance Test Suite + * + * Tests every FrameworkAdapter implementation against the contract. + * This is the P0 gate — nothing ships until all adapters pass. + * + * Tests: + * 1. Interface compliance (all 5 methods exist and are callable) + * 2. Event emission (correct event types and payloads) + * 3. Assignment retrieval (DB query works) + * 4. Error resilience (bad inputs don't crash) + * 5. Framework identity (each adapter tags events correctly) + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { FrameworkAdapter, AgentRegistration, HeartbeatPayload, TaskReport } from '../adapter' +import { getAdapter, listAdapters } from '../index' + +// Mock event bus +const mockBroadcast = vi.fn() +vi.mock('@/lib/event-bus', () => ({ + eventBus: { broadcast: (...args: unknown[]) => mockBroadcast(...args) }, +})) + +// Mock DB query for getAssignments +const mockQuery = vi.fn() +vi.mock('../adapter', async (importOriginal) => { + const original = await importOriginal() + return { + ...original, + queryPendingAssignments: (...args: unknown[]) => mockQuery(...args), + } +}) + +// ─── Test Data ─────────────────────────────────────────────────────────────── + +const testAgent: AgentRegistration = { + agentId: 'test-agent-001', + name: 'Test Agent', + framework: 'test-framework', + metadata: { version: '1.0', runtime: 'node' }, +} + +const testHeartbeat: HeartbeatPayload = { + agentId: 'test-agent-001', + status: 'busy', + metrics: { cpu: 42, memory: 1024, tasksCompleted: 5 }, +} + +const testReport: TaskReport = { + taskId: 'task-123', + agentId: 'test-agent-001', + progress: 75, + status: 'in_progress', + output: { summary: 'Processing step 3 of 4' }, +} + +// ─── Shared Compliance Tests ───────────────────────────────────────────────── + +const ALL_FRAMEWORKS = ['openclaw', 'generic', 'crewai', 'langgraph', 'autogen', 'claude-sdk'] + +describe('Adapter Registry', () => { + it('lists all registered adapters', () => { + const adapters = listAdapters() + expect(adapters).toEqual(expect.arrayContaining(ALL_FRAMEWORKS)) + expect(adapters.length).toBe(ALL_FRAMEWORKS.length) + }) + + it('returns an adapter for each registered framework', () => { + for (const fw of ALL_FRAMEWORKS) { + const adapter = getAdapter(fw) + expect(adapter).toBeDefined() + expect(adapter.framework).toBe(fw) + } + }) + + it('throws for unknown framework', () => { + expect(() => getAdapter('nonexistent')).toThrow('Unknown framework adapter') + }) +}) + +// Run the full compliance suite for EVERY adapter +describe.each(ALL_FRAMEWORKS)('FrameworkAdapter compliance: %s', (framework) => { + let adapter: FrameworkAdapter + + beforeEach(() => { + adapter = getAdapter(framework) + mockBroadcast.mockClear() + mockQuery.mockClear() + }) + + // ── 1. Interface Compliance ────────────────────────────────────────────── + + describe('interface compliance', () => { + it('implements all 5 required methods', () => { + expect(typeof adapter.register).toBe('function') + expect(typeof adapter.heartbeat).toBe('function') + expect(typeof adapter.reportTask).toBe('function') + expect(typeof adapter.getAssignments).toBe('function') + expect(typeof adapter.disconnect).toBe('function') + }) + + it('has a readonly framework property', () => { + expect(adapter.framework).toBe(framework) + }) + + it('all methods return promises', async () => { + mockQuery.mockResolvedValue([]) + + const results = [ + adapter.register(testAgent), + adapter.heartbeat(testHeartbeat), + adapter.reportTask(testReport), + adapter.getAssignments('any-id'), + adapter.disconnect('any-id'), + ] + + // All should be thenables + for (const r of results) { + expect(r).toBeInstanceOf(Promise) + } + + await Promise.all(results) + }) + }) + + // ── 2. Event Emission ──────────────────────────────────────────────────── + + describe('register()', () => { + it('broadcasts agent.created with correct payload', async () => { + await adapter.register(testAgent) + + expect(mockBroadcast).toHaveBeenCalledTimes(1) + expect(mockBroadcast).toHaveBeenCalledWith( + 'agent.created', + expect.objectContaining({ + id: 'test-agent-001', + name: 'Test Agent', + status: 'online', + }) + ) + }) + + it('includes framework tag in event', async () => { + await adapter.register(testAgent) + + const payload = mockBroadcast.mock.calls[0][1] + // Generic adapter may use agent.framework; others use this.framework + expect(payload.framework).toBeTruthy() + }) + + it('passes through metadata', async () => { + await adapter.register(testAgent) + + const payload = mockBroadcast.mock.calls[0][1] + // Metadata is spread into the event payload + expect(payload.version).toBe('1.0') + expect(payload.runtime).toBe('node') + }) + + it('handles agent with no metadata', async () => { + await adapter.register({ + agentId: 'minimal-agent', + name: 'Minimal', + framework, + }) + + expect(mockBroadcast).toHaveBeenCalledWith( + 'agent.created', + expect.objectContaining({ + id: 'minimal-agent', + name: 'Minimal', + status: 'online', + }) + ) + }) + }) + + describe('heartbeat()', () => { + it('broadcasts agent.status_changed with status and metrics', async () => { + await adapter.heartbeat(testHeartbeat) + + expect(mockBroadcast).toHaveBeenCalledTimes(1) + expect(mockBroadcast).toHaveBeenCalledWith( + 'agent.status_changed', + expect.objectContaining({ + id: 'test-agent-001', + status: 'busy', + }) + ) + }) + + it('includes metrics in event payload', async () => { + await adapter.heartbeat(testHeartbeat) + + const payload = mockBroadcast.mock.calls[0][1] + expect(payload.metrics).toBeDefined() + expect(payload.metrics.cpu).toBe(42) + }) + + it('handles heartbeat with no metrics', async () => { + await adapter.heartbeat({ + agentId: 'test-agent-001', + status: 'idle', + }) + + expect(mockBroadcast).toHaveBeenCalledWith( + 'agent.status_changed', + expect.objectContaining({ + id: 'test-agent-001', + status: 'idle', + }) + ) + }) + }) + + describe('reportTask()', () => { + it('broadcasts task.updated with progress and status', async () => { + await adapter.reportTask(testReport) + + expect(mockBroadcast).toHaveBeenCalledTimes(1) + expect(mockBroadcast).toHaveBeenCalledWith( + 'task.updated', + expect.objectContaining({ + id: 'task-123', + agentId: 'test-agent-001', + progress: 75, + status: 'in_progress', + }) + ) + }) + + it('passes through output data', async () => { + await adapter.reportTask(testReport) + + const payload = mockBroadcast.mock.calls[0][1] + expect(payload.output).toEqual({ summary: 'Processing step 3 of 4' }) + }) + + it('handles report with no output', async () => { + await adapter.reportTask({ + taskId: 'task-456', + agentId: 'test-agent-001', + progress: 100, + status: 'completed', + }) + + expect(mockBroadcast).toHaveBeenCalledWith( + 'task.updated', + expect.objectContaining({ + id: 'task-456', + status: 'completed', + progress: 100, + }) + ) + }) + }) + + describe('getAssignments()', () => { + it('delegates to queryPendingAssignments', async () => { + const mockAssignments = [ + { taskId: '1', description: 'Fix bug', priority: 1 }, + { taskId: '2', description: 'Write tests', priority: 2 }, + ] + mockQuery.mockResolvedValue(mockAssignments) + + const result = await adapter.getAssignments('test-agent-001') + + expect(mockQuery).toHaveBeenCalledWith('test-agent-001') + expect(result).toEqual(mockAssignments) + }) + + it('returns empty array when no assignments', async () => { + mockQuery.mockResolvedValue([]) + + const result = await adapter.getAssignments('idle-agent') + + expect(result).toEqual([]) + }) + + it('does not broadcast events', async () => { + mockQuery.mockResolvedValue([]) + + await adapter.getAssignments('test-agent-001') + + expect(mockBroadcast).not.toHaveBeenCalled() + }) + }) + + describe('disconnect()', () => { + it('broadcasts agent.status_changed with offline status', async () => { + await adapter.disconnect('test-agent-001') + + expect(mockBroadcast).toHaveBeenCalledTimes(1) + expect(mockBroadcast).toHaveBeenCalledWith( + 'agent.status_changed', + expect.objectContaining({ + id: 'test-agent-001', + status: 'offline', + }) + ) + }) + + it('tags disconnect event with framework', async () => { + await adapter.disconnect('test-agent-001') + + const payload = mockBroadcast.mock.calls[0][1] + expect(payload.framework).toBe(framework) + }) + }) + + // ── 3. Framework Identity ──────────────────────────────────────────────── + + describe('framework identity', () => { + it('tags all emitted events with its framework name', async () => { + mockQuery.mockResolvedValue([]) + + await adapter.register(testAgent) + await adapter.heartbeat(testHeartbeat) + await adapter.reportTask(testReport) + await adapter.disconnect('test-agent-001') + + // All 4 event-emitting calls should tag with framework + for (const call of mockBroadcast.mock.calls) { + const payload = call[1] + expect(payload.framework).toBeTruthy() + } + }) + }) +}) + +// ── 4. Cross-Adapter Behavioral Consistency ──────────────────────────────── + +describe('Cross-adapter consistency', () => { + beforeEach(() => { + mockBroadcast.mockClear() + mockQuery.mockClear() + }) + + it('all adapters emit the same event types for the same actions', async () => { + const eventsByFramework: Record = {} + + for (const fw of ALL_FRAMEWORKS) { + mockBroadcast.mockClear() + mockQuery.mockResolvedValue([]) + + const adapter = getAdapter(fw) + await adapter.register(testAgent) + await adapter.heartbeat(testHeartbeat) + await adapter.reportTask(testReport) + await adapter.disconnect('test-agent-001') + + eventsByFramework[fw] = mockBroadcast.mock.calls.map(c => c[0]) + } + + const expected = ['agent.created', 'agent.status_changed', 'task.updated', 'agent.status_changed'] + + for (const fw of ALL_FRAMEWORKS) { + expect(eventsByFramework[fw]).toEqual(expected) + } + }) + + it('all adapters return the same assignment data for the same agent', async () => { + const mockAssignments = [{ taskId: '99', description: 'Shared task', priority: 0 }] + mockQuery.mockResolvedValue(mockAssignments) + + for (const fw of ALL_FRAMEWORKS) { + const adapter = getAdapter(fw) + const result = await adapter.getAssignments('shared-agent') + expect(result).toEqual(mockAssignments) + } + }) +}) + +// ── 5. Generic Adapter Specialization ────────────────────────────────────── + +describe('GenericAdapter special behavior', () => { + beforeEach(() => { + mockBroadcast.mockClear() + }) + + it('respects agent.framework from registration payload', async () => { + const adapter = getAdapter('generic') + await adapter.register({ + agentId: 'custom-agent', + name: 'Custom Framework Agent', + framework: 'my-custom-framework', + }) + + const payload = mockBroadcast.mock.calls[0][1] + expect(payload.framework).toBe('my-custom-framework') + }) + + it('falls back to "generic" when no framework in payload', async () => { + const adapter = getAdapter('generic') + await adapter.register({ + agentId: 'unknown-agent', + name: 'Unknown Agent', + framework: '', + }) + + const payload = mockBroadcast.mock.calls[0][1] + // Empty string is falsy, should fall back to 'generic' + expect(payload.framework).toBe('generic') + }) +}) diff --git a/src/lib/framework-templates.ts b/src/lib/framework-templates.ts new file mode 100644 index 0000000..4300182 --- /dev/null +++ b/src/lib/framework-templates.ts @@ -0,0 +1,427 @@ +/** + * Framework-Agnostic Template System + * + * Extends the existing OpenClaw templates with framework-neutral archetypes + * that any adapter can use. Each framework template defines: + * - What the agent does (role, capabilities) + * - How it connects (framework-specific connection config) + * - What permissions it needs (tool scopes) + * + * The existing AGENT_TEMPLATES in agent-templates.ts remain for OpenClaw-native + * use. This module wraps them with a framework-aware registry. + */ + +import { AGENT_TEMPLATES, type AgentTemplate } from './agent-templates' +import { listAdapters } from './adapters' + +// ─── Framework Connection Config ──────────────────────────────────────────── + +export interface FrameworkConnectionConfig { + /** How the agent connects to MC (webhook, polling, websocket) */ + connectionMode: 'webhook' | 'polling' | 'websocket' + /** Default heartbeat interval in seconds */ + heartbeatInterval: number + /** Framework-specific setup hints shown in the UI */ + setupHints: string[] + /** Example connection code snippet */ + exampleSnippet: string +} + +export interface FrameworkInfo { + id: string + label: string + description: string + docsUrl: string + connection: FrameworkConnectionConfig +} + +// ─── Framework Registry ───────────────────────────────────────────────────── + +export const FRAMEWORK_REGISTRY: Record = { + openclaw: { + id: 'openclaw', + label: 'OpenClaw', + description: 'Native gateway-managed agents with full lifecycle control', + docsUrl: 'https://github.com/openclaw/openclaw', + connection: { + connectionMode: 'websocket', + heartbeatInterval: 30, + setupHints: [ + 'Agents are managed via the OpenClaw gateway', + 'Config syncs bidirectionally via openclaw.json', + 'Use "pnpm openclaw agents add" to provision', + ], + exampleSnippet: `# OpenClaw agents are auto-managed by the gateway. +# No manual registration needed — sync happens automatically. +# See: openclaw.json in your state directory.`, + }, + }, + generic: { + id: 'generic', + label: 'Generic HTTP', + description: 'Any agent that can make HTTP calls — the universal adapter', + docsUrl: '', + connection: { + connectionMode: 'polling', + heartbeatInterval: 60, + setupHints: [ + 'POST to /api/adapters with framework: "generic"', + 'Use any language — just call the REST API', + 'Poll /api/adapters for assignments or use SSE for push', + ], + exampleSnippet: `# Register your agent +curl -X POST http://localhost:3000/api/adapters \\ + -H "Content-Type: application/json" \\ + -H "x-api-key: YOUR_API_KEY" \\ + -d '{ + "framework": "generic", + "action": "register", + "payload": { + "agentId": "my-agent-1", + "name": "My Custom Agent", + "metadata": { "version": "1.0" } + } + }' + +# Send heartbeat +curl -X POST http://localhost:3000/api/adapters \\ + -H "Content-Type: application/json" \\ + -H "x-api-key: YOUR_API_KEY" \\ + -d '{ + "framework": "generic", + "action": "heartbeat", + "payload": { "agentId": "my-agent-1", "status": "online" } + }' + +# Get assignments +curl -X POST http://localhost:3000/api/adapters \\ + -H "Content-Type: application/json" \\ + -H "x-api-key: YOUR_API_KEY" \\ + -d '{ + "framework": "generic", + "action": "assignments", + "payload": { "agentId": "my-agent-1" } + }'`, + }, + }, + langgraph: { + id: 'langgraph', + label: 'LangGraph', + description: 'LangChain\'s graph-based agent orchestration framework', + docsUrl: 'https://langchain-ai.github.io/langgraph/', + connection: { + connectionMode: 'webhook', + heartbeatInterval: 30, + setupHints: [ + 'Wrap your LangGraph graph with the MC adapter client', + 'Register nodes as capabilities for task routing', + 'Use checkpointers for durable state across MC task assignments', + ], + exampleSnippet: `import requests + +MC_URL = "http://localhost:3000" +API_KEY = "YOUR_API_KEY" +HEADERS = {"Content-Type": "application/json", "x-api-key": API_KEY} + +# Register your LangGraph agent +requests.post(f"{MC_URL}/api/adapters", headers=HEADERS, json={ + "framework": "langgraph", + "action": "register", + "payload": { + "agentId": "langgraph-research-agent", + "name": "Research Agent", + "metadata": { + "graph_type": "StateGraph", + "nodes": ["research", "summarize", "review"], + "checkpointer": "sqlite" + } + } +}) + +# After your graph completes a task: +requests.post(f"{MC_URL}/api/adapters", headers=HEADERS, json={ + "framework": "langgraph", + "action": "report", + "payload": { + "taskId": "task-123", + "agentId": "langgraph-research-agent", + "progress": 100, + "status": "completed", + "output": {"summary": "Research complete", "sources": 12} + } +})`, + }, + }, + crewai: { + id: 'crewai', + label: 'CrewAI', + description: 'Role-based multi-agent orchestration framework', + docsUrl: 'https://docs.crewai.com/', + connection: { + connectionMode: 'webhook', + heartbeatInterval: 30, + setupHints: [ + 'Register each CrewAI agent role as a separate MC agent', + 'Map Crew tasks to MC task assignments', + 'Use callbacks to report progress back to MC', + ], + exampleSnippet: `from crewai import Agent, Task, Crew +import requests + +MC_URL = "http://localhost:3000" +HEADERS = {"Content-Type": "application/json", "x-api-key": "YOUR_API_KEY"} + +def register_crew_agent(agent: Agent): + """Register a CrewAI agent with Mission Control.""" + requests.post(f"{MC_URL}/api/adapters", headers=HEADERS, json={ + "framework": "crewai", + "action": "register", + "payload": { + "agentId": f"crewai-{agent.role.lower().replace(' ', '-')}", + "name": agent.role, + "metadata": { + "goal": agent.goal, + "backstory": agent.backstory[:200], + "tools": [t.name for t in (agent.tools or [])] + } + } + }) + +def report_task_complete(agent_id: str, task_id: str, output: str): + """Report task completion to Mission Control.""" + requests.post(f"{MC_URL}/api/adapters", headers=HEADERS, json={ + "framework": "crewai", + "action": "report", + "payload": { + "taskId": task_id, + "agentId": agent_id, + "progress": 100, + "status": "completed", + "output": {"result": output} + } + })`, + }, + }, + autogen: { + id: 'autogen', + label: 'AutoGen', + description: 'Microsoft\'s multi-agent conversation framework', + docsUrl: 'https://microsoft.github.io/autogen/', + connection: { + connectionMode: 'webhook', + heartbeatInterval: 30, + setupHints: [ + 'Register each AutoGen AssistantAgent with MC', + 'Use message hooks to report conversation progress', + 'Map GroupChat rounds to MC task progress updates', + ], + exampleSnippet: `import requests +# AutoGen v0.4+ (ag2) +from autogen import AssistantAgent, UserProxyAgent + +MC_URL = "http://localhost:3000" +HEADERS = {"Content-Type": "application/json", "x-api-key": "YOUR_API_KEY"} + +def register_autogen_agent(agent_name: str, system_message: str): + """Register an AutoGen agent with Mission Control.""" + requests.post(f"{MC_URL}/api/adapters", headers=HEADERS, json={ + "framework": "autogen", + "action": "register", + "payload": { + "agentId": f"autogen-{agent_name.lower().replace(' ', '-')}", + "name": agent_name, + "metadata": { + "type": "AssistantAgent", + "system_message_preview": system_message[:200] + } + } + }) + +# Register your agents +register_autogen_agent("Coder", "You are a coding assistant...") +register_autogen_agent("Reviewer", "You review code for bugs...")`, + }, + }, + 'claude-sdk': { + id: 'claude-sdk', + label: 'Claude Agent SDK', + description: 'Anthropic\'s native agent SDK for building Claude-powered agents', + docsUrl: 'https://docs.anthropic.com/en/docs/agents/agent-sdk', + connection: { + connectionMode: 'webhook', + heartbeatInterval: 30, + setupHints: [ + 'Register your Claude Agent SDK agent after initialization', + 'Use tool callbacks to report progress to MC', + 'Map agent turns to MC task progress updates', + ], + exampleSnippet: `import Anthropic from "@anthropic-ai/sdk"; + +const MC_URL = "http://localhost:3000"; +const HEADERS = { "Content-Type": "application/json", "x-api-key": "YOUR_API_KEY" }; + +// Register your Claude SDK agent +await fetch(\`\${MC_URL}/api/adapters\`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + framework: "claude-sdk", + action: "register", + payload: { + agentId: "claude-agent-1", + name: "Claude Development Agent", + metadata: { + model: "claude-sonnet-4-20250514", + tools: ["computer", "text_editor", "bash"] + } + } + }) +}); + +// Report task completion +await fetch(\`\${MC_URL}/api/adapters\`, { + method: "POST", + headers: HEADERS, + body: JSON.stringify({ + framework: "claude-sdk", + action: "report", + payload: { + taskId: "task-456", + agentId: "claude-agent-1", + progress: 100, + status: "completed", + output: { files_changed: 3, tests_passed: true } + } + }) +});`, + }, + }, +} + +// ─── Universal Template Archetypes ────────────────────────────────────────── + +export interface UniversalTemplate { + type: string + label: string + description: string + emoji: string + /** Which frameworks this template supports */ + frameworks: string[] + /** Role-based capabilities (framework-agnostic) */ + capabilities: string[] + /** The OpenClaw template to use when framework is openclaw */ + openclawTemplateType?: string +} + +/** + * Universal templates that work across all frameworks. + * These describe WHAT the agent does, not HOW it's configured. + * Framework-specific config is resolved at creation time. + */ +export const UNIVERSAL_TEMPLATES: UniversalTemplate[] = [ + { + type: 'orchestrator', + label: 'Orchestrator', + description: 'Coordinates other agents, routes tasks, and manages workflows. Full access.', + emoji: '\ud83e\udded', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['task_routing', 'agent_management', 'workflow_control', 'full_access'], + openclawTemplateType: 'orchestrator', + }, + { + type: 'developer', + label: 'Developer', + description: 'Writes and edits code, runs builds and tests. Read-write workspace access.', + emoji: '\ud83d\udee0\ufe0f', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['code_write', 'code_execute', 'testing', 'debugging'], + openclawTemplateType: 'developer', + }, + { + type: 'reviewer', + label: 'Reviewer / QA', + description: 'Reviews code and validates quality. Read-only access, lightweight model.', + emoji: '\ud83d\udd2c', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['code_read', 'quality_review', 'security_audit'], + openclawTemplateType: 'reviewer', + }, + { + type: 'researcher', + label: 'Researcher', + description: 'Browses the web and gathers information. No code execution.', + emoji: '\ud83d\udd0d', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['web_browse', 'data_gathering', 'summarization'], + openclawTemplateType: 'researcher', + }, + { + type: 'content-creator', + label: 'Content Creator', + description: 'Generates and edits written content. No code execution or browsing.', + emoji: '\u270f\ufe0f', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['content_write', 'content_edit'], + openclawTemplateType: 'content-creator', + }, + { + type: 'security-auditor', + label: 'Security Auditor', + description: 'Scans for vulnerabilities. Read-only with shell access for scanning tools.', + emoji: '\ud83d\udee1\ufe0f', + frameworks: ['openclaw', 'generic', 'langgraph', 'crewai', 'autogen', 'claude-sdk'], + capabilities: ['code_read', 'shell_execute', 'security_scan'], + openclawTemplateType: 'security-auditor', + }, +] + +// ─── Template Resolution ──────────────────────────────────────────────────── + +/** + * Get a universal template by type. + */ +export function getUniversalTemplate(type: string): UniversalTemplate | undefined { + return UNIVERSAL_TEMPLATES.find(t => t.type === type) +} + +/** + * List templates available for a specific framework. + */ +export function getTemplatesForFramework(framework: string): UniversalTemplate[] { + return UNIVERSAL_TEMPLATES.filter(t => t.frameworks.includes(framework)) +} + +/** + * Get framework connection info. + */ +export function getFrameworkInfo(framework: string): FrameworkInfo | undefined { + return FRAMEWORK_REGISTRY[framework] +} + +/** + * List all supported frameworks. + */ +export function listFrameworks(): FrameworkInfo[] { + return Object.values(FRAMEWORK_REGISTRY) +} + +/** + * Resolve a universal template to its OpenClaw-specific config (if applicable). + * For non-OpenClaw frameworks, returns the universal template metadata + * since config is managed externally by the framework. + */ +export function resolveTemplateConfig( + universalType: string, + framework: string +): { template?: AgentTemplate; universal: UniversalTemplate } | undefined { + const universal = getUniversalTemplate(universalType) + if (!universal) return undefined + if (!universal.frameworks.includes(framework)) return undefined + + if (framework === 'openclaw' && universal.openclawTemplateType) { + const template = AGENT_TEMPLATES.find(t => t.type === universal.openclawTemplateType) + return { template, universal } + } + + return { universal } +}