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
+724 -3
View File
@@ -1,9 +1,9 @@
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut, apiDelete } from '../api/client';
import { apiGet, apiPost, apiPut, apiDelete, ApiError } from '../api/client';
import { Card, LoadingSpinner, StatusBadge } from '../components/ui';
import {
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, Legend,
} from 'recharts';
// ---------------------------------------------------------------------------
@@ -52,6 +52,29 @@ interface PerfHistoryPoint {
avg_confidence: number;
}
interface AgentVariant {
id: string;
agent_id: string;
variant_name: string;
variant_slug: string;
description: string;
model_provider: string;
model_name: string;
system_prompt: string;
user_prompt_template: string;
prompt_version: string;
temperature: number;
max_tokens: number;
context_window: number;
input_token_limit: number;
token_budget: number;
timeout_seconds: number;
max_retries: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
@@ -79,6 +102,82 @@ function useAgentPerfHistory(agentId: string | undefined, hours = 24) {
});
}
// -- Variant query hooks --
function useAgentVariants(agentId: string | undefined) {
return useQuery<AgentVariant[]>({
queryKey: ['agent-variants', agentId],
queryFn: () => apiGet<AgentVariant[]>('query', `/api/agents/${agentId}/variants`),
enabled: !!agentId,
});
}
export function useVariantPerformance(agentId: string, variantId: string, hours = 24) {
return useQuery<AgentPerformance>({
queryKey: ['variant-performance', agentId, variantId, hours],
queryFn: () => apiGet<AgentPerformance>('query', `/api/agents/${agentId}/variants/${variantId}/performance?hours=${hours}`),
enabled: !!agentId && !!variantId,
});
}
export function useVariantPerfHistory(agentId: string, variantId: string, hours = 24) {
return useQuery<PerfHistoryPoint[]>({
queryKey: ['variant-perf-history', agentId, variantId, hours],
queryFn: () => apiGet<PerfHistoryPoint[]>('query', `/api/agents/${agentId}/variants/${variantId}/performance/history?hours=${hours}`),
enabled: !!agentId && !!variantId,
});
}
// -- Variant mutation hooks --
function useCloneAgentAsVariant(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/clone`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
export function useCreateVariant(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useUpdateVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Record<string, unknown>) => apiPut<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useDeleteVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agentId}/variants/${variantId}`),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
function useActivateVariant(agentId: string, variantId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${variantId}/activate`, {}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
export function useDeactivateVariants(agentId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => apiPost<unknown>('query', `/api/agents/${agentId}/variants/deactivate`, {}),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
}
// ---------------------------------------------------------------------------
// Page Component
// ---------------------------------------------------------------------------
@@ -162,6 +261,36 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
const qc = useQueryClient();
const { data: perf } = useAgentPerformance(agent.id);
const { data: history } = useAgentPerfHistory(agent.id);
const { data: variants } = useAgentVariants(agent.id);
// Variant UI state
const [variantView, setVariantView] = useState<
| { mode: 'list' }
| { mode: 'clone-agent' }
| { mode: 'clone-variant'; source: AgentVariant }
| { mode: 'edit-variant'; variant: AgentVariant }
| { mode: 'delete-confirm'; variant: AgentVariant }
>({ mode: 'list' });
// Comparison selection state
const [selectedVariantIds, setSelectedVariantIds] = useState<Set<string>>(new Set());
// Clear selection when variants change (e.g., after activation, deletion)
useEffect(() => {
setSelectedVariantIds(new Set());
}, [variants]);
const toggleVariantSelection = useCallback((variantId: string) => {
setSelectedVariantIds((prev) => {
const next = new Set(prev);
if (next.has(variantId)) {
next.delete(variantId);
} else {
next.add(variantId);
}
return next;
});
}, []);
const deleteMut = useMutation({
mutationFn: () => apiDelete<unknown>('query', `/api/agents/${agent.id}`),
@@ -188,6 +317,7 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
</span>
</div>
<div className="flex gap-2">
<button onClick={() => setVariantView({ mode: 'clone-agent' })} className="rounded-md border border-brand-500/50 px-3 py-1.5 text-sm font-medium text-brand-300 hover:bg-brand-600/20">Clone as Variant</button>
<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>
@@ -258,10 +388,601 @@ function AgentDetail({ agent, onEdit, onDeleted }: { agent: Agent; onEdit: () =>
</ResponsiveContainer>
</Card>
)}
{/* Variant Section */}
{variantView.mode === 'clone-agent' && (
<VariantCloneForm
agentId={agent.id}
source={{
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,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
context_window: 0,
input_token_limit: 0,
token_budget: 0,
timeout_seconds: agent.timeout_seconds,
max_retries: agent.max_retries,
}}
sourceType="agent"
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'clone-variant' && (
<VariantCloneForm
agentId={agent.id}
source={variantView.source}
sourceType="variant"
sourceVariantId={variantView.source.id}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'edit-variant' && (
<VariantEditForm
agentId={agent.id}
variant={variantView.variant}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
{variantView.mode === 'delete-confirm' && (
<DeleteVariantDialog
agentId={agent.id}
variant={variantView.variant}
onDone={() => setVariantView({ mode: 'list' })}
/>
)}
<VariantList
agentId={agent.id}
variants={variants ?? []}
selectedIds={selectedVariantIds}
onToggleSelect={toggleVariantSelection}
onClone={(v) => setVariantView({ mode: 'clone-variant', source: v })}
onEdit={(v) => setVariantView({ mode: 'edit-variant', variant: v })}
onDelete={(v) => setVariantView({ mode: 'delete-confirm', variant: v })}
/>
{selectedVariantIds.size >= 2 && (
<VariantCompare
agentId={agent.id}
variants={(variants ?? []).filter((v) => selectedVariantIds.has(v.id))}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Variant List
// ---------------------------------------------------------------------------
function VariantList({ agentId, variants, selectedIds, onToggleSelect, onClone, onEdit, onDelete }: {
agentId: string;
variants: AgentVariant[];
selectedIds: Set<string>;
onToggleSelect: (id: string) => void;
onClone: (v: AgentVariant) => void;
onEdit: (v: AgentVariant) => void;
onDelete: (v: AgentVariant) => void;
}) {
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Variants ({variants.length})</h2>
{variants.length === 0 ? (
<p className="text-xs text-gray-500">No variants yet. Clone this agent to create one.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700 text-left text-xs text-gray-500">
<th className="pb-2 pr-2 w-8">
<span className="sr-only">Select</span>
</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Model</th>
<th className="pb-2 pr-4">Status</th>
<th className="pb-2 pr-4">Created</th>
<th className="pb-2 text-right">Actions</th>
</tr>
</thead>
<tbody>
{variants.map((v) => (
<VariantRow
key={v.id}
agentId={agentId}
variant={v}
selected={selectedIds.has(v.id)}
onToggleSelect={onToggleSelect}
onClone={onClone}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</tbody>
</table>
{selectedIds.size > 0 && selectedIds.size < 2 && (
<p className="mt-2 text-[10px] text-gray-500">Select at least 2 variants to compare.</p>
)}
</div>
)}
</Card>
);
}
function VariantRow({ agentId, variant, selected, onToggleSelect, onClone, onEdit, onDelete }: {
agentId: string;
variant: AgentVariant;
selected: boolean;
onToggleSelect: (id: string) => void;
onClone: (v: AgentVariant) => void;
onEdit: (v: AgentVariant) => void;
onDelete: (v: AgentVariant) => void;
}) {
const activateMut = useActivateVariant(agentId, variant.id);
return (
<tr className={`border-b border-surface-800 ${variant.is_active ? 'bg-green-900/10' : ''}`}>
<td className="py-2 pr-2">
<input
type="checkbox"
checked={selected}
onChange={() => onToggleSelect(variant.id)}
className="rounded border-surface-600 bg-surface-950 text-brand-500 focus:ring-brand-500 focus:ring-offset-0 h-3.5 w-3.5"
/>
</td>
<td className="py-2 pr-4">
<span className="font-medium text-gray-200">{variant.variant_name}</span>
{variant.description && <p className="text-[10px] text-gray-500 truncate max-w-[200px]">{variant.description}</p>}
</td>
<td className="py-2 pr-4 font-mono text-xs text-gray-400">{variant.model_name}</td>
<td className="py-2 pr-4">
{variant.is_active ? <StatusBadge status="active" /> : <span className="text-xs text-gray-600">inactive</span>}
</td>
<td className="py-2 pr-4 text-xs text-gray-500">{new Date(variant.created_at).toLocaleDateString()}</td>
<td className="py-2 text-right">
<div className="flex justify-end gap-1">
<button onClick={() => onEdit(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Edit</button>
<button onClick={() => onClone(variant)} className="rounded px-2 py-0.5 text-[10px] text-gray-400 hover:bg-surface-800 hover:text-gray-200">Clone</button>
<button onClick={() => onDelete(variant)} className="rounded px-2 py-0.5 text-[10px] text-red-400 hover:bg-red-900/20">Delete</button>
<button
onClick={() => activateMut.mutate()}
disabled={variant.is_active || activateMut.isPending}
className="rounded px-2 py-0.5 text-[10px] font-medium text-brand-400 hover:bg-brand-600/20 disabled:opacity-30 disabled:cursor-not-allowed"
>
{activateMut.isPending ? '…' : 'Activate'}
</button>
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// Variant Comparison View
// ---------------------------------------------------------------------------
const COMPARE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16'];
function VariantCompareColumn({ agentId, variant, color }: {
agentId: string;
variant: AgentVariant;
color: string;
}) {
const { data: perf } = useVariantPerformance(agentId, variant.id);
const activateMut = useActivateVariant(agentId, variant.id);
return (
<td className="px-3 py-2 text-center text-sm">
<div className="mb-2">
<span className="font-medium text-gray-200">{variant.variant_name}</span>
<span className="ml-2 text-[10px] font-mono text-gray-500">{variant.model_name}</span>
{variant.is_active && <StatusBadge status="active" />}
</div>
<div className="space-y-1.5 text-xs">
<div>
<span className="text-gray-500">Success Rate</span>
<div className={`font-bold ${perf?.success_rate != null && perf.success_rate >= 0.95 ? 'text-green-400' : 'text-yellow-400'}`}>
{perf?.success_rate != null ? `${(perf.success_rate * 100).toFixed(1)}%` : '—'}
</div>
</div>
<div>
<span className="text-gray-500">Avg Latency</span>
<div className="text-gray-200">{perf?.avg_duration_ms != null ? `${Math.round(perf.avg_duration_ms)}ms` : '—'}</div>
</div>
<div>
<span className="text-gray-500">P95 Latency</span>
<div className="text-gray-200">{perf?.p95_duration_ms != null ? `${Math.round(perf.p95_duration_ms)}ms` : '—'}</div>
</div>
<div>
<span className="text-gray-500">Avg Confidence</span>
<div className="text-gray-200">{perf?.avg_confidence != null ? `${(perf.avg_confidence * 100).toFixed(0)}%` : '—'}</div>
</div>
<div>
<span className="text-gray-500">Total Tokens</span>
<div className="text-gray-200">
{perf?.total_input_tokens != null || perf?.total_output_tokens != null
? ((perf.total_input_tokens ?? 0) + (perf.total_output_tokens ?? 0)).toLocaleString()
: '—'}
</div>
</div>
</div>
{!variant.is_active && (
<button
onClick={() => activateMut.mutate()}
disabled={activateMut.isPending}
className="mt-3 rounded-md px-3 py-1 text-[10px] font-medium text-white hover:opacity-90 disabled:opacity-30"
style={{ backgroundColor: color }}
>
{activateMut.isPending ? 'Activating…' : 'Activate'}
</button>
)}
</td>
);
}
function VariantCompareChart({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
// Fetch history for each selected variant
const historyQueries = variants.map((v) =>
// eslint-disable-next-line react-hooks/rules-of-hooks
useVariantPerfHistory(agentId, v.id)
);
// Merge all history data into a unified time-series keyed by hour
const hourMap = new Map<string, Record<string, number | string>>();
variants.forEach((v, idx) => {
const history = historyQueries[idx].data ?? [];
for (const pt of history) {
const hourKey = new Date(pt.hour).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (!hourMap.has(hourKey)) {
hourMap.set(hourKey, { hour: hourKey });
}
const entry = hourMap.get(hourKey)!;
entry[`sr_${v.id}`] = pt.invocations > 0 ? Math.round((pt.successes / pt.invocations) * 100) : 0;
entry[`lat_${v.id}`] = Math.round(pt.avg_duration_ms);
}
});
const chartData = Array.from(hourMap.values());
if (chartData.length < 2) return null;
return (
<div className="mt-4">
<h3 className="mb-2 text-xs font-medium text-gray-500">Success Rate Over Time</h3>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="hour" tick={{ fill: '#6b7280', fontSize: 10 }} />
<YAxis tick={{ fill: '#6b7280', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155', borderRadius: 8 }} />
<Legend />
{variants.map((v, idx) => (
<Line
key={v.id}
type="monotone"
dataKey={`sr_${v.id}`}
name={v.variant_name}
stroke={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
function VariantCompare({ agentId, variants }: { agentId: string; variants: AgentVariant[] }) {
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Variant Comparison ({variants.length} selected)</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-surface-700">
<th className="pb-2 pr-4 text-left text-xs text-gray-500">Metric</th>
{variants.map((v, idx) => (
<th key={v.id} className="pb-2 px-3 text-center text-xs" style={{ color: COMPARE_COLORS[idx % COMPARE_COLORS.length] }}>
{v.variant_name}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="py-1 pr-4 text-xs text-gray-500">Metrics</td>
{variants.map((v, idx) => (
<VariantCompareColumn
key={v.id}
agentId={agentId}
variant={v}
color={COMPARE_COLORS[idx % COMPARE_COLORS.length]}
/>
))}
</tr>
</tbody>
</table>
</div>
<VariantCompareChart agentId={agentId} variants={variants} />
</Card>
);
}
// ---------------------------------------------------------------------------
// Variant Clone Form
// ---------------------------------------------------------------------------
interface VariantConfigSource {
model_provider: string;
model_name: string;
system_prompt: string;
user_prompt_template: string;
prompt_version: string;
temperature: number;
max_tokens: number;
context_window: number;
input_token_limit: number;
token_budget: number;
timeout_seconds: number;
max_retries: number;
}
function VariantCloneForm({ agentId, source, sourceType, sourceVariantId, onDone }: {
agentId: string;
source: VariantConfigSource;
sourceType: 'agent' | 'variant';
sourceVariantId?: string;
onDone: () => void;
}) {
const [form, setForm] = useState({
variant_name: '',
description: '',
model_provider: source.model_provider,
model_name: source.model_name,
system_prompt: source.system_prompt,
user_prompt_template: source.user_prompt_template,
prompt_version: source.prompt_version,
temperature: source.temperature,
max_tokens: source.max_tokens,
context_window: source.context_window,
input_token_limit: source.input_token_limit,
token_budget: source.token_budget,
timeout_seconds: source.timeout_seconds,
max_retries: source.max_retries,
});
const cloneAgentMut = useCloneAgentAsVariant(agentId);
const qc = useQueryClient();
// For cloning from a variant, we use a direct mutation since the hook needs variantId
const cloneVariantMut = useMutation({
mutationFn: (body: Record<string, unknown>) => apiPost<AgentVariant>('query', `/api/agents/${agentId}/variants/${sourceVariantId}/clone`, body),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['agent-variants'] }); },
});
const mutation = sourceType === 'agent' ? cloneAgentMut : cloneVariantMut;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form, { onSuccess: () => onDone() });
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">
Clone {sourceType === 'agent' ? 'Agent' : 'Variant'} as New Variant
</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormRow label="Variant Name">
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_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" required />
</FormRow>
<FormRow label="Description">
<input value={form.description} onChange={(e) => setForm({ ...form, description: 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="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" required />
</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-24 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-20 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="grid grid-cols-3 gap-3">
<FormRow label="Context Window (0 = default)">
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: 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="Input Token Limit (0 = unlimited)">
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: 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="Token Budget/hr (0 = unlimited)">
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: 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>
<FormRow label="Prompt Version">
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: 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 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 ? 'Cloning…' : 'Clone'}
</button>
<button type="button" onClick={onDone} 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 clone: {(mutation.error as ApiError)?.status === 409 ? 'Slug already exists' : 'Unknown error'}</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Variant Edit Form
// ---------------------------------------------------------------------------
function VariantEditForm({ agentId, variant, onDone }: {
agentId: string;
variant: AgentVariant;
onDone: () => void;
}) {
const [form, setForm] = useState({
variant_name: variant.variant_name,
description: variant.description,
model_provider: variant.model_provider,
model_name: variant.model_name,
system_prompt: variant.system_prompt,
user_prompt_template: variant.user_prompt_template,
prompt_version: variant.prompt_version,
temperature: variant.temperature,
max_tokens: variant.max_tokens,
context_window: variant.context_window,
input_token_limit: variant.input_token_limit,
token_budget: variant.token_budget,
timeout_seconds: variant.timeout_seconds,
max_retries: variant.max_retries,
});
const mutation = useUpdateVariant(agentId, variant.id);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
mutation.mutate(form, { onSuccess: () => onDone() });
}
return (
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">Edit Variant: {variant.variant_name}</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormRow label="Variant Name">
<input value={form.variant_name} onChange={(e) => setForm({ ...form, variant_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" required />
</FormRow>
<FormRow label="Description">
<input value={form.description} onChange={(e) => setForm({ ...form, description: 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="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" required />
</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-24 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-20 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="grid grid-cols-3 gap-3">
<FormRow label="Context Window (0 = default)">
<input type="number" min="0" value={form.context_window} onChange={(e) => setForm({ ...form, context_window: 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="Input Token Limit (0 = unlimited)">
<input type="number" min="0" value={form.input_token_limit} onChange={(e) => setForm({ ...form, input_token_limit: 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="Token Budget/hr (0 = unlimited)">
<input type="number" min="0" value={form.token_budget} onChange={(e) => setForm({ ...form, token_budget: 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>
<FormRow label="Prompt Version">
<input value={form.prompt_version} onChange={(e) => setForm({ ...form, prompt_version: 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 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={onDone} 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 variant</p>}
</form>
</Card>
);
}
// ---------------------------------------------------------------------------
// Delete Variant Dialog
// ---------------------------------------------------------------------------
function DeleteVariantDialog({ agentId, variant, onDone }: {
agentId: string;
variant: AgentVariant;
onDone: () => void;
}) {
const mutation = useDeleteVariant(agentId, variant.id);
const [error, setError] = useState<string | null>(null);
function handleDelete() {
setError(null);
mutation.mutate(undefined, {
onSuccess: () => onDone(),
onError: (err) => {
if (err instanceof ApiError && err.status === 400) {
setError('Cannot delete the active variant. Deactivate it first or activate a different variant.');
} else {
setError('Failed to delete variant.');
}
},
});
}
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-red-400">Delete Variant</h2>
<p className="text-sm text-gray-300 mb-1">
Are you sure you want to delete <span className="font-medium text-gray-100">{variant.variant_name}</span>?
</p>
<p className="text-xs text-gray-500 mb-4">This action cannot be undone. Associated performance log entries will be unlinked.</p>
{error && <p className="text-xs text-red-400 mb-3">{error}</p>}
<div className="flex gap-2">
<button onClick={handleDelete} disabled={mutation.isPending} className="rounded-md bg-red-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
{mutation.isPending ? 'Deleting…' : 'Delete'}
</button>
<button onClick={onDone} className="rounded-md border border-surface-700 px-4 py-1.5 text-sm text-gray-400 hover:bg-surface-800">Cancel</button>
</div>
</Card>
);
}
// ---------------------------------------------------------------------------
// Edit Form
// ---------------------------------------------------------------------------