feat: add interactive control session and tool UI components

- Add global control session API (/api/control/*) for interactive agent chat
- Add Tool UI components (OptionList) for structured frontend interactions
- Add frontend tool hub for awaiting user responses to tool calls
- Update executor to support cancellation and control events
- Add deployment documentation to cursor rules
- Configure dashboard to run on port 3001 (backend on 3000)
- Add files page and improve console with tabbed interface
- Add LLM error handling module
- Update retry strategy with better failure analysis
This commit is contained in:
Thomas Marchand
2025-12-15 21:37:20 +00:00
parent 7dbbfbe1c9
commit 8415ec6d80
47 changed files with 4416 additions and 827 deletions

View File

@@ -391,6 +391,36 @@ MEMORY_RERANK_MODEL - Optional. Default: qwen/qwen3-reranker-8b
- **Budget fallback**: `anthropic/claude-3.5-haiku` - Fast, cheap, good for simple tasks
- **Complex tasks**: `anthropic/claude-opus-4.5` - Highest capability when needed
## Deployment
### Production Server
- **Host**: `95.216.112.253`
- **SSH Access**: `ssh root@95.216.112.253` (key-based auth)
- **Backend URL**: `https://agent-backend.thomas.md` (proxied to localhost:3000)
- **Dashboard URL**: `https://agent.thomas.md` (Vercel deployment)
- **Environment files**: `/etc/open_agent/open_agent.env`
- **Service**: `systemctl status open_agent` (runs as systemd service)
- **Binary**: `/usr/local/bin/open_agent`
### Local Development
- **Backend API**: `http://127.0.0.1:3000` (Rust server via `cargo run`)
- **Dashboard**: `http://127.0.0.1:3001` (Next.js via `bun run dev`)
- **Environment files**:
- Backend: `.env` in project root
- Dashboard: `dashboard/.env.local`
### Accessing Environment Variables
The cursor agent has SSH access to the production server and can:
- Read/modify env variables at `/etc/open_agent/open_agent.env`
- Restart services: `systemctl restart open_agent`
- Check logs: `journalctl -u open_agent -f`
### Port Configuration
| Service | Local Port | Production URL |
|---------|-----------|----------------|
| Backend API | 3000 | https://agent-backend.thomas.md |
| Dashboard | 3001 | https://agent.thomas.md |
## Security Considerations
This agent has **full machine access**. It can:

View File

@@ -142,6 +142,20 @@ PORT - Optional. Default: 3000
MAX_ITERATIONS - Optional. Default: 50
```
## Deployment
### Production Server
- **Host**: `95.216.112.253`
- **SSH Access**: `ssh root@95.216.112.253` (key-based auth available to agent)
- **Environment files**: Located in `/root/open_agent/.env` on the server
### Local Development
- **Backend API**: `http://127.0.0.1:3000` (Rust server via `cargo run`)
- **Dashboard**: `http://127.0.0.1:3001` (Next.js via `bun run dev`)
- **Environment files**:
- Backend: `.env` in project root
- Dashboard: `dashboard/.env.local`
## Security Considerations
This agent has **full machine access**. It can:

View File

@@ -17,6 +17,7 @@
"tailwind-merge": "^3.4.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"zod": "^4.2.0",
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

@@ -4,7 +4,7 @@
"private": true,
"packageManager": "bun@1.3.4",
"scripts": {
"dev": "next dev",
"dev": "next dev --port 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
@@ -18,10 +18,11 @@
"next": "16.0.10",
"react": "19.2.1",
"react-dom": "19.2.1",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"recharts": "^3.6.0",
"tailwind-merge": "^3.4.0"
"zod": "^4.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
'use client';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
import {
createTask,
getTask,
stopTask,
streamTask,
TaskLogEntry,
cancelControl,
postControlMessage,
postControlToolResult,
streamControl,
type ControlRunState,
} from '@/lib/api';
import {
Send,
@@ -16,33 +15,75 @@ import {
Bot,
User,
Loader,
Terminal,
CheckCircle,
XCircle,
Code,
FileText,
Ban,
Clock,
} from 'lucide-react';
import {
OptionList,
OptionListErrorBoundary,
parseSerializableOptionList,
type OptionListSelection,
} from '@/components/tool-ui/option-list';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: Date;
status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
logs?: TaskLogEntry[];
type ChatItem =
| {
kind: 'user';
id: string;
content: string;
}
| {
kind: 'assistant';
id: string;
content: string;
success: boolean;
costCents: number;
model: string | null;
}
| {
kind: 'tool';
id: string;
toolCallId: string;
name: string;
args: unknown;
result?: unknown;
}
| {
kind: 'system';
id: string;
content: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function statusLabel(state: ControlRunState): {
label: string;
Icon: typeof Loader;
className: string;
} {
switch (state) {
case 'idle':
return { label: 'Idle', Icon: Clock, className: 'text-[var(--foreground-muted)]' };
case 'running':
return { label: 'Running', Icon: Loader, className: 'text-[var(--accent)]' };
case 'waiting_for_tool':
return { label: 'Waiting', Icon: Loader, className: 'text-[var(--warning)]' };
}
}
export default function ControlClient() {
const searchParams = useSearchParams();
const [messages, setMessages] = useState<Message[]>([]);
const [items, setItems] = useState<ChatItem[]>([]);
const [input, setInput] = useState('');
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [runState, setRunState] = useState<ControlRunState>('idle');
const [queueLen, setQueueLen] = useState(0);
const isBusy = runState !== 'idle';
const messagesEndRef = useRef<HTMLDivElement>(null);
const loadedFromUrlTaskIdRef = useRef<string | null>(null);
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
const streamCleanupRef = useRef<null | (() => void)>(null);
const scrollToBottom = () => {
@@ -51,424 +92,365 @@ export default function ControlClient() {
useEffect(() => {
scrollToBottom();
}, [messages]);
}, [items]);
useEffect(() => {
streamCleanupRef.current?.();
const cleanup = streamControl((event) => {
const data: unknown = event.data;
if (event.type === 'status' && isRecord(data)) {
const st = data['state'];
setRunState(typeof st === 'string' ? (st as ControlRunState) : 'idle');
const q = data['queue_len'];
setQueueLen(typeof q === 'number' ? q : 0);
return;
}
if (event.type === 'user_message' && isRecord(data)) {
setItems((prev) => [
...prev,
{
kind: 'user',
id: String(data['id'] ?? Date.now()),
content: String(data['content'] ?? ''),
},
]);
return;
}
if (event.type === 'assistant_message' && isRecord(data)) {
setItems((prev) => [
...prev,
{
kind: 'assistant',
id: String(data['id'] ?? Date.now()),
content: String(data['content'] ?? ''),
success: Boolean(data['success']),
costCents: Number(data['cost_cents'] ?? 0),
model: data['model'] ? String(data['model']) : null,
},
]);
return;
}
if (event.type === 'tool_call' && isRecord(data)) {
const name = String(data['name'] ?? '');
if (!name.startsWith('ui_')) return;
setItems((prev) => [
...prev,
{
kind: 'tool',
id: `tool-${String(data['tool_call_id'] ?? Date.now())}`,
toolCallId: String(data['tool_call_id'] ?? ''),
name,
args: data['args'],
},
]);
return;
}
if (event.type === 'tool_result' && isRecord(data)) {
const name = String(data['name'] ?? '');
if (!name.startsWith('ui_')) return;
const toolCallId = String(data['tool_call_id'] ?? '');
setItems((prev) =>
prev.map((it) =>
it.kind === 'tool' && it.toolCallId === toolCallId
? { ...it, result: data['result'] }
: it,
),
);
return;
}
if (event.type === 'error') {
const msg =
(isRecord(data) && data['message'] ? String(data['message']) : null) ??
'An error occurred.';
setItems((prev) => [
...prev,
{ kind: 'system', id: `err-${Date.now()}`, content: msg },
]);
}
});
streamCleanupRef.current = cleanup;
return () => {
// Ensure we don't leak SSE connections when navigating away.
streamCleanupRef.current?.();
streamCleanupRef.current = null;
};
}, []);
const loadTask = useCallback(async (taskId: string) => {
try {
const task = await getTask(taskId);
const userMessage: Message = {
id: `user-${taskId}`,
role: 'user',
content: task.task,
timestamp: new Date(),
};
const assistantMessage: Message = {
id: `assistant-${taskId}`,
role: 'assistant',
content: task.result || 'Processing...',
timestamp: new Date(),
status: task.status,
logs: task.log,
};
setMessages([userMessage, assistantMessage]);
const shouldStream = task.status === 'running' || task.status === 'pending';
setCurrentTaskId(shouldStream ? taskId : null);
// If the task is still running, attach streaming so the UI keeps updating (and Stop button is visible).
if (shouldStream) {
setIsLoading(true);
streamCleanupRef.current?.();
const cleanup = streamTask(taskId, (event) => {
if (event.type === 'log') {
const logEntry = event.data as TaskLogEntry;
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${taskId}` ? { ...m, logs: [...(m.logs || []), logEntry] } : m
)
);
} else if (event.type === 'done') {
const doneData = event.data as { status: string; result: string | null };
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${taskId}`
? {
...m,
content: doneData.result || 'Task completed',
status: doneData.status as Message['status'],
}
: m
)
);
setCurrentTaskId(null);
setIsLoading(false);
streamCleanupRef.current = null;
} else if (event.type === 'error') {
const err = event.data as { message?: string; status?: number };
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${taskId}`
? { ...m, content: err?.message || 'Streaming failed', status: 'failed' }
: m
)
);
setCurrentTaskId(null);
setIsLoading(false);
streamCleanupRef.current = null;
}
});
streamCleanupRef.current = cleanup;
} else {
setIsLoading(false);
streamCleanupRef.current?.();
streamCleanupRef.current = null;
}
} catch (error) {
console.error('Failed to load task:', error);
}
}, []);
// Load task from URL if provided
useEffect(() => {
const taskId = searchParams.get('task');
if (!taskId) return;
if (loadedFromUrlTaskIdRef.current === taskId) return;
loadedFromUrlTaskIdRef.current = taskId;
// eslint-disable-next-line react-hooks/set-state-in-effect
void loadTask(taskId);
}, [searchParams, loadTask]);
const status = useMemo(() => statusLabel(runState), [runState]);
const StatusIcon = status.Icon;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
const content = input.trim();
if (!content) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const response = await createTask({ task: input });
setCurrentTaskId(response.id);
const assistantMessage: Message = {
id: `assistant-${response.id}`,
role: 'assistant',
content: 'Processing your request...',
timestamp: new Date(),
status: 'running',
logs: [],
};
setMessages((prev) => [...prev, assistantMessage]);
// Start streaming
// Abort any previous stream.
streamCleanupRef.current?.();
const cleanup = streamTask(response.id, (event) => {
if (event.type === 'log') {
const logEntry = event.data as TaskLogEntry;
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${response.id}`
? { ...m, logs: [...(m.logs || []), logEntry] }
: m
)
);
} else if (event.type === 'done') {
const doneData = event.data as { status: string; result: string | null };
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${response.id}`
? {
...m,
content: doneData.result || 'Task completed',
status: doneData.status as Message['status'],
}
: m
)
);
setCurrentTaskId(null);
setIsLoading(false);
streamCleanupRef.current = null;
} else if (event.type === 'error') {
const err = event.data as { message?: string; status?: number };
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${response.id}`
? {
...m,
content: err?.message || 'Streaming failed',
status: 'failed',
}
: m
)
);
setCurrentTaskId(null);
setIsLoading(false);
streamCleanupRef.current = null;
}
});
streamCleanupRef.current = cleanup;
} catch (error) {
console.error('Failed to create task:', error);
setMessages((prev) => [
await postControlMessage(content);
} catch (err) {
console.error(err);
setItems((prev) => [
...prev,
{
id: `error-${Date.now()}`,
role: 'system',
content: 'Failed to create task. Please try again.',
timestamp: new Date(),
kind: 'system',
id: `err-${Date.now()}`,
content: 'Failed to send message to control session.',
},
]);
setIsLoading(false);
}
};
const handleStop = async () => {
if (!currentTaskId) return;
const taskId = currentTaskId;
try {
// Do NOT disconnect the stream until we know the stop request succeeded.
await stopTask(taskId);
// Now stop streaming locally.
streamCleanupRef.current?.();
streamCleanupRef.current = null;
setMessages((prev) =>
prev.map((m) =>
m.id === `assistant-${taskId}`
? { ...m, status: 'cancelled' as const, content: 'Task was cancelled' }
: m
)
);
setCurrentTaskId(null);
setIsLoading(false);
} catch (error) {
console.error('Failed to stop task:', error);
setMessages((prev) => [
await cancelControl();
} catch (err) {
console.error(err);
setItems((prev) => [
...prev,
{
id: `stop-error-${Date.now()}`,
role: 'system',
content: 'Failed to stop task. The stream is still connected; try again.',
timestamp: new Date(),
kind: 'system',
id: `err-${Date.now()}`,
content: 'Failed to cancel control session.',
},
]);
}
};
const toggleLogs = (messageId: string) => {
setExpandedLogs((prev) => {
const next = new Set(prev);
if (next.has(messageId)) {
next.delete(messageId);
} else {
next.add(messageId);
}
return next;
});
};
const getLogIcon = (type: string) => {
switch (type) {
case 'tool_call':
return Terminal;
case 'response':
return FileText;
case 'error':
return XCircle;
default:
return Code;
}
};
return (
<div className="flex h-screen flex-col">
{/* Header */}
<div className="border-b border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur px-6 py-4">
<h1 className="text-xl font-semibold text-[var(--foreground)]">Agent Control</h1>
<p className="text-sm text-[var(--foreground-muted)]">
Give tasks to the autonomous agent
</p>
<div className="flex min-h-screen flex-col p-8">
<div className="mb-6 flex items-start justify-between gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--foreground)]">
Agent Control
</h1>
<p className="mt-1 text-sm text-[var(--foreground-muted)]">
Talk to the global RootAgent session (messages queue while busy)
</p>
</div>
<div className="flex items-center gap-3">
<div className={cn('flex items-center gap-2 text-sm', status.className)}>
<StatusIcon className={cn('h-4 w-4', runState !== 'idle' && 'animate-spin')} />
<span>{status.label}</span>
<span className="text-[var(--foreground-muted)]"></span>
<span className="text-[var(--foreground-muted)]">Queue: {queueLen}</span>
</div>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Bot className="mx-auto h-12 w-12 text-[var(--foreground-muted)]" />
<h2 className="mt-4 text-lg font-medium text-[var(--foreground)]">
Start a conversation
</h2>
<p className="mt-2 text-sm text-[var(--foreground-muted)]">
Describe a task for the agent to complete
</p>
</div>
</div>
) : (
<div className="mx-auto max-w-3xl space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={cn(
'flex gap-4',
message.role === 'user' ? 'justify-end' : 'justify-start'
)}
>
{message.role !== 'user' && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
<Bot className="h-4 w-4 text-white" />
</div>
)}
<div
className={cn(
'max-w-[80%] rounded-lg px-4 py-3',
message.role === 'user'
? 'bg-[var(--accent)] text-white'
: 'bg-[var(--background-secondary)] text-[var(--foreground)]'
)}
>
{/* Status badge */}
{message.status && message.role === 'assistant' && (
<div className="mb-2 flex items-center gap-2">
{message.status === 'pending' && (
<span className="flex items-center gap-1 text-xs text-[var(--warning)]">
<Clock className="h-3 w-3" />
Pending
</span>
)}
{message.status === 'running' && (
<span className="flex items-center gap-1 text-xs text-[var(--accent)]">
<Loader className="h-3 w-3 animate-spin" />
Running
</span>
)}
{message.status === 'completed' && (
<span className="flex items-center gap-1 text-xs text-[var(--success)]">
<CheckCircle className="h-3 w-3" />
Completed
</span>
)}
{message.status === 'cancelled' && (
<span className="flex items-center gap-1 text-xs text-[var(--foreground-muted)]">
<Ban className="h-3 w-3" />
Cancelled
</span>
)}
{message.status === 'failed' && (
<span className="flex items-center gap-1 text-xs text-[var(--error)]">
<XCircle className="h-3 w-3" />
Failed
</span>
)}
</div>
)}
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
{/* Logs */}
{message.logs && message.logs.length > 0 && (
<div className="mt-3 border-t border-[var(--border)] pt-3">
<button
onClick={() => toggleLogs(message.id)}
className="text-xs text-[var(--foreground-muted)] hover:text-[var(--foreground)]"
>
{expandedLogs.has(message.id)
? `Hide ${message.logs.length} logs`
: `Show ${message.logs.length} logs`}
</button>
{expandedLogs.has(message.id) && (
<div className="mt-2 space-y-2">
{message.logs.map((log, i) => {
const Icon = getLogIcon(log.entry_type);
return (
<div
key={i}
className="flex items-start gap-2 rounded-lg bg-[var(--background-tertiary)] p-2 text-xs"
>
<Icon className="mt-0.5 h-3 w-3 text-[var(--foreground-muted)]" />
<span className="font-mono text-[var(--foreground-muted)]">
{log.content.length > 200
? `${log.content.slice(0, 200)}...`
: log.content}
</span>
</div>
);
})}
</div>
)}
</div>
)}
</div>
{message.role === 'user' && (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
<User className="h-4 w-4 text-[var(--foreground-muted)]" />
</div>
)}
<div className="panel flex-1 min-h-0 overflow-hidden rounded-lg border border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur-xl">
<div className="flex-1 overflow-y-auto p-6">
{items.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<Bot className="mx-auto h-12 w-12 text-[var(--foreground-muted)]" />
<h2 className="mt-4 text-lg font-medium text-[var(--foreground)]">
Start a conversation
</h2>
<p className="mt-2 text-sm text-[var(--foreground-muted)]">
Ask the agent to do something (it will queue if already busy)
</p>
</div>
))}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input */}
<div className="border-t border-[var(--border)] bg-[var(--background-secondary)]/70 backdrop-blur p-4">
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Describe a task for the agent..."
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)]/60 px-4 py-3 text-sm text-[var(--foreground)] placeholder-[var(--foreground-muted)] focus:border-[var(--accent)] focus:outline-none focus-visible:!border-[var(--border)]"
disabled={isLoading}
/>
{isLoading ? (
<button
type="button"
onClick={handleStop}
className="flex items-center gap-2 rounded-lg bg-[var(--error)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--error)]/90"
>
<Square className="h-4 w-4" />
Stop
</button>
</div>
) : (
<button
type="submit"
disabled={!input.trim()}
className="flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--accent)]/90 disabled:opacity-50"
>
<Send className="h-4 w-4" />
Send
</button>
<div className="mx-auto max-w-3xl space-y-6">
{items.map((item) => {
if (item.kind === 'user') {
return (
<div key={item.id} className="flex justify-end gap-4">
<div className="max-w-[80%] rounded-lg bg-[var(--accent)] px-4 py-3 text-white">
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
</div>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
<User className="h-4 w-4 text-[var(--foreground-muted)]" />
</div>
</div>
);
}
if (item.kind === 'assistant') {
const statusIcon = item.success ? CheckCircle : XCircle;
const StatusIcon = statusIcon;
return (
<div key={item.id} className="flex justify-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
<div className="mb-2 flex items-center gap-2 text-xs text-[var(--foreground-muted)]">
<StatusIcon
className={cn(
'h-3 w-3',
item.success ? 'text-[var(--success)]' : 'text-[var(--error)]',
)}
/>
<span>{item.success ? 'Completed' : 'Failed'}</span>
{item.model && (
<>
<span></span>
<span className="font-mono">{item.model}</span>
</>
)}
</div>
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
</div>
</div>
);
}
if (item.kind === 'tool') {
if (item.name === 'ui_optionList') {
const toolCallId = item.toolCallId;
const rawArgs: Record<string, unknown> = isRecord(item.args) ? item.args : {};
let optionList: ReturnType<typeof parseSerializableOptionList> | null = null;
let parseErr: string | null = null;
try {
optionList = parseSerializableOptionList({
...rawArgs,
id:
typeof rawArgs['id'] === 'string' && rawArgs['id']
? (rawArgs['id'] as string)
: `option-list-${toolCallId}`,
});
} catch (e) {
parseErr = e instanceof Error ? e.message : 'Invalid option list payload';
}
const confirmed = item.result as OptionListSelection | undefined;
return (
<div key={item.id} className="flex justify-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
<div className="mb-2 text-xs text-[var(--foreground-muted)]">
Tool UI: <span className="font-mono">{item.name}</span>
</div>
{parseErr || !optionList ? (
<div className="rounded-lg border border-[var(--border)] bg-[var(--background-tertiary)] p-3 text-sm text-[var(--error)]">
{parseErr ?? 'Failed to render OptionList'}
</div>
) : (
<OptionListErrorBoundary>
<OptionList
{...optionList}
value={undefined}
confirmed={confirmed}
onConfirm={async (selection) => {
// Optimistic receipt state.
setItems((prev) =>
prev.map((it) =>
it.kind === 'tool' && it.toolCallId === toolCallId
? { ...it, result: selection }
: it,
),
);
await postControlToolResult({
tool_call_id: toolCallId,
name: item.name,
result: selection,
});
}}
onCancel={async () => {
setItems((prev) =>
prev.map((it) =>
it.kind === 'tool' && it.toolCallId === toolCallId
? { ...it, result: null }
: it,
),
);
await postControlToolResult({
tool_call_id: toolCallId,
name: item.name,
result: null,
});
}}
/>
</OptionListErrorBoundary>
)}
</div>
</div>
);
}
// Unknown UI tool.
return (
<div key={item.id} className="flex justify-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-[var(--accent)] to-[var(--accent-secondary)]">
<Bot className="h-4 w-4 text-white" />
</div>
<div className="max-w-[80%] rounded-lg bg-[var(--background-secondary)] px-4 py-3 text-[var(--foreground)]">
<p className="text-sm">
Unsupported Tool UI: <span className="font-mono">{item.name}</span>
</p>
</div>
</div>
);
}
// system
return (
<div key={item.id} className="flex justify-start gap-4">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-[var(--background-tertiary)]">
<Ban className="h-4 w-4 text-[var(--foreground-muted)]" />
</div>
<div className="max-w-[80%] rounded-lg bg-[var(--background-tertiary)] px-4 py-3 text-[var(--foreground)]">
<p className="whitespace-pre-wrap text-sm">{item.content}</p>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
)}
</form>
</div>
<div className="border-t border-[var(--border)] bg-[var(--background-secondary)]/40 backdrop-blur p-4">
<form onSubmit={handleSubmit} className="mx-auto flex max-w-3xl gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Message the root agent…"
className="flex-1 rounded-lg border border-[var(--border)] bg-[var(--background)]/60 px-4 py-3 text-sm text-[var(--foreground)] placeholder-[var(--foreground-muted)] focus:border-[var(--accent)] focus:outline-none"
/>
{isBusy ? (
<button
type="button"
onClick={handleStop}
className="flex items-center gap-2 rounded-lg bg-[var(--error)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--error)]/90"
>
<Square className="h-4 w-4" />
Stop
</button>
) : (
<button
type="submit"
disabled={!input.trim()}
className="flex items-center gap-2 rounded-lg bg-[var(--accent)] px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-[var(--accent)]/90 disabled:opacity-50"
>
<Send className="h-4 w-4" />
Send
</button>
)}
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,305 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { authHeader, getValidJwt } from '@/lib/auth';
import { getRuntimeApiBase } from '@/lib/settings';
type FsEntry = {
name: string;
path: string;
kind: 'file' | 'dir' | 'link' | 'other' | string;
size: number;
mtime: number;
};
function formatBytes(n: number) {
if (!Number.isFinite(n)) return '-';
if (n < 1024) return `${n} B`;
const units = ['KB', 'MB', 'GB', 'TB'] as const;
let v = n / 1024;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i += 1;
}
return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[i]}`;
}
async function listDir(path: string): Promise<FsEntry[]> {
const API_BASE = getRuntimeApiBase();
const res = await fetch(`${API_BASE}/api/fs/list?path=${encodeURIComponent(path)}`, {
headers: { ...authHeader() },
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function mkdir(path: string): Promise<void> {
const API_BASE = getRuntimeApiBase();
const res = await fetch(`${API_BASE}/api/fs/mkdir`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
}
async function rm(path: string, recursive = false): Promise<void> {
const API_BASE = getRuntimeApiBase();
const res = await fetch(`${API_BASE}/api/fs/rm`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeader() },
body: JSON.stringify({ path, recursive }),
});
if (!res.ok) throw new Error(await res.text());
}
async function downloadFile(path: string) {
const API_BASE = getRuntimeApiBase();
const res = await fetch(`${API_BASE}/api/fs/download?path=${encodeURIComponent(path)}`, {
headers: { ...authHeader() },
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const name = path.split('/').filter(Boolean).pop() ?? 'download';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
async function uploadFiles(dir: string, files: File[], onProgress?: (done: number, total: number) => void) {
let done = 0;
for (const f of files) {
await new Promise<void>((resolve, reject) => {
const API_BASE = getRuntimeApiBase();
const form = new FormData();
form.append('file', f, f.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE}/api/fs/upload?path=${encodeURIComponent(dir)}`, true);
const jwt = getValidJwt()?.token;
if (jwt) xhr.setRequestHeader('Authorization', `Bearer ${jwt}`);
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(xhr.responseText || `Upload failed (${xhr.status})`));
};
xhr.onerror = () => reject(new Error('Upload failed (network error)'));
xhr.send(form);
});
done += 1;
onProgress?.(done, files.length);
}
}
export default function FilesPage() {
const [cwd, setCwd] = useState('/root');
const [entries, setEntries] = useState<FsEntry[]>([]);
const [fsLoading, setFsLoading] = useState(false);
const [fsError, setFsError] = useState<string | null>(null);
const [selected, setSelected] = useState<FsEntry | null>(null);
const [uploading, setUploading] = useState<{ done: number; total: number } | null>(null);
const sortedEntries = useMemo(() => {
const dirs = entries.filter((e) => e.kind === 'dir').sort((a, b) => a.name.localeCompare(b.name));
const files = entries.filter((e) => e.kind !== 'dir').sort((a, b) => a.name.localeCompare(b.name));
return [...dirs, ...files];
}, [entries]);
async function refreshDir(path: string) {
setFsLoading(true);
setFsError(null);
try {
const data = await listDir(path);
setEntries(data);
setSelected(null);
} catch (e) {
setFsError(e instanceof Error ? e.message : String(e));
} finally {
setFsLoading(false);
}
}
useEffect(() => {
void refreshDir(cwd);
}, [cwd]);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold text-[var(--foreground)]">Files</h1>
<p className="mt-1 text-sm text-[var(--foreground-muted)]">Remote file explorer (SFTP).</p>
</div>
<div className="panel rounded-lg border border-[var(--border)] bg-[var(--background-secondary)]/70 p-3 backdrop-blur-xl">
<div className="mb-2 flex items-center justify-between">
<div className="text-sm font-medium text-[var(--foreground)]">Explorer</div>
<div className="flex items-center gap-2">
<button
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
onClick={() => void refreshDir(cwd)}
>
Refresh
</button>
<button
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
onClick={async () => {
const name = prompt('New folder name');
if (!name) return;
const target = cwd.endsWith('/') ? `${cwd}${name}` : `${cwd}/${name}`;
await mkdir(target);
await refreshDir(cwd);
}}
>
New folder
</button>
</div>
</div>
<div className="mb-3 flex items-center gap-2">
<button
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
onClick={() => {
const parts = cwd.split('/').filter(Boolean);
if (parts.length === 0) return;
parts.pop();
setCwd('/' + parts.join('/'));
}}
disabled={cwd === '/'}
>
Up
</button>
<input
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)]/40 px-3 py-2 text-sm text-[var(--foreground)] placeholder:text-[var(--foreground-muted)] focus-visible:!border-[var(--border)]"
value={cwd}
onChange={(e) => setCwd(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void refreshDir(cwd);
}}
/>
</div>
<div
className="mb-3 rounded-md border border-dashed border-[var(--border)] bg-[var(--background)]/20 p-3 text-sm text-[var(--foreground-muted)]"
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files || []);
if (files.length === 0) return;
setUploading({ done: 0, total: files.length });
try {
await uploadFiles(cwd, files, (done, total) => setUploading({ done, total }));
await refreshDir(cwd);
} catch (err) {
setFsError(err instanceof Error ? err.message : String(err));
} finally {
setUploading(null);
}
}}
>
Drag & drop to upload into <span className="text-[var(--foreground)]">{cwd}</span>
{uploading ? (
<span className="ml-2 text-xs">
({uploading.done}/{uploading.total})
</span>
) : null}
</div>
{fsError ? (
<div className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{fsError}
</div>
) : null}
<div className="grid gap-3 md:grid-cols-5">
<div className="md:col-span-3">
<div className="rounded-md border border-[var(--border)] bg-[var(--background)]/30">
<div className="grid grid-cols-12 gap-2 border-b border-[var(--border)] px-3 py-2 text-xs text-[var(--foreground-muted)]">
<div className="col-span-7">Name</div>
<div className="col-span-3">Size</div>
<div className="col-span-2">Type</div>
</div>
<div className="max-h-[520px] overflow-auto">
{fsLoading ? (
<div className="px-3 py-3 text-sm text-[var(--foreground-muted)]">Loading</div>
) : sortedEntries.length === 0 ? (
<div className="px-3 py-3 text-sm text-[var(--foreground-muted)]">Empty</div>
) : (
sortedEntries.map((e) => (
<button
key={e.path}
className={
'grid w-full grid-cols-12 gap-2 px-3 py-2 text-left text-sm hover:bg-[var(--background-tertiary)]/60 ' +
(selected?.path === e.path ? 'bg-[var(--accent)]/10' : '')
}
onClick={() => setSelected(e)}
onDoubleClick={() => {
if (e.kind === 'dir') setCwd(e.path);
}}
>
<div className="col-span-7 truncate text-[var(--foreground)]">{e.name}</div>
<div className="col-span-3 text-[var(--foreground-muted)]">
{e.kind === 'file' ? formatBytes(e.size) : '-'}
</div>
<div className="col-span-2 text-[var(--foreground-muted)]">{e.kind}</div>
</button>
))
)}
</div>
</div>
</div>
<div className="md:col-span-2">
<div className="rounded-md border border-[var(--border)] bg-[var(--background)]/30 p-3">
<div className="text-sm font-medium text-[var(--foreground)]">Selection</div>
{selected ? (
<div className="mt-2 space-y-2 text-sm">
<div className="break-words text-[var(--foreground)]">{selected.path}</div>
<div className="text-[var(--foreground-muted)]">
<span className="text-[var(--foreground)]">Type:</span> {selected.kind}
</div>
{selected.kind === 'file' ? (
<div className="text-[var(--foreground-muted)]">
<span className="text-[var(--foreground)]">Size:</span> {formatBytes(selected.size)}
</div>
) : null}
<div className="flex flex-wrap gap-2 pt-1">
{selected.kind === 'file' ? (
<button
className="rounded-md border border-[var(--border)] bg-[var(--background-tertiary)] px-2 py-1 text-xs text-[var(--foreground)] hover:bg-[var(--background-tertiary)]/70"
onClick={() => void downloadFile(selected.path)}
>
Download
</button>
) : null}
<button
className="rounded-md border border-red-500/30 bg-red-500/10 px-2 py-1 text-xs text-red-200 hover:bg-red-500/15"
onClick={async () => {
if (!confirm(`Delete ${selected.path}?`)) return;
await rm(selected.path, selected.kind === 'dir');
await refreshDir(cwd);
}}
>
Delete
</button>
</div>
</div>
) : (
<div className="mt-2 text-sm text-[var(--foreground-muted)]">Click a file/folder.</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -20,6 +20,18 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--background-secondary);
--color-card-foreground: var(--foreground);
--color-muted: var(--background-tertiary);
--color-muted-foreground: var(--foreground-muted);
--color-border: var(--border);
--color-primary: var(--accent);
--color-primary-foreground: #ffffff;
--color-destructive: var(--error);
--color-destructive-foreground: #ffffff;
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}

