feat: agent variants — migration, API, service integration, frontend, tests

- Migration 027: agent_variants table with single-active enforcement,
  variant_id column on agent_performance_log
- API: full CRUD, clone from agent/variant, activate/deactivate,
  per-variant performance metrics and history endpoints
- Services: extractor, event classifier, thesis rewriter all wired
  to AgentConfigResolver with variant override support
- Frontend: variant list, comparison view, create/edit/clone forms,
  activate/delete actions on Agents page
- Tests: API tests + 5 property-based tests (single-active invariant,
  clone preservation, config resolution, slug determinism, update idempotence)
- Spec files for agent-variants feature
This commit is contained in:
Celes Renata
2026-04-17 05:15:42 +00:00
parent 734bf001a7
commit 7c23c044d7
14 changed files with 3118 additions and 120 deletions
+67
View File
@@ -54,6 +54,25 @@ export const mockCorporateDecisions = [
{ catalyst_type: 'm_and_a', date: '2026-03-15T00:00:00Z', summary: 'Acquisition of AI startup for $2B', trend_direction: 'bullish', trend_strength: 0.7, sample_count: 5, pattern_confidence: 0.68, document_id: 'd1' },
];
export const mockAgents = [
{ id: 'agent-1', name: 'Document Extractor', slug: 'document-extractor', purpose: 'Extract structured data from documents', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.0, max_tokens: 32768, timeout_seconds: 120, max_retries: 2, active: true, source: 'system', created_at: '2026-04-01T00:00:00Z', updated_at: '2026-04-01T00:00:00Z' },
{ id: 'agent-2', name: 'Event Classifier', slug: 'event-classifier', purpose: 'Classify global events', model_provider: 'ollama', model_name: 'llama3.1:8b', system_prompt: 'You classify events.', user_prompt_template: 'Classify: {event}', prompt_version: 'v1', schema_version: '1.0', temperature: 0.1, max_tokens: 16384, timeout_seconds: 60, max_retries: 3, active: true, source: 'system', created_at: '2026-04-02T00:00:00Z', updated_at: '2026-04-02T00:00:00Z' },
];
export const mockAgentVariants = [
{ id: 'var-1', agent_id: 'agent-1', variant_name: 'GPT-4o Variant', variant_slug: 'gpt-4o-variant', description: 'Uses GPT-4o for extraction', model_provider: 'openai', model_name: 'gpt-4o', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v2', temperature: 0.2, max_tokens: 65536, context_window: 128000, input_token_limit: 100000, token_budget: 500000, timeout_seconds: 90, max_retries: 3, is_active: true, created_at: '2026-04-05T00:00:00Z', updated_at: '2026-04-05T00:00:00Z' },
{ id: 'var-2', agent_id: 'agent-1', variant_name: 'Mistral Variant', variant_slug: 'mistral-variant', description: 'Uses Mistral for extraction', model_provider: 'ollama', model_name: 'mistral:7b', system_prompt: 'You are a document extractor.', user_prompt_template: 'Extract from: {doc}', prompt_version: 'v1', temperature: 0.0, max_tokens: 32768, context_window: 0, input_token_limit: 0, token_budget: 0, timeout_seconds: 120, max_retries: 2, is_active: false, created_at: '2026-04-06T00:00:00Z', updated_at: '2026-04-06T00:00:00Z' },
];
export const mockVariantPerformance = {
total_invocations: 50, successes: 45, failures: 5, avg_duration_ms: 1200, p95_duration_ms: 2500, avg_confidence: 0.85, avg_retries: 0.3, total_input_tokens: 50000, total_output_tokens: 15000, success_rate: 0.9,
};
export const mockVariantPerfHistory = [
{ hour: '2026-04-10T10:00:00Z', invocations: 10, successes: 9, avg_duration_ms: 1100, avg_confidence: 0.88 },
{ hour: '2026-04-10T11:00:00Z', invocations: 12, successes: 11, avg_duration_ms: 1300, avg_confidence: 0.82 },
];
export const handlers = [
// Query API (proxied at /api/)
http.get('/api/companies', () => HttpResponse.json(mockCompanies)),
@@ -142,6 +161,54 @@ export const handlers = [
}),
http.get('/api/trends/:id/projection', () => HttpResponse.json(mockTrendProjection)),
// Agents
http.get('/api/agents', () => HttpResponse.json(mockAgents)),
http.get('/api/agents/:agent_id', ({ params }) => {
const a = mockAgents.find((a) => a.id === params.agent_id);
return a ? HttpResponse.json(a) : new HttpResponse(null, { status: 404 });
}),
http.get('/api/agents/:agent_id/performance', () => HttpResponse.json(mockVariantPerformance)),
http.get('/api/agents/:agent_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
// Agent Variants
http.get('/api/agents/:agent_id/variants', ({ params }) =>
HttpResponse.json(mockAgentVariants.filter((v) => v.agent_id === params.agent_id)),
),
http.get('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id && v.agent_id === params.agent_id);
return v ? HttpResponse.json(v) : new HttpResponse(null, { status: 404 });
}),
http.post('/api/agents/:agent_id/variants', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-new', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: body.variant_slug ?? 'new-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.post('/api/agents/:agent_id/clone', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-cloned', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'cloned-variant', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.post('/api/agents/:agent_id/variants/:variant_id/clone', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'var-clone2', agent_id: params.agent_id, variant_name: body.variant_name, variant_slug: 'clone2', is_active: false, ...body, created_at: '2026-04-10T00:00:00Z', updated_at: '2026-04-10T00:00:00Z' }, { status: 201 });
}),
http.put('/api/agents/:agent_id/variants/:variant_id', async ({ params, request }) => {
const body = await request.json() as Record<string, unknown>;
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
return v ? HttpResponse.json({ ...v, ...body, updated_at: '2026-04-10T12:00:00Z' }) : new HttpResponse(null, { status: 404 });
}),
http.delete('/api/agents/:agent_id/variants/:variant_id', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
if (!v) return new HttpResponse(null, { status: 404 });
if (v.is_active) return HttpResponse.json({ detail: 'Cannot delete active variant' }, { status: 400 });
return HttpResponse.json({ status: 'deleted' });
}),
http.post('/api/agents/:agent_id/variants/:variant_id/activate', ({ params }) => {
const v = mockAgentVariants.find((v) => v.id === params.variant_id);
return v ? HttpResponse.json({ ...v, is_active: true }) : new HttpResponse(null, { status: 404 });
}),
http.post('/api/agents/:agent_id/variants/deactivate', () => HttpResponse.json({ deactivated: true })),
http.get('/api/agents/:agent_id/variants/:variant_id/performance', () => HttpResponse.json(mockVariantPerformance)),
http.get('/api/agents/:agent_id/variants/:variant_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
// Competitive intelligence endpoints
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
+40
View File
@@ -168,3 +168,43 @@ describe('Global Events page', () => {
});
});
});
describe('Agents page', () => {
it('renders agent list in sidebar', async () => {
renderRoute('/agents');
await waitFor(() => {
expect(screen.getByText('Document Extractor')).toBeInTheDocument();
expect(screen.getByText('Event Classifier')).toBeInTheDocument();
});
});
it('renders variant list when an agent is selected', async () => {
renderRoute('/agents');
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
await userEvent.click(screen.getByText('Document Extractor'));
await waitFor(() => {
expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument();
expect(screen.getByText('Mistral Variant')).toBeInTheDocument();
});
});
it('shows comparison view when multiple variants are checked', async () => {
renderRoute('/agents');
await waitFor(() => expect(screen.getByText('Document Extractor')).toBeInTheDocument());
await userEvent.click(screen.getByText('Document Extractor'));
await waitFor(() => expect(screen.getByText('GPT-4o Variant')).toBeInTheDocument());
// Select both variant checkboxes
const checkboxes = screen.getAllByRole('checkbox');
await userEvent.click(checkboxes[0]);
await userEvent.click(checkboxes[1]);
await waitFor(() => {
expect(screen.getByText(/Variant Comparison/)).toBeInTheDocument();
});
});
});