Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/(dashboard)/harnesses/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Harness>(`/api/harnesses/${id}`)

if (loading && !harness) {
return <p className="text-muted-foreground">Loading...</p>
}
if (harness && (harness.runtime === 'letta' || harness.runtime === 'letta-server')) {
return <LettaAgentDetail harness={harness} />
}
return <HermesHarnessDetail params={params} />
}

function HermesHarnessDetail({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()

Expand Down
61 changes: 44 additions & 17 deletions app/(dashboard)/harnesses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,20 @@ import { toast } from 'sonner'

export default function HarnessesPage() {
const router = useRouter()
const { data: harnesses, loading, refetch } = useApi<Harness[]>('/api/harnesses', 5000)
const { data: containerHarnesses, loading, refetch } = useApi<Harness[]>('/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<Harness[]>('/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 {
Expand Down Expand Up @@ -178,24 +189,40 @@ export default function HarnessesPage() {
<td className="px-4 py-3">
<TierBadge tier={h.tier} />
</td>
<td className="px-4 py-3 text-muted-foreground">{h.platform} / {h.channel}</td>
<td className="px-4 py-3 text-muted-foreground">
{isLetta(h) ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-xs uppercase tracking-wide">{h.runtime}</span>
) : (
<>{h.platform} / {h.channel}</>
)}
</td>
<td className="px-4 py-3 text-muted-foreground">{h.models?.[0] ?? '—'}</td>
<td className="px-4 py-3 text-right">${(h.costToday ?? 0).toFixed(2)}</td>
<td className="px-4 py-3 text-right">{h.invocations ?? 0}</td>
{/* Letta agents have no per-agent container stats (design §4b) */}
<td className="px-4 py-3 text-right">{isLetta(h) ? '—' : `$${(h.costToday ?? 0).toFixed(2)}`}</td>
<td className="px-4 py-3 text-right">{isLetta(h) ? '—' : (h.invocations ?? 0)}</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="xs" onClick={() => restartOne(h.id)} disabled={h.status === 'restarting'}>
{h.status === 'restarting' ? 'Rebuilding...' : 'Restart'}
</Button>
<Button variant="ghost" size="xs" onClick={() => stopOne(h.id)}>
Stop
</Button>
<Button variant="ghost" size="xs" onClick={() => duplicateOne(h.id, h.name)} title="Duplicate">
</Button>
<Button variant="ghost" size="xs" onClick={() => removeOne(h.id, h.name)} title="Remove" className="text-destructive hover:text-destructive">
</Button>
{isLetta(h) ? (
// No container lifecycle for a Postgres row — just open it.
<Link href={`/harnesses/${h.id}`}>
<Button variant="ghost" size="xs">Open</Button>
</Link>
) : (
<>
<Button variant="ghost" size="xs" onClick={() => restartOne(h.id)} disabled={h.status === 'restarting'}>
{h.status === 'restarting' ? 'Rebuilding...' : 'Restart'}
</Button>
<Button variant="ghost" size="xs" onClick={() => stopOne(h.id)}>
Stop
</Button>
<Button variant="ghost" size="xs" onClick={() => duplicateOne(h.id, h.name)} title="Duplicate">
</Button>
<Button variant="ghost" size="xs" onClick={() => removeOne(h.id, h.name)} title="Remove" className="text-destructive hover:text-destructive">
</Button>
</>
)}
</div>
</td>
</tr>
Expand Down
199 changes: 199 additions & 0 deletions app/(dashboard)/letta/page.tsx
Original file line number Diff line number Diff line change
@@ -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<LettaMessage[] | null>(null)
const [blocks, setBlocks] = useState<LettaBlock[] | null>(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 (
<>
<tr className="border-b border-[var(--border)] last:border-0 hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<span className="font-medium">{agent.name ?? agent.id}</span>
</td>
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{agent.id}</td>
<td className="px-4 py-3 text-muted-foreground">{agent.model ?? '—'}</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="xs" onClick={() => setOpen((o) => !o)}>
{open ? 'Close' : 'Message'}
</Button>
</td>
</tr>
{open && (
<tr className="border-b border-[var(--border)] last:border-0 bg-muted/20">
<td colSpan={4} className="px-4 py-4 space-y-3">
{/* Single action: send a message */}
<div className="flex items-start gap-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={`Message ${agent.name ?? agent.id}…`}
rows={2}
className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 py-2 text-sm resize-y"
/>
<Button size="sm" onClick={send} disabled={sending || !text.trim()}>
{sending ? 'Sending…' : 'Send'}
</Button>
</div>

{reply && (
<div className="space-y-1.5 text-sm">
{reply.map((m, i) => (
<div key={m.id ?? i} className="rounded-md border border-[var(--border)] px-3 py-2">
<span className="text-xs text-muted-foreground uppercase tracking-wide">
{m.message_type ?? m.role ?? 'message'}
</span>
<div className="mt-1 whitespace-pre-wrap">{renderContent(m.content)}</div>
</div>
))}
</div>
)}

{/* Read-only peek at core-memory blocks */}
<div>
<Button variant="ghost" size="xs" onClick={loadBlocks} disabled={blocksLoading}>
{blocksLoading ? 'Loading memory…' : blocks ? 'Reload memory blocks' : 'View memory blocks'}
</Button>
{blocks && (
<div className="mt-2 space-y-1.5">
{blocks.length === 0 && (
<p className="text-xs text-muted-foreground">No memory blocks.</p>
)}
{blocks.map((b, i) => (
<div key={b.id ?? b.label ?? i} className="rounded-md border border-[var(--border)] px-3 py-2 text-sm">
<span className="font-mono text-xs text-accent-foreground">{b.label}</span>
<div className="mt-1 whitespace-pre-wrap text-muted-foreground">{b.value}</div>
</div>
))}
</div>
)}
</div>
</td>
</tr>
)}
</>
)
}

export default function LettaPage() {
const { data: agents, loading, error, refetch } = useApi<LettaAgent[]>('/api/letta/agents', 10000)

return (
<div>
<div className="flex items-center justify-between mb-2">
<h2 className="text-2xl font-semibold">Letta agents</h2>
<Button variant="outline" size="sm" onClick={refetch}>
Refresh
</Button>
</div>
<p className="text-xs text-muted-foreground mb-6">
SPIKE (Path 1) · agents on one Letta server (REST :8283), not containers.
</p>

{loading && <p className="text-muted-foreground">Loading…</p>}

{!loading && error && (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-8 text-center space-y-2">
<p className="text-sm text-destructive">Couldn&apos;t reach the Letta server.</p>
<p className="text-xs text-muted-foreground">{error}</p>
<p className="text-xs text-muted-foreground">
Start it: <code>docker compose -f docker/letta-compose.yml -p letta up -d</code>
</p>
</div>
)}

{!loading && !error && agents && agents.length === 0 && (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] p-8 text-center">
<p className="text-sm text-muted-foreground">
No agents yet. Create one via the Letta REST API (POST /v1/agents) or the ADE.
</p>
</div>
)}

{!loading && !error && agents && agents.length > 0 && (
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-xs text-muted-foreground uppercase tracking-wide">
<th className="text-left px-4 py-3">Name</th>
<th className="text-left px-4 py-3">ID</th>
<th className="text-left px-4 py-3">Model</th>
<th className="text-right px-4 py-3">Action</th>
</tr>
</thead>
<tbody>
{agents.map((a) => (
<AgentRow key={a.id} agent={a} />
))}
</tbody>
</table>
</div>
)}
</div>
)
}
10 changes: 10 additions & 0 deletions app/api/harnesses/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
import { NextResponse } from 'next/server'
import { services } from '@/lib/services'
import { isLettaHarnessId } from '@/lib/services/letta-agent-provider'

export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
// Letta agents/server aren't containers — resolve them over REST (design §1c),
// not through the sync container discovery in HarnessService.get().
if (isLettaHarnessId(id)) {
const harness = await services.lettaAgents.get(id)
if (!harness) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(harness)
}
const harness = services.harness.get(id)
if (!harness) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
Expand Down
4 changes: 2 additions & 2 deletions app/api/harnesses/[id]/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import os from 'os'
import { buildSettingsEnvValue } from '@/lib/env-helpers'
import { resolveIdentifier, expandSignalAllowlist } from '@/lib/resolvers'
import { services } from '@/lib/services'
import { generateStandaloneCompose } from '@/lib/services/harness-compose'
import { adapterForRuntime } from '@/lib/services/harness'

function agentDataDir(harnessId: string): string {
const name = harnessId.replace(/^h_/, '').replace(/_/g, '-')
Expand Down Expand Up @@ -397,7 +397,7 @@ export async function PUT(
})()
: undefined

const compose = generateStandaloneCompose(harness.name, port, dataDir, {
const compose = adapterForRuntime(harness.runtime).generateCompose(harness.name, port, dataDir, {
vpnEnabled: vpnForCompose,
imageOrBuild,
defaultImage: settings.defaultImage,
Expand Down
19 changes: 19 additions & 0 deletions app/api/letta/agents/[id]/blocks/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// SPIKE (Path 1): read one agent's core-memory blocks — the granularity the
// "Librarian in an Airlock" use-case hangs on. Read-only.
import { NextResponse } from 'next/server'
import { services } from '@/lib/services'

export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
return NextResponse.json(await services.letta.getMemoryBlocks(id))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to read memory blocks' },
{ status: 502 },
)
}
}
24 changes: 24 additions & 0 deletions app/api/letta/agents/[id]/messages/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPIKE (Path 1): send a message to one Letta agent. The single "action" of the
// otherwise read-only view.
import { NextResponse } from 'next/server'
import { services } from '@/lib/services'

export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
try {
const body = await request.json().catch(() => ({}))
const text = typeof body?.text === 'string' ? body.text.trim() : ''
if (!text) {
return NextResponse.json({ error: 'Message text is required' }, { status: 400 })
}
return NextResponse.json(await services.letta.sendMessage(id, text))
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : 'Failed to send message' },
{ status: 502 },
)
}
}
Loading