View File

@@ -62,8 +62,12 @@ export default function HistoryPage() {
<div className="p-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-2xl font-bold text-[var(--foreground)]">History</h1>
<p className="text-sm text-[var(--foreground-muted)]">View all past and current tasks</p>
<h1 className="text-2xl font-semibold tracking-tight text-[var(--foreground)]">
History
</h1>
<p className="mt-1 text-sm text-[var(--foreground-muted)]">
View all past and current tasks
</p>
</div>
{/* Filters */}

View File

@@ -0,0 +1,14 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
* Separator → shadcn/ui Separator
*/
export { cn } from "../../../lib/ui/cn";
export { Button } from "../../ui/button";
export { Separator } from "../../ui/separator";

View File

@@ -0,0 +1,19 @@
"use client";
import * as React from "react";
import {
ToolUIErrorBoundary,
type ToolUIErrorBoundaryProps,
} from "../shared";
export function OptionListErrorBoundary(
props: Omit<ToolUIErrorBoundaryProps, "componentName">,
) {
const { children, ...rest } = props;
return (
<ToolUIErrorBoundary componentName="OptionList" {...rest}>
{children}
</ToolUIErrorBoundary>
);
}

View File

@@ -0,0 +1,14 @@
export { OptionList } from "./option-list";
export { OptionListErrorBoundary } from "./error-boundary";
export type {
OptionListProps,
OptionListOption,
OptionListSelection,
SerializableOptionList,
} from "./schema";
export {
OptionListOptionSchema,
OptionListPropsSchema,
SerializableOptionListSchema,
parseSerializableOptionList,
} from "./schema";

View File

@@ -0,0 +1,628 @@
"use client";
import {
useMemo,
useState,
useCallback,
useEffect,
useRef,
Fragment,
} from "react";
import type { KeyboardEvent } from "react";
import type {
OptionListProps,
OptionListSelection,
OptionListOption,
} from "./schema";
import { ActionButtons, normalizeActionsConfig } from "../shared";
import type { Action } from "../shared";
import { cn, Button, Separator } from "./_adapter";
import { Check } from "lucide-react";
function parseSelectionToIdSet(
value: OptionListSelection | undefined,
mode: "multi" | "single",
maxSelections?: number,
): Set<string> {
if (mode === "single") {
const single =
typeof value === "string"
? value
: Array.isArray(value)
? value[0]
: null;
return single ? new Set([single]) : new Set();
}
const arr =
typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
return new Set(maxSelections ? arr.slice(0, maxSelections) : arr);
}
function convertIdSetToSelection(
selected: Set<string>,
mode: "multi" | "single",
): OptionListSelection {
if (mode === "single") {
const [first] = selected;
return first ?? null;
}
return Array.from(selected);
}
function areSetsEqual(a: Set<string>, b: Set<string>) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
return true;
}
interface SelectionIndicatorProps {
mode: "multi" | "single";
isSelected: boolean;
disabled?: boolean;
}
function SelectionIndicator({
mode,
isSelected,
disabled,
}: SelectionIndicatorProps) {
const shape = mode === "single" ? "rounded-full" : "rounded";
return (
<div
className={cn(
"flex size-4 shrink-0 items-center justify-center border-2 transition-colors",
shape,
isSelected && "border-primary bg-primary text-primary-foreground",
!isSelected && "border-muted-foreground/50",
disabled && "opacity-50",
)}
>
{mode === "multi" && isSelected && <Check className="size-3" />}
{mode === "single" && isSelected && (
<span className="size-2 rounded-full bg-current" />
)}
</div>
);
}
interface OptionItemProps {
option: OptionListOption;
isSelected: boolean;
isDisabled: boolean;
selectionMode: "multi" | "single";
isFirst: boolean;
isLast: boolean;
onToggle: () => void;
tabIndex?: number;
onFocus?: () => void;
buttonRef?: (el: HTMLButtonElement | null) => void;
}
function OptionItem({
option,
isSelected,
isDisabled,
selectionMode,
isFirst,
isLast,
onToggle,
tabIndex,
onFocus,
buttonRef,
}: OptionItemProps) {
const hasAdjacentOptions = !isFirst && !isLast;
return (
<Button
ref={buttonRef}
data-id={option.id}
variant="ghost"
size="lg"
role="option"
aria-selected={isSelected}
onClick={onToggle}
onFocus={onFocus}
tabIndex={tabIndex}
disabled={isDisabled}
className={cn(
"peer group relative h-auto min-h-[50px] w-full justify-start text-left text-sm font-medium",
"rounded-none border-0 bg-transparent px-0 py-2 text-base shadow-none transition-none hover:bg-transparent! @md/option-list:text-sm",
isFirst && "pb-2.5",
hasAdjacentOptions && "py-2.5",
)}
>
<span
className={cn(
"bg-primary/5 absolute inset-0 -mx-3 -my-0.5 rounded-xl opacity-0 group-hover:opacity-100",
)}
/>
<div className="relative flex items-start gap-3">
<span className="flex h-6 items-center">
<SelectionIndicator
mode={selectionMode}
isSelected={isSelected}
disabled={option.disabled}
/>
</span>
{option.icon && (
<span className="flex h-6 items-center">{option.icon}</span>
)}
<div className="flex flex-col text-left">
<span className="leading-6 text-pretty">{option.label}</span>
{option.description && (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{option.description}
</span>
)}
</div>
</div>
</Button>
);
}
interface OptionListConfirmationProps {
id: string;
options: OptionListOption[];
selectedIds: Set<string>;
className?: string;
}
function OptionListConfirmation({
id,
options,
selectedIds,
className,
}: OptionListConfirmationProps) {
const confirmedOptions = options.filter((opt) => selectedIds.has(opt.id));
return (
<div
className={cn(
"@container/option-list flex w-full max-w-md min-w-80 flex-col",
"text-foreground",
className,
)}
data-slot="option-list"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label="Confirmed selection"
>
<div
className={cn(
"bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border px-5 py-2.5 shadow-xs",
)}
>
{confirmedOptions.map((option, index) => (
<Fragment key={option.id}>
{index > 0 && <Separator orientation="horizontal" />}
<div className="flex items-start gap-3 py-1">
<span className="flex h-6 items-center">
<Check className="text-primary size-4 shrink-0" />
</span>
{option.icon && (
<span className="flex h-6 items-center">{option.icon}</span>
)}
<div className="flex flex-col text-left">
<span className="text-base leading-6 font-medium text-pretty @md/option-list:text-sm">
{option.label}
</span>
{option.description && (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{option.description}
</span>
)}
</div>
</div>
</Fragment>
))}
</div>
</div>
);
}
export function OptionList({
id,
options,
selectionMode = "multi",
minSelections = 1,
maxSelections,
value,
defaultValue,
confirmed,
onChange,
onConfirm,
onCancel,
responseActions,
onResponseAction,
onBeforeResponseAction,
className,
}: OptionListProps) {
if (process.env["NODE_ENV"] !== "production") {
if (value !== undefined && defaultValue !== undefined) {
console.warn(
"[OptionList] Both `value` (controlled) and `defaultValue` (uncontrolled) were provided. `defaultValue` is ignored when `value` is set.",
);
}
if (value !== undefined && !onChange) {
console.warn(
"[OptionList] `value` was provided without `onChange`. This makes OptionList controlled; selection will not update unless the parent updates `value`.",
);
}
}
const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections;
const [uncontrolledSelected, setUncontrolledSelected] = useState<Set<string>>(
() =>
parseSelectionToIdSet(
defaultValue,
selectionMode,
effectiveMaxSelections,
),
);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setUncontrolledSelected((prev) => {
const normalized = parseSelectionToIdSet(
Array.from(prev),
selectionMode,
effectiveMaxSelections,
);
return areSetsEqual(prev, normalized) ? prev : normalized;
});
}, [selectionMode, effectiveMaxSelections]);
const selectedIds = useMemo(
() =>
value !== undefined
? parseSelectionToIdSet(value, selectionMode, effectiveMaxSelections)
: uncontrolledSelected,
[value, uncontrolledSelected, selectionMode, effectiveMaxSelections],
);
const selectedCount = selectedIds.size;
const optionStates = useMemo(() => {
return options.map((option) => {
const isSelected = selectedIds.has(option.id);
const isSelectionLocked =
selectionMode === "multi" &&
effectiveMaxSelections !== undefined &&
selectedCount >= effectiveMaxSelections &&
!isSelected;
const isDisabled = option.disabled || isSelectionLocked;
return { option, isSelected, isDisabled };
});
}, [
options,
selectedIds,
selectionMode,
effectiveMaxSelections,
selectedCount,
]);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const [activeIndex, setActiveIndex] = useState(() => {
const firstSelected = optionStates.findIndex(
(s) => s.isSelected && !s.isDisabled,
);
if (firstSelected >= 0) return firstSelected;
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
return firstEnabled >= 0 ? firstEnabled : 0;
});
useEffect(() => {
if (optionStates.length === 0) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setActiveIndex((prev) => {
if (
prev < 0 ||
prev >= optionStates.length ||
optionStates[prev].isDisabled
) {
const firstEnabled = optionStates.findIndex((s) => !s.isDisabled);
return firstEnabled >= 0 ? firstEnabled : 0;
}
return prev;
});
}, [optionStates]);
const updateSelection = useCallback(
(next: Set<string>) => {
const normalizedNext = parseSelectionToIdSet(
Array.from(next),
selectionMode,
effectiveMaxSelections,
);
if (value === undefined) {
if (!areSetsEqual(uncontrolledSelected, normalizedNext)) {
setUncontrolledSelected(normalizedNext);
}
}
onChange?.(convertIdSetToSelection(normalizedNext, selectionMode));
},
[
effectiveMaxSelections,
selectionMode,
uncontrolledSelected,
value,
onChange,
],
);
const toggleSelection = useCallback(
(optionId: string) => {
const next = new Set(selectedIds);
const isSelected = next.has(optionId);
if (selectionMode === "single") {
if (isSelected) {
next.delete(optionId);
} else {
next.clear();
next.add(optionId);
}
} else {
if (isSelected) {
next.delete(optionId);
} else {
if (effectiveMaxSelections && next.size >= effectiveMaxSelections) {
return;
}
next.add(optionId);
}
}
updateSelection(next);
},
[effectiveMaxSelections, selectedIds, selectionMode, updateSelection],
);
const handleConfirm = useCallback(async () => {
if (!onConfirm) return;
if (selectedCount === 0 || selectedCount < minSelections) return;
await onConfirm(convertIdSetToSelection(selectedIds, selectionMode));
}, [minSelections, onConfirm, selectedCount, selectedIds, selectionMode]);
const handleCancel = useCallback(() => {
const empty = new Set<string>();
updateSelection(empty);
onCancel?.();
}, [onCancel, updateSelection]);
const hasCustomResponseActions = responseActions !== undefined;
const handleFooterAction = useCallback(
async (actionId: string) => {
if (hasCustomResponseActions) {
await onResponseAction?.(actionId);
return;
}
if (actionId === "confirm") {
await handleConfirm();
} else if (actionId === "cancel") {
handleCancel();
}
},
[handleConfirm, handleCancel, hasCustomResponseActions, onResponseAction],
);
const normalizedFooterActions = useMemo(() => {
const normalized = normalizeActionsConfig(responseActions);
if (normalized) return normalized;
return {
items: [
{ id: "cancel", label: "Clear", variant: "ghost" as const },
{ id: "confirm", label: "Confirm", variant: "default" as const },
],
align: "right" as const,
} satisfies ReturnType<typeof normalizeActionsConfig>;
}, [responseActions]);
const isConfirmDisabled =
selectedCount < minSelections || selectedCount === 0;
const hasNothingToClear = selectedCount === 0;
const focusOptionAt = useCallback((index: number) => {
const el = optionRefs.current[index];
if (el) el.focus();
setActiveIndex(index);
}, []);
const findFirstEnabledIndex = useCallback(() => {
const idx = optionStates.findIndex((s) => !s.isDisabled);
return idx >= 0 ? idx : 0;
}, [optionStates]);
const findLastEnabledIndex = useCallback(() => {
for (let i = optionStates.length - 1; i >= 0; i--) {
if (!optionStates[i].isDisabled) return i;
}
return 0;
}, [optionStates]);
const findNextEnabledIndex = useCallback(
(start: number, direction: 1 | -1) => {
const len = optionStates.length;
if (len === 0) return 0;
for (let step = 1; step <= len; step++) {
const idx = (start + direction * step + len) % len;
if (!optionStates[idx].isDisabled) return idx;
}
return start;
},
[optionStates],
);
const handleListboxKeyDown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
if (optionStates.length === 0) return;
const key = e.key;
if (key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findNextEnabledIndex(activeIndex, 1));
return;
}
if (key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findNextEnabledIndex(activeIndex, -1));
return;
}
if (key === "Home") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findFirstEnabledIndex());
return;
}
if (key === "End") {
e.preventDefault();
e.stopPropagation();
focusOptionAt(findLastEnabledIndex());
return;
}
if (key === "Enter" || key === " ") {
e.preventDefault();
e.stopPropagation();
const current = optionStates[activeIndex];
if (!current || current.isDisabled) return;
toggleSelection(current.option.id);
return;
}
if (key === "Escape") {
e.preventDefault();
e.stopPropagation();
if (!hasNothingToClear) {
handleCancel();
}
}
},
[
activeIndex,
findFirstEnabledIndex,
findLastEnabledIndex,
findNextEnabledIndex,
focusOptionAt,
handleCancel,
hasNothingToClear,
optionStates,
toggleSelection,
],
);
const actionsWithDisabledState = useMemo((): Action[] => {
return normalizedFooterActions.items.map((action) => {
const isDisabledByValidation =
(action.id === "confirm" && isConfirmDisabled) ||
(action.id === "cancel" && hasNothingToClear);
return {
...action,
disabled: action.disabled || isDisabledByValidation,
label:
action.id === "confirm" &&
selectionMode === "multi" &&
selectedCount > 0
? `${action.label} (${selectedCount})`
: action.label,
};
});
}, [
normalizedFooterActions.items,
isConfirmDisabled,
hasNothingToClear,
selectionMode,
selectedCount,
]);
if (confirmed !== undefined && confirmed !== null) {
const selectedIds = parseSelectionToIdSet(confirmed, selectionMode);
return (
<OptionListConfirmation
id={id}
options={options}
selectedIds={selectedIds}
className={className}
/>
);
}
return (
<div
className={cn(
"@container/option-list flex w-full max-w-md min-w-80 flex-col gap-3",
"text-foreground",
className,
)}
data-slot="option-list"
data-tool-ui-id={id}
role="group"
aria-label="Option list"
>
<div
className={cn(
"group/list bg-card flex w-full flex-col overflow-hidden rounded-2xl border px-4 py-1.5 shadow-xs",
)}
role="listbox"
aria-multiselectable={selectionMode === "multi"}
onKeyDown={handleListboxKeyDown}
>
{optionStates.map(({ option, isSelected, isDisabled }, index) => {
return (
<Fragment key={option.id}>
{index > 0 && (
<Separator
className="[@media(hover:hover)]:[&:has(+_:hover)]:opacity-0 [@media(hover:hover)]:[.peer:hover+&]:opacity-0"
orientation="horizontal"
/>
)}
<OptionItem
option={option}
isSelected={isSelected}
isDisabled={isDisabled}
selectionMode={selectionMode}
isFirst={index === 0}
isLast={index === optionStates.length - 1}
tabIndex={index === activeIndex ? 0 : -1}
onFocus={() => setActiveIndex(index)}
buttonRef={(el) => {
optionRefs.current[index] = el;
}}
onToggle={() => toggleSelection(option.id)}
/>
</Fragment>
);
})}
</div>
<div className="@container/actions">
<ActionButtons
actions={actionsWithDisabledState}
align={normalizedFooterActions.align}
confirmTimeout={normalizedFooterActions.confirmTimeout}
onAction={handleFooterAction}
onBeforeAction={
hasCustomResponseActions ? onBeforeResponseAction : undefined
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import { z } from "zod";
import type { ReactNode } from "react";
import type { ActionsProp } from "../shared";
import {
ActionSchema,
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
parseWithSchema,
} from "../shared";
export const OptionListOptionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
icon: z.custom<ReactNode>().optional(),
disabled: z.boolean().optional(),
});
export const OptionListPropsSchema = z.object({
/**
* Unique identifier for this tool UI instance in the conversation.
*
* Used for:
* - Assistant referencing ("the options above")
* - Receipt generation (linking selections to their source)
* - Narration context
*
* Should be stable across re-renders, meaningful, and unique within the conversation.
*
* @example "option-list-deploy-target", "format-selection"
*/
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
options: z.array(OptionListOptionSchema).min(1),
selectionMode: z.enum(["multi", "single"]).optional(),
/**
* Controlled selection value (advanced / runtime only).
*
* For Tool UI tool payloads, prefer `defaultValue` (initial selection) and
* `confirmed` (receipt state). Controlled `value` is intentionally excluded
* from `SerializableOptionListSchema` to avoid accidental "controlled but
* non-interactive" states when an LLM includes `value` in args.
*/
value: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
defaultValue: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
/**
* When set, renders the component in receipt/confirmed state.
*
* In receipt state:
* - Only the confirmed option(s) are shown
* - Response actions are hidden
* - The component is read-only
*
* Use this with assistant-ui's `addResult` to show the outcome of a decision.
*
* @example
* ```tsx
* // In makeAssistantToolUI render:
* if (result) {
* return <OptionList {...args} confirmed={result} />;
* }
* ```
*/
confirmed: z.union([z.array(z.string()), z.string(), z.null()]).optional(),
responseActions: z
.union([z.array(ActionSchema), SerializableActionsConfigSchema])
.optional(),
minSelections: z.number().min(0).optional(),
maxSelections: z.number().min(1).optional(),
});
export type OptionListSelection = string[] | string | null;
export type OptionListOption = z.infer<typeof OptionListOptionSchema>;
export type OptionListProps = Omit<
z.infer<typeof OptionListPropsSchema>,
"value" | "defaultValue" | "confirmed"
> & {
/** @see OptionListPropsSchema.id */
id: string;
value?: OptionListSelection;
defaultValue?: OptionListSelection;
/** @see OptionListPropsSchema.confirmed */
confirmed?: OptionListSelection;
onChange?: (value: OptionListSelection) => void;
onConfirm?: (value: OptionListSelection) => void | Promise<void>;
onCancel?: () => void;
responseActions?: ActionsProp;
onResponseAction?: (actionId: string) => void | Promise<void>;
onBeforeResponseAction?: (actionId: string) => boolean | Promise<boolean>;
className?: string;
};
export const SerializableOptionListSchema = OptionListPropsSchema.omit({
// Exclude controlled selection from tool/LLM payloads.
value: true,
}).extend({
options: z.array(OptionListOptionSchema.omit({ icon: true })),
responseActions: z
.union([z.array(SerializableActionSchema), SerializableActionsConfigSchema])
.optional(),
});
export type SerializableOptionList = z.infer<
typeof SerializableOptionListSchema
>;
export function parseSerializableOptionList(
input: unknown,
): SerializableOptionList {
return parseWithSchema(SerializableOptionListSchema, input, "OptionList");
}

View File

@@ -0,0 +1,12 @@
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "../../../lib/ui/cn";
export { Button } from "../../ui/button";

View File

@@ -0,0 +1,102 @@
"use client";
import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";
export interface ActionButtonsProps {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
align?: "left" | "center" | "right";
className?: string;
}
export function ActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
align = "right",
className,
}: ActionButtonsProps) {
const { actions: resolvedActions, runAction } = useActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout,
});
return (
<div
className={cn(
"flex flex-col gap-3",
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
align === "left" && "@sm/actions:justify-start",
align === "center" && "@sm/actions:justify-center",
align === "right" && "@sm/actions:justify-end",
className,
)}
>
{resolvedActions.map((action) => {
const label = action.currentLabel;
const variant = action.variant || "default";
return (
<Button
key={action.id}
variant={variant}
size="lg"
onClick={() => runAction(action.id)}
disabled={action.isDisabled}
className={cn(
"rounded-full",
"justify-center",
"min-w-24",
"min-h-11 w-full text-base",
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
action.isConfirming &&
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
)}
aria-label={
action.shortcut ? `${label} (${action.shortcut})` : label
}
>
{action.isLoading && (
<svg
className="mr-2 h-4 w-4 motion-safe:animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{action.icon && !action.isLoading && (
<span className="mr-2">{action.icon}</span>
)}
{label}
{action.shortcut && !action.isLoading && (
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
{action.shortcut}
</kbd>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import type { Action, ActionsConfig } from "./schema";
export type ActionsProp = ActionsConfig | Action[];
export function normalizeActionsConfig(
actions?: ActionsProp,
): ActionsConfig | null {
if (!actions) return null;
const resolved = Array.isArray(actions)
? { items: actions }
: {
items: actions.items ?? [],
align: actions.align,
confirmTimeout: actions.confirmTimeout,
};
if (!resolved.items || resolved.items.length === 0) {
return null;
}
return resolved;
}

View File

@@ -0,0 +1,51 @@
"use client";
import * as React from "react";
export interface ToolUIErrorBoundaryProps {
componentName: string;
children: React.ReactNode;
fallback?: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface ToolUIErrorBoundaryState {
hasError: boolean;
error?: Error;
}
export class ToolUIErrorBoundary extends React.Component<
ToolUIErrorBoundaryProps,
ToolUIErrorBoundaryState
> {
constructor(props: ToolUIErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ToolUIErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error(`[${this.props.componentName}] render error:`, error, errorInfo);
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div className="border-destructive text-destructive rounded-lg border p-4">
<p className="font-semibold">
{this.props.componentName} failed to render
</p>
<p className="text-sm">{this.state.error?.message}</p>
</div>
)
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,8 @@
export { ActionButtons } from "./action-buttons";
export type { ActionButtonsProps } from "./action-buttons";
export { normalizeActionsConfig, type ActionsProp } from "./actions-config";
export * from "./error-boundary";
export * from "./parse";
export * from "./schema";
export * from "./use-copy-to-clipboard";
export * from "./utils";

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}

View File

@@ -0,0 +1,122 @@
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)`
* - Actions: `responseActions`, `onResponseAction`, `onBeforeResponseAction`
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;

View File

@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Action } from "./schema";
export type UseActionButtonsOptions = {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
};
export type UseActionButtonsResult = {
actions: Array<
Action & {
currentLabel: string;
isConfirming: boolean;
isExecuting: boolean;
isDisabled: boolean;
isLoading: boolean;
}
>;
runAction: (actionId: string) => Promise<void>;
confirmingActionId: string | null;
executingActionId: string | null;
};
export function useActionButtons(
options: UseActionButtonsOptions,
): UseActionButtonsResult {
const {
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
} = options;
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
null,
);
const [executingActionId, setExecutingActionId] = useState<string | null>(
null,
);
useEffect(() => {
if (!confirmingActionId) return;
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
return () => clearTimeout(id);
}, [confirmingActionId, confirmTimeout]);
useEffect(() => {
if (!confirmingActionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmingActionId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmingActionId]);
const runAction = useCallback(
async (actionId: string) => {
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const isAnyActionExecuting = executingActionId !== null;
if (action.disabled || action.loading || isAnyActionExecuting) {
return;
}
if (action.confirmLabel && confirmingActionId !== action.id) {
setConfirmingActionId(action.id);
return;
}
if (onBeforeAction) {
const shouldProceed = await onBeforeAction(action.id);
if (!shouldProceed) {
setConfirmingActionId(null);
return;
}
}
try {
setExecutingActionId(action.id);
await onAction(action.id);
} finally {
setExecutingActionId(null);
setConfirmingActionId(null);
}
},
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
);
const resolvedActions = useMemo(
() =>
actions.map((action) => {
const isConfirming = confirmingActionId === action.id;
const isThisActionExecuting = executingActionId === action.id;
const isLoading = action.loading || isThisActionExecuting;
const isDisabled =
action.disabled || (executingActionId !== null && !isThisActionExecuting);
const currentLabel =
isConfirming && action.confirmLabel
? action.confirmLabel
: action.label;
return {
...action,
currentLabel,
isConfirming,
isExecuting: isThisActionExecuting,
isDisabled,
isLoading,
};
}),
[actions, confirmingActionId, executingActionId],
);
return {
actions: resolvedActions,
runAction,
confirmingActionId,
executingActionId,
};
}

View File

@@ -0,0 +1,63 @@
"use client";
import { useCallback, useEffect, useState } from "react";
function fallbackCopyToClipboard(text: string): boolean {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "fixed";
textArea.style.top = "-9999px";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textArea);
return ok;
} catch {
return false;
}
}
export function useCopyToClipboard(options?: {
resetAfterMs?: number;
}): {
copiedId: string | null;
copy: (text: string, id?: string) => Promise<boolean>;
} {
const resetAfterMs = options?.resetAfterMs ?? 2000;
const [copiedId, setCopiedId] = useState<string | null>(null);
const copy = useCallback(
async (text: string, id: string = "default") => {
let ok = false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ok = true;
} else {
ok = fallbackCopyToClipboard(text);
}
} catch {
ok = fallbackCopyToClipboard(text);
}
if (ok) {
setCopiedId(id);
}
return ok;
},
[],
);
useEffect(() => {
if (!copiedId) return;
const timeout = setTimeout(() => setCopiedId(null), resetAfterMs);
return () => clearTimeout(timeout);
}, [copiedId, resetAfterMs]);
return { copiedId, copy };
}

View File

@@ -0,0 +1,22 @@
export function formatRelativeTime(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${Math.round(seconds / 3600)}h`;
if (seconds < 604800) return `${Math.round(seconds / 86400)}d`;
return `${Math.round(seconds / 604800)}w`;
}
export function formatCount(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
export function getDomain(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}

View File

@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type ButtonVariant =
| "default"
| "secondary"
| "ghost"
| "outline"
| "destructive";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
}
const variantClasses: Record<ButtonVariant, string> = {
default:
"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-primary/30",
secondary:
"bg-muted text-foreground hover:bg-muted/80 focus-visible:ring-primary/30",
ghost:
"bg-transparent text-foreground hover:bg-muted/50 focus-visible:ring-primary/30",
outline:
"border border-border bg-transparent text-foreground hover:bg-muted/40 focus-visible:ring-primary/30",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/30",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-11 px-5 text-base",
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "md", type, ...props }, ref) => {
return (
<button
ref={ref}
type={type ?? "button"}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors",
"disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...props}
/>
);
},
);
Button.displayName = "Button";

View File

@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorProps) {
return (
<div
role="separator"
aria-orientation={orientation}
className={cn(
"bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}

View File

@@ -242,3 +242,117 @@ export async function getRunTasks(id: string): Promise<{ run_id: string; tasks:
return res.json();
}
// ==================== Global Control Session ====================
export type ControlRunState = 'idle' | 'running' | 'waiting_for_tool';
export type ControlAgentEvent =
| { type: 'status'; state: ControlRunState; queue_len: number }
| { type: 'user_message'; id: string; content: string }
| {
type: 'assistant_message';
id: string;
content: string;
success: boolean;
cost_cents: number;
model: string | null;
}
| { type: 'tool_call'; tool_call_id: string; name: string; args: unknown }
| { type: 'tool_result'; tool_call_id: string; name: string; result: unknown }
| { type: 'error'; message: string };
export async function postControlMessage(content: string): Promise<{ id: string; queued: boolean }> {
const res = await apiFetch('/api/control/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error('Failed to post control message');
return res.json();
}
export async function postControlToolResult(payload: {
tool_call_id: string;
name: string;
result: unknown;
}): Promise<void> {
const res = await apiFetch('/api/control/tool_result', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error('Failed to post tool result');
}
export async function cancelControl(): Promise<void> {
const res = await apiFetch('/api/control/cancel', { method: 'POST' });
if (!res.ok) throw new Error('Failed to cancel control session');
}
export function streamControl(
onEvent: (event: { type: string; data: unknown }) => void
): () => void {
const controller = new AbortController();
const decoder = new TextDecoder();
let buffer = '';
void (async () => {
try {
const res = await apiFetch('/api/control/stream', {
method: 'GET',
headers: { Accept: 'text/event-stream' },
signal: controller.signal,
});
if (!res.ok) {
onEvent({
type: 'error',
data: { message: `Stream request failed (${res.status})`, status: res.status },
});
return;
}
if (!res.body) {
onEvent({ type: 'error', data: { message: 'Stream response had no body' } });
return;
}
const reader = res.body.getReader();
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx = buffer.indexOf('\n\n');
while (idx !== -1) {
const raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
idx = buffer.indexOf('\n\n');
let eventType = 'message';
let data = '';
for (const line of raw.split('\n')) {
if (line.startsWith('event:')) {
eventType = line.slice('event:'.length).trim();
} else if (line.startsWith('data:')) {
data += line.slice('data:'.length).trim();
}
}
if (!data) continue;
try {
onEvent({ type: eventType, data: JSON.parse(data) });
} catch {
// ignore parse errors
}
}
}
} catch {
if (!controller.signal.aborted) {
onEvent({ type: 'error', data: { message: 'Stream connection failed' } });
}
}
})();
return () => controller.abort();
}

View File

@@ -0,0 +1 @@
export { cn } from "../utils";

View File

@@ -8,6 +8,7 @@ use crate::config::Config;
use crate::llm::LlmClient;
use crate::memory::MemorySystem;
use crate::tools::ToolRegistry;
use tokio::sync::broadcast;
/// Shared context passed to all agents during execution.
///
@@ -38,6 +39,18 @@ pub struct AgentContext {
/// Memory system for persistent storage (optional)
pub memory: Option<MemorySystem>,
/// Optional event sink for streaming agent events (e.g. control session SSE).
pub control_events: Option<broadcast::Sender<crate::api::control::AgentEvent>>,
/// Optional hub for awaiting frontend (interactive) tool results.
pub frontend_tool_hub: Option<Arc<crate::api::control::FrontendToolHub>>,
/// Optional shared control-session status (so the executor can switch to WaitingForTool).
pub control_status: Option<Arc<tokio::sync::RwLock<crate::api::control::ControlStatus>>>,
/// Optional cancellation token for cooperative cancellation.
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
}
impl AgentContext {
@@ -58,6 +71,10 @@ impl AgentContext {
workspace,
max_split_depth: 3, // Default max recursion for splitting
memory: None,
control_events: None,
frontend_tool_hub: None,
control_status: None,
cancel_token: None,
}
}
@@ -79,6 +96,10 @@ impl AgentContext {
workspace,
max_split_depth: 3,
memory,
control_events: None,
frontend_tool_hub: None,
control_status: None,
cancel_token: None,
}
}
@@ -96,6 +117,10 @@ impl AgentContext {
max_split_depth: self.max_split_depth.saturating_sub(1),
max_iterations: self.max_iterations,
memory: self.memory.clone(),
control_events: self.control_events.clone(),
frontend_tool_hub: self.frontend_tool_hub.clone(),
control_status: self.control_status.clone(),
cancel_token: self.cancel_token.clone(),
}
}
@@ -113,5 +138,13 @@ impl AgentContext {
pub fn has_memory(&self) -> bool {
self.memory.is_some()
}
/// Check if cooperative cancellation was requested.
pub fn is_cancelled(&self) -> bool {
self.cancel_token
.as_ref()
.map(|t| t.is_cancelled())
.unwrap_or(false)
}
}

View File

@@ -203,6 +203,10 @@ impl Agent for ComplexityEstimator {
/// # Returns
/// AgentResult with Complexity data in the `data` field.
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", 0);
}
let prompt = self.build_prompt(task);
let messages = vec![

View File

@@ -9,6 +9,7 @@ use serde_json::json;
use crate::agents::{
Agent, AgentContext, AgentId, AgentResult, AgentType, LeafAgent, LeafCapability,
};
use crate::api::control::{AgentEvent, ControlRunState};
use crate::budget::ExecutionSignals;
use crate::llm::{ChatMessage, Role, ToolCall};
use crate::task::{Task, TokenUsageSummary};
@@ -74,6 +75,8 @@ You operate in the workspace: {workspace}
3. Verify your work when possible
4. If stuck, explain what's blocking you
5. When done, summarize what you accomplished
6. For structured output (tables, lists of choices), prefer calling UI tools (ui_*) so the dashboard can render rich components.
7. If you call an interactive UI tool (e.g. ui_optionList), wait for the tool result and continue based on the user's selection.
## Response
When task is complete, provide a clear summary of:
@@ -147,6 +150,35 @@ When task is complete, provide a clear summary of:
iterations_completed = iteration as u32 + 1;
tracing::debug!("TaskExecutor iteration {}", iteration + 1);
// Cooperative cancellation (control session).
if let Some(token) = &ctx.cancel_token {
if token.is_cancelled() {
has_error_messages = true;
let signals = ExecutionSignals {
iterations: iterations_completed,
max_iterations: ctx.max_iterations as u32,
successful_tool_calls,
failed_tool_calls,
files_modified,
repetitive_actions,
has_error_messages,
partial_progress: files_modified || successful_tool_calls > 0,
cost_spent_cents: total_cost_cents,
budget_total_cents: task.budget().total_cents(),
final_output: "Cancelled".to_string(),
model_used: model.to_string(),
};
return ExecutionLoopResult {
output: "Cancelled".to_string(),
cost_cents: total_cost_cents,
tool_log,
usage,
signals,
success: false,
};
}
}
// Check budget
let remaining = task.budget().remaining_cents();
if remaining == 0 && total_cost_cents > 0 {
@@ -249,6 +281,28 @@ When task is complete, provide a clear summary of:
// Execute each tool call
for tool_call in tool_calls {
let tool_name = tool_call.function.name.clone();
let args_json: serde_json::Value =
serde_json::from_str(&tool_call.function.arguments)
.unwrap_or(serde_json::Value::Null);
// For interactive frontend tools, register the tool_call_id before notifying the UI,
// so a fast tool_result POST can't race ahead of registration.
let mut pending_frontend_rx: Option<tokio::sync::oneshot::Receiver<serde_json::Value>> = None;
if tool_name == "ui_optionList" {
if let Some(hub) = &ctx.frontend_tool_hub {
pending_frontend_rx = Some(hub.register(tool_call.id.clone()).await);
}
}
if let Some(events) = &ctx.control_events {
let _ = events.send(AgentEvent::ToolCall {
tool_call_id: tool_call.id.clone(),
name: tool_name.clone(),
args: args_json.clone(),
});
}
tool_log.push(format!(
"Tool: {} Args: {}",
tool_call.function.name,
@@ -256,27 +310,124 @@ When task is complete, provide a clear summary of:
));
// Track file modifications
if tool_call.function.name == "write_file"
|| tool_call.function.name == "delete_file" {
if tool_name == "write_file" || tool_name == "delete_file" {
files_modified = true;
}
let result = match self.execute_tool_call(tool_call, ctx).await {
Ok(output) => {
successful_tool_calls += 1;
output
}
Err(e) => {
failed_tool_calls += 1;
has_error_messages = true;
format!("Error: {}", e)
}
};
// UI tools are handled by the frontend. We emit events and (optionally) wait for a user result.
let (tool_message_content, tool_result_json): (String, serde_json::Value) =
if tool_name.starts_with("ui_") {
// Interactive tool: wait for frontend to POST result.
if tool_name == "ui_optionList" {
if let Some(rx) = pending_frontend_rx {
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
let mut s = status.write().await;
s.state = ControlRunState::WaitingForTool;
let q = s.queue_len;
drop(s);
let _ = events.send(AgentEvent::Status { state: ControlRunState::WaitingForTool, queue_len: q });
}
let recv = if let Some(token) = &ctx.cancel_token {
tokio::select! {
v = rx => v,
_ = token.cancelled() => {
has_error_messages = true;
let signals = ExecutionSignals {
iterations: iterations_completed,
max_iterations: ctx.max_iterations as u32,
successful_tool_calls,
failed_tool_calls,
files_modified,
repetitive_actions,
has_error_messages,
partial_progress: files_modified || successful_tool_calls > 0,
cost_spent_cents: total_cost_cents,
budget_total_cents: task.budget().total_cents(),
final_output: "Cancelled".to_string(),
model_used: model.to_string(),
};
return ExecutionLoopResult {
output: "Cancelled".to_string(),
cost_cents: total_cost_cents,
tool_log,
usage,
signals,
success: false,
};
}
}
} else {
rx.await
};
match recv {
Ok(v) => {
successful_tool_calls += 1;
let msg = serde_json::to_string(&v)
.unwrap_or_else(|_| v.to_string());
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
let mut s = status.write().await;
s.state = ControlRunState::Running;
let q = s.queue_len;
drop(s);
let _ = events.send(AgentEvent::Status { state: ControlRunState::Running, queue_len: q });
}
(msg, v)
}
Err(_) => {
has_error_messages = true;
failed_tool_calls += 1;
if let (Some(status), Some(events)) = (&ctx.control_status, &ctx.control_events) {
let mut s = status.write().await;
s.state = ControlRunState::Running;
let q = s.queue_len;
drop(s);
let _ = events.send(AgentEvent::Status { state: ControlRunState::Running, queue_len: q });
}
("Error: tool result channel closed".to_string(), serde_json::Value::Null)
}
}
} else {
has_error_messages = true;
failed_tool_calls += 1;
("Error: frontend tool hub not configured".to_string(), serde_json::Value::Null)
}
} else {
// Non-interactive UI render: echo args as the tool result payload.
let msg = serde_json::to_string(&args_json)
.unwrap_or_else(|_| args_json.to_string());
successful_tool_calls += 1;
(msg, args_json.clone())
}
} else {
// Regular server tool.
match self.execute_tool_call(tool_call, ctx).await {
Ok(output) => {
successful_tool_calls += 1;
(output.clone(), serde_json::Value::String(output))
}
Err(e) => {
failed_tool_calls += 1;
has_error_messages = true;
let s = format!("Error: {}", e);
(s.clone(), serde_json::Value::String(s))
}
}
};
if let Some(events) = &ctx.control_events {
let _ = events.send(AgentEvent::ToolResult {
tool_call_id: tool_call.id.clone(),
name: tool_name.clone(),
result: tool_result_json.clone(),
});
}
// Add tool result
messages.push(ChatMessage {
role: Role::Tool,
content: Some(result),
content: Some(tool_message_content),
tool_calls: None,
tool_call_id: Some(tool_call.id.clone()),
});

View File

@@ -266,6 +266,10 @@ impl Agent for ModelSelector {
/// # Returns
/// AgentResult with ModelRecommendation in the `data` field.
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", 0);
}
// Get complexity + estimated tokens from task analysis (populated by ComplexityEstimator).
let complexity = task
.analysis()

View File

@@ -313,6 +313,10 @@ impl Agent for Verifier {
}
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", 0);
}
let result = self.verify(task, ctx).await;
if result.passed() {

View File

@@ -93,6 +93,10 @@ impl Agent for NodeAgent {
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
tracing::debug!("NodeAgent '{}' executing task", self.name);
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", 0);
}
// Execute the task
let result = self.task_executor.execute(task, ctx).await;

View File

@@ -206,6 +206,10 @@ Respond ONLY with the JSON object."#,
// Execute each subtask with planning + verification + smart retry.
for task in &mut tasks {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", total_cost);
}
let subtask_result = self
.execute_single_subtask_with_retry(task, ctx, &retry_config)
.await;
@@ -260,6 +264,10 @@ Respond ONLY with the JSON object."#,
let mut retry_history = Vec::new();
loop {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", total_cost);
}
// 1) Estimate complexity for this subtask.
let est = self.complexity_estimator.execute(task, ctx).await;
total_cost += est.cost_cents;
@@ -500,6 +508,10 @@ impl Agent for RootAgent {
async fn execute(&self, task: &mut Task, ctx: &AgentContext) -> AgentResult {
let mut total_cost = 0u64;
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", total_cost);
}
// Step 1: Estimate complexity (cost is tracked in the result)
let complexity_result = self.complexity_estimator.execute(task, ctx).await;
total_cost += complexity_result.cost_cents;
@@ -576,6 +588,10 @@ impl RootAgent {
let mut retry_history = Vec::new();
loop {
if ctx.is_cancelled() {
return AgentResult::failure("Cancelled", total_cost);
}
// Select model (U-curve) for execution
let sel = self.model_selector.execute(task, ctx).await;
total_cost += sel.cost_cents;

529
src/api/control.rs Normal file
View File

@@ -0,0 +1,529 @@
//! Global control session API (interactive, queued).
//!
//! This module implements a single global "control session" that:
//! - accepts user messages at any time (queued FIFO)
//! - runs a persistent root-agent conversation sequentially
//! - streams structured events via SSE (Tool UI friendly)
//! - supports frontend/interactive tools by accepting tool results
use std::collections::{HashMap, VecDeque};
use std::convert::Infallible;
use std::sync::Arc;
use axum::{
extract::State,
http::StatusCode,
response::sse::{Event, Sse},
Json,
};
use futures::stream::Stream;
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, mpsc, oneshot, Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::agents::{AgentContext, AgentRef};
use crate::budget::{Budget, ModelPricing};
use crate::config::Config;
use crate::llm::OpenRouterClient;
use crate::memory::MemorySystem;
use crate::task::VerificationCriteria;
use crate::tools::ToolRegistry;
use super::routes::AppState;
/// Message posted by a user to the control session.
#[derive(Debug, Clone, Deserialize)]
pub struct ControlMessageRequest {
pub content: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ControlMessageResponse {
pub id: Uuid,
pub queued: bool,
}
/// Tool result posted by the frontend for an interactive tool call.
#[derive(Debug, Clone, Deserialize)]
pub struct ControlToolResultRequest {
pub tool_call_id: String,
pub name: String,
pub result: serde_json::Value,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ControlRunState {
Idle,
Running,
WaitingForTool,
}
impl Default for ControlRunState {
fn default() -> Self {
ControlRunState::Idle
}
}
/// A structured event emitted by the control session.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
Status {
state: ControlRunState,
queue_len: usize,
},
UserMessage {
id: Uuid,
content: String,
},
AssistantMessage {
id: Uuid,
content: String,
success: bool,
cost_cents: u64,
model: Option<String>,
},
ToolCall {
tool_call_id: String,
name: String,
args: serde_json::Value,
},
ToolResult {
tool_call_id: String,
name: String,
result: serde_json::Value,
},
Error {
message: String,
},
}
impl AgentEvent {
pub fn event_name(&self) -> &'static str {
match self {
AgentEvent::Status { .. } => "status",
AgentEvent::UserMessage { .. } => "user_message",
AgentEvent::AssistantMessage { .. } => "assistant_message",
AgentEvent::ToolCall { .. } => "tool_call",
AgentEvent::ToolResult { .. } => "tool_result",
AgentEvent::Error { .. } => "error",
}
}
}
/// Internal control commands (queued and processed by the actor).
#[derive(Debug)]
pub enum ControlCommand {
UserMessage { id: Uuid, content: String },
ToolResult {
tool_call_id: String,
name: String,
result: serde_json::Value,
},
Cancel,
}
/// Shared tool hub used to await frontend tool results.
#[derive(Debug)]
pub struct FrontendToolHub {
pending: Mutex<HashMap<String, oneshot::Sender<serde_json::Value>>>,
}
impl FrontendToolHub {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
}
}
/// Register a tool call that expects a frontend-provided result.
pub async fn register(&self, tool_call_id: String) -> oneshot::Receiver<serde_json::Value> {
let (tx, rx) = oneshot::channel();
let mut pending = self.pending.lock().await;
pending.insert(tool_call_id, tx);
rx
}
/// Resolve a pending tool call by id.
pub async fn resolve(
&self,
tool_call_id: &str,
result: serde_json::Value,
) -> Result<(), ()> {
let mut pending = self.pending.lock().await;
let Some(tx) = pending.remove(tool_call_id) else {
return Err(());
};
let _ = tx.send(result);
Ok(())
}
}
/// Control session runtime stored in `AppState`.
#[derive(Clone)]
pub struct ControlState {
pub cmd_tx: mpsc::Sender<ControlCommand>,
pub events_tx: broadcast::Sender<AgentEvent>,
pub tool_hub: Arc<FrontendToolHub>,
pub status: Arc<RwLock<ControlStatus>>,
}
#[derive(Debug, Clone, Serialize, Default)]
pub struct ControlStatus {
pub state: ControlRunState,
pub queue_len: usize,
}
async fn set_and_emit_status(
status: &Arc<RwLock<ControlStatus>>,
events: &broadcast::Sender<AgentEvent>,
state: ControlRunState,
queue_len: usize,
) {
{
let mut s = status.write().await;
s.state = state;
s.queue_len = queue_len;
}
let _ = events.send(AgentEvent::Status { state, queue_len });
}
/// Enqueue a user message for the global control session.
pub async fn post_message(
State(state): State<Arc<AppState>>,
Json(req): Json<ControlMessageRequest>,
) -> Result<Json<ControlMessageResponse>, (StatusCode, String)> {
let content = req.content.trim().to_string();
if content.is_empty() {
return Err((StatusCode::BAD_REQUEST, "content is required".to_string()));
}
let id = Uuid::new_v4();
let queued = true;
state
.control
.cmd_tx
.send(ControlCommand::UserMessage { id, content })
.await
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
Ok(Json(ControlMessageResponse { id, queued }))
}
/// Submit a frontend tool result to resume the running agent.
pub async fn post_tool_result(
State(state): State<Arc<AppState>>,
Json(req): Json<ControlToolResultRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
if req.tool_call_id.trim().is_empty() {
return Err((
StatusCode::BAD_REQUEST,
"tool_call_id is required".to_string(),
));
}
if req.name.trim().is_empty() {
return Err((StatusCode::BAD_REQUEST, "name is required".to_string()));
}
state
.control
.cmd_tx
.send(ControlCommand::ToolResult {
tool_call_id: req.tool_call_id,
name: req.name,
result: req.result,
})
.await
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Cancel the currently running control session task.
pub async fn post_cancel(
State(state): State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
state
.control
.cmd_tx
.send(ControlCommand::Cancel)
.await
.map_err(|_| (StatusCode::SERVICE_UNAVAILABLE, "control session unavailable".to_string()))?;
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Stream control session events via SSE.
pub async fn stream(
State(state): State<Arc<AppState>>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, (StatusCode, String)> {
let mut rx = state.control.events_tx.subscribe();
// Emit an initial status snapshot immediately.
let initial = state.control.status.read().await.clone();
let stream = async_stream::stream! {
let init_ev = Event::default()
.event("status")
.json_data(AgentEvent::Status { state: initial.state, queue_len: initial.queue_len })
.unwrap();
yield Ok(init_ev);
loop {
match rx.recv().await {
Ok(ev) => {
let sse = Event::default().event(ev.event_name()).json_data(&ev).unwrap();
yield Ok(sse);
}
Err(broadcast::error::RecvError::Lagged(_)) => {
let sse = Event::default()
.event("error")
.json_data(AgentEvent::Error { message: "event stream lagged; some events were dropped".to_string() })
.unwrap();
yield Ok(sse);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
};
Ok(Sse::new(stream))
}
/// Spawn the global control session actor.
pub fn spawn_control_session(
config: Config,
root_agent: AgentRef,
memory: Option<MemorySystem>,
) -> ControlState {
let (cmd_tx, cmd_rx) = mpsc::channel::<ControlCommand>(256);
let (events_tx, _events_rx) = broadcast::channel::<AgentEvent>(1024);
let tool_hub = Arc::new(FrontendToolHub::new());
let status = Arc::new(RwLock::new(ControlStatus {
state: ControlRunState::Idle,
queue_len: 0,
}));
let state = ControlState {
cmd_tx,
events_tx: events_tx.clone(),
tool_hub: Arc::clone(&tool_hub),
status: Arc::clone(&status),
};
tokio::spawn(control_actor_loop(
config,
root_agent,
memory,
cmd_rx,
events_tx,
tool_hub,
status,
));
state
}
async fn control_actor_loop(
config: Config,
root_agent: AgentRef,
memory: Option<MemorySystem>,
mut cmd_rx: mpsc::Receiver<ControlCommand>,
events_tx: broadcast::Sender<AgentEvent>,
tool_hub: Arc<FrontendToolHub>,
status: Arc<RwLock<ControlStatus>>,
) {
let mut queue: VecDeque<(Uuid, String)> = VecDeque::new();
let mut history: Vec<(String, String)> = Vec::new(); // (role, content) pairs (user/assistant)
let pricing = Arc::new(ModelPricing::new());
let mut running: Option<tokio::task::JoinHandle<(Uuid, String, crate::agents::AgentResult)>> = None;
let mut running_cancel: Option<CancellationToken> = None;
loop {
tokio::select! {
cmd = cmd_rx.recv() => {
let Some(cmd) = cmd else { break };
match cmd {
ControlCommand::UserMessage { id, content } => {
queue.push_back((id, content));
set_and_emit_status(
&status,
&events_tx,
if running.is_some() { ControlRunState::Running } else { ControlRunState::Idle },
queue.len(),
).await;
if running.is_none() {
if let Some((mid, msg)) = queue.pop_front() {
set_and_emit_status(&status, &events_tx, ControlRunState::Running, queue.len()).await;
let _ = events_tx.send(AgentEvent::UserMessage { id: mid, content: msg.clone() });
let cfg = config.clone();
let agent = Arc::clone(&root_agent);
let mem = memory.clone();
let events = events_tx.clone();
let tools_hub = Arc::clone(&tool_hub);
let status_ref = Arc::clone(&status);
let cancel = CancellationToken::new();
let pricing = Arc::clone(&pricing);
let hist_snapshot = history.clone();
running_cancel = Some(cancel.clone());
running = Some(tokio::spawn(async move {
let result = run_single_control_turn(
cfg,
agent,
mem,
pricing,
events,
tools_hub,
status_ref,
cancel,
hist_snapshot,
msg.clone(),
)
.await;
(mid, msg, result)
}));
} else {
set_and_emit_status(&status, &events_tx, ControlRunState::Idle, 0).await;
}
}
}
ControlCommand::ToolResult { tool_call_id, name, result } => {
// Deliver to the tool hub. The executor emits ToolResult events when it receives it.
if tool_hub.resolve(&tool_call_id, result).await.is_err() {
let _ = events_tx.send(AgentEvent::Error { message: format!("Unknown tool_call_id '{}' for tool '{}'", tool_call_id, name) });
}
}
ControlCommand::Cancel => {
if let Some(token) = &running_cancel {
token.cancel();
let _ = events_tx.send(AgentEvent::Error { message: "Cancellation requested".to_string() });
} else {
let _ = events_tx.send(AgentEvent::Error { message: "No running task to cancel".to_string() });
}
}
}
}
finished = async {
match &mut running {
Some(handle) => Some(handle.await),
None => None
}
}, if running.is_some() => {
if let Some(res) = finished {
running = None;
running_cancel = None;
match res {
Ok((_mid, user_msg, agent_result)) => {
// Append to conversation history.
history.push(("user".to_string(), user_msg));
history.push(("assistant".to_string(), agent_result.output.clone()));
let _ = events_tx.send(AgentEvent::AssistantMessage {
id: Uuid::new_v4(),
content: agent_result.output.clone(),
success: agent_result.success,
cost_cents: agent_result.cost_cents,
model: agent_result.model_used,
});
}
Err(e) => {
let _ = events_tx.send(AgentEvent::Error { message: format!("Control session task join failed: {}", e) });
}
}
}
// Start next queued message, if any.
if let Some((mid, msg)) = queue.pop_front() {
set_and_emit_status(&status, &events_tx, ControlRunState::Running, queue.len()).await;
let _ = events_tx.send(AgentEvent::UserMessage { id: mid, content: msg.clone() });
let cfg = config.clone();
let agent = Arc::clone(&root_agent);
let mem = memory.clone();
let events = events_tx.clone();
let tools_hub = Arc::clone(&tool_hub);
let status_ref = Arc::clone(&status);
let cancel = CancellationToken::new();
let pricing = Arc::clone(&pricing);
let hist_snapshot = history.clone();
running_cancel = Some(cancel.clone());
running = Some(tokio::spawn(async move {
let result = run_single_control_turn(
cfg,
agent,
mem,
pricing,
events,
tools_hub,
status_ref,
cancel,
hist_snapshot,
msg.clone(),
)
.await;
(mid, msg, result)
}));
} else {
set_and_emit_status(&status, &events_tx, ControlRunState::Idle, 0).await;
}
}
}
}
}
async fn run_single_control_turn(
config: Config,
root_agent: AgentRef,
memory: Option<MemorySystem>,
pricing: Arc<ModelPricing>,
events_tx: broadcast::Sender<AgentEvent>,
tool_hub: Arc<FrontendToolHub>,
status: Arc<RwLock<ControlStatus>>,
cancel: CancellationToken,
history: Vec<(String, String)>,
user_message: String,
) -> crate::agents::AgentResult {
// Build a task prompt that includes lightweight conversation context.
let mut convo = String::new();
if !history.is_empty() {
convo.push_str("Conversation so far:\n");
for (role, content) in &history {
convo.push_str(&format!("{}: {}\n", role, content));
}
convo.push('\n');
}
convo.push_str("User:\n");
convo.push_str(&user_message);
convo.push_str("\n\nInstructions:\n- Continue the conversation helpfully.\n- You may use tools to gather information or make changes.\n- When appropriate, use Tool UI tools (ui_*) for structured output or to ask for user selections.\n");
let budget = Budget::new(1000);
let verification = VerificationCriteria::None;
let mut task = match crate::task::Task::new(convo, verification, budget) {
Ok(t) => t,
Err(e) => {
let r = crate::agents::AgentResult::failure(format!("Failed to create task: {}", e), 0);
return r;
}
};
// Context for agent execution.
let llm = Arc::new(OpenRouterClient::new(config.api_key.clone()));
let tools = ToolRegistry::new();
let mut ctx = AgentContext::with_memory(
config.clone(),
llm,
tools,
pricing,
config.workspace_path.clone(),
memory,
);
ctx.control_events = Some(events_tx);
ctx.frontend_tool_hub = Some(tool_hub);
ctx.control_status = Some(status);
ctx.cancel_token = Some(cancel);
let result = root_agent.execute(&mut task, &ctx).await;
result
}

View File

@@ -12,13 +12,12 @@ use axum::{
response::{IntoResponse, Response},
Json,
};
use base64::Engine;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
use super::routes::AppState;
use super::ssh_util::{materialize_private_key, sftp_batch, ssh_exec};
use super::ssh_util::{materialize_private_key, sftp_batch, ssh_exec, ssh_exec_with_stdin};
#[derive(Debug, Deserialize)]
pub struct PathQuery {
@@ -45,9 +44,7 @@ pub struct FsEntry {
pub mtime: i64,
}
fn python_list_script_b64() -> String {
// Reads sys.argv[1] as path; prints JSON list to stdout.
let script = r#"
const LIST_SCRIPT: &str = r#"
import os, sys, json, stat
path = sys.argv[1]
@@ -80,8 +77,6 @@ except FileNotFoundError:
print(json.dumps(out))
"#;
base64::engine::general_purpose::STANDARD.encode(script.as_bytes())
}
async fn get_key_and_cfg(state: &Arc<AppState>) -> Result<(crate::config::ConsoleSshConfig, super::ssh_util::TempKeyFile), (StatusCode, String)> {
let cfg = state.config.console_ssh.clone();
@@ -101,13 +96,13 @@ pub async fn list(
) -> Result<Json<Vec<FsEntry>>, (StatusCode, String)> {
let (cfg, key_file) = get_key_and_cfg(&state).await?;
let b64 = python_list_script_b64();
let code = format!("import base64,sys; exec(base64.b64decode('{}'))", b64);
let out = ssh_exec(
// Avoid ssh quoting issues by piping the script on stdin.
let out = ssh_exec_with_stdin(
&cfg,
key_file.path(),
"python3",
&vec!["-c".into(), code, q.path.clone()],
&vec!["-".into(), q.path.clone()],
LIST_SCRIPT,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;

View File

@@ -10,6 +10,7 @@
mod routes;
mod auth;
mod console;
pub mod control;
mod fs;
mod ssh_util;
pub mod types;

View File

@@ -32,6 +32,7 @@ use crate::tools::ToolRegistry;
use super::types::*;
use super::auth;
use super::console;
use super::control;
use super::fs;
/// Shared application state.
@@ -42,6 +43,8 @@ pub struct AppState {
pub root_agent: AgentRef,
/// Memory system (optional)
pub memory: Option<MemorySystem>,
/// Global interactive control session
pub control: control::ControlState,
}
/// Start the HTTP server.
@@ -54,12 +57,20 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
// Initialize memory system (optional - needs Supabase config)
let memory = memory::init_memory(&config.memory, &config.api_key).await;
// Spawn the single global control session actor.
let control_state = control::spawn_control_session(
config.clone(),
Arc::clone(&root_agent),
memory.clone(),
);
let state = Arc::new(AppState {
config: config.clone(),
tasks: RwLock::new(HashMap::new()),
root_agent,
memory,
control: control_state,
});
let public_routes = Router::new()
@@ -75,6 +86,11 @@ pub async fn serve(config: Config) -> anyhow::Result<()> {
.route("/api/task/:id/stop", post(stop_task))
.route("/api/task/:id/stream", get(stream_task))
.route("/api/tasks", get(list_tasks))
// Global control session endpoints
.route("/api/control/message", post(control::post_message))
.route("/api/control/tool_result", post(control::post_tool_result))
.route("/api/control/stream", get(control::stream))
.route("/api/control/cancel", post(control::post_cancel))
// Memory endpoints
.route("/api/runs", get(list_runs))
.route("/api/runs/:id", get(get_run))

View File

@@ -52,6 +52,8 @@ fn ssh_base_args(cfg: &ConsoleSshConfig, key_path: &Path) -> Vec<String> {
"-o".to_string(),
"BatchMode=yes".to_string(),
"-o".to_string(),
"LogLevel=ERROR".to_string(),
"-o".to_string(),
"StrictHostKeyChecking=accept-new".to_string(),
"-o".to_string(),
// Keep known_hosts separate from system to avoid permission issues.
@@ -88,12 +90,53 @@ pub async fn ssh_exec(cfg: &ConsoleSshConfig, key_path: &Path, remote_cmd: &str,
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
/// Execute a remote command and feed `stdin_data` to its stdin (useful to avoid shell quoting issues).
pub async fn ssh_exec_with_stdin(
cfg: &ConsoleSshConfig,
key_path: &Path,
remote_cmd: &str,
args: &[String],
stdin_data: &str,
) -> anyhow::Result<String> {
let mut cmd = Command::new("ssh");
for a in ssh_base_args(cfg, key_path) {
cmd.arg(a);
}
cmd.arg(format!("{}@{}", cfg.user, cfg.host));
cmd.arg("--");
cmd.arg(remote_cmd);
for a in args {
cmd.arg(a);
}
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
stdin.write_all(stdin_data.as_bytes()).await?;
}
let out = tokio::time::timeout(Duration::from_secs(30), child.wait_with_output()).await??;
if !out.status.success() {
return Err(anyhow::anyhow!(
"ssh failed (code {:?}): {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub async fn sftp_batch(cfg: &ConsoleSshConfig, key_path: &Path, batch: &str) -> anyhow::Result<()> {
let mut cmd = Command::new("sftp");
cmd.arg("-b").arg("-");
cmd.arg("-i").arg(key_path);
cmd.arg("-P").arg(cfg.port.to_string());
cmd.arg("-o").arg("BatchMode=yes");
cmd.arg("-o").arg("LogLevel=ERROR");
cmd.arg("-o").arg("StrictHostKeyChecking=accept-new");
cmd.arg("-o").arg(format!(
"UserKnownHostsFile={}",

View File

@@ -255,6 +255,22 @@ impl ExecutionSignals {
|| output_lower.contains("connection refused")
|| output_lower.contains("timeout")
|| output_lower.contains("rate limit")
|| output_lower.contains("429")
|| output_lower.contains("too many requests")
|| output_lower.contains("rate limited")
|| output_lower.contains("server error")
|| output_lower.contains("502")
|| output_lower.contains("503")
|| output_lower.contains("504")
}
/// Check if this is specifically a rate limit error.
pub fn is_rate_limit_error(&self) -> bool {
let output_lower = self.final_output.to_lowercase();
output_lower.contains("rate limit")
|| output_lower.contains("429")
|| output_lower.contains("too many requests")
|| output_lower.contains("rate limited")
}
/// Generate a retry recommendation based on analysis.
@@ -308,10 +324,20 @@ impl ExecutionSignals {
}
FailureMode::ExternalError => {
// External issue, retry with same setup
RetryRecommendation::ContinueSameModel {
additional_budget_cents: self.cost_spent_cents / 2, // Less budget needed for retry
reason: "External error occurred. Retry with same configuration.".to_string(),
// Check if it's a rate limit error
if self.is_rate_limit_error() {
// Rate limit - try a different model to avoid hitting the same limit
RetryRecommendation::TryCheaperModel {
suggested_model: self.suggest_alternative_model(),
additional_budget_cents: self.cost_spent_cents,
reason: "Rate limited by provider. Trying alternative model to avoid same limit.".to_string(),
}
} else {
// Other external issue, retry with same setup
RetryRecommendation::ContinueSameModel {
additional_budget_cents: self.cost_spent_cents / 2, // Less budget needed for retry
reason: "External error occurred. Retry with same configuration.".to_string(),
}
}
}
@@ -388,6 +414,33 @@ impl ExecutionSignals {
}
}
}
/// Suggest an alternative model from a different provider (useful for rate limits).
fn suggest_alternative_model(&self) -> Option<String> {
// When rate limited, try a model from a different provider
match self.model_used.as_str() {
// Anthropic -> OpenAI
m if m.starts_with("anthropic/") => {
Some("openai/gpt-4o-mini".to_string())
}
// OpenAI -> Google
m if m.starts_with("openai/") => {
Some("google/gemini-2.0-flash-001".to_string())
}
// Google -> Anthropic
m if m.starts_with("google/") => {
Some("anthropic/claude-haiku-4.5".to_string())
}
// Mistral -> Anthropic (particularly for free tier rate limits)
m if m.starts_with("mistralai/") || m.contains("mistral") => {
Some("anthropic/claude-haiku-4.5".to_string())
}
// Default: try Anthropic
_ => {
Some("anthropic/claude-haiku-4.5".to_string())
}
}
}
}
/// Configuration for retry behavior.

View File

@@ -221,13 +221,21 @@ impl Config {
}
// Memory configuration (optional)
let embed_model = std::env::var("MEMORY_EMBED_MODEL")
.unwrap_or_else(|_| "openai/text-embedding-3-small".to_string());
// Determine embed dimension from env or infer from model
let embed_dimension = std::env::var("MEMORY_EMBED_DIMENSION")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or_else(|| infer_embed_dimension(&embed_model));
let memory = MemoryConfig {
supabase_url: std::env::var("SUPABASE_URL").ok(),
supabase_service_role_key: std::env::var("SUPABASE_SERVICE_ROLE_KEY").ok(),
embed_model: std::env::var("MEMORY_EMBED_MODEL")
.unwrap_or_else(|_| "openai/text-embedding-3-small".to_string()),
embed_model,
rerank_model: std::env::var("MEMORY_RERANK_MODEL").ok(),
embed_dimension: 1536, // OpenAI text-embedding-3-small default
embed_dimension,
};
let console_ssh = ConsoleSshConfig {
@@ -284,6 +292,37 @@ fn parse_bool(value: &str) -> Result<bool, String> {
}
}
/// Infer embedding dimension from model name.
fn infer_embed_dimension(model: &str) -> usize {
let model_lower = model.to_lowercase();
// Qwen embedding models output 4096 dimensions
if model_lower.contains("qwen") && model_lower.contains("embedding") {
return 4096;
}
// OpenAI text-embedding-3 models
if model_lower.contains("text-embedding-3") {
if model_lower.contains("large") {
return 3072;
}
return 1536; // small
}
// OpenAI ada
if model_lower.contains("ada") {
return 1536;
}
// Cohere embed models
if model_lower.contains("embed-english") || model_lower.contains("embed-multilingual") {
return 1024;
}
// Default fallback
1536
}
fn read_private_key_from_env() -> Result<Option<String>, ConfigError> {
// Recommended: load from file path to avoid large/multiline env values.
if let Ok(path) = std::env::var("CONSOLE_SSH_PRIVATE_KEY_PATH") {

258
src/llm/error.rs Normal file
View File

@@ -0,0 +1,258 @@
//! LLM error types with retry classification.
//!
//! Distinguishes between transient errors (should retry) and permanent errors (should not retry).
use std::time::Duration;
/// Error from LLM API calls.
#[derive(Debug)]
pub struct LlmError {
/// The kind of error
pub kind: LlmErrorKind,
/// HTTP status code, if applicable
pub status_code: Option<u16>,
/// Error message
pub message: String,
/// Suggested retry delay (from Retry-After header or calculated)
pub retry_after: Option<Duration>,
}
impl LlmError {
/// Create a rate limit error.
pub fn rate_limited(message: String, retry_after: Option<Duration>) -> Self {
Self {
kind: LlmErrorKind::RateLimited,
status_code: Some(429),
message,
retry_after,
}
}
/// Create a server error.
pub fn server_error(status_code: u16, message: String) -> Self {
Self {
kind: LlmErrorKind::ServerError,
status_code: Some(status_code),
message,
retry_after: None,
}
}
/// Create a client error (bad request, auth, etc.).
pub fn client_error(status_code: u16, message: String) -> Self {
Self {
kind: LlmErrorKind::ClientError,
status_code: Some(status_code),
message,
retry_after: None,
}
}
/// Create a network error.
pub fn network_error(message: String) -> Self {
Self {
kind: LlmErrorKind::NetworkError,
status_code: None,
message,
retry_after: None,
}
}
/// Create a parse error.
pub fn parse_error(message: String) -> Self {
Self {
kind: LlmErrorKind::ParseError,
status_code: None,
message,
retry_after: None,
}
}
/// Check if this error is transient and should be retried.
pub fn is_transient(&self) -> bool {
self.kind.is_transient()
}
/// Get the suggested delay before retry.
///
/// Returns the `retry_after` if set, otherwise returns a default based on error kind.
pub fn suggested_delay(&self, attempt: u32) -> Duration {
if let Some(retry_after) = self.retry_after {
return retry_after;
}
// Exponential backoff with jitter
let base_delay = match self.kind {
LlmErrorKind::RateLimited => Duration::from_secs(5), // Start higher for rate limits
LlmErrorKind::ServerError => Duration::from_secs(2),
LlmErrorKind::NetworkError => Duration::from_secs(1),
_ => Duration::from_secs(1),
};
// Exponential backoff: base * 2^attempt, capped at 60 seconds
let multiplier = 2u64.saturating_pow(attempt);
let delay_secs = base_delay.as_secs().saturating_mul(multiplier).min(60);
// Add jitter (up to 25% of delay)
let jitter_range = delay_secs / 4;
let jitter = if jitter_range > 0 {
// Simple deterministic jitter based on attempt number
(attempt as u64 * 7) % jitter_range
} else {
0
};
Duration::from_secs(delay_secs + jitter)
}
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status_code {
Some(code) => write!(f, "{} (HTTP {}): {}", self.kind, code, self.message),
None => write!(f, "{}: {}", self.kind, self.message),
}
}
}
impl std::error::Error for LlmError {}
/// Classification of LLM errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LlmErrorKind {
/// Rate limited (429) - transient, should retry with backoff
RateLimited,
/// Server error (500, 502, 503, 504) - transient, should retry
ServerError,
/// Client error (400, 401, 403, 404) - permanent, should not retry
ClientError,
/// Network error (connection failed, timeout) - transient, should retry
NetworkError,
/// Response parsing error - usually permanent
ParseError,
}
impl LlmErrorKind {
/// Check if this error kind is transient (should retry).
pub fn is_transient(&self) -> bool {
matches!(
self,
LlmErrorKind::RateLimited | LlmErrorKind::ServerError | LlmErrorKind::NetworkError
)
}
}
impl std::fmt::Display for LlmErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LlmErrorKind::RateLimited => write!(f, "Rate limited"),
LlmErrorKind::ServerError => write!(f, "Server error"),
LlmErrorKind::ClientError => write!(f, "Client error"),
LlmErrorKind::NetworkError => write!(f, "Network error"),
LlmErrorKind::ParseError => write!(f, "Parse error"),
}
}
}
/// Configuration for retry behavior.
#[derive(Debug, Clone)]
pub struct RetryConfig {
/// Maximum number of retry attempts
pub max_retries: u32,
/// Maximum total time to spend retrying
pub max_retry_duration: Duration,
/// Whether to retry on rate limit errors
pub retry_rate_limits: bool,
/// Whether to retry on server errors
pub retry_server_errors: bool,
/// Whether to retry on network errors
pub retry_network_errors: bool,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
max_retry_duration: Duration::from_secs(120), // 2 minutes max
retry_rate_limits: true,
retry_server_errors: true,
retry_network_errors: true,
}
}
}
impl RetryConfig {
/// Check if the given error should be retried based on this config.
pub fn should_retry(&self, error: &LlmError) -> bool {
match error.kind {
LlmErrorKind::RateLimited => self.retry_rate_limits,
LlmErrorKind::ServerError => self.retry_server_errors,
LlmErrorKind::NetworkError => self.retry_network_errors,
LlmErrorKind::ClientError | LlmErrorKind::ParseError => false,
}
}
}
/// Parse HTTP status code into error kind.
pub fn classify_http_status(status: u16) -> LlmErrorKind {
match status {
429 => LlmErrorKind::RateLimited,
500 | 502 | 503 | 504 => LlmErrorKind::ServerError,
400..=499 => LlmErrorKind::ClientError,
_ => LlmErrorKind::ServerError,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transient_classification() {
assert!(LlmErrorKind::RateLimited.is_transient());
assert!(LlmErrorKind::ServerError.is_transient());
assert!(LlmErrorKind::NetworkError.is_transient());
assert!(!LlmErrorKind::ClientError.is_transient());
assert!(!LlmErrorKind::ParseError.is_transient());
}
#[test]
fn test_http_status_classification() {
assert_eq!(classify_http_status(429), LlmErrorKind::RateLimited);
assert_eq!(classify_http_status(500), LlmErrorKind::ServerError);
assert_eq!(classify_http_status(502), LlmErrorKind::ServerError);
assert_eq!(classify_http_status(503), LlmErrorKind::ServerError);
assert_eq!(classify_http_status(400), LlmErrorKind::ClientError);
assert_eq!(classify_http_status(401), LlmErrorKind::ClientError);
assert_eq!(classify_http_status(403), LlmErrorKind::ClientError);
}
#[test]
fn test_exponential_backoff() {
let error = LlmError::rate_limited("test".to_string(), None);
let delay_0 = error.suggested_delay(0);
let delay_1 = error.suggested_delay(1);
let delay_2 = error.suggested_delay(2);
// Should increase exponentially
assert!(delay_1 > delay_0);
assert!(delay_2 > delay_1);
// Should be capped
let delay_10 = error.suggested_delay(10);
assert!(delay_10.as_secs() <= 60);
}
#[test]
fn test_retry_after_respected() {
let error = LlmError::rate_limited(
"test".to_string(),
Some(Duration::from_secs(30)),
);
// Should use the provided retry_after, not calculate
assert_eq!(error.suggested_delay(0), Duration::from_secs(30));
assert_eq!(error.suggested_delay(5), Duration::from_secs(30));
}
}

View File

@@ -3,8 +3,10 @@
//! This module provides a trait-based abstraction over LLM providers,
//! with OpenRouter as the primary implementation.
mod error;
mod openrouter;
pub use error::{LlmError, LlmErrorKind, RetryConfig, classify_http_status};
pub use openrouter::OpenRouterClient;
use async_trait::async_trait;

View File

@@ -1,25 +1,206 @@
//! OpenRouter API client implementation.
//! OpenRouter API client implementation with automatic retry for transient errors.
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use super::error::{classify_http_status, LlmError, LlmErrorKind, RetryConfig};
use super::{ChatMessage, ChatOptions, ChatResponse, LlmClient, TokenUsage, ToolCall, ToolDefinition};
const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/v1/chat/completions";
/// OpenRouter API client.
/// OpenRouter API client with automatic retry for transient errors.
pub struct OpenRouterClient {
client: Client,
api_key: String,
retry_config: RetryConfig,
}
impl OpenRouterClient {
/// Create a new OpenRouter client.
/// Create a new OpenRouter client with default retry configuration.
pub fn new(api_key: String) -> Self {
Self {
client: Client::new(),
api_key,
retry_config: RetryConfig::default(),
}
}
/// Create a new OpenRouter client with custom retry configuration.
pub fn with_retry_config(api_key: String, retry_config: RetryConfig) -> Self {
Self {
client: Client::new(),
api_key,
retry_config,
}
}
/// Parse Retry-After header if present.
fn parse_retry_after(headers: &reqwest::header::HeaderMap) -> Option<Duration> {
headers
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| {
// Try parsing as seconds first
s.parse::<u64>().ok().map(Duration::from_secs)
})
}
/// Create an LlmError from HTTP response status and body.
fn create_error(
status: reqwest::StatusCode,
body: &str,
retry_after: Option<Duration>,
) -> LlmError {
let status_code = status.as_u16();
let kind = classify_http_status(status_code);
match kind {
LlmErrorKind::RateLimited => LlmError::rate_limited(body.to_string(), retry_after),
LlmErrorKind::ServerError => LlmError::server_error(status_code, body.to_string()),
LlmErrorKind::ClientError => LlmError::client_error(status_code, body.to_string()),
_ => LlmError::server_error(status_code, body.to_string()),
}
}
/// Execute a single request without retry.
async fn execute_request(
&self,
request: &OpenRouterRequest,
) -> Result<ChatResponse, LlmError> {
let response = match self
.client
.post(OPENROUTER_API_URL)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://github.com/open-agent")
.header("X-Title", "Open Agent")
.json(request)
.send()
.await
{
Ok(r) => r,
Err(e) => {
// Network or connection error
if e.is_timeout() {
return Err(LlmError::network_error(format!("Request timeout: {}", e)));
} else if e.is_connect() {
return Err(LlmError::network_error(format!("Connection failed: {}", e)));
} else {
return Err(LlmError::network_error(format!("Request failed: {}", e)));
}
}
};
let status = response.status();
let retry_after = Self::parse_retry_after(response.headers());
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(Self::create_error(status, &body, retry_after));
}
let parsed: OpenRouterResponse = serde_json::from_str(&body).map_err(|e| {
LlmError::parse_error(format!("Failed to parse response: {}, body: {}", e, body))
})?;
let choice = parsed
.choices
.into_iter()
.next()
.ok_or_else(|| LlmError::parse_error("No choices in response".to_string()))?;
Ok(ChatResponse {
content: choice.message.content,
tool_calls: choice.message.tool_calls,
finish_reason: choice.finish_reason,
usage: parsed
.usage
.map(|u| TokenUsage::new(u.prompt_tokens, u.completion_tokens)),
model: parsed.model.or_else(|| Some(request.model.clone())),
})
}
/// Execute a request with automatic retry for transient errors.
async fn execute_with_retry(
&self,
request: &OpenRouterRequest,
) -> anyhow::Result<ChatResponse> {
let start = Instant::now();
let mut attempt = 0;
let mut last_error: Option<LlmError> = None;
loop {
// Check if we've exceeded max retry duration
if start.elapsed() > self.retry_config.max_retry_duration {
let err = last_error.unwrap_or_else(|| {
LlmError::network_error("Max retry duration exceeded".to_string())
});
return Err(anyhow::anyhow!("{}", err));
}
match self.execute_request(request).await {
Ok(response) => {
if attempt > 0 {
tracing::info!(
"Request succeeded after {} retries (total time: {:?})",
attempt,
start.elapsed()
);
}
return Ok(response);
}
Err(error) => {
let should_retry =
self.retry_config.should_retry(&error) && attempt < self.retry_config.max_retries;
if should_retry {
let delay = error.suggested_delay(attempt);
// Make sure we won't exceed max retry duration
let remaining = self
.retry_config
.max_retry_duration
.saturating_sub(start.elapsed());
let actual_delay = delay.min(remaining);
if actual_delay.is_zero() {
tracing::warn!(
"Retry attempt {} failed, no time remaining: {}",
attempt + 1,
error
);
return Err(anyhow::anyhow!("{}", error));
}
tracing::warn!(
"Retry attempt {} failed with {}, retrying in {:?}: {}",
attempt + 1,
error.kind,
actual_delay,
error.message
);
tokio::time::sleep(actual_delay).await;
attempt += 1;
last_error = Some(error);
} else {
// Non-retryable error or max retries exceeded
if attempt > 0 {
tracing::error!(
"Request failed after {} retries (total time: {:?}): {}",
attempt,
start.elapsed(),
error
);
} else {
tracing::error!("Request failed (non-retryable): {}", error);
}
return Err(anyhow::anyhow!("{}", error));
}
}
}
}
}
}
@@ -55,47 +236,7 @@ impl LlmClient for OpenRouterClient {
tracing::debug!("Sending request to OpenRouter: model={}", model);
let response = self
.client
.post(OPENROUTER_API_URL)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("HTTP-Referer", "https://github.com/open-agent")
.header("X-Title", "Open Agent")
.json(&request)
.send()
.await?;
let status = response.status();
let body = response.text().await?;
if !status.is_success() {
tracing::error!("OpenRouter error: status={}, body={}", status, body);
return Err(anyhow::anyhow!(
"OpenRouter API error: {} - {}",
status,
body
));
}
let response: OpenRouterResponse = serde_json::from_str(&body).map_err(|e| {
tracing::error!("Failed to parse response: {}, body: {}", e, body);
anyhow::anyhow!("Failed to parse OpenRouter response: {}", e)
})?;
let choice = response
.choices
.into_iter()
.next()
.ok_or_else(|| anyhow::anyhow!("No choices in response"))?;
Ok(ChatResponse {
content: choice.message.content,
tool_calls: choice.message.tool_calls,
finish_reason: choice.finish_reason,
usage: response.usage.map(|u| TokenUsage::new(u.prompt_tokens, u.completion_tokens)),
model: response.model.or_else(|| Some(model.to_string())),
})
self.execute_with_retry(&request).await
}
}

View File

@@ -8,6 +8,7 @@ mod file_ops;
mod git;
mod search;
mod terminal;
mod ui;
mod web;
use std::collections::HashMap;
@@ -77,6 +78,10 @@ impl ToolRegistry {
tools.insert("git_commit".to_string(), Arc::new(git::GitCommit));
tools.insert("git_log".to_string(), Arc::new(git::GitLog));
// Frontend Tool UI (schemas for rich rendering in the dashboard)
tools.insert("ui_optionList".to_string(), Arc::new(ui::UiOptionList));
tools.insert("ui_dataTable".to_string(), Arc::new(ui::UiDataTable));
Self { tools }
}

94
src/tools/ui.rs Normal file
View File

@@ -0,0 +1,94 @@
//! Frontend (Tool UI) tools.
//!
//! These tools are intended to be rendered in the dashboard UI rather than executed
//! as real side-effecting operations. They exist mainly to provide tool schemas to
//! the LLM so it can request structured UI renderings.
use async_trait::async_trait;
use serde_json::{json, Value};
use std::path::Path;
use super::Tool;
/// Ask the user to pick from a list of options (interactive).
pub struct UiOptionList;
#[async_trait]
impl Tool for UiOptionList {
fn name(&self) -> &str {
"ui_optionList"
}
fn description(&self) -> &str {
"Render an interactive option list for the user to choose from (frontend Tool UI)."
}
fn parameters_schema(&self) -> Value {
// Intentionally permissive: we validate on the frontend before rendering.
json!({
"type": "object",
"required": ["id", "title", "options"],
"properties": {
"id": { "type": "string", "description": "Stable identifier for this UI element." },
"title": { "type": "string" },
"description": { "type": "string" },
"multiple": { "type": "boolean", "default": false },
"confirmLabel": { "type": "string" },
"options": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["id", "label"],
"properties": {
"id": { "type": "string" },
"label": { "type": "string" },
"description": { "type": "string" }
},
"additionalProperties": true
}
}
},
"additionalProperties": true
})
}
async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result<String> {
// The agent runtime intercepts ui_* tools and routes them to the frontend.
// This is a safe fallback for non-control-session executions.
Ok(serde_json::to_string(&args).unwrap_or_else(|_| args.to_string()))
}
}
/// Render a read-only data table (non-interactive).
pub struct UiDataTable;
#[async_trait]
impl Tool for UiDataTable {
fn name(&self) -> &str {
"ui_dataTable"
}
fn description(&self) -> &str {
"Render a data table with rows/columns (frontend Tool UI)."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"required": ["id", "columns", "rows"],
"properties": {
"id": { "type": "string" },
"title": { "type": "string" },
"columns": { "type": "array", "items": { "type": "object" }, "minItems": 1 },
"rows": { "type": "array", "items": { "type": "object" } }
},
"additionalProperties": true
})
}
async fn execute(&self, args: Value, _workspace: &Path) -> anyhow::Result<String> {
Ok(serde_json::to_string(&args).unwrap_or_else(|_| args.to_string()))
}
}