diff --git a/app/(dashboard)/harnesses/[id]/page.tsx b/app/(dashboard)/harnesses/[id]/page.tsx index 22db863..1212e32 100644 --- a/app/(dashboard)/harnesses/[id]/page.tsx +++ b/app/(dashboard)/harnesses/[id]/page.tsx @@ -26,6 +26,7 @@ import { MessageSquare, Globe, Bot, Hash, Pencil, ChevronDown, ChevronRight, Shi import { TagInput } from '@/components/ui/tag-input' import { Switch } from '@/components/ui/switch' import { TIER_LABELS } from '@/lib/constants' +import { LettaAgentDetail } from '@/components/harness/letta-agent-detail' type PairingUser = { userId: string @@ -122,7 +123,25 @@ type UsageData = { recentSessions: UsageSession[] } +// Runtime-branching wrapper (design §4a). Letta harnesses have no container, so +// their detail view is a dedicated read-only component rather than a thicket of +// `runtime === 'letta'` branches through the container-shaped Hermes page below. +// Fetching harness here (before branching) keeps rules-of-hooks intact — each +// child owns its own hooks. export default function HarnessDetailPage({ params }: { params: Promise<{ id: string }> }) { + const { id } = use(params) + const { data: harness, loading } = useApi(`/api/harnesses/${id}`) + + if (loading && !harness) { + return

Loading...

+ } + if (harness && (harness.runtime === 'letta' || harness.runtime === 'letta-server')) { + return + } + return +} + +function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params) const router = useRouter() diff --git a/app/(dashboard)/harnesses/page.tsx b/app/(dashboard)/harnesses/page.tsx index 2c75dfd..4f492fb 100644 --- a/app/(dashboard)/harnesses/page.tsx +++ b/app/(dashboard)/harnesses/page.tsx @@ -11,9 +11,20 @@ import { toast } from 'sonner' export default function HarnessesPage() { const router = useRouter() - const { data: harnesses, loading, refetch } = useApi('/api/harnesses', 5000) + const { data: containerHarnesses, loading, refetch } = useApi('/api/harnesses', 5000) + // Letta agents come from a separate async REST path (design §1c) — merge them + // into the fleet list. Failure is soft: no Letta server → just no Letta rows. + const { data: lettaHarnesses } = useApi('/api/letta/harnesses', 10000) - const running = harnesses?.filter((h) => h.status === 'running') ?? [] + const harnesses = + containerHarnesses || lettaHarnesses + ? [...(containerHarnesses ?? []), ...(Array.isArray(lettaHarnesses) ? lettaHarnesses : [])] + : undefined + + // Only container harnesses can be bulk-restarted (Letta agents aren't containers). + const running = containerHarnesses?.filter((h) => h.status === 'running') ?? [] + + const isLetta = (h: Harness) => h.runtime === 'letta' || h.runtime === 'letta-server' async function restartAll() { try { @@ -178,24 +189,40 @@ export default function HarnessesPage() { - {h.platform} / {h.channel} + + {isLetta(h) ? ( + {h.runtime} + ) : ( + <>{h.platform} / {h.channel} + )} + {h.models?.[0] ?? '—'} - ${(h.costToday ?? 0).toFixed(2)} - {h.invocations ?? 0} + {/* Letta agents have no per-agent container stats (design §4b) */} + {isLetta(h) ? '—' : `$${(h.costToday ?? 0).toFixed(2)}`} + {isLetta(h) ? '—' : (h.invocations ?? 0)}
- - - - + {isLetta(h) ? ( + // No container lifecycle for a Postgres row — just open it. + + + + ) : ( + <> + + + + + + )}
diff --git a/app/(dashboard)/letta/page.tsx b/app/(dashboard)/letta/page.tsx new file mode 100644 index 0000000..eaf55e0 --- /dev/null +++ b/app/(dashboard)/letta/page.tsx @@ -0,0 +1,199 @@ +'use client' + +/** + * SPIKE (Path 1) — read-only "Letta agents" view. See spec §4 steps 2–3. + * + * Proves the "agents-as-API-resources" shape distinct from HSM's container + * model: it lists agents from one Letta *server* (GET /v1/agents), lets you send + * one message (the single action), and peek at an agent's core-memory blocks. + * No create/delete UI, no per-agent lifecycle buttons — those don't exist for + * Letta agents (they're rows in Postgres, not containers). + */ + +import { useState } from 'react' +import { useApi } from '@/lib/hooks/use-api' +import { Button } from '@/components/ui/button' +import type { LettaAgent, LettaBlock, LettaMessage } from '@/lib/services/letta' +import { toast } from 'sonner' + +/** Best-effort stringify of a Letta message's `content` (spike-loose typing). */ +function renderContent(content: unknown): string { + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((c) => (typeof c === 'string' ? c : (c as { text?: string })?.text ?? JSON.stringify(c))) + .join('') + } + return content == null ? '' : JSON.stringify(content) +} + +function AgentRow({ agent }: { agent: LettaAgent }) { + const [open, setOpen] = useState(false) + const [text, setText] = useState('') + const [sending, setSending] = useState(false) + const [reply, setReply] = useState(null) + const [blocks, setBlocks] = useState(null) + const [blocksLoading, setBlocksLoading] = useState(false) + + async function send() { + if (!text.trim()) return + setSending(true) + setReply(null) + try { + const res = await fetch(`/api/letta/agents/${agent.id}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text.trim() }), + }) + const json = await res.json() + if (!res.ok) throw new Error(json?.error ?? 'Send failed') + setReply(json.messages ?? []) + setText('') + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Send failed') + } finally { + setSending(false) + } + } + + async function loadBlocks() { + setBlocksLoading(true) + try { + const res = await fetch(`/api/letta/agents/${agent.id}/blocks`) + const json = await res.json() + if (!res.ok) throw new Error(json?.error ?? 'Failed to read memory') + setBlocks(json) + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to read memory') + } finally { + setBlocksLoading(false) + } + } + + return ( + <> + + + {agent.name ?? agent.id} + + {agent.id} + {agent.model ?? '—'} + + + + + {open && ( + + + {/* Single action: send a message */} +
+