feat: add framework template adapters
This commit is contained in:
parent
b8c121ebea
commit
dd7d663a36
|
|
@ -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<string, unknown> = { framework: info }
|
||||||
|
if (includeTemplates) {
|
||||||
|
response.templates = getTemplatesForFramework(frameworkFilter)
|
||||||
|
}
|
||||||
|
return NextResponse.json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List all frameworks
|
||||||
|
const frameworks = listFrameworks()
|
||||||
|
const response: Record<string, unknown> = { frameworks }
|
||||||
|
|
||||||
|
if (includeTemplates) {
|
||||||
|
response.templates = UNIVERSAL_TEMPLATES
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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<typeof import('../adapter')>()
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
queryPendingAssignments: (...args: unknown[]) => mockQuery(...args),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Simulate what POST /api/adapters does internally
|
||||||
|
async function simulateAdapterAction(
|
||||||
|
framework: string,
|
||||||
|
action: string,
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): 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<string, unknown>,
|
||||||
|
})
|
||||||
|
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<string, unknown>,
|
||||||
|
})
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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<typeof import('../adapter')>()
|
||||||
|
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<string, string[]> = {}
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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<string, FrameworkInfo> = {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue