feat: AI Agents management page with per-agent performance tracking
New Agents tab in the sidebar (Ops group) for viewing, editing, and
creating AI agent configurations:
Database (migration 026):
- ai_agents table: editable configs for each LLM agent (model, prompts,
temperature, tokens, retries). source='system' for built-in,
source='user' for custom. Seeds 3 system agents (Document Extractor,
Event Classifier, Thesis Rewriter) using WHERE NOT EXISTS to never
overwrite user edits across reinstalls.
- agent_performance_log table: per-invocation metrics (duration,
confidence, retries, tokens, errors) linked to agent config.
API endpoints:
- GET/POST /api/agents — list and create agents
- GET/PUT/DELETE /api/agents/{id} — view, edit, delete (system agents
can be edited but not deleted)
- GET /api/agents/{id}/performance — aggregated metrics (success rate,
avg/p95 latency, confidence, token usage)
- GET /api/agents/{id}/performance/history — hourly time series
Frontend:
- AgentsPage with sidebar list + detail panel
- Agent detail: config display, system prompt viewer, performance
dashboard with metrics cards and time-series chart
- Edit form: all config fields editable including system prompt,
model, temperature, tokens, retries
- Create form: new user-defined agents with auto-slug generation
- System agents show blue badge, user agents show green badge
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
List,
|
||||
Globe,
|
||||
BarChart3,
|
||||
Bot,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface NavItem {
|
||||
@@ -42,6 +43,7 @@ const navItems: NavItem[] = [
|
||||
{ to: '/ops/pipeline', label: 'Pipeline', icon: <Activity size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/ingestion', label: 'Ingestion', icon: <Download size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/model', label: 'Model Perf', icon: <Cpu size={18} />, group: 'Ops' },
|
||||
{ to: '/agents', label: 'Agents', icon: <Bot size={18} />, group: 'Ops' },
|
||||
{ to: '/ops/coverage', label: 'Coverage', icon: <Radar size={18} />, group: 'Ops' },
|
||||
{ to: '/analytics/query', label: 'SQL Explorer', icon: <Terminal size={18} />, group: 'Analytics' },
|
||||
{ to: '/analytics/dashboards', label: 'Dashboards', icon: <LayoutDashboard size={18} />, group: 'Analytics' },
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
|
||||
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
|
||||
} from 'recharts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
purpose: string;
|
||||
model_provider: string;
|
||||
model_name: string;
|
||||
system_prompt: string;
|
||||
user_prompt_template: string;
|
||||
prompt_version: string;
|
||||
schema_version: string;
|
||||
temperature: number;
|
||||
max_tokens: number;
|
||||
timeout_seconds: number;
|
||||
max_retries: number;
|
||||
active: boolean;
|
||||
source: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentPerformance {
|
||||
total_invocations: number;
|
||||
successes: number;
|
||||
failures: number;
|
||||
avg_duration_ms: number | null;
|
||||
p95_duration_ms: number | null;
|
||||
avg_confidence: number | null;
|
||||
avg_retries: number | null;
|
||||
total_input_tokens: number | null;
|
||||
total_output_tokens: number | null;
|
||||
success_rate: number | null;
|
||||
}
|
||||
|
||||
interface PerfHistoryPoint {
|
||||
hour: string;
|
||||
invocations: number;
|
||||
successes: number;
|
||||
avg_duration_ms: number;
|
||||
avg_confidence: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useAgents() {
|
||||
return useQuery<Agent[]>({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => apiGet<Agent[]>('query', '/api/agents'),
|
||||
});
|
||||
}
|
||||
|
||||
function useAgentPerformance(agentId: string | undefined, hours = 24) {
|
||||
return useQuery<AgentPerformance>({
|
||||
queryKey: ['agent-performance', agentId, hours],
|
||||
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/performance?hours=${hours}`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
|
||||
return useQuery<PerfHistoryPoint[]>({
|
||||
queryKey: ['agent-perf-history', agentId, hours],
|
||||
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/performance/history?hours=${hours}`),
|
||||
enabled: !!agentId,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentsPage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: agents, isLoading } = useAgents();
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>(undefined);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const selected = agents?.find((a) => a.id === selectedId);
|
||||
|
||||
if (isLoading) return <LoadingSpinner />;
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[calc(100vh-3rem)]">
|
||||
{/* Agent List Sidebar */}
|
||||
<div className="w-64 shrink-0 overflow-y-auto rounded-lg border border-surface-700 bg-surface-900 p-3 space-y-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-gray-500">Agents</h2>
|
||||
<button
|
||||
onClick={() => { setCreating(true); setSelectedId(undefined); setEditing(false); }}
|
||||
className="rounded bg-brand-600 px-2 py-0.5 text-[10px] font-medium text-white hover:bg-brand-700"
|
||||
>
|
||||
+ New
|
||||
</button>
|
||||
</div>
|
||||
{(agents ?? []).map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => { setSelectedId(agent.id); setEditing(false); setCreating(false); }}
|
||||
className={`w-full rounded-md px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedId === agent.id
|
||||
? 'bg-brand-600/20 border border-brand-500/50 text-brand-300'
|
||||
: 'text-gray-300 hover:bg-surface-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium truncate">{agent.name}</span>
|
||||
{!agent.active && <span className="text-[9px] text-gray-600">OFF</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-[10px] text-gray-500">{agent.model_name}</span>
|
||||
<span className={`rounded px-1 py-0 text-[9px] ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400' : 'bg-emerald-900/30 text-emerald-400'}`}>
|
||||
{agent.source}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{creating ? (
|
||||
<CreateAgentForm
|
||||
onCreated={(id) => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(id); setCreating(false); }}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
) : selected ? (
|
||||
editing ? (
|
||||
<EditAgentForm agent={selected} onSaved={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setEditing(false); }} onCancel={() => setEditing(false)} />
|
||||
) : (
|
||||
<AgentDetail agent={selected} onEdit={() => setEditing(true)} onDeleted={() => { qc.invalidateQueries({ queryKey: ['agents'] }); setSelectedId(undefined); }} />
|
||||
)
|
||||
) : (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-500">Select an agent to view its configuration and performance, or create a new one.</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Detail View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () => void; onDeleted: () => void }) {
|
||||
const qc = useQueryClient();
|
||||
const { data: perf } = useAgentPerformance(agent.id);
|
||||
const { data: history } = useAgentPerfHistory(agent.id);
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agents'] }); onDeleted(); },
|
||||
});
|
||||
|
||||
const chartData = (history ?? []).map((pt) => ({
|
||||
hour: new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
invocations: pt.invocations,
|
||||
successRate: pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0,
|
||||
avgLatency: pt.avg_duration_ms,
|
||||
avgConfidence: Math.round((pt.avg_confidence ?? 0) * 100),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-xl font-semibold text-gray-100">{agent.name}</h1>
|
||||
<StatusBadge status={agent.active ? 'active' : 'disabled'} />
|
||||
<span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${agent.source === 'system' ? 'bg-blue-900/30 text-blue-400 border border-blue-700/50' : 'bg-emerald-900/30 text-emerald-400 border border-emerald-700/50'}`}>
|
||||
{agent.source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onEdit} className="rounded-md bg-brand-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-brand-700">Edit</button>
|
||||
{agent.source === 'user' && (
|
||||
<button onClick={() => deleteMut.mutate()} className="rounded-md border border-red-700/50 px-3 py-1.5 text-sm text-red-400 hover:bg-red-900/20">Delete</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config */}
|
||||
<Card>
|
||||
<dl className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm sm:grid-cols-4">
|
||||
<div><dt className="text-gray-500">Model</dt><dd className="font-mono text-gray-200">{agent.model_provider}/{agent.model_name}</dd></div>
|
||||
<div><dt className="text-gray-500">Prompt Version</dt><dd className="text-gray-200">{agent.prompt_version || '—'}</dd></div>
|
||||
<div><dt className="text-gray-500">Schema Version</dt><dd className="text-gray-200">{agent.schema_version}</dd></div>
|
||||
<div><dt className="text-gray-500">Temperature</dt><dd className="text-gray-200">{agent.temperature}</dd></div>
|
||||
<div><dt className="text-gray-500">Max Tokens</dt><dd className="text-gray-200">{agent.max_tokens.toLocaleString()}</dd></div>
|
||||
<div><dt className="text-gray-500">Timeout</dt><dd className="text-gray-200">{agent.timeout_seconds}s</dd></div>
|
||||
<div><dt className="text-gray-500">Max Retries</dt><dd className="text-gray-200">{agent.max_retries}</dd></div>
|
||||
<div><dt className="text-gray-500">Updated</dt><dd className="text-gray-300">{new Date(agent.updated_at).toLocaleString()}</dd></div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{agent.purpose && (
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">Purpose</h2>
|
||||
<p className="text-sm text-gray-300">{agent.purpose}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h2 className="mb-2 text-sm font-medium text-gray-400">System Prompt</h2>
|
||||
<pre className="whitespace-pre-wrap rounded-md bg-surface-950 p-3 text-xs text-gray-300 max-h-40 overflow-y-auto">{agent.system_prompt || '(none)'}</pre>
|
||||
</Card>
|
||||
|
||||
{/* Performance Metrics */}
|
||||
{perf && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance (24h)</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<MetricCard label="Invocations" value={String(perf.total_invocations ?? 0)} />
|
||||
<MetricCard label="Success Rate" value={perf.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'} color={perf.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'} />
|
||||
<MetricCard label="Avg Latency" value={perf.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'} />
|
||||
<MetricCard label="P95 Latency" value={perf.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'} />
|
||||
<MetricCard label="Avg Confidence" value={perf.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'} />
|
||||
</div>
|
||||
<div className="mt-2 flex gap-4 text-xs text-gray-500">
|
||||
<span>Retries avg: {perf.avg_retries?.toFixed(1) ?? '—'}</span>
|
||||
<span>Input tokens: {perf.total_input_tokens?.toLocaleString() ?? '—'}</span>
|
||||
<span>Output tokens: {perf.total_output_tokens?.toLocaleString() ?? '—'}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Performance Chart */}
|
||||
{chartData.length > 1 && (
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Performance Over Time</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis yAxisId="left" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={{ fill: '#6b7280', fontSize: 10 }} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="invocations" name="Invocations" stroke="#3b82f6" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="successRate" name="Success %" stroke="#10b981" strokeWidth={2} dot={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="avgConfidence" name="Confidence %" stroke="#f59e0b" strokeWidth={1.5} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function EditAgentForm({ agent, onSaved, onCancel }: { agent: Agent; onSaved: () => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: agent.name,
|
||||
purpose: agent.purpose,
|
||||
model_provider: agent.model_provider,
|
||||
model_name: agent.model_name,
|
||||
system_prompt: agent.system_prompt,
|
||||
user_prompt_template: agent.user_prompt_template,
|
||||
prompt_version: agent.prompt_version,
|
||||
schema_version: agent.schema_version,
|
||||
temperature: agent.temperature,
|
||||
max_tokens: agent.max_tokens,
|
||||
timeout_seconds: agent.timeout_seconds,
|
||||
max_retries: agent.max_retries,
|
||||
active: agent.active,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPut<Agent>('query', `/api/agents/${agent.id}`, body),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Agent: {agent.name}</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<FormRow label="Name">
|
||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Purpose">
|
||||
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<FormRow label="User Prompt Template">
|
||||
<textarea value={form.user_prompt_template} onChange={(e) => setForm({ ...form, user_prompt_template: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-24 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<input type="checkbox" checked={form.active} onChange={(e) => setForm({ ...form, active: e.target.checked })} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to save</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create Form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CreateAgentForm({ onCreated, onCancel }: { onCreated: (id: string) => void; onCancel: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
purpose: '',
|
||||
model_provider: 'ollama',
|
||||
model_name: 'llama3.1:8b',
|
||||
system_prompt: '',
|
||||
user_prompt_template: '',
|
||||
temperature: 0,
|
||||
max_tokens: 32768,
|
||||
timeout_seconds: 120,
|
||||
max_retries: 2,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => apiPost<{ id: string }>('query', '/api/agents', body),
|
||||
onSuccess: (data) => onCreated(data.id),
|
||||
});
|
||||
|
||||
// Auto-generate slug from name
|
||||
function handleNameChange(name: string) {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
setForm({ ...form, name, slug });
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
mutation.mutate(form);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Create New Agent</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Name">
|
||||
<input value={form.name} onChange={(e) => handleNameChange(e.target.value)} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" required />
|
||||
</FormRow>
|
||||
<FormRow label="Slug">
|
||||
<input value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none font-mono" required />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="Purpose">
|
||||
<textarea value={form.purpose} onChange={(e) => setForm({ ...form, purpose: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-16" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Provider">
|
||||
<input value={form.model_provider} onChange={(e) => setForm({ ...form, model_provider: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Model">
|
||||
<input value={form.model_name} onChange={(e) => setForm({ ...form, model_name: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<FormRow label="System Prompt">
|
||||
<textarea value={form.system_prompt} onChange={(e) => setForm({ ...form, system_prompt: e.target.value })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none h-32 font-mono text-xs" />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<FormRow label="Temperature">
|
||||
<input type="number" step="0.1" min="0" max="2" value={form.temperature} onChange={(e) => setForm({ ...form, temperature: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Tokens">
|
||||
<input type="number" value={form.max_tokens} onChange={(e) => setForm({ ...form, max_tokens: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Timeout (s)">
|
||||
<input type="number" value={form.timeout_seconds} onChange={(e) => setForm({ ...form, timeout_seconds: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
<FormRow label="Max Retries">
|
||||
<input type="number" value={form.max_retries} onChange={(e) => setForm({ ...form, max_retries: Number(e.target.value) })} className="w-full rounded-md border border-surface-700 bg-surface-950 px-2 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none" />
|
||||
</FormRow>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button type="submit" disabled={mutation.isPending} className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50">
|
||||
{mutation.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
|
||||
</div>
|
||||
{mutation.isError && <p className="text-xs text-red-400">Failed to create agent</p>}
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FormRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-gray-500">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import { DashboardsPage } from './pages/Dashboards';
|
||||
import { HomePage } from './pages/Home';
|
||||
import { GlobalEventsPage } from './pages/GlobalEvents';
|
||||
import { GlobalEventDetailPage } from './pages/GlobalEventDetail';
|
||||
import { AgentsPage } from './pages/Agents';
|
||||
|
||||
// Root route wraps everything in the app shell layout
|
||||
const rootRoute = createRootRoute({
|
||||
@@ -157,6 +158,12 @@ const globalEventDetailRoute = createRoute({
|
||||
component: GlobalEventDetailPage,
|
||||
});
|
||||
|
||||
const agentsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/agents',
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
companiesRoute,
|
||||
@@ -181,6 +188,7 @@ const routeTree = rootRoute.addChildren([
|
||||
analyticsDashboardsRoute,
|
||||
globalEventsRoute,
|
||||
globalEventDetailRoute,
|
||||
agentsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
-- AI Agent configurations: user-editable agent profiles.
|
||||
-- Seed rows have source='system' and are re-inserted on migration only if
|
||||
-- missing, so user edits (source='user') are never overwritten.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_agents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(100) NOT NULL UNIQUE,
|
||||
slug VARCHAR(100) NOT NULL UNIQUE,
|
||||
purpose TEXT NOT NULL DEFAULT '',
|
||||
model_provider VARCHAR(50) NOT NULL DEFAULT 'ollama',
|
||||
model_name VARCHAR(200) NOT NULL DEFAULT 'llama3.1:8b',
|
||||
system_prompt TEXT NOT NULL DEFAULT '',
|
||||
user_prompt_template TEXT NOT NULL DEFAULT '',
|
||||
prompt_version VARCHAR(100) NOT NULL DEFAULT '',
|
||||
schema_version VARCHAR(50) NOT NULL DEFAULT '1.0.0',
|
||||
temperature FLOAT DEFAULT 0.0,
|
||||
max_tokens INTEGER DEFAULT 32768,
|
||||
timeout_seconds INTEGER DEFAULT 120,
|
||||
max_retries INTEGER DEFAULT 2,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'system', -- 'system' or 'user'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agents_slug ON ai_agents(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_agents_active ON ai_agents(active);
|
||||
|
||||
-- Seed the three built-in agents (only if they don't already exist)
|
||||
INSERT INTO ai_agents (name, slug, purpose, model_provider, model_name, system_prompt, prompt_version, schema_version, source)
|
||||
SELECT * FROM (VALUES
|
||||
(
|
||||
'Document Intelligence Extractor',
|
||||
'document-extractor',
|
||||
'Extracts structured intelligence (sentiment, catalysts, impact scores, key facts, risks) from company news, SEC filings, earnings transcripts, and press releases.',
|
||||
'ollama',
|
||||
'llama3.1:8b',
|
||||
'You are a financial document analyst. Extract structured data as JSON. Return ONLY a single JSON object. No markdown fences, no explanation, no text before or after the JSON. Every field in the schema is required. Use "other" for catalyst_type if unsure. Keep evidence_spans short (under 20 words each). Keep key_facts to 3-5 items max.',
|
||||
'document-intel-v2',
|
||||
'2.0.0',
|
||||
'system'
|
||||
),
|
||||
(
|
||||
'Global Event Classifier',
|
||||
'event-classifier',
|
||||
'Classifies global/geopolitical news into structured macro events with impact type, severity, affected regions/sectors/commodities, and estimated duration.',
|
||||
'ollama',
|
||||
'llama3.1:8b',
|
||||
'Classify this global news article as a macro event. Fill every field. RULES: - Only extract facts EXPLICITLY stated in the article - Do NOT infer geopolitical implications not stated - Distinguish between announced policy and rumored policy - If severity is unclear, default to "low" - confidence: 0.0-1.0 your confidence in this classification',
|
||||
'event-classification-v1',
|
||||
'1.0.0',
|
||||
'system'
|
||||
),
|
||||
(
|
||||
'Thesis Rewriter',
|
||||
'thesis-rewriter',
|
||||
'Rewrites deterministic trade thesis summaries into clear, professional analyst prose. Optional layer — system falls back to deterministic thesis if this fails.',
|
||||
'ollama',
|
||||
'llama3.1:8b',
|
||||
'You are a concise financial analyst. You rewrite structured trade thesis summaries into clear, professional prose suitable for an internal research note. STRICT RULES: 1. Do NOT add any information not present in the input. 2. Do NOT fabricate numbers, dates, company names. 3. Keep under 150 words. 4. Preserve all factual claims, risk notes, evidence counts. 5. Neutral, professional tone. 6. Return ONLY the rewritten thesis text.',
|
||||
'thesis-rewrite-v1',
|
||||
'1.0.0',
|
||||
'system'
|
||||
)
|
||||
) AS v(name, slug, purpose, model_provider, model_name, system_prompt, prompt_version, schema_version, source)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM ai_agents WHERE ai_agents.slug = v.slug);
|
||||
|
||||
-- Agent performance log: per-invocation metrics linked to agent config.
|
||||
-- This supplements model_performance_metrics with agent-level attribution.
|
||||
CREATE TABLE IF NOT EXISTS agent_performance_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
agent_id UUID NOT NULL REFERENCES ai_agents(id) ON DELETE CASCADE,
|
||||
document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||
ticker VARCHAR(20),
|
||||
success BOOLEAN NOT NULL,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
confidence FLOAT DEFAULT 0.0,
|
||||
retry_count INTEGER DEFAULT 0,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_perf_agent ON agent_performance_log(agent_id, recorded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_perf_time ON agent_performance_log(recorded_at DESC);
|
||||
@@ -2640,3 +2640,190 @@ async def get_decision_history(
|
||||
"decisions": decisions,
|
||||
"count": len(decisions),
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AI Agents (Editable agent configurations + performance tracking)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AgentUpdateBody(BaseModel):
|
||||
name: Optional[str] = None
|
||||
purpose: Optional[str] = None
|
||||
model_provider: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
user_prompt_template: Optional[str] = None
|
||||
prompt_version: Optional[str] = None
|
||||
schema_version: Optional[str] = None
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
timeout_seconds: Optional[int] = None
|
||||
max_retries: Optional[int] = None
|
||||
active: Optional[bool] = None
|
||||
|
||||
|
||||
class AgentCreateBody(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
purpose: str = ""
|
||||
model_provider: str = "ollama"
|
||||
model_name: str = "llama3.1:8b"
|
||||
system_prompt: str = ""
|
||||
user_prompt_template: str = ""
|
||||
prompt_version: str = ""
|
||||
schema_version: str = "1.0.0"
|
||||
temperature: float = 0.0
|
||||
max_tokens: int = 32768
|
||||
timeout_seconds: int = 120
|
||||
max_retries: int = 2
|
||||
|
||||
|
||||
@app.get("/api/agents")
|
||||
async def list_agents(active_only: bool = False):
|
||||
"""List all AI agent configurations."""
|
||||
where = "WHERE active = TRUE" if active_only else ""
|
||||
rows = await pool.fetch(
|
||||
f"""SELECT id, name, slug, purpose, model_provider, model_name,
|
||||
system_prompt, user_prompt_template, prompt_version,
|
||||
schema_version, temperature, max_tokens, timeout_seconds,
|
||||
max_retries, active, source, created_at, updated_at
|
||||
FROM ai_agents {where}
|
||||
ORDER BY source DESC, name ASC"""
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/agents/{agent_id}")
|
||||
async def get_agent(agent_id: str):
|
||||
"""Get a single agent configuration."""
|
||||
row = await pool.fetchrow(
|
||||
"""SELECT id, name, slug, purpose, model_provider, model_name,
|
||||
system_prompt, user_prompt_template, prompt_version,
|
||||
schema_version, temperature, max_tokens, timeout_seconds,
|
||||
max_retries, active, source, created_at, updated_at
|
||||
FROM ai_agents WHERE id = $1""",
|
||||
agent_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@app.post("/api/agents", status_code=201)
|
||||
async def create_agent(body: AgentCreateBody):
|
||||
"""Create a new user-defined agent."""
|
||||
row = await pool.fetchrow(
|
||||
"""INSERT INTO ai_agents (
|
||||
name, slug, purpose, model_provider, model_name,
|
||||
system_prompt, user_prompt_template, prompt_version,
|
||||
schema_version, temperature, max_tokens, timeout_seconds,
|
||||
max_retries, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, 'user')
|
||||
RETURNING id, name, slug, source, created_at""",
|
||||
body.name, body.slug, body.purpose, body.model_provider, body.model_name,
|
||||
body.system_prompt, body.user_prompt_template, body.prompt_version,
|
||||
body.schema_version, body.temperature, body.max_tokens, body.timeout_seconds,
|
||||
body.max_retries,
|
||||
)
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@app.put("/api/agents/{agent_id}")
|
||||
async def update_agent(agent_id: str, body: AgentUpdateBody):
|
||||
"""Update an agent configuration.
|
||||
|
||||
Both system and user agents can be edited. User changes are preserved
|
||||
across reinstalls because migration 026 only inserts system agents
|
||||
that don't already exist (by slug).
|
||||
"""
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
for field_name, value in body.model_dump(exclude_none=True).items():
|
||||
updates.append(f"{field_name} = ${idx}")
|
||||
params.append(value)
|
||||
idx += 1
|
||||
|
||||
if not updates:
|
||||
raise HTTPException(400, "No fields to update")
|
||||
|
||||
updates.append("updated_at = NOW()")
|
||||
set_clause = ", ".join(updates)
|
||||
params.append(agent_id)
|
||||
|
||||
row = await pool.fetchrow(
|
||||
f"""UPDATE ai_agents SET {set_clause}
|
||||
WHERE id = ${idx}
|
||||
RETURNING id, name, slug, purpose, model_provider, model_name,
|
||||
system_prompt, user_prompt_template, prompt_version,
|
||||
schema_version, temperature, max_tokens, timeout_seconds,
|
||||
max_retries, active, source, created_at, updated_at""",
|
||||
*params,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return _row_to_dict(row)
|
||||
|
||||
|
||||
@app.delete("/api/agents/{agent_id}")
|
||||
async def delete_agent(agent_id: str):
|
||||
"""Delete a user-created agent. System agents cannot be deleted."""
|
||||
row = await pool.fetchrow(
|
||||
"SELECT source FROM ai_agents WHERE id = $1", agent_id,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
if row["source"] == "system":
|
||||
raise HTTPException(403, "Cannot delete system agents — deactivate instead")
|
||||
|
||||
await pool.execute("DELETE FROM ai_agents WHERE id = $1", agent_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@app.get("/api/agents/{agent_id}/performance")
|
||||
async def get_agent_performance(agent_id: str, hours: int = Query(default=24, le=720)):
|
||||
"""Get aggregated performance metrics for an agent."""
|
||||
row = await pool.fetchrow(
|
||||
"""SELECT
|
||||
COUNT(*) AS total_invocations,
|
||||
COUNT(*) FILTER (WHERE success) AS successes,
|
||||
COUNT(*) FILTER (WHERE NOT success) AS failures,
|
||||
ROUND(AVG(duration_ms)::numeric) AS avg_duration_ms,
|
||||
ROUND(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY duration_ms)::numeric) AS p95_duration_ms,
|
||||
ROUND(AVG(confidence)::numeric, 4) AS avg_confidence,
|
||||
ROUND(AVG(retry_count)::numeric, 2) AS avg_retries,
|
||||
SUM(input_tokens) AS total_input_tokens,
|
||||
SUM(output_tokens) AS total_output_tokens
|
||||
FROM agent_performance_log
|
||||
WHERE agent_id = $1
|
||||
AND recorded_at >= NOW() - make_interval(hours => $2)""",
|
||||
agent_id, hours,
|
||||
)
|
||||
d = _row_to_dict(row) if row else {}
|
||||
total = int(d.get("total_invocations", 0) or 0)
|
||||
successes = int(d.get("successes", 0) or 0)
|
||||
d["success_rate"] = round(successes / total, 4) if total > 0 else None
|
||||
return d
|
||||
|
||||
|
||||
@app.get("/api/agents/{agent_id}/performance/history")
|
||||
async def get_agent_performance_history(
|
||||
agent_id: str,
|
||||
hours: int = Query(default=24, le=720),
|
||||
):
|
||||
"""Get hourly performance time-series for an agent."""
|
||||
rows = await pool.fetch(
|
||||
"""SELECT
|
||||
date_trunc('hour', recorded_at) AS hour,
|
||||
COUNT(*) AS invocations,
|
||||
COUNT(*) FILTER (WHERE success) AS successes,
|
||||
ROUND(AVG(duration_ms)::numeric) AS avg_duration_ms,
|
||||
ROUND(AVG(confidence)::numeric, 4) AS avg_confidence
|
||||
FROM agent_performance_log
|
||||
WHERE agent_id = $1
|
||||
AND recorded_at >= NOW() - make_interval(hours => $2)
|
||||
GROUP BY 1 ORDER BY 1""",
|
||||
agent_id, hours,
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
Reference in New Issue
Block a user