mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
4 Commits
fix/dev-se
...
feat/agent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04b9c605fa | ||
|
|
4388ce66a8 | ||
|
|
84dfd4d073 | ||
|
|
085e5368b6 |
@@ -2,7 +2,9 @@ import { Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import type { OpenClawStatus } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
@@ -74,14 +76,28 @@ function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(
|
||||
status: string | undefined,
|
||||
agent: AgentEntry | undefined,
|
||||
openClawStatus: OpenClawStatus | null,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
if (streaming) return 'Working on your request'
|
||||
if (status === 'running') return 'Ready for the next task'
|
||||
if (status === 'starting') return 'Connecting to OpenClaw'
|
||||
if (status === 'error') return 'OpenClaw needs attention'
|
||||
if (status === 'stopped') return 'OpenClaw is offline'
|
||||
if (agent?.adapterType !== 'openclaw') return 'Ready for the next task'
|
||||
if (canChatWithAgent(agent, openClawStatus)) return 'Ready for the next task'
|
||||
if (
|
||||
openClawStatus?.status === 'starting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'connecting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'reconnecting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'recovering'
|
||||
) {
|
||||
return 'Connecting to OpenClaw'
|
||||
}
|
||||
if (
|
||||
openClawStatus?.status === 'error' ||
|
||||
openClawStatus?.controlPlaneStatus === 'failed'
|
||||
) {
|
||||
return 'OpenClaw needs attention'
|
||||
}
|
||||
if (openClawStatus?.status === 'stopped') return 'OpenClaw is offline'
|
||||
return 'Open agent setup to continue'
|
||||
}
|
||||
|
||||
@@ -91,7 +107,7 @@ export const AgentCommandConversation: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const initialQuerySent = useRef(false)
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const { status, agents, agentsLoading } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
@@ -126,7 +142,7 @@ export const AgentCommandConversation: FC = () => {
|
||||
})
|
||||
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
if (shouldRedirectHome || (!agentsLoading && !agent)) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
@@ -134,7 +150,12 @@ export const AgentCommandConversation: FC = () => {
|
||||
navigate(`/home/agents/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
||||
const canSendToAgent = canChatWithAgent(agent, status)
|
||||
const statusCopy = getConversationStatusCopy(agent, status, streaming)
|
||||
const placeholder =
|
||||
agent?.adapterType === 'openclaw' && !canSendToAgent
|
||||
? `${agentName} is unavailable until OpenClaw reconnects...`
|
||||
: `Message ${agentName}...`
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
@@ -183,9 +204,9 @@ export const AgentCommandConversation: FC = () => {
|
||||
}}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={streaming}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
disabled={!canSendToAgent}
|
||||
openClawStatus={status}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { type FC, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
|
||||
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
|
||||
@@ -25,7 +26,7 @@ function AgentCommandSetupState({
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
Set up OpenClaw agents to turn your new tab into an agent command
|
||||
Create BrowserOS agents to turn your new tab into an agent command
|
||||
center.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
@@ -37,21 +38,6 @@ function AgentCommandSetupState({
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is running, but you do not have any agents yet.
|
||||
</p>
|
||||
<Button variant="outline" onClick={onOpenAgents}>
|
||||
Create your first agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenClawUnavailableState({
|
||||
onOpenAgents,
|
||||
}: {
|
||||
@@ -61,8 +47,8 @@ function OpenClawUnavailableState({
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is unavailable right now. Open the Agents page to restart the
|
||||
gateway or review setup.
|
||||
Some OpenClaw agents are unavailable right now. Open the Agents page
|
||||
to restart the gateway or review setup.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
Open Agent Setup
|
||||
@@ -76,10 +62,10 @@ function OpenClawUnavailableState({
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const { status, agents, agentsLoading } = useAgentCommandData()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = useAgentCardData(agents, status?.status)
|
||||
const cardData = useAgentCardData(agents, status)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
@@ -93,16 +79,22 @@ export const AgentCommandHome: FC = () => {
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackAgent =
|
||||
agents.find((agent) => canChatWithAgent(agent, status)) ?? agents[0]
|
||||
|
||||
if (
|
||||
!selectedAgentId ||
|
||||
!agents.some((agent) => agent.agentId === selectedAgentId)
|
||||
) {
|
||||
setSelectedAgentId(agents[0].agentId)
|
||||
setSelectedAgentId(fallbackAgent?.agentId ?? null)
|
||||
}
|
||||
}, [agents, selectedAgentId])
|
||||
}, [agents, selectedAgentId, status])
|
||||
|
||||
const handleSend = (text: string) => {
|
||||
if (!selectedAgentId) return
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
if (!selectedAgentId || !canChatWithAgent(selectedAgent, status)) return
|
||||
navigate(`/home/agents/${selectedAgentId}?q=${encodeURIComponent(text)}`)
|
||||
}
|
||||
|
||||
@@ -110,13 +102,18 @@ export const AgentCommandHome: FC = () => {
|
||||
setSelectedAgentId(agent.agentId)
|
||||
}
|
||||
|
||||
const openClawStatus = status?.status
|
||||
const isSetup = openClawStatus != null && openClawStatus !== 'uninitialized'
|
||||
const shouldShowUnavailableState =
|
||||
openClawStatus != null &&
|
||||
openClawStatus !== 'running' &&
|
||||
openClawStatus !== 'uninitialized' &&
|
||||
cardData.length === 0
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
const canSendToSelectedAgent = canChatWithAgent(selectedAgent, status)
|
||||
const hasUnavailableOpenClawAgent = agents.some(
|
||||
(agent) =>
|
||||
agent.adapterType === 'openclaw' && !canChatWithAgent(agent, status),
|
||||
)
|
||||
const inputPlaceholder =
|
||||
selectedAgent?.adapterType === 'openclaw' && !canSendToSelectedAgent
|
||||
? 'Selected OpenClaw agent is unavailable...'
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
@@ -131,42 +128,41 @@ export const AgentCommandHome: FC = () => {
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? undefined
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
disabled={!canSendToSelectedAgent}
|
||||
openClawStatus={status}
|
||||
placeholder={inputPlaceholder}
|
||||
/>
|
||||
|
||||
{mounted ? <NewTabTip /> : null}
|
||||
|
||||
{isSetup ? (
|
||||
shouldShowUnavailableState ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
) : cardData.length > 0 ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick up where your agents left off.
|
||||
</p>
|
||||
</div>
|
||||
{agentsLoading ? (
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="p-6 text-center text-muted-foreground text-sm">
|
||||
Loading agents...
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : cardData.length > 0 ? (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick up where your agents left off.
|
||||
</p>
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={cardData}
|
||||
activeAgentId={selectedAgentId ?? undefined}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={cardData}
|
||||
activeAgentId={selectedAgentId ?? undefined}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
/>
|
||||
{hasUnavailableOpenClawAgent ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
</section>
|
||||
) : (
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
) : (
|
||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
||||
)}
|
||||
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { canChatWithAgent } from '@/entrypoints/app/agents/agent-availability'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -26,13 +28,36 @@ interface AgentSelectorProps {
|
||||
selectedAgentId: string | null
|
||||
onSelectAgent: (agent: AgentEntry) => void
|
||||
onCreateAgent?: () => void
|
||||
status?: string
|
||||
openClawStatus?: OpenClawStatus | null
|
||||
}
|
||||
|
||||
function getStatusDot(status?: string) {
|
||||
if (status === 'running') return 'bg-emerald-500'
|
||||
if (status === 'starting') return 'bg-amber-500 animate-pulse'
|
||||
if (status === 'error') return 'bg-destructive'
|
||||
function getStatusDot(
|
||||
agent: AgentEntry | undefined,
|
||||
openClawStatus?: OpenClawStatus | null,
|
||||
) {
|
||||
if (!agent) {
|
||||
return 'bg-muted-foreground/50'
|
||||
}
|
||||
if (agent.adapterType !== 'openclaw') {
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
if (canChatWithAgent(agent, openClawStatus ?? null)) {
|
||||
return 'bg-emerald-500'
|
||||
}
|
||||
if (
|
||||
openClawStatus?.status === 'starting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'connecting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'reconnecting' ||
|
||||
openClawStatus?.controlPlaneStatus === 'recovering'
|
||||
) {
|
||||
return 'bg-amber-500 animate-pulse'
|
||||
}
|
||||
if (
|
||||
openClawStatus?.status === 'error' ||
|
||||
openClawStatus?.controlPlaneStatus === 'failed'
|
||||
) {
|
||||
return 'bg-destructive'
|
||||
}
|
||||
return 'bg-muted-foreground/50'
|
||||
}
|
||||
|
||||
@@ -41,7 +66,7 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
selectedAgentId,
|
||||
onSelectAgent,
|
||||
onCreateAgent,
|
||||
status,
|
||||
openClawStatus,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedAgent = agents.find(
|
||||
@@ -60,7 +85,12 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
<span className={cn('size-2 rounded-full', getStatusDot(status))} />
|
||||
<span
|
||||
className={cn(
|
||||
'size-2 rounded-full',
|
||||
getStatusDot(selectedAgent, openClawStatus),
|
||||
)}
|
||||
/>
|
||||
<span className="max-w-32 truncate">
|
||||
{selectedAgent?.name ?? 'Select agent'}
|
||||
</span>
|
||||
@@ -75,7 +105,13 @@ export const AgentSelector: FC<AgentSelectorProps> = ({
|
||||
<CommandGroup>
|
||||
{agents.map((agent) => {
|
||||
const isSelected = selectedAgentId === agent.agentId
|
||||
const modelLabel = getModelDisplayName(agent.model)
|
||||
const modelLabel =
|
||||
getModelDisplayName(agent.model) ??
|
||||
(agent.adapterType === 'codex_local'
|
||||
? 'Codex local'
|
||||
: agent.adapterType === 'claude_local'
|
||||
? 'Claude local'
|
||||
: 'OpenClaw')
|
||||
return (
|
||||
<CommandItem
|
||||
key={agent.agentId}
|
||||
|
||||
@@ -13,7 +13,8 @@ import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import type { OpenClawStatus } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
import { Feature } from '@/lib/browseros/capabilities'
|
||||
@@ -32,7 +33,7 @@ interface ConversationInputProps {
|
||||
onCreateAgent?: () => void
|
||||
streaming: boolean
|
||||
disabled?: boolean
|
||||
status?: string
|
||||
openClawStatus?: OpenClawStatus | null
|
||||
placeholder?: string
|
||||
variant?: 'home' | 'conversation'
|
||||
}
|
||||
@@ -122,7 +123,7 @@ function ContextControls({
|
||||
selectedTabs,
|
||||
onToggleTab,
|
||||
showAgentSelector,
|
||||
status,
|
||||
openClawStatus,
|
||||
}: {
|
||||
agents: AgentEntry[]
|
||||
onCreateAgent?: () => void
|
||||
@@ -131,7 +132,7 @@ function ContextControls({
|
||||
selectedTabs: chrome.tabs.Tab[]
|
||||
onToggleTab: (tab: chrome.tabs.Tab) => void
|
||||
showAgentSelector: boolean
|
||||
status?: string
|
||||
openClawStatus?: OpenClawStatus | null
|
||||
}) {
|
||||
const { supports } = useCapabilities()
|
||||
const { selectedFolder } = useWorkspace()
|
||||
@@ -154,7 +155,7 @@ function ContextControls({
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onCreateAgent}
|
||||
status={status}
|
||||
openClawStatus={openClawStatus}
|
||||
/>
|
||||
) : null}
|
||||
{supports(Feature.WORKSPACE_FOLDER_SUPPORT) ? (
|
||||
@@ -256,7 +257,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
onCreateAgent,
|
||||
streaming,
|
||||
disabled,
|
||||
status,
|
||||
openClawStatus,
|
||||
placeholder,
|
||||
variant = 'conversation',
|
||||
}) => {
|
||||
@@ -349,7 +350,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
selectedTabs={selectedTabs}
|
||||
onToggleTab={toggleTab}
|
||||
showAgentSelector={variant === 'home'}
|
||||
status={status}
|
||||
openClawStatus={openClawStatus}
|
||||
/>
|
||||
</Shell>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import { Outlet, useOutletContext } from 'react-router'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgents } from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type OpenClawStatus,
|
||||
useOpenClawAgents,
|
||||
useOpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
|
||||
@@ -16,9 +16,7 @@ interface AgentCommandContextValue {
|
||||
|
||||
export const AgentCommandLayout: FC = () => {
|
||||
const { status, loading: statusLoading } = useOpenClawStatus(5000)
|
||||
const { agents, loading: agentsLoading } = useOpenClawAgents(
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected',
|
||||
)
|
||||
const { agents, loading: agentsLoading } = useAgents()
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isOpenClawAgentReady } from '@/entrypoints/app/agents/agent-availability'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
@@ -8,16 +9,32 @@ import { getLatestConversation } from '@/lib/agent-conversations/storage'
|
||||
import type { AgentCardData } from '@/lib/agent-conversations/types'
|
||||
|
||||
function getAgentStatusTone(
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
agent: AgentEntry,
|
||||
status: OpenClawStatus | null,
|
||||
): AgentCardData['status'] {
|
||||
if (status === 'error') return 'error'
|
||||
if (status === 'starting') return 'working'
|
||||
if (agent.adapterType !== 'openclaw') {
|
||||
return 'idle'
|
||||
}
|
||||
if (status?.status === 'error' || status?.controlPlaneStatus === 'failed') {
|
||||
return 'error'
|
||||
}
|
||||
if (
|
||||
status?.status === 'starting' ||
|
||||
status?.controlPlaneStatus === 'connecting' ||
|
||||
status?.controlPlaneStatus === 'reconnecting' ||
|
||||
status?.controlPlaneStatus === 'recovering'
|
||||
) {
|
||||
return 'working'
|
||||
}
|
||||
if (!isOpenClawAgentReady(status)) {
|
||||
return 'error'
|
||||
}
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
async function getAgentCardData(
|
||||
agent: AgentEntry,
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
status: OpenClawStatus | null,
|
||||
): Promise<AgentCardData> {
|
||||
const conversation = await getLatestConversation(agent.agentId)
|
||||
const lastTurn = conversation?.turns[conversation.turns.length - 1]
|
||||
@@ -26,8 +43,14 @@ async function getAgentCardData(
|
||||
return {
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
model: getModelDisplayName(agent.model),
|
||||
status: getAgentStatusTone(status),
|
||||
model:
|
||||
getModelDisplayName(agent.model) ??
|
||||
(agent.adapterType === 'codex_local'
|
||||
? 'Codex local'
|
||||
: agent.adapterType === 'claude_local'
|
||||
? 'Claude local'
|
||||
: 'OpenClaw'),
|
||||
status: getAgentStatusTone(agent, status),
|
||||
lastMessage:
|
||||
lastTextPart?.kind === 'text'
|
||||
? lastTextPart.text.slice(0, 120)
|
||||
@@ -38,7 +61,7 @@ async function getAgentCardData(
|
||||
|
||||
export function useAgentCardData(
|
||||
agents: AgentEntry[],
|
||||
status: OpenClawStatus['status'] | undefined,
|
||||
status: OpenClawStatus | null,
|
||||
) {
|
||||
const [cardData, setCardData] = useState<AgentCardData[]>([])
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { chatWithAgent } from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
chatWithAgent,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
buildBrowserOsConversation,
|
||||
createBrowserOsAgentStreamState,
|
||||
reduceBrowserOsAgentStreamEvent,
|
||||
} from '@/lib/agent-conversations/browseros-agent-chat'
|
||||
import {
|
||||
getLatestConversation,
|
||||
saveConversation,
|
||||
@@ -10,7 +13,6 @@ import {
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
|
||||
@@ -19,8 +21,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const sessionKeyRef = useRef('')
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
const streamStateRef = useRef(createBrowserOsAgentStreamState())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -66,155 +67,62 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
}
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
updater: (turn: AgentConversationTurn) => AgentConversationTurn,
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
const updated = [...prev.slice(0, -1), updater(last)]
|
||||
if (updated[updated.length - 1]?.done) {
|
||||
persistTurns(updated)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running' as const,
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const toolStatus: 'completed' | 'error' =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId ? { ...t, status: toolStatus, durationMs } : t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
const updated = [...prev.slice(0, -1), { ...last, done: true }]
|
||||
persistTurns(updated)
|
||||
return updated
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
const processStreamEvent = (event: UIMessageStreamEvent) => {
|
||||
streamStateRef.current = reduceBrowserOsAgentStreamEvent(
|
||||
streamStateRef.current,
|
||||
event,
|
||||
)
|
||||
updateCurrentTurnParts((turn) => ({
|
||||
...turn,
|
||||
parts: streamStateRef.current.parts,
|
||||
done: streamStateRef.current.done,
|
||||
}))
|
||||
}
|
||||
|
||||
const send = async (text: string) => {
|
||||
if (!text.trim() || streaming) return
|
||||
|
||||
const message = text.trim()
|
||||
const conversation = buildBrowserOsConversation(turns)
|
||||
const turn: AgentConversationTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text.trim(),
|
||||
userText: message,
|
||||
parts: [],
|
||||
done: false,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setStreaming(true)
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
streamStateRef.current = createBrowserOsAgentStreamState()
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current,
|
||||
{
|
||||
message,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
conversation,
|
||||
},
|
||||
abortController.signal,
|
||||
)
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
const err = await readErrorResponse(response)
|
||||
processStreamEvent({ type: 'error', errorText: err })
|
||||
return
|
||||
}
|
||||
await consumeSSEStream(
|
||||
@@ -225,10 +133,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
processStreamEvent({ type: 'error', errorText: msg })
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
@@ -243,6 +148,7 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
setTurns([])
|
||||
setStreaming(false)
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
streamStateRef.current = createBrowserOsAgentStreamState()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -254,3 +160,13 @@ export function useAgentConversation(agentId: string, agentName: string) {
|
||||
resetConversation,
|
||||
}
|
||||
}
|
||||
|
||||
async function readErrorResponse(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) {
|
||||
return body.error
|
||||
}
|
||||
} catch {}
|
||||
return `Request failed with status ${response.status}`
|
||||
}
|
||||
|
||||
@@ -1,45 +1,9 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { ArrowLeft, Loader2, Send } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
import { ConversationMessage } from '@/entrypoints/app/agent-command/ConversationMessage'
|
||||
import { useAgentConversation } from '@/entrypoints/app/agent-command/useAgentConversation'
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
@@ -52,209 +16,25 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
const { turns, streaming, loading, send } = useAgentConversation(
|
||||
agentId,
|
||||
agentName,
|
||||
)
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on conversation changes
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
if (!text || streaming) {
|
||||
return
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
await send(text)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,108 +47,30 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
void handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
@@ -376,7 +78,9 @@ export const AgentChat: FC<AgentChatProps> = ({
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
onClick={() => {
|
||||
void handleSend()
|
||||
}}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { canChatWithAgent, isOpenClawAgentReady } from './agent-availability'
|
||||
import type { AgentEntry } from './useAgents'
|
||||
|
||||
describe('agent-availability', () => {
|
||||
it('treats only a connected running OpenClaw runtime as ready', () => {
|
||||
expect(
|
||||
isOpenClawAgentReady({
|
||||
status: 'running',
|
||||
controlPlaneStatus: 'connected',
|
||||
} as never),
|
||||
).toBe(true)
|
||||
|
||||
expect(
|
||||
isOpenClawAgentReady({
|
||||
status: 'running',
|
||||
controlPlaneStatus: 'disconnected',
|
||||
} as never),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows local agents even when OpenClaw is unavailable', () => {
|
||||
const localAgent: AgentEntry = {
|
||||
agentId: 'codex-agent',
|
||||
name: 'Codex Agent',
|
||||
workspace: '/tmp/codex-agent',
|
||||
adapterType: 'codex_local',
|
||||
}
|
||||
const openClawAgent: AgentEntry = {
|
||||
agentId: 'main',
|
||||
name: 'Main',
|
||||
workspace: '/home/node/.openclaw/workspace',
|
||||
adapterType: 'openclaw',
|
||||
}
|
||||
|
||||
expect(canChatWithAgent(localAgent, null)).toBe(true)
|
||||
expect(
|
||||
canChatWithAgent(openClawAgent, {
|
||||
status: 'stopped',
|
||||
controlPlaneStatus: 'disconnected',
|
||||
} as never),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { AgentEntry } from './useAgents'
|
||||
import type { OpenClawStatus } from './useOpenClaw'
|
||||
|
||||
export function isOpenClawAgentReady(
|
||||
status: Pick<OpenClawStatus, 'status' | 'controlPlaneStatus'> | null,
|
||||
): boolean {
|
||||
return (
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected'
|
||||
)
|
||||
}
|
||||
|
||||
export function canChatWithAgent(
|
||||
agent: Pick<AgentEntry, 'adapterType'> | null | undefined,
|
||||
openClawStatus: Pick<OpenClawStatus, 'status' | 'controlPlaneStatus'> | null,
|
||||
): boolean {
|
||||
if (!agent) {
|
||||
return false
|
||||
}
|
||||
if (agent.adapterType !== 'openclaw') {
|
||||
return true
|
||||
}
|
||||
return isOpenClawAgentReady(openClawStatus)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
BrowserOsAgentAdapterType,
|
||||
BrowserOsStoredAgent,
|
||||
} from '@browseros/shared/types/browseros-agents'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface AgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
}
|
||||
|
||||
export interface AgentCatalogEntry {
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AgentMutationInput {
|
||||
id: string
|
||||
name: string
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
binaryPath?: string
|
||||
dangerouslyBypassApprovalsAndSandbox?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface AgentChatInput {
|
||||
message: string
|
||||
sessionKey?: string
|
||||
conversation?: Array<{ role: 'user' | 'assistant'; text: string }>
|
||||
}
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
agents: 'browseros-agents',
|
||||
catalog: 'browseros-agent-catalog',
|
||||
} as const
|
||||
|
||||
async function agentFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${baseUrl}/agents${path}`, init)
|
||||
if (!response.ok) {
|
||||
let message = `Request failed with status ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) {
|
||||
message = body.error
|
||||
}
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function fetchAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
const data = await agentFetch<{ agents: BrowserOsStoredAgent[] }>(baseUrl, '')
|
||||
return (data.agents ?? []).map(toAgentEntry)
|
||||
}
|
||||
|
||||
async function fetchAgentCatalog(
|
||||
baseUrl: string,
|
||||
): Promise<AgentCatalogEntry[]> {
|
||||
const data = await agentFetch<{ adapters: AgentCatalogEntry[] }>(
|
||||
baseUrl,
|
||||
'/catalog',
|
||||
)
|
||||
return data.adapters ?? []
|
||||
}
|
||||
|
||||
async function invalidateAgentQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
): Promise<void> {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: [AGENT_QUERY_KEYS.agents] }),
|
||||
queryClient.invalidateQueries({ queryKey: [AGENT_QUERY_KEYS.catalog] }),
|
||||
])
|
||||
}
|
||||
|
||||
export function useAgents() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentEntry[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
queryFn: () => fetchAgents(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
})
|
||||
|
||||
return {
|
||||
agents: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentCatalog() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentCatalogEntry[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.catalog, baseUrl],
|
||||
queryFn: () => fetchAgentCatalog(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return {
|
||||
adapters: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAgentMutations() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const ensureBaseUrl = () => {
|
||||
if (!baseUrl || urlLoading) {
|
||||
throw new Error('BrowserOS agent server URL is not ready')
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
const onSuccess = () => invalidateAgentQueries(queryClient)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (input: AgentMutationInput) =>
|
||||
agentFetch<{ agent: BrowserOsStoredAgent }>(ensureBaseUrl(), '', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}).then((data) => ({
|
||||
agent: toAgentEntry(data.agent),
|
||||
})),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (agentId: string) =>
|
||||
agentFetch<{ success: boolean }>(ensureBaseUrl(), `/${agentId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
return {
|
||||
createAgent: createMutation.mutateAsync,
|
||||
deleteAgent: deleteMutation.mutateAsync,
|
||||
actionInProgress: createMutation.isPending || deleteMutation.isPending,
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAgents(): Promise<AgentEntry[]> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetchAgents(baseUrl)
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
input: AgentChatInput,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
function toAgentEntry(record: BrowserOsStoredAgent): AgentEntry {
|
||||
return {
|
||||
agentId: record.id,
|
||||
name: record.name,
|
||||
workspace:
|
||||
typeof record.runtimeBinding?.workspace === 'string'
|
||||
? record.runtimeBinding.workspace
|
||||
: record.paths.cwd,
|
||||
model:
|
||||
record.runtimeBinding?.model ?? record.adapterConfig.modelId ?? undefined,
|
||||
adapterType: record.adapterType,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import {
|
||||
buildBrowserOsConversation,
|
||||
createBrowserOsAgentStreamState,
|
||||
reduceBrowserOsAgentStreamEvent,
|
||||
} from './browseros-agent-chat'
|
||||
import type { AgentConversationTurn } from './types'
|
||||
|
||||
describe('browseros-agent-chat', () => {
|
||||
it('builds user and assistant conversation history from stored turns', () => {
|
||||
const turns: AgentConversationTurn[] = [
|
||||
{
|
||||
id: 'turn-1',
|
||||
userText: 'What changed?',
|
||||
parts: [
|
||||
{ kind: 'thinking', text: 'reviewing', done: true },
|
||||
{ kind: 'text', text: 'We shipped the migration.' },
|
||||
],
|
||||
done: true,
|
||||
timestamp: 1,
|
||||
},
|
||||
{
|
||||
id: 'turn-2',
|
||||
userText: 'Anything else?',
|
||||
parts: [{ kind: 'tool-batch', tools: [] }],
|
||||
done: true,
|
||||
timestamp: 2,
|
||||
},
|
||||
]
|
||||
|
||||
expect(buildBrowserOsConversation(turns)).toEqual([
|
||||
{ role: 'user', text: 'What changed?' },
|
||||
{ role: 'assistant', text: 'We shipped the migration.' },
|
||||
{ role: 'user', text: 'Anything else?' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reduces generic UI stream events into conversation parts', () => {
|
||||
const events: UIMessageStreamEvent[] = [
|
||||
{ type: 'start' },
|
||||
{ type: 'reasoning-start', id: 'reasoning-1' },
|
||||
{ type: 'reasoning-delta', id: 'reasoning-1', delta: 'Thinking...' },
|
||||
{ type: 'text-start', id: 'text-1' },
|
||||
{ type: 'text-delta', id: 'text-1', delta: 'Hello' },
|
||||
{
|
||||
type: 'tool-input-start',
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'search',
|
||||
},
|
||||
{
|
||||
type: 'tool-output-available',
|
||||
toolCallId: 'tool-1',
|
||||
output: { ok: true },
|
||||
},
|
||||
{ type: 'text-delta', id: 'text-1', delta: ' world' },
|
||||
{ type: 'reasoning-end', id: 'reasoning-1' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]
|
||||
|
||||
const state = events.reduce(
|
||||
(current, event) => reduceBrowserOsAgentStreamEvent(current, event),
|
||||
createBrowserOsAgentStreamState(),
|
||||
)
|
||||
|
||||
expect(state.done).toBe(true)
|
||||
expect(state.parts).toEqual([
|
||||
{ kind: 'thinking', text: 'Thinking...', done: true },
|
||||
{
|
||||
kind: 'tool-batch',
|
||||
tools: [{ id: 'tool-1', name: 'search', status: 'completed' }],
|
||||
},
|
||||
{ kind: 'text', text: 'Hello world' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { AgentConversationTurn, AssistantPart, ToolEntry } from './types'
|
||||
|
||||
export interface BrowserOsConversationTurn {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentStreamState {
|
||||
text: string
|
||||
reasoning: string
|
||||
reasoningStarted: boolean
|
||||
reasoningDone: boolean
|
||||
tools: ToolEntry[]
|
||||
done: boolean
|
||||
parts: AssistantPart[]
|
||||
}
|
||||
|
||||
export function buildBrowserOsConversation(
|
||||
turns: AgentConversationTurn[],
|
||||
): BrowserOsConversationTurn[] {
|
||||
return turns.flatMap((turn) => {
|
||||
const conversation: BrowserOsConversationTurn[] = [
|
||||
{ role: 'user', text: turn.userText },
|
||||
]
|
||||
const assistantText = turn.parts
|
||||
.filter(
|
||||
(part): part is Extract<AssistantPart, { kind: 'text' }> =>
|
||||
part.kind === 'text',
|
||||
)
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim()
|
||||
if (assistantText) {
|
||||
conversation.push({ role: 'assistant', text: assistantText })
|
||||
}
|
||||
return conversation
|
||||
})
|
||||
}
|
||||
|
||||
export function createBrowserOsAgentStreamState(): BrowserOsAgentStreamState {
|
||||
return withDerivedParts({
|
||||
text: '',
|
||||
reasoning: '',
|
||||
reasoningStarted: false,
|
||||
reasoningDone: false,
|
||||
tools: [],
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function reduceBrowserOsAgentStreamEvent(
|
||||
state: BrowserOsAgentStreamState,
|
||||
event: UIMessageStreamEvent,
|
||||
): BrowserOsAgentStreamState {
|
||||
switch (event.type) {
|
||||
case 'text-delta':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
text: state.text + event.delta,
|
||||
})
|
||||
case 'reasoning-start':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
reasoningStarted: true,
|
||||
reasoningDone: false,
|
||||
})
|
||||
case 'reasoning-delta':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
reasoningStarted: true,
|
||||
reasoningDone: false,
|
||||
reasoning: state.reasoning + event.delta,
|
||||
})
|
||||
case 'reasoning-end':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
reasoningStarted: true,
|
||||
reasoningDone: true,
|
||||
})
|
||||
case 'tool-input-start':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
tools: upsertTool(state.tools, {
|
||||
id: event.toolCallId,
|
||||
name: event.toolName,
|
||||
status: 'running',
|
||||
}),
|
||||
})
|
||||
case 'tool-output-available':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
tools: updateToolStatus(state.tools, event.toolCallId, 'completed'),
|
||||
})
|
||||
case 'tool-output-error':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
tools: updateToolStatus(state.tools, event.toolCallId, 'error'),
|
||||
})
|
||||
case 'error':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
done: true,
|
||||
reasoningDone: state.reasoningStarted ? true : state.reasoningDone,
|
||||
text: appendErrorText(state.text, event.errorText),
|
||||
})
|
||||
case 'finish':
|
||||
return withDerivedParts({
|
||||
...state,
|
||||
done: true,
|
||||
reasoningDone: state.reasoningStarted ? true : state.reasoningDone,
|
||||
})
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function withDerivedParts(
|
||||
state: Omit<BrowserOsAgentStreamState, 'parts'>,
|
||||
): BrowserOsAgentStreamState {
|
||||
return {
|
||||
...state,
|
||||
parts: buildAssistantParts(state),
|
||||
}
|
||||
}
|
||||
|
||||
function buildAssistantParts(
|
||||
state: Omit<BrowserOsAgentStreamState, 'parts'>,
|
||||
): AssistantPart[] {
|
||||
const parts: AssistantPart[] = []
|
||||
if (state.reasoningStarted || state.reasoning) {
|
||||
parts.push({
|
||||
kind: 'thinking',
|
||||
text: state.reasoning,
|
||||
done: state.reasoningDone,
|
||||
})
|
||||
}
|
||||
if (state.tools.length > 0) {
|
||||
parts.push({
|
||||
kind: 'tool-batch',
|
||||
tools: state.tools,
|
||||
})
|
||||
}
|
||||
if (state.text) {
|
||||
parts.push({
|
||||
kind: 'text',
|
||||
text: state.text,
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function upsertTool(tools: ToolEntry[], tool: ToolEntry): ToolEntry[] {
|
||||
const existingIndex = tools.findIndex((entry) => entry.id === tool.id)
|
||||
if (existingIndex === -1) {
|
||||
return [...tools, tool]
|
||||
}
|
||||
return tools.map((entry, index) =>
|
||||
index === existingIndex ? { ...entry, ...tool } : entry,
|
||||
)
|
||||
}
|
||||
|
||||
function updateToolStatus(
|
||||
tools: ToolEntry[],
|
||||
toolId: string,
|
||||
status: ToolEntry['status'],
|
||||
): ToolEntry[] {
|
||||
const existingIndex = tools.findIndex((entry) => entry.id === toolId)
|
||||
if (existingIndex === -1) {
|
||||
return [
|
||||
...tools,
|
||||
{
|
||||
id: toolId,
|
||||
name: toolId,
|
||||
status,
|
||||
},
|
||||
]
|
||||
}
|
||||
return tools.map((entry, index) =>
|
||||
index === existingIndex ? { ...entry, status } : entry,
|
||||
)
|
||||
}
|
||||
|
||||
function appendErrorText(text: string, errorText: string): string {
|
||||
if (!text) {
|
||||
return `Error: ${errorText}`
|
||||
}
|
||||
return `${text}\n\nError: ${errorText}`
|
||||
}
|
||||
211
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
211
packages/browseros-agent/apps/server/src/api/routes/agents.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { type Context, Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
BrowserOsAgentCatalogEntry,
|
||||
BrowserOsAgentChatInput,
|
||||
BrowserOsAgentCreateInput,
|
||||
} from '../services/agents/adapters/types'
|
||||
import { getBrowserOsAgentService } from '../services/agents/agent-service'
|
||||
import {
|
||||
formatUIMessageStreamDone,
|
||||
formatUIMessageStreamEvent,
|
||||
} from '../utils/ui-message-stream'
|
||||
|
||||
interface BrowserOsAgentRoutesService {
|
||||
catalog(): BrowserOsAgentCatalogEntry[]
|
||||
list(): Promise<unknown>
|
||||
create(input: BrowserOsAgentCreateInput): Promise<unknown>
|
||||
remove(agentId: string): Promise<void>
|
||||
chat(
|
||||
agentId: string,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>>
|
||||
}
|
||||
|
||||
const CreateAgentRequestSchema = z
|
||||
.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
adapterType: z.enum(['openclaw', 'codex_local', 'claude_local']),
|
||||
binaryPath: z.string().optional(),
|
||||
dangerouslyBypassApprovalsAndSandbox: z.boolean().optional(),
|
||||
dangerouslySkipPermissions: z.boolean().optional(),
|
||||
providerType: z.string().optional(),
|
||||
providerName: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
modelId: z.string().optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (
|
||||
(value.adapterType === 'codex_local' ||
|
||||
value.adapterType === 'claude_local') &&
|
||||
!value.binaryPath?.trim()
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['binaryPath'],
|
||||
message: 'binaryPath is required for local adapters',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ChatAgentRequestSchema = z.object({
|
||||
message: z.string().trim().min(1),
|
||||
sessionKey: z.string().trim().min(1).optional(),
|
||||
conversation: z
|
||||
.array(
|
||||
z.object({
|
||||
role: z.enum(['user', 'assistant']),
|
||||
text: z.string().trim().min(1),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export function createAgentsRoutes(
|
||||
service: BrowserOsAgentRoutesService = getBrowserOsAgentService(),
|
||||
) {
|
||||
return new Hono()
|
||||
.get('/catalog', async (c) => c.json({ adapters: service.catalog() }))
|
||||
.get('/', async (c) => {
|
||||
try {
|
||||
return c.json({ agents: await service.list() })
|
||||
} catch (error) {
|
||||
return c.json({ error: toErrorMessage(error) }, 500)
|
||||
}
|
||||
})
|
||||
.post(
|
||||
'/',
|
||||
zValidator('json', CreateAgentRequestSchema, validationErrorResponse),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
try {
|
||||
const agent = await service.create(body as BrowserOsAgentCreateInput)
|
||||
return c.json({ agent }, 201)
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ error: toErrorMessage(error) },
|
||||
toErrorStatusCode(error),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.delete('/:id', async (c) => {
|
||||
try {
|
||||
await service.remove(c.req.param('id'))
|
||||
return c.json({ success: true })
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ error: toErrorMessage(error) },
|
||||
toErrorStatusCode(error),
|
||||
)
|
||||
}
|
||||
})
|
||||
.post(
|
||||
'/:id/chat',
|
||||
zValidator('json', ChatAgentRequestSchema, validationErrorResponse),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const sessionKey = body.sessionKey || crypto.randomUUID()
|
||||
try {
|
||||
const eventStream = await service.chat(c.req.param('id'), {
|
||||
sessionKey,
|
||||
message: body.message,
|
||||
conversation: normalizeConversation(body.conversation),
|
||||
})
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', sessionKey)
|
||||
c.header('x-vercel-ai-ui-message-stream', 'v1')
|
||||
return stream(c, async (honoStream) => {
|
||||
const reader = eventStream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
await honoStream.write(formatUIMessageStreamEvent(value))
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
await honoStream.write(formatUIMessageStreamDone())
|
||||
})
|
||||
} catch (error) {
|
||||
return c.json(
|
||||
{ error: toErrorMessage(error) },
|
||||
toErrorStatusCode(error),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeConversation(
|
||||
conversation: BrowserOsAgentChatInput['conversation'],
|
||||
): BrowserOsAgentChatInput['conversation'] {
|
||||
if (!Array.isArray(conversation)) {
|
||||
return undefined
|
||||
}
|
||||
return conversation
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is NonNullable<
|
||||
BrowserOsAgentChatInput['conversation']
|
||||
>[number] =>
|
||||
!!entry &&
|
||||
(entry.role === 'user' || entry.role === 'assistant') &&
|
||||
typeof entry.text === 'string' &&
|
||||
entry.text.trim().length > 0,
|
||||
)
|
||||
.map((entry) => ({
|
||||
role: entry.role,
|
||||
text: entry.text,
|
||||
}))
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function toErrorStatusCode(error: unknown): 400 | 404 | 409 | 500 {
|
||||
const message = toErrorMessage(error)
|
||||
if (/not found/i.test(message)) {
|
||||
return 404
|
||||
}
|
||||
if (/already exists/i.test(message)) {
|
||||
return 409
|
||||
}
|
||||
if (isBadRequestMessage(message)) {
|
||||
return 400
|
||||
}
|
||||
return 500
|
||||
}
|
||||
|
||||
function isBadRequestMessage(message: string): boolean {
|
||||
return [
|
||||
/requires/i,
|
||||
/must be running/i,
|
||||
/invalid/i,
|
||||
/hello probe failed/i,
|
||||
/unsupported openclaw provider/i,
|
||||
].some((pattern) => pattern.test(message))
|
||||
}
|
||||
|
||||
function validationErrorResponse(result: { success: boolean }, c: Context) {
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid request body' }, 400)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { createAclRoutes } from './routes/acl'
|
||||
import { createAgentsRoutes } from './routes/agents'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
@@ -107,6 +108,10 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createOpenClawRoutes())
|
||||
|
||||
const agentsRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createAgentsRoutes())
|
||||
|
||||
const terminalRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route(
|
||||
@@ -195,6 +200,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
}),
|
||||
)
|
||||
.route('/claw', clawRoutes)
|
||||
.route('/agents', agentsRoutes)
|
||||
|
||||
// Error handler
|
||||
app.onError((err, c) => {
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import { getAgentDir, getAgentRuntimeDir } from '../../../../lib/browseros-dir'
|
||||
import { buildLocalAgentPrompt } from './local-prompt'
|
||||
import type {
|
||||
BrowserOsAgentAdapter,
|
||||
BrowserOsAgentChatInput,
|
||||
BrowserOsAgentCreateInput,
|
||||
BrowserOsAgentMaterializationResult,
|
||||
} from './types'
|
||||
|
||||
interface SpawnResultLike {
|
||||
stdout: ReadableStream<Uint8Array> | null
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
}
|
||||
|
||||
interface SpawnOptionsLike {
|
||||
cwd?: string
|
||||
stdin?: Uint8Array
|
||||
stdout?: 'pipe'
|
||||
stderr?: 'pipe'
|
||||
}
|
||||
|
||||
type SpawnLike = (cmd: string[], options: SpawnOptionsLike) => SpawnResultLike
|
||||
|
||||
const CLAUDE_SYSTEM_PROMPT_FILE_NAME = 'claude-system-prompt.md'
|
||||
|
||||
interface ClaudeExecutionSettings {
|
||||
binaryPath: string
|
||||
dangerouslySkipPermissions: boolean
|
||||
}
|
||||
|
||||
export class ClaudeLocalAgentAdapter implements BrowserOsAgentAdapter {
|
||||
readonly adapterType = 'claude_local' as const
|
||||
private readonly spawn: SpawnLike
|
||||
|
||||
constructor(options: { spawn?: SpawnLike } = {}) {
|
||||
this.spawn =
|
||||
options.spawn ?? ((cmd, spawnOptions) => Bun.spawn(cmd, spawnOptions))
|
||||
}
|
||||
|
||||
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
|
||||
if (input.adapterType !== this.adapterType) {
|
||||
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
|
||||
}
|
||||
const settings = readCreateSettings(input)
|
||||
const agentCwd = getAgentDir(input.id)
|
||||
await mkdir(agentCwd, { recursive: true })
|
||||
const probe = await runClaudeCommand({
|
||||
spawn: this.spawn,
|
||||
settings,
|
||||
cwd: agentCwd,
|
||||
prompt: 'Respond with hello.',
|
||||
})
|
||||
if (probe.exitCode !== 0 || !/\bhello\b/i.test(probe.text)) {
|
||||
throw new Error('Claude hello probe failed')
|
||||
}
|
||||
}
|
||||
|
||||
async materialize(): Promise<BrowserOsAgentMaterializationResult> {
|
||||
return { runtimeBinding: null }
|
||||
}
|
||||
|
||||
async remove(): Promise<void> {}
|
||||
|
||||
async streamChat(
|
||||
record: BrowserOsStoredAgent,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>> {
|
||||
const settings = readStoredSettings(record)
|
||||
const prompt = await buildLocalAgentPrompt(record, {
|
||||
message: input.message,
|
||||
conversation: input.conversation,
|
||||
})
|
||||
const systemPromptFile = await writeClaudeSystemPromptFile(record)
|
||||
const process = this.spawn(
|
||||
[
|
||||
settings.binaryPath,
|
||||
...buildClaudeArgs({
|
||||
dangerouslySkipPermissions: settings.dangerouslySkipPermissions,
|
||||
systemPromptFile,
|
||||
}),
|
||||
],
|
||||
{
|
||||
cwd: record.paths.cwd,
|
||||
stdin: new TextEncoder().encode(prompt),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
return createClaudeUiStream(process, `${record.id}-text`)
|
||||
}
|
||||
}
|
||||
|
||||
function readCreateSettings(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): ClaudeExecutionSettings {
|
||||
const binaryPath = normalizeBinaryPath(input.binaryPath)
|
||||
if (!binaryPath) {
|
||||
throw new Error('claude_local requires a configured binaryPath')
|
||||
}
|
||||
return {
|
||||
binaryPath,
|
||||
dangerouslySkipPermissions: input.dangerouslySkipPermissions === true,
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSettings(
|
||||
record: BrowserOsStoredAgent,
|
||||
): ClaudeExecutionSettings {
|
||||
const binaryPath = normalizeBinaryPath(record.adapterConfig.binaryPath)
|
||||
if (!binaryPath) {
|
||||
throw new Error('claude_local requires adapterConfig.binaryPath')
|
||||
}
|
||||
return {
|
||||
binaryPath,
|
||||
dangerouslySkipPermissions:
|
||||
record.adapterConfig.dangerouslySkipPermissions === true,
|
||||
}
|
||||
}
|
||||
|
||||
async function runClaudeCommand(input: {
|
||||
spawn: SpawnLike
|
||||
settings: ClaudeExecutionSettings
|
||||
cwd: string
|
||||
prompt: string
|
||||
}): Promise<{ exitCode: number; text: string; stderr: string }> {
|
||||
const process = input.spawn(
|
||||
[input.settings.binaryPath, ...buildClaudeArgs(input.settings)],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
stdin: new TextEncoder().encode(input.prompt),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
||||
readStreamText(process.stdout),
|
||||
readStreamText(process.stderr),
|
||||
process.exited,
|
||||
])
|
||||
return {
|
||||
exitCode,
|
||||
text: parseClaudeStreamJson(stdoutText).join(''),
|
||||
stderr: stderrText,
|
||||
}
|
||||
}
|
||||
|
||||
async function writeClaudeSystemPromptFile(
|
||||
record: BrowserOsStoredAgent,
|
||||
): Promise<string> {
|
||||
const runtimeDir = getAgentRuntimeDir(record.id)
|
||||
await mkdir(runtimeDir, { recursive: true })
|
||||
const [agentsMd, soulMd, toolsMd] = await Promise.all([
|
||||
readFile(join(record.paths.agentDir, 'AGENTS.md'), 'utf8'),
|
||||
readFile(join(record.paths.agentDir, 'SOUL.md'), 'utf8'),
|
||||
readFile(join(record.paths.agentDir, 'TOOLS.md'), 'utf8'),
|
||||
])
|
||||
const filePath = join(runtimeDir, CLAUDE_SYSTEM_PROMPT_FILE_NAME)
|
||||
const content = [
|
||||
'# BrowserOS Claude System Prompt',
|
||||
'',
|
||||
'## AGENTS.md',
|
||||
agentsMd.trim(),
|
||||
'',
|
||||
'## SOUL.md',
|
||||
soulMd.trim(),
|
||||
'',
|
||||
'## TOOLS.md',
|
||||
toolsMd.trim(),
|
||||
'',
|
||||
].join('\n')
|
||||
await writeFile(filePath, content, 'utf8')
|
||||
return filePath
|
||||
}
|
||||
|
||||
function createClaudeUiStream(
|
||||
process: SpawnResultLike,
|
||||
textId: string,
|
||||
): ReadableStream<UIMessageStreamEvent> {
|
||||
return new ReadableStream<UIMessageStreamEvent>({
|
||||
async start(controller) {
|
||||
controller.enqueue({ type: 'start' })
|
||||
controller.enqueue({ type: 'text-start', id: textId })
|
||||
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
||||
readStreamText(process.stdout),
|
||||
readStreamText(process.stderr),
|
||||
process.exited,
|
||||
])
|
||||
for (const delta of parseClaudeStreamJson(stdoutText)) {
|
||||
controller.enqueue({ type: 'text-delta', id: textId, delta })
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
errorText:
|
||||
stderrText.trim() || `Claude exited with status ${exitCode}`,
|
||||
})
|
||||
}
|
||||
controller.enqueue({ type: 'text-end', id: textId })
|
||||
controller.enqueue({
|
||||
type: 'finish',
|
||||
finishReason: exitCode === 0 ? 'stop' : 'error',
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function readStreamText(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
): Promise<string> {
|
||||
if (!stream) {
|
||||
return ''
|
||||
}
|
||||
return new Response(stream).text()
|
||||
}
|
||||
|
||||
function parseClaudeStreamJson(stdoutText: string): string[] {
|
||||
const textParts: string[] = []
|
||||
const lines = stdoutText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const line of lines) {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
textParts.push(...extractClaudeText(parsed))
|
||||
}
|
||||
return textParts
|
||||
}
|
||||
|
||||
function extractClaudeText(payload: Record<string, unknown>): string[] {
|
||||
const message = payload.message
|
||||
if (message && typeof message === 'object') {
|
||||
return extractClaudeContentBlocks(
|
||||
(message as Record<string, unknown>).content,
|
||||
)
|
||||
}
|
||||
return extractClaudeContentBlocks(payload.content)
|
||||
}
|
||||
|
||||
function extractClaudeContentBlocks(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.flatMap((block) => {
|
||||
if (!block || typeof block !== 'object') {
|
||||
return []
|
||||
}
|
||||
const item = block as Record<string, unknown>
|
||||
if (item.type !== 'text') {
|
||||
return []
|
||||
}
|
||||
return typeof item.text === 'string' ? [item.text] : []
|
||||
})
|
||||
}
|
||||
|
||||
function buildClaudeArgs(input: {
|
||||
dangerouslySkipPermissions: boolean
|
||||
systemPromptFile?: string
|
||||
}): string[] {
|
||||
const args = ['--print', '-', '--output-format', 'stream-json', '--verbose']
|
||||
if (input.dangerouslySkipPermissions) {
|
||||
args.push('--dangerously-skip-permissions')
|
||||
}
|
||||
if (input.systemPromptFile) {
|
||||
args.push('--append-system-prompt-file', input.systemPromptFile)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function normalizeBinaryPath(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import { getAgentDir } from '../../../../lib/browseros-dir'
|
||||
import { buildLocalAgentPrompt } from './local-prompt'
|
||||
import type {
|
||||
BrowserOsAgentAdapter,
|
||||
BrowserOsAgentChatInput,
|
||||
BrowserOsAgentCreateInput,
|
||||
BrowserOsAgentMaterializationResult,
|
||||
} from './types'
|
||||
|
||||
interface SpawnResultLike {
|
||||
stdout: ReadableStream<Uint8Array> | null
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
}
|
||||
|
||||
interface SpawnOptionsLike {
|
||||
cwd?: string
|
||||
stdin?: Uint8Array
|
||||
stdout?: 'pipe'
|
||||
stderr?: 'pipe'
|
||||
}
|
||||
|
||||
type SpawnLike = (cmd: string[], options: SpawnOptionsLike) => SpawnResultLike
|
||||
|
||||
interface CodexExecutionSettings {
|
||||
binaryPath: string
|
||||
dangerouslyBypassApprovalsAndSandbox: boolean
|
||||
}
|
||||
|
||||
export class CodexLocalAgentAdapter implements BrowserOsAgentAdapter {
|
||||
readonly adapterType = 'codex_local' as const
|
||||
private readonly spawn: SpawnLike
|
||||
|
||||
constructor(options: { spawn?: SpawnLike } = {}) {
|
||||
this.spawn =
|
||||
options.spawn ?? ((cmd, spawnOptions) => Bun.spawn(cmd, spawnOptions))
|
||||
}
|
||||
|
||||
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
|
||||
if (input.adapterType !== this.adapterType) {
|
||||
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
|
||||
}
|
||||
const settings = readCreateSettings(input)
|
||||
const agentCwd = getAgentDir(input.id)
|
||||
await mkdir(agentCwd, { recursive: true })
|
||||
const probe = await runCodexCommand({
|
||||
spawn: this.spawn,
|
||||
settings,
|
||||
cwd: agentCwd,
|
||||
prompt: 'Respond with hello.',
|
||||
})
|
||||
if (probe.exitCode !== 0 || !/\bhello\b/i.test(probe.text)) {
|
||||
throw new Error('Codex hello probe failed')
|
||||
}
|
||||
}
|
||||
|
||||
async materialize(): Promise<BrowserOsAgentMaterializationResult> {
|
||||
return { runtimeBinding: null }
|
||||
}
|
||||
|
||||
async remove(): Promise<void> {}
|
||||
|
||||
async streamChat(
|
||||
record: BrowserOsStoredAgent,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>> {
|
||||
const settings = readStoredSettings(record)
|
||||
const prompt = await buildLocalAgentPrompt(record, {
|
||||
message: input.message,
|
||||
conversation: input.conversation,
|
||||
})
|
||||
const process = this.spawn(
|
||||
[settings.binaryPath, ...buildCodexArgs(settings)],
|
||||
{
|
||||
cwd: record.paths.cwd,
|
||||
stdin: new TextEncoder().encode(prompt),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
return createCodexUiStream(process, `${record.id}-text`)
|
||||
}
|
||||
}
|
||||
|
||||
function readCreateSettings(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): CodexExecutionSettings {
|
||||
const binaryPath = normalizeBinaryPath(input.binaryPath)
|
||||
if (!binaryPath) {
|
||||
throw new Error('codex_local requires a configured binaryPath')
|
||||
}
|
||||
return {
|
||||
binaryPath,
|
||||
dangerouslyBypassApprovalsAndSandbox:
|
||||
input.dangerouslyBypassApprovalsAndSandbox === true,
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSettings(
|
||||
record: BrowserOsStoredAgent,
|
||||
): CodexExecutionSettings {
|
||||
const binaryPath = normalizeBinaryPath(record.adapterConfig.binaryPath)
|
||||
if (!binaryPath) {
|
||||
throw new Error('codex_local requires adapterConfig.binaryPath')
|
||||
}
|
||||
return {
|
||||
binaryPath,
|
||||
dangerouslyBypassApprovalsAndSandbox:
|
||||
record.adapterConfig.dangerouslyBypassApprovalsAndSandbox === true,
|
||||
}
|
||||
}
|
||||
|
||||
async function runCodexCommand(input: {
|
||||
spawn: SpawnLike
|
||||
settings: CodexExecutionSettings
|
||||
cwd: string
|
||||
prompt: string
|
||||
}): Promise<{ exitCode: number; text: string; stderr: string }> {
|
||||
const process = input.spawn(
|
||||
[input.settings.binaryPath, ...buildCodexArgs(input.settings)],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
stdin: new TextEncoder().encode(input.prompt),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
)
|
||||
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
||||
readStreamText(process.stdout),
|
||||
readStreamText(process.stderr),
|
||||
process.exited,
|
||||
])
|
||||
return {
|
||||
exitCode,
|
||||
text: parseCodexJsonlText(stdoutText).join(''),
|
||||
stderr: stderrText,
|
||||
}
|
||||
}
|
||||
|
||||
function createCodexUiStream(
|
||||
process: SpawnResultLike,
|
||||
textId: string,
|
||||
): ReadableStream<UIMessageStreamEvent> {
|
||||
return new ReadableStream<UIMessageStreamEvent>({
|
||||
async start(controller) {
|
||||
controller.enqueue({ type: 'start' })
|
||||
controller.enqueue({ type: 'text-start', id: textId })
|
||||
const [stdoutText, stderrText, exitCode] = await Promise.all([
|
||||
readStreamText(process.stdout),
|
||||
readStreamText(process.stderr),
|
||||
process.exited,
|
||||
])
|
||||
for (const delta of parseCodexJsonlText(stdoutText)) {
|
||||
controller.enqueue({ type: 'text-delta', id: textId, delta })
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
errorText:
|
||||
stderrText.trim() || `Codex exited with status ${exitCode}`,
|
||||
})
|
||||
}
|
||||
controller.enqueue({ type: 'text-end', id: textId })
|
||||
controller.enqueue({
|
||||
type: 'finish',
|
||||
finishReason: exitCode === 0 ? 'stop' : 'error',
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function readStreamText(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
): Promise<string> {
|
||||
if (!stream) {
|
||||
return ''
|
||||
}
|
||||
return new Response(stream).text()
|
||||
}
|
||||
|
||||
function parseCodexJsonlText(stdoutText: string): string[] {
|
||||
const deltas: string[] = []
|
||||
const lines = stdoutText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
for (const line of lines) {
|
||||
let parsed: Record<string, unknown> | null = null
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
deltas.push(...extractTextParts(parsed))
|
||||
}
|
||||
return deltas
|
||||
}
|
||||
|
||||
function extractTextParts(payload: Record<string, unknown>): string[] {
|
||||
const fromItem = extractFromItemEnvelope(payload.item)
|
||||
if (fromItem.length > 0) {
|
||||
return fromItem
|
||||
}
|
||||
const fromMessage = extractFromContentContainer(payload.message)
|
||||
if (fromMessage.length > 0) {
|
||||
return fromMessage
|
||||
}
|
||||
const fromContent = extractFromContentContainer(payload.content)
|
||||
if (fromContent.length > 0) {
|
||||
return fromContent
|
||||
}
|
||||
if (typeof payload.delta === 'string') {
|
||||
return [payload.delta]
|
||||
}
|
||||
if (typeof payload.text === 'string') {
|
||||
return [payload.text]
|
||||
}
|
||||
if (typeof payload.output_text === 'string') {
|
||||
return [payload.output_text]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function extractFromItemEnvelope(value: unknown): string[] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return []
|
||||
}
|
||||
const item = value as Record<string, unknown>
|
||||
if (typeof item.text === 'string') {
|
||||
return [item.text]
|
||||
}
|
||||
return extractFromContentContainer(item)
|
||||
}
|
||||
|
||||
function extractFromContentContainer(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => extractTextPartsFromItem(item))
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const content = (value as Record<string, unknown>).content
|
||||
if (Array.isArray(content)) {
|
||||
return content.flatMap((item) => extractTextPartsFromItem(item))
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function extractTextPartsFromItem(value: unknown): string[] {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return []
|
||||
}
|
||||
const item = value as Record<string, unknown>
|
||||
if (typeof item.text === 'string') {
|
||||
return [item.text]
|
||||
}
|
||||
if (typeof item.delta === 'string') {
|
||||
return [item.delta]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function buildCodexArgs(settings: CodexExecutionSettings): string[] {
|
||||
const args = ['exec', '--json']
|
||||
if (settings.dangerouslyBypassApprovalsAndSandbox) {
|
||||
args.push('--dangerously-bypass-approvals-and-sandbox')
|
||||
}
|
||||
args.push('-')
|
||||
return args
|
||||
}
|
||||
|
||||
function normalizeBinaryPath(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import type { BrowserOsAgentConversationTurn } from './types'
|
||||
|
||||
export interface LocalPromptInput {
|
||||
message: string
|
||||
conversation?: BrowserOsAgentConversationTurn[]
|
||||
}
|
||||
|
||||
export async function buildLocalAgentPrompt(
|
||||
record: BrowserOsStoredAgent,
|
||||
input: LocalPromptInput,
|
||||
): Promise<string> {
|
||||
const [agentsMd, soulMd, toolsMd] = await Promise.all([
|
||||
readAgentFile(record, 'AGENTS.md'),
|
||||
readAgentFile(record, 'SOUL.md'),
|
||||
readAgentFile(record, 'TOOLS.md'),
|
||||
])
|
||||
return [
|
||||
'# BrowserOS Local Agent Prompt',
|
||||
'',
|
||||
'## AGENTS.md',
|
||||
agentsMd.trim(),
|
||||
'',
|
||||
'## SOUL.md',
|
||||
soulMd.trim(),
|
||||
'',
|
||||
'## TOOLS.md',
|
||||
toolsMd.trim(),
|
||||
'',
|
||||
'## Recent Conversation',
|
||||
renderRecentConversation(input.conversation ?? []),
|
||||
'',
|
||||
'## Latest User Message',
|
||||
input.message,
|
||||
'',
|
||||
'Respond with the best possible answer for the latest user message.',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function readAgentFile(
|
||||
record: BrowserOsStoredAgent,
|
||||
fileName: string,
|
||||
): Promise<string> {
|
||||
return readFile(join(record.paths.agentDir, fileName), 'utf8')
|
||||
}
|
||||
|
||||
function renderRecentConversation(
|
||||
messages: BrowserOsAgentConversationTurn[],
|
||||
): string {
|
||||
if (messages.length === 0) {
|
||||
return 'None.'
|
||||
}
|
||||
return messages
|
||||
.map((message) => `${message.role.toUpperCase()}: ${message.text}`)
|
||||
.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import type { OpenClawAgentEntry } from '../../openclaw/openclaw-service'
|
||||
import type { OpenClawStreamEvent } from '../../openclaw/openclaw-types'
|
||||
import { normalizeOpenClawStream } from '../ui-stream'
|
||||
import type {
|
||||
BrowserOsAgentAdapter,
|
||||
BrowserOsAgentChatInput,
|
||||
BrowserOsAgentCreateInput,
|
||||
BrowserOsAgentMaterializationResult,
|
||||
} from './types'
|
||||
|
||||
interface OpenClawServiceLike {
|
||||
getStatus(): Promise<{
|
||||
status: string
|
||||
controlPlaneStatus: string
|
||||
}>
|
||||
createAgent(input: {
|
||||
name: string
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): Promise<OpenClawAgentEntry>
|
||||
removeAgent(agentId: string): Promise<void>
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): Promise<ReadableStream<OpenClawStreamEvent>>
|
||||
}
|
||||
|
||||
export class OpenClawAgentAdapter implements BrowserOsAgentAdapter {
|
||||
readonly adapterType = 'openclaw' as const
|
||||
|
||||
constructor(private readonly openClawService: OpenClawServiceLike) {}
|
||||
|
||||
async validateCreate(input: BrowserOsAgentCreateInput): Promise<void> {
|
||||
if (input.adapterType !== this.adapterType) {
|
||||
throw new Error(`Unsupported adapter type: ${input.adapterType}`)
|
||||
}
|
||||
const status = await this.openClawService.getStatus()
|
||||
if (
|
||||
status.status !== 'running' ||
|
||||
status.controlPlaneStatus !== 'connected'
|
||||
) {
|
||||
throw new Error(
|
||||
'OpenClaw must be running with a connected control plane before creating agents.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async materialize(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): Promise<BrowserOsAgentMaterializationResult> {
|
||||
const agent = await this.openClawService.createAgent({
|
||||
name: input.id,
|
||||
providerType: input.providerType,
|
||||
providerName: input.providerName,
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
modelId: input.modelId,
|
||||
})
|
||||
return {
|
||||
runtimeBinding: {
|
||||
agentId: agent.agentId,
|
||||
workspace: agent.workspace,
|
||||
model: agent.model,
|
||||
},
|
||||
adapterConfig: toStoredAdapterConfig(input),
|
||||
}
|
||||
}
|
||||
|
||||
async remove(record: BrowserOsStoredAgent): Promise<void> {
|
||||
await this.openClawService.removeAgent(resolveRuntimeAgentId(record))
|
||||
}
|
||||
|
||||
async streamChat(
|
||||
record: BrowserOsStoredAgent,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>> {
|
||||
const stream = await this.openClawService.chatStream(
|
||||
resolveRuntimeAgentId(record),
|
||||
input.sessionKey,
|
||||
input.message,
|
||||
)
|
||||
return normalizeOpenClawStream(stream, resolveRuntimeAgentId(record))
|
||||
}
|
||||
}
|
||||
|
||||
function toStoredAdapterConfig(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): Record<string, unknown> {
|
||||
const config = {
|
||||
providerType: input.providerType,
|
||||
providerName: input.providerName,
|
||||
baseUrl: input.baseUrl,
|
||||
modelId: input.modelId,
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).filter(([, value]) => value !== undefined),
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRuntimeAgentId(record: BrowserOsStoredAgent): string {
|
||||
const runtimeAgentId = record.runtimeBinding?.agentId
|
||||
return typeof runtimeAgentId === 'string' && runtimeAgentId
|
||||
? runtimeAgentId
|
||||
: record.id
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type {
|
||||
BrowserOsAgentAdapterType,
|
||||
BrowserOsStoredAgent,
|
||||
} from '@browseros/shared/types/browseros-agents'
|
||||
|
||||
export interface BrowserOsAgentCreateInput {
|
||||
id: string
|
||||
name: string
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
binaryPath?: string
|
||||
dangerouslyBypassApprovalsAndSandbox?: boolean
|
||||
dangerouslySkipPermissions?: boolean
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentMaterializationResult {
|
||||
runtimeBinding: Record<string, unknown> | null
|
||||
adapterConfig?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentChatInput {
|
||||
sessionKey: string
|
||||
message: string
|
||||
conversation?: BrowserOsAgentConversationTurn[]
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentConversationTurn {
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentCatalogEntry {
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BrowserOsAgentAdapter {
|
||||
readonly adapterType: BrowserOsAgentAdapterType
|
||||
validateCreate(input: BrowserOsAgentCreateInput): Promise<void>
|
||||
materialize(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): Promise<BrowserOsAgentMaterializationResult>
|
||||
remove(record: BrowserOsStoredAgent): Promise<void>
|
||||
streamChat(
|
||||
record: BrowserOsStoredAgent,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>>
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface AgentBootstrapFiles {
|
||||
'AGENTS.md': string
|
||||
'SOUL.md': string
|
||||
'TOOLS.md': string
|
||||
'HEARTBEAT.md': string
|
||||
}
|
||||
|
||||
export interface AgentBootstrapInput {
|
||||
agentName: string
|
||||
}
|
||||
|
||||
export function buildAgentBootstrapFiles(
|
||||
input: AgentBootstrapInput,
|
||||
): AgentBootstrapFiles {
|
||||
return {
|
||||
'AGENTS.md': `# ${input.agentName}
|
||||
|
||||
You are a BrowserOS-managed agent for this workspace.
|
||||
|
||||
## Core Purpose
|
||||
- Carry out the responsibilities configured for this agent.
|
||||
- Keep work inspectable inside the managed BrowserOS agent directory.
|
||||
- Surface blockers, missing context, and approvals clearly.
|
||||
|
||||
## Default Output Style
|
||||
- concise
|
||||
- action-oriented
|
||||
- explicit about next steps
|
||||
`,
|
||||
'SOUL.md': `# Operating Style
|
||||
|
||||
You act like a reliable BrowserOS operator.
|
||||
|
||||
## Working Posture
|
||||
- calm
|
||||
- structured
|
||||
- direct
|
||||
- explicit about tradeoffs
|
||||
|
||||
## Collaboration Rules
|
||||
- Prefer reversible actions when possible.
|
||||
- Ask before high-impact external mutations.
|
||||
- Leave durable artifacts in the workspace when useful.
|
||||
`,
|
||||
'TOOLS.md': `# Tooling Guidelines
|
||||
|
||||
- Use BrowserOS MCP for browser and connected SaaS tasks.
|
||||
- Use browseros-cli for local BrowserOS workflows when a CLI path is more direct.
|
||||
- Prefer read, summarize, and draft flows until higher-impact mutations are approved.
|
||||
- Keep outputs in the workspace when possible so work remains inspectable.
|
||||
`,
|
||||
'HEARTBEAT.md': `# Heartbeat
|
||||
|
||||
This file is reserved for future autonomous wake/schedule behavior.
|
||||
It is unused in v1 chats and should remain informational only for now.
|
||||
`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import type {
|
||||
BrowserOsAgentAdapterType,
|
||||
BrowserOsStoredAgent,
|
||||
BrowserOsValidationState,
|
||||
} from '@browseros/shared/types/browseros-agents'
|
||||
import {
|
||||
ensureBrowserosDir,
|
||||
getAgentDir,
|
||||
getAgentMetadataPath,
|
||||
getAgentRuntimeDir,
|
||||
getAgentsDir,
|
||||
} from '../../../lib/browseros-dir'
|
||||
import { buildAgentBootstrapFiles } from './agent-bootstrap'
|
||||
|
||||
export interface CreateAgentRegistryInput {
|
||||
id: string
|
||||
name: string
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
adapterConfig?: Record<string, unknown>
|
||||
runtimeBinding?: Record<string, unknown> | null
|
||||
lastValidation?: BrowserOsValidationState | null
|
||||
}
|
||||
|
||||
export class AgentRegistryService {
|
||||
async list(): Promise<BrowserOsStoredAgent[]> {
|
||||
await ensureBrowserosDir()
|
||||
const entries = await readdir(getAgentsDir(), { withFileTypes: true })
|
||||
const records = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => this.get(entry.name)),
|
||||
)
|
||||
return records
|
||||
.filter((record): record is BrowserOsStoredAgent => record !== null)
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<BrowserOsStoredAgent | null> {
|
||||
this.assertValidAgentId(agentId)
|
||||
try {
|
||||
const raw = await readFile(getAgentMetadataPath(agentId), 'utf8')
|
||||
return JSON.parse(raw) as BrowserOsStoredAgent
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateAgentRegistryInput): Promise<BrowserOsStoredAgent> {
|
||||
this.assertValidAgentId(input.id)
|
||||
if (await this.get(input.id)) {
|
||||
throw new Error(`Agent "${input.id}" already exists`)
|
||||
}
|
||||
await ensureBrowserosDir()
|
||||
const agentDir = getAgentDir(input.id)
|
||||
const now = new Date().toISOString()
|
||||
const record: BrowserOsStoredAgent = {
|
||||
version: 1,
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
adapterType: input.adapterType,
|
||||
paths: {
|
||||
agentDir,
|
||||
cwd: agentDir,
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: input.adapterConfig ?? {},
|
||||
runtimeBinding: input.runtimeBinding ?? null,
|
||||
lastValidation: input.lastValidation ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await mkdir(getAgentRuntimeDir(input.id), { recursive: true })
|
||||
await this.writeBootstrapFiles(record.name, agentDir)
|
||||
await this.writeRecord(record)
|
||||
return record
|
||||
}
|
||||
|
||||
async update(record: BrowserOsStoredAgent): Promise<BrowserOsStoredAgent> {
|
||||
this.assertValidAgentId(record.id)
|
||||
await ensureBrowserosDir()
|
||||
const agentDir = getAgentDir(record.id)
|
||||
const updatedRecord: BrowserOsStoredAgent = {
|
||||
...record,
|
||||
version: 1,
|
||||
paths: {
|
||||
agentDir,
|
||||
cwd: record.paths.cwd,
|
||||
contextDirs: record.paths.contextDirs ?? [],
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await mkdir(getAgentRuntimeDir(record.id), { recursive: true })
|
||||
await this.writeRecord(updatedRecord)
|
||||
return updatedRecord
|
||||
}
|
||||
|
||||
async remove(agentId: string): Promise<void> {
|
||||
this.assertValidAgentId(agentId)
|
||||
await rm(getAgentDir(agentId), { recursive: true, force: true })
|
||||
}
|
||||
|
||||
private async writeBootstrapFiles(
|
||||
agentName: string,
|
||||
agentDir: string,
|
||||
): Promise<void> {
|
||||
const files = buildAgentBootstrapFiles({ agentName })
|
||||
await Promise.all(
|
||||
Object.entries(files).map(([fileName, content]) =>
|
||||
writeFile(resolve(agentDir, fileName), content, 'utf8'),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private async writeRecord(record: BrowserOsStoredAgent): Promise<void> {
|
||||
await writeFile(
|
||||
getAgentMetadataPath(record.id),
|
||||
`${JSON.stringify(record, null, 2)}\n`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
private assertValidAgentId(agentId: string): void {
|
||||
if (!agentId || agentId.includes('/') || agentId.includes('\\')) {
|
||||
throw new Error('Invalid agent id')
|
||||
}
|
||||
const agentsDir = getAgentsDir()
|
||||
const resolved = resolve(agentsDir, agentId)
|
||||
if (resolved !== getAgentDir(agentId)) {
|
||||
throw new Error('Invalid agent id')
|
||||
}
|
||||
if (!resolved.startsWith(`${agentsDir}${sep}`)) {
|
||||
throw new Error('Invalid agent id')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import { getOpenClawService } from '../openclaw/openclaw-service'
|
||||
import { ClaudeLocalAgentAdapter } from './adapters/claude-local-adapter'
|
||||
import { CodexLocalAgentAdapter } from './adapters/codex-local-adapter'
|
||||
import { OpenClawAgentAdapter } from './adapters/openclaw-adapter'
|
||||
import type {
|
||||
BrowserOsAgentAdapter,
|
||||
BrowserOsAgentCatalogEntry,
|
||||
BrowserOsAgentChatInput,
|
||||
BrowserOsAgentCreateInput,
|
||||
} from './adapters/types'
|
||||
import { AgentRegistryService } from './agent-registry-service'
|
||||
|
||||
interface BrowserOsAgentServiceOptions {
|
||||
registry?: AgentRegistryService
|
||||
adapters?: BrowserOsAgentAdapter[]
|
||||
openClawService?: ReturnType<typeof getOpenClawService>
|
||||
}
|
||||
|
||||
export class BrowserOsAgentService {
|
||||
private readonly registry: AgentRegistryService
|
||||
private readonly adapters: Map<string, BrowserOsAgentAdapter>
|
||||
private readonly openClawService: ReturnType<typeof getOpenClawService>
|
||||
|
||||
constructor(options: BrowserOsAgentServiceOptions = {}) {
|
||||
this.registry = options.registry ?? new AgentRegistryService()
|
||||
this.openClawService = options.openClawService ?? getOpenClawService()
|
||||
this.adapters = new Map(
|
||||
(options.adapters ?? this.createDefaultAdapters()).map((adapter) => [
|
||||
adapter.adapterType,
|
||||
adapter,
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
catalog(): BrowserOsAgentCatalogEntry[] {
|
||||
return Array.from(this.adapters.values()).map((adapter) => ({
|
||||
adapterType: adapter.adapterType,
|
||||
label: toAdapterLabel(adapter.adapterType),
|
||||
}))
|
||||
}
|
||||
|
||||
async list(): Promise<BrowserOsStoredAgent[]> {
|
||||
return this.registry.list()
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<BrowserOsStoredAgent | null> {
|
||||
return this.registry.get(agentId)
|
||||
}
|
||||
|
||||
async create(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): Promise<BrowserOsStoredAgent> {
|
||||
const adapter = this.getAdapter(input.adapterType)
|
||||
const existing = await this.registry.get(input.id)
|
||||
if (existing) {
|
||||
throw new Error(`Agent "${input.id}" already exists`)
|
||||
}
|
||||
await adapter.validateCreate(input)
|
||||
const created = await this.registry.create({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
adapterType: input.adapterType,
|
||||
adapterConfig: buildInitialAdapterConfig(input),
|
||||
runtimeBinding: null,
|
||||
})
|
||||
try {
|
||||
const materialized = await adapter.materialize(input)
|
||||
return await this.registry.update({
|
||||
...created,
|
||||
adapterConfig: buildStoredAdapterConfig(input, materialized),
|
||||
runtimeBinding: materialized.runtimeBinding,
|
||||
})
|
||||
} catch (error) {
|
||||
try {
|
||||
await adapter.remove(created)
|
||||
} catch {}
|
||||
await this.registry.remove(input.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async remove(agentId: string): Promise<void> {
|
||||
const record = await this.registry.get(agentId)
|
||||
if (!record) {
|
||||
throw new Error(`Agent "${agentId}" not found`)
|
||||
}
|
||||
const adapter = this.getAdapter(record.adapterType)
|
||||
await adapter.remove(record)
|
||||
await this.registry.remove(agentId)
|
||||
}
|
||||
|
||||
async chat(
|
||||
agentId: string,
|
||||
input: BrowserOsAgentChatInput,
|
||||
): Promise<ReadableStream<UIMessageStreamEvent>> {
|
||||
const record = await this.registry.get(agentId)
|
||||
if (!record) {
|
||||
throw new Error(`Agent "${agentId}" not found`)
|
||||
}
|
||||
const adapter = this.getAdapter(record.adapterType)
|
||||
return adapter.streamChat(record, input)
|
||||
}
|
||||
|
||||
private getAdapter(adapterType: string): BrowserOsAgentAdapter {
|
||||
const adapter = this.adapters.get(adapterType)
|
||||
if (!adapter) {
|
||||
throw new Error(`Unsupported agent adapter: ${adapterType}`)
|
||||
}
|
||||
return adapter
|
||||
}
|
||||
|
||||
private createDefaultAdapters(): BrowserOsAgentAdapter[] {
|
||||
return [
|
||||
new OpenClawAgentAdapter(this.openClawService),
|
||||
new CodexLocalAgentAdapter(),
|
||||
new ClaudeLocalAgentAdapter(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
let browserOsAgentService: BrowserOsAgentService | null = null
|
||||
|
||||
export function getBrowserOsAgentService(): BrowserOsAgentService {
|
||||
if (!browserOsAgentService) {
|
||||
browserOsAgentService = new BrowserOsAgentService({
|
||||
openClawService: getOpenClawService(),
|
||||
})
|
||||
}
|
||||
return browserOsAgentService
|
||||
}
|
||||
|
||||
function toAdapterLabel(adapterType: string): string {
|
||||
switch (adapterType) {
|
||||
case 'openclaw':
|
||||
return 'OpenClaw'
|
||||
case 'codex_local':
|
||||
return 'Codex Local'
|
||||
case 'claude_local':
|
||||
return 'Claude Local'
|
||||
default:
|
||||
return adapterType
|
||||
}
|
||||
}
|
||||
|
||||
function buildInitialAdapterConfig(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
): Record<string, unknown> {
|
||||
const config: Record<string, unknown> = {}
|
||||
if (
|
||||
(input.adapterType === 'codex_local' ||
|
||||
input.adapterType === 'claude_local') &&
|
||||
input.binaryPath?.trim()
|
||||
) {
|
||||
config.binaryPath = input.binaryPath.trim()
|
||||
}
|
||||
if (
|
||||
input.adapterType === 'codex_local' &&
|
||||
input.dangerouslyBypassApprovalsAndSandbox === true
|
||||
) {
|
||||
config.dangerouslyBypassApprovalsAndSandbox = true
|
||||
}
|
||||
if (
|
||||
input.adapterType === 'claude_local' &&
|
||||
input.dangerouslySkipPermissions === true
|
||||
) {
|
||||
config.dangerouslySkipPermissions = true
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
function buildStoredAdapterConfig(
|
||||
input: BrowserOsAgentCreateInput,
|
||||
materialized: { adapterConfig?: Record<string, unknown> },
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...buildInitialAdapterConfig(input),
|
||||
...(materialized.adapterConfig ?? {}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { OpenClawStreamEvent } from '../openclaw/openclaw-types'
|
||||
|
||||
const OPENCLAW_TEXT_STREAM_ID_SUFFIX = 'text'
|
||||
const OPENCLAW_REASONING_STREAM_ID_SUFFIX = 'reasoning'
|
||||
const OPENCLAW_TOOL_NAME_FALLBACK = 'openclaw-tool'
|
||||
|
||||
export function normalizeOpenClawStream(
|
||||
stream: ReadableStream<OpenClawStreamEvent>,
|
||||
runtimeAgentId: string,
|
||||
): ReadableStream<UIMessageStreamEvent> {
|
||||
const textId = `${runtimeAgentId}-${OPENCLAW_TEXT_STREAM_ID_SUFFIX}`
|
||||
const reasoningId = `${runtimeAgentId}-${OPENCLAW_REASONING_STREAM_ID_SUFFIX}`
|
||||
return new ReadableStream<UIMessageStreamEvent>({
|
||||
async start(controller) {
|
||||
controller.enqueue({ type: 'start' })
|
||||
controller.enqueue({ type: 'text-start', id: textId })
|
||||
const reader = stream.getReader()
|
||||
let closed = false
|
||||
const state: OpenClawNormalizationState = {
|
||||
reasoningStarted: false,
|
||||
toolCallCounter: 0,
|
||||
pendingFallbackToolCallId: null,
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done || closed) {
|
||||
break
|
||||
}
|
||||
closed = handleOpenClawEvent(
|
||||
value,
|
||||
controller,
|
||||
textId,
|
||||
reasoningId,
|
||||
state,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
if (!closed) {
|
||||
closeReasoningStreamIfNeeded(controller, reasoningId, state)
|
||||
controller.enqueue({ type: 'text-end', id: textId })
|
||||
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface OpenClawNormalizationState {
|
||||
reasoningStarted: boolean
|
||||
toolCallCounter: number
|
||||
pendingFallbackToolCallId: string | null
|
||||
}
|
||||
|
||||
function handleOpenClawEvent(
|
||||
event: OpenClawStreamEvent,
|
||||
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
|
||||
textId: string,
|
||||
reasoningId: string,
|
||||
state: OpenClawNormalizationState,
|
||||
): boolean {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = typeof event.data.text === 'string' ? event.data.text : ''
|
||||
if (delta) {
|
||||
controller.enqueue({ type: 'text-delta', id: textId, delta })
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'thinking': {
|
||||
const delta = readNarrativeDelta(event.data)
|
||||
if (delta) {
|
||||
ensureReasoningStream(controller, reasoningId, state)
|
||||
controller.enqueue({ type: 'reasoning-delta', id: reasoningId, delta })
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'tool-start': {
|
||||
const toolCallId = resolveToolCallId(event.data, state, textId, 'start')
|
||||
const toolName = resolveToolName(event.data)
|
||||
controller.enqueue({
|
||||
type: 'tool-input-start',
|
||||
toolCallId,
|
||||
toolName,
|
||||
})
|
||||
if ('input' in event.data) {
|
||||
controller.enqueue({
|
||||
type: 'tool-input-available',
|
||||
toolCallId,
|
||||
toolName,
|
||||
input: event.data.input,
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
case 'tool-output':
|
||||
controller.enqueue({
|
||||
type: 'tool-output-available',
|
||||
toolCallId: resolveToolCallId(event.data, state, textId, 'output'),
|
||||
output: 'output' in event.data ? event.data.output : event.data,
|
||||
})
|
||||
return false
|
||||
case 'tool-end':
|
||||
controller.enqueue({
|
||||
type: 'tool-output-available',
|
||||
toolCallId: resolveToolCallId(event.data, state, textId, 'end'),
|
||||
output: stripToolMetadata(event.data),
|
||||
})
|
||||
return false
|
||||
case 'lifecycle':
|
||||
ensureReasoningStream(controller, reasoningId, state)
|
||||
controller.enqueue({
|
||||
type: 'reasoning-delta',
|
||||
id: reasoningId,
|
||||
delta: JSON.stringify(event.data),
|
||||
})
|
||||
return false
|
||||
case 'done':
|
||||
closeReasoningStreamIfNeeded(controller, reasoningId, state)
|
||||
controller.enqueue({ type: 'text-end', id: textId })
|
||||
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
||||
controller.close()
|
||||
return true
|
||||
case 'error':
|
||||
closeReasoningStreamIfNeeded(controller, reasoningId, state)
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
errorText:
|
||||
typeof event.data.message === 'string'
|
||||
? event.data.message
|
||||
: 'OpenClaw chat stream failed',
|
||||
})
|
||||
controller.close()
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ensureReasoningStream(
|
||||
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
|
||||
reasoningId: string,
|
||||
state: OpenClawNormalizationState,
|
||||
): void {
|
||||
if (state.reasoningStarted) {
|
||||
return
|
||||
}
|
||||
controller.enqueue({ type: 'reasoning-start', id: reasoningId })
|
||||
state.reasoningStarted = true
|
||||
}
|
||||
|
||||
function closeReasoningStreamIfNeeded(
|
||||
controller: ReadableStreamDefaultController<UIMessageStreamEvent>,
|
||||
reasoningId: string,
|
||||
state: OpenClawNormalizationState,
|
||||
): void {
|
||||
if (!state.reasoningStarted) {
|
||||
return
|
||||
}
|
||||
controller.enqueue({ type: 'reasoning-end', id: reasoningId })
|
||||
state.reasoningStarted = false
|
||||
}
|
||||
|
||||
function readNarrativeDelta(data: Record<string, unknown>): string {
|
||||
const candidate = data.text ?? data.message ?? data.content
|
||||
return typeof candidate === 'string' ? candidate : JSON.stringify(data)
|
||||
}
|
||||
|
||||
function resolveToolCallId(
|
||||
data: Record<string, unknown>,
|
||||
state: OpenClawNormalizationState,
|
||||
prefix: string,
|
||||
kind: 'start' | 'output' | 'end',
|
||||
): string {
|
||||
if (typeof data.toolCallId === 'string' && data.toolCallId) {
|
||||
if (kind === 'end') {
|
||||
state.pendingFallbackToolCallId = null
|
||||
}
|
||||
return data.toolCallId
|
||||
}
|
||||
if (kind === 'start' || !state.pendingFallbackToolCallId) {
|
||||
state.toolCallCounter += 1
|
||||
state.pendingFallbackToolCallId = `${prefix}-tool-${state.toolCallCounter}`
|
||||
}
|
||||
const toolCallId = state.pendingFallbackToolCallId
|
||||
if (kind === 'end') {
|
||||
state.pendingFallbackToolCallId = null
|
||||
}
|
||||
return toolCallId
|
||||
}
|
||||
|
||||
function resolveToolName(data: Record<string, unknown>): string {
|
||||
const candidate = data.toolName ?? data.name
|
||||
return typeof candidate === 'string' && candidate
|
||||
? candidate
|
||||
: OPENCLAW_TOOL_NAME_FALLBACK
|
||||
}
|
||||
|
||||
function stripToolMetadata(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const output = { ...data }
|
||||
delete output.toolCallId
|
||||
delete output.toolName
|
||||
delete output.name
|
||||
return output
|
||||
}
|
||||
@@ -25,6 +25,22 @@ export function getMemoryDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.MEMORY_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getAgentsDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.AGENTS_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getAgentDir(agentId: string): string {
|
||||
return join(getAgentsDir(), agentId)
|
||||
}
|
||||
|
||||
export function getAgentMetadataPath(agentId: string): string {
|
||||
return join(getAgentDir(agentId), PATHS.AGENT_METADATA_FILE_NAME)
|
||||
}
|
||||
|
||||
export function getAgentRuntimeDir(agentId: string): string {
|
||||
return join(getAgentDir(agentId), PATHS.AGENT_RUNTIME_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getSessionsDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.SESSIONS_DIR_NAME)
|
||||
}
|
||||
@@ -69,6 +85,7 @@ export function removeServerConfigSync(): void {
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
logDevelopmentBrowserosDir()
|
||||
await mkdir(getAgentsDir(), { recursive: true })
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it, mock } from 'bun:test'
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
import { Hono } from 'hono'
|
||||
import { requireTrustedAppOrigin } from '../../../src/api/utils/request-auth'
|
||||
|
||||
describe('createAgentsRoutes', () => {
|
||||
it('serves catalog, list, create, delete, and SSE chat through the generic agent service', async () => {
|
||||
const catalog = mock(() => [
|
||||
{ adapterType: 'openclaw', label: 'OpenClaw' },
|
||||
{ adapterType: 'codex_local', label: 'Codex Local' },
|
||||
])
|
||||
const list = mock(async () => [createStoredAgent()])
|
||||
const create = mock(async () => createStoredAgent())
|
||||
const remove = mock(async () => {})
|
||||
const chat = mock(async (_agentId: string, _input: unknown) =>
|
||||
createEventStream([
|
||||
{ type: 'start' },
|
||||
{ type: 'text-start', id: 'agent-text' },
|
||||
{ type: 'text-delta', id: 'agent-text', delta: 'Hello' },
|
||||
{ type: 'text-end', id: 'agent-text' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
]),
|
||||
)
|
||||
|
||||
const { createAgentsRoutes } = await import(
|
||||
'../../../src/api/routes/agents'
|
||||
)
|
||||
const route = createAgentsRoutes({
|
||||
catalog,
|
||||
list,
|
||||
create,
|
||||
remove,
|
||||
chat,
|
||||
} as never)
|
||||
|
||||
const catalogResponse = await route.request('/catalog')
|
||||
expect(catalogResponse.status).toBe(200)
|
||||
expect(await catalogResponse.json()).toEqual({
|
||||
adapters: [
|
||||
{ adapterType: 'openclaw', label: 'OpenClaw' },
|
||||
{ adapterType: 'codex_local', label: 'Codex Local' },
|
||||
],
|
||||
})
|
||||
|
||||
const listResponse = await route.request('/')
|
||||
expect(listResponse.status).toBe(200)
|
||||
expect(await listResponse.json()).toEqual({
|
||||
agents: [createStoredAgent()],
|
||||
})
|
||||
|
||||
const createResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
}),
|
||||
})
|
||||
expect(createResponse.status).toBe(201)
|
||||
expect(await createResponse.json()).toEqual({
|
||||
agent: createStoredAgent(),
|
||||
})
|
||||
expect(create).toHaveBeenCalledWith({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
})
|
||||
|
||||
const deleteResponse = await route.request('/agent', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(deleteResponse.status).toBe(200)
|
||||
expect(await deleteResponse.json()).toEqual({ success: true })
|
||||
expect(remove).toHaveBeenCalledWith('agent')
|
||||
|
||||
const chatResponse = await route.request('/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Summarize it.',
|
||||
sessionKey: 'session-123',
|
||||
conversation: [
|
||||
{ role: 'user', text: 'What happened?' },
|
||||
{ role: 'assistant', text: 'We shipped Task 5.' },
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(chatResponse.status).toBe(200)
|
||||
expect(chatResponse.headers.get('Content-Type')).toContain(
|
||||
'text/event-stream',
|
||||
)
|
||||
expect(chatResponse.headers.get('X-Session-Key')).toBe('session-123')
|
||||
expect(chatResponse.headers.get('x-vercel-ai-ui-message-stream')).toBe('v1')
|
||||
expect(chat).toHaveBeenCalledWith('agent', {
|
||||
sessionKey: 'session-123',
|
||||
message: 'Summarize it.',
|
||||
conversation: [
|
||||
{ role: 'user', text: 'What happened?' },
|
||||
{ role: 'assistant', text: 'We shipped Task 5.' },
|
||||
],
|
||||
})
|
||||
expect(await chatResponse.text()).toBe(
|
||||
'data: {"type":"start"}\n\n' +
|
||||
'data: {"type":"text-start","id":"agent-text"}\n\n' +
|
||||
'data: {"type":"text-delta","id":"agent-text","delta":"Hello"}\n\n' +
|
||||
'data: {"type":"text-end","id":"agent-text"}\n\n' +
|
||||
'data: {"type":"finish","finishReason":"stop"}\n\n' +
|
||||
'data: [DONE]\n\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 400 for malformed JSON and invalid payloads on create and chat', async () => {
|
||||
const { createAgentsRoutes } = await import(
|
||||
'../../../src/api/routes/agents'
|
||||
)
|
||||
const route = createAgentsRoutes({
|
||||
catalog: mock(() => []),
|
||||
list: mock(async () => []),
|
||||
create: mock(async () => createStoredAgent()),
|
||||
remove: mock(async () => {}),
|
||||
chat: mock(async () => createEventStream([])),
|
||||
} as never)
|
||||
|
||||
const createResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"id":',
|
||||
})
|
||||
expect(createResponse.status).toBe(400)
|
||||
|
||||
const invalidCreateResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: '',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
}),
|
||||
})
|
||||
expect(invalidCreateResponse.status).toBe(400)
|
||||
|
||||
const missingBinaryResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'claude_local',
|
||||
}),
|
||||
})
|
||||
expect(missingBinaryResponse.status).toBe(400)
|
||||
|
||||
const chatResponse = await route.request('/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"message":',
|
||||
})
|
||||
expect(chatResponse.status).toBe(400)
|
||||
|
||||
const invalidChatResponse = await route.request('/agent/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: '',
|
||||
}),
|
||||
})
|
||||
expect(invalidChatResponse.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 404, 409, and 400 for known service errors and validation failures', async () => {
|
||||
const { createAgentsRoutes } = await import(
|
||||
'../../../src/api/routes/agents'
|
||||
)
|
||||
const route = createAgentsRoutes({
|
||||
catalog: mock(() => []),
|
||||
list: mock(async () => []),
|
||||
create: mock(async (input: { id: string; adapterType: string }) => {
|
||||
if (input.id === 'existing') {
|
||||
throw new Error('Agent "agent" already exists')
|
||||
}
|
||||
if (input.id === 'probe') {
|
||||
throw new Error('Codex hello probe failed')
|
||||
}
|
||||
if (input.adapterType === 'openclaw') {
|
||||
throw new Error('Unsupported OpenClaw provider: unsupported')
|
||||
}
|
||||
throw new Error('claude_local requires a configured binaryPath')
|
||||
}),
|
||||
remove: mock(async () => {
|
||||
throw new Error('Agent "missing" not found')
|
||||
}),
|
||||
chat: mock(async () => {
|
||||
throw new Error('Agent "missing" not found')
|
||||
}),
|
||||
} as never)
|
||||
|
||||
const conflictResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'existing',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
}),
|
||||
})
|
||||
expect(conflictResponse.status).toBe(409)
|
||||
|
||||
const invalidResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'claude_local',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
}),
|
||||
})
|
||||
expect(invalidResponse.status).toBe(400)
|
||||
|
||||
const probeFailureResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'probe',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
}),
|
||||
})
|
||||
expect(probeFailureResponse.status).toBe(400)
|
||||
|
||||
const unsupportedProviderResponse = await route.request('/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'openclaw',
|
||||
providerType: 'unsupported',
|
||||
}),
|
||||
})
|
||||
expect(unsupportedProviderResponse.status).toBe(400)
|
||||
|
||||
const deleteResponse = await route.request('/missing', {
|
||||
method: 'DELETE',
|
||||
})
|
||||
expect(deleteResponse.status).toBe(404)
|
||||
|
||||
const missingChatResponse = await route.request('/missing/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hello',
|
||||
}),
|
||||
})
|
||||
expect(missingChatResponse.status).toBe(404)
|
||||
})
|
||||
|
||||
it('can be mounted behind trusted-origin protection for agent control routes', async () => {
|
||||
const { createAgentsRoutes } = await import(
|
||||
'../../../src/api/routes/agents'
|
||||
)
|
||||
const app = new Hono().use('/*', requireTrustedAppOrigin()).route(
|
||||
'/agents',
|
||||
createAgentsRoutes({
|
||||
catalog: mock(() => []),
|
||||
list: mock(async () => []),
|
||||
create: mock(async () => createStoredAgent()),
|
||||
remove: mock(async () => {}),
|
||||
chat: mock(async () => createEventStream([])),
|
||||
} as never),
|
||||
)
|
||||
|
||||
const forbidden = await app.request('http://localhost/agents', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Origin: 'https://evil.example',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
}),
|
||||
})
|
||||
expect(forbidden.status).toBe(403)
|
||||
|
||||
const allowed = await app.request('http://localhost/agents/catalog', {
|
||||
headers: { Origin: 'chrome-extension://browseros' },
|
||||
})
|
||||
expect(allowed.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
function createStoredAgent(): BrowserOsStoredAgent {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
paths: {
|
||||
agentDir: '/tmp/agent',
|
||||
cwd: '/tmp/agent',
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
},
|
||||
runtimeBinding: null,
|
||||
lastValidation: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function createEventStream(events: UIMessageStreamEvent[]) {
|
||||
return new ReadableStream<UIMessageStreamEvent>({
|
||||
start(controller) {
|
||||
for (const event of events) {
|
||||
controller.enqueue(event)
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdtemp, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
describe('AgentRegistryService', () => {
|
||||
let homeDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
|
||||
mock.module('node:os', () => ({
|
||||
homedir: () => homeDir,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('creates a managed agent directory with boot files and metadata', async () => {
|
||||
const { AgentRegistryService } = await import(
|
||||
'../../../../src/api/services/agents/agent-registry-service'
|
||||
)
|
||||
|
||||
const service = new AgentRegistryService()
|
||||
await service.create({
|
||||
id: 'chief-of-staff',
|
||||
name: 'chief-of-staff',
|
||||
adapterType: 'codex_local',
|
||||
adapterConfig: {
|
||||
binaryPath: '/opt/homebrew/bin/codex',
|
||||
},
|
||||
runtimeBinding: null,
|
||||
lastValidation: {
|
||||
status: 'ok',
|
||||
checkedAt: '2026-04-16T18:30:00.000Z',
|
||||
message: 'create validation passed',
|
||||
},
|
||||
})
|
||||
|
||||
const record = await service.get('chief-of-staff')
|
||||
expect(record?.paths.cwd).toBe(
|
||||
join(homeDir, '.browseros', 'agents', 'chief-of-staff'),
|
||||
)
|
||||
|
||||
const toolsMd = await readFile(
|
||||
join(homeDir, '.browseros', 'agents', 'chief-of-staff', 'TOOLS.md'),
|
||||
'utf8',
|
||||
)
|
||||
const heartbeatMd = await readFile(
|
||||
join(homeDir, '.browseros', 'agents', 'chief-of-staff', 'HEARTBEAT.md'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(toolsMd).toContain('browseros-cli')
|
||||
expect(heartbeatMd).toContain('reserved for future')
|
||||
expect(record).toMatchObject({
|
||||
id: 'chief-of-staff',
|
||||
name: 'chief-of-staff',
|
||||
adapterType: 'codex_local',
|
||||
adapterConfig: {
|
||||
binaryPath: '/opt/homebrew/bin/codex',
|
||||
},
|
||||
runtimeBinding: null,
|
||||
lastValidation: {
|
||||
status: 'ok',
|
||||
checkedAt: '2026-04-16T18:30:00.000Z',
|
||||
message: 'create validation passed',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('lists stored agents in id order and removes them recursively', async () => {
|
||||
const { AgentRegistryService } = await import(
|
||||
'../../../../src/api/services/agents/agent-registry-service'
|
||||
)
|
||||
|
||||
const service = new AgentRegistryService()
|
||||
await service.create({
|
||||
id: 'zeta',
|
||||
name: 'zeta',
|
||||
adapterType: 'claude_local',
|
||||
})
|
||||
await service.create({
|
||||
id: 'alpha',
|
||||
name: 'alpha',
|
||||
adapterType: 'openclaw',
|
||||
runtimeBinding: {
|
||||
agentId: 'alpha',
|
||||
},
|
||||
})
|
||||
|
||||
expect((await service.list()).map((record) => record.id)).toEqual([
|
||||
'alpha',
|
||||
'zeta',
|
||||
])
|
||||
|
||||
await service.remove('alpha')
|
||||
expect(await service.get('alpha')).toBeNull()
|
||||
expect((await service.list()).map((record) => record.id)).toEqual(['zeta'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { UIMessageStreamEvent } from '@browseros/shared/schemas/ui-stream'
|
||||
|
||||
describe('BrowserOsAgentService', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'browseros-agent-service-'))
|
||||
mock.module('node:os', () => ({
|
||||
homedir: () => homeDir,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await rm(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('creates agents through the matching adapter and persists adapter config', async () => {
|
||||
const { AgentRegistryService } = await import(
|
||||
'../../../../src/api/services/agents/agent-registry-service'
|
||||
)
|
||||
const { BrowserOsAgentService } = await import(
|
||||
'../../../../src/api/services/agents/agent-service'
|
||||
)
|
||||
|
||||
const validateCreate = mock(async () => {})
|
||||
const materialize = mock(async () => ({
|
||||
runtimeBinding: null,
|
||||
adapterConfig: {
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
},
|
||||
}))
|
||||
const remove = mock(async () => {})
|
||||
const streamChat = mock(
|
||||
async () =>
|
||||
new ReadableStream<UIMessageStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: 'start' })
|
||||
controller.enqueue({ type: 'finish', finishReason: 'stop' })
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
const registry = new AgentRegistryService()
|
||||
const service = new BrowserOsAgentService({
|
||||
registry,
|
||||
adapters: [
|
||||
{
|
||||
adapterType: 'codex_local',
|
||||
validateCreate,
|
||||
materialize,
|
||||
remove,
|
||||
streamChat,
|
||||
} as never,
|
||||
],
|
||||
openClawService: {} as never,
|
||||
})
|
||||
|
||||
const created = await service.create({
|
||||
id: 'codex-agent',
|
||||
name: 'codex-agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
})
|
||||
|
||||
expect(service.catalog()).toEqual([
|
||||
{ adapterType: 'codex_local', label: 'Codex Local' },
|
||||
])
|
||||
expect(validateCreate).toHaveBeenCalledWith({
|
||||
id: 'codex-agent',
|
||||
name: 'codex-agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
})
|
||||
expect(created.adapterConfig).toEqual({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
})
|
||||
expect((await registry.get('codex-agent'))?.adapterConfig).toEqual({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('rolls back the registry record when materialize fails', async () => {
|
||||
const { AgentRegistryService } = await import(
|
||||
'../../../../src/api/services/agents/agent-registry-service'
|
||||
)
|
||||
const { BrowserOsAgentService } = await import(
|
||||
'../../../../src/api/services/agents/agent-service'
|
||||
)
|
||||
|
||||
const remove = mock(async () => {})
|
||||
const registry = new AgentRegistryService()
|
||||
const service = new BrowserOsAgentService({
|
||||
registry,
|
||||
adapters: [
|
||||
{
|
||||
adapterType: 'claude_local',
|
||||
validateCreate: mock(async () => {}),
|
||||
materialize: mock(async () => {
|
||||
throw new Error('materialize failed')
|
||||
}),
|
||||
remove,
|
||||
streamChat: mock(async () => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
} as never,
|
||||
],
|
||||
openClawService: {} as never,
|
||||
})
|
||||
|
||||
await expect(
|
||||
service.create({
|
||||
id: 'claude-agent',
|
||||
name: 'claude-agent',
|
||||
adapterType: 'claude_local',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
}),
|
||||
).rejects.toThrow('materialize failed')
|
||||
|
||||
expect(remove).toHaveBeenCalledTimes(1)
|
||||
expect(await registry.get('claude-agent')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,293 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
|
||||
interface SpawnInvocation {
|
||||
cmd: string[]
|
||||
cwd?: string
|
||||
stdinText: string
|
||||
}
|
||||
|
||||
describe('ClaudeLocalAgentAdapter', () => {
|
||||
let homeDir: string
|
||||
let invocations: SpawnInvocation[]
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'browseros-claude-local-'))
|
||||
invocations = []
|
||||
mock.module('node:os', () => ({
|
||||
homedir: () => homeDir,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await rm(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects create when binaryPath is missing or empty', async () => {
|
||||
const { ClaudeLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/claude-local-adapter'
|
||||
)
|
||||
const adapter = new ClaudeLocalAgentAdapter({
|
||||
spawn: mock(() => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.validateCreate({
|
||||
id: 'claude-agent',
|
||||
name: 'Claude Agent',
|
||||
adapterType: 'claude_local',
|
||||
binaryPath: ' ',
|
||||
}),
|
||||
).rejects.toThrow('claude_local requires a configured binaryPath')
|
||||
})
|
||||
|
||||
it('rejects create when the hello probe fails', async () => {
|
||||
const spawn = mock(
|
||||
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
|
||||
invocations.push({
|
||||
cmd,
|
||||
cwd: options?.cwd,
|
||||
stdinText: decodeStdin(options?.stdin),
|
||||
})
|
||||
return createMockProcess({
|
||||
stdoutLines: [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'goodbye' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
const { ClaudeLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/claude-local-adapter'
|
||||
)
|
||||
const adapter = new ClaudeLocalAgentAdapter({ spawn })
|
||||
|
||||
await expect(
|
||||
adapter.validateCreate({
|
||||
id: 'claude-agent',
|
||||
name: 'Claude Agent',
|
||||
adapterType: 'claude_local',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
dangerouslySkipPermissions: true,
|
||||
}),
|
||||
).rejects.toThrow('Claude hello probe failed')
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0]).toEqual({
|
||||
cmd: [
|
||||
'/usr/local/bin/claude',
|
||||
'--print',
|
||||
'-',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions',
|
||||
],
|
||||
cwd: join(homeDir, '.browseros', 'agents', 'claude-agent'),
|
||||
stdinText: 'Respond with hello.',
|
||||
})
|
||||
})
|
||||
|
||||
it('writes the Claude system prompt file and normalizes assistant content blocks into text events', async () => {
|
||||
const agentDir = join(homeDir, 'agents', 'claude-agent')
|
||||
const runtimeDir = join(
|
||||
homeDir,
|
||||
'.browseros',
|
||||
'agents',
|
||||
'claude-agent',
|
||||
'runtime',
|
||||
)
|
||||
const agentCwd = join(homeDir, 'workspace', 'claude-agent')
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await mkdir(runtimeDir, { recursive: true })
|
||||
await mkdir(agentCwd, { recursive: true })
|
||||
await writeFile(
|
||||
join(agentDir, 'AGENTS.md'),
|
||||
'# Agent Rules\nFollow the BrowserOS plan.\n',
|
||||
)
|
||||
await writeFile(join(agentDir, 'SOUL.md'), '# Soul\nStay calm.\n')
|
||||
await writeFile(join(agentDir, 'TOOLS.md'), '# Tools\nUse the workspace.\n')
|
||||
|
||||
const spawn = mock(
|
||||
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
|
||||
invocations.push({
|
||||
cmd,
|
||||
cwd: options?.cwd,
|
||||
stdinText: decodeStdin(options?.stdin),
|
||||
})
|
||||
return createMockProcess({
|
||||
stdoutLines: [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'tool_use', name: 'search' },
|
||||
{ type: 'text', text: ' world' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'text', text: '!' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
const { ClaudeLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/claude-local-adapter'
|
||||
)
|
||||
const adapter = new ClaudeLocalAgentAdapter({ spawn })
|
||||
const record = createStoredAgent({
|
||||
id: 'claude-agent',
|
||||
name: 'Claude Agent',
|
||||
adapterType: 'claude_local',
|
||||
paths: {
|
||||
agentDir,
|
||||
cwd: agentCwd,
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
dangerouslySkipPermissions: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await adapter.materialize({
|
||||
id: 'claude-agent',
|
||||
name: 'Claude Agent',
|
||||
adapterType: 'claude_local',
|
||||
binaryPath: '/usr/local/bin/claude',
|
||||
dangerouslySkipPermissions: true,
|
||||
}),
|
||||
).toEqual({
|
||||
runtimeBinding: null,
|
||||
})
|
||||
await expect(adapter.remove(record)).resolves.toBeUndefined()
|
||||
|
||||
const stream = await adapter.streamChat(record, {
|
||||
sessionKey: 'session-123',
|
||||
conversation: [
|
||||
{ role: 'user', text: 'What happened yesterday?' },
|
||||
{ role: 'assistant', text: 'We finished the migration.' },
|
||||
],
|
||||
message: 'Summarize the current state.',
|
||||
})
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0]?.cmd).toEqual([
|
||||
'/usr/local/bin/claude',
|
||||
'--print',
|
||||
'-',
|
||||
'--output-format',
|
||||
'stream-json',
|
||||
'--verbose',
|
||||
'--dangerously-skip-permissions',
|
||||
'--append-system-prompt-file',
|
||||
join(runtimeDir, 'claude-system-prompt.md'),
|
||||
])
|
||||
expect(invocations[0]?.cwd).toBe(agentCwd)
|
||||
expect(invocations[0]?.stdinText).toContain('What happened yesterday?')
|
||||
expect(invocations[0]?.stdinText).toContain('We finished the migration.')
|
||||
expect(invocations[0]?.stdinText).toContain('Summarize the current state.')
|
||||
|
||||
const systemPrompt = await readFile(
|
||||
join(runtimeDir, 'claude-system-prompt.md'),
|
||||
'utf8',
|
||||
)
|
||||
expect(systemPrompt).toContain('# Agent Rules')
|
||||
expect(systemPrompt).toContain('# Soul')
|
||||
expect(systemPrompt).toContain('# Tools')
|
||||
|
||||
expect(await readEvents(stream)).toEqual([
|
||||
{ type: 'start' },
|
||||
{ type: 'text-start', id: 'claude-agent-text' },
|
||||
{ type: 'text-delta', id: 'claude-agent-text', delta: 'Hello' },
|
||||
{ type: 'text-delta', id: 'claude-agent-text', delta: ' world' },
|
||||
{ type: 'text-delta', id: 'claude-agent-text', delta: '!' },
|
||||
{ type: 'text-end', id: 'claude-agent-text' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function createStoredAgent(
|
||||
overrides: Partial<BrowserOsStoredAgent> = {},
|
||||
): BrowserOsStoredAgent {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'claude_local',
|
||||
paths: {
|
||||
agentDir: '/tmp/agent',
|
||||
cwd: '/tmp/agent',
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {},
|
||||
runtimeBinding: null,
|
||||
lastValidation: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockProcess(input: {
|
||||
stdoutLines?: string[]
|
||||
stderrLines?: string[]
|
||||
exitCode?: number
|
||||
}) {
|
||||
return {
|
||||
stdout: createByteStream(input.stdoutLines ?? []),
|
||||
stderr: createByteStream(input.stderrLines ?? []),
|
||||
exited: Promise.resolve(input.exitCode ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
function createByteStream(lines: string[]): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${line}\n`))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function decodeStdin(stdin: unknown): string {
|
||||
if (stdin instanceof Uint8Array) {
|
||||
return new TextDecoder().decode(stdin)
|
||||
}
|
||||
return String(stdin ?? '')
|
||||
}
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<Record<string, unknown>>,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const reader = stream.getReader()
|
||||
const events: Record<string, unknown>[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
|
||||
interface SpawnInvocation {
|
||||
cmd: string[]
|
||||
cwd?: string
|
||||
stdinText: string
|
||||
}
|
||||
|
||||
describe('CodexLocalAgentAdapter', () => {
|
||||
let homeDir: string
|
||||
let invocations: SpawnInvocation[]
|
||||
|
||||
beforeEach(async () => {
|
||||
homeDir = await mkdtemp(join(tmpdir(), 'browseros-codex-local-'))
|
||||
invocations = []
|
||||
mock.module('node:os', () => ({
|
||||
homedir: () => homeDir,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await rm(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('rejects create when binaryPath is missing or empty', async () => {
|
||||
const { CodexLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/codex-local-adapter'
|
||||
)
|
||||
const adapter = new CodexLocalAgentAdapter({
|
||||
spawn: mock(() => {
|
||||
throw new Error('not used')
|
||||
}),
|
||||
})
|
||||
|
||||
await expect(
|
||||
adapter.validateCreate({
|
||||
id: 'codex-agent',
|
||||
name: 'Codex Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: ' ',
|
||||
}),
|
||||
).rejects.toThrow('codex_local requires a configured binaryPath')
|
||||
})
|
||||
|
||||
it('rejects create when the hello probe fails', async () => {
|
||||
const spawn = mock(
|
||||
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
|
||||
invocations.push({
|
||||
cmd,
|
||||
cwd: options?.cwd,
|
||||
stdinText: decodeStdin(options?.stdin),
|
||||
})
|
||||
return createMockProcess({
|
||||
stdoutLines: [
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [{ type: 'output_text', text: 'goodbye' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
const { CodexLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/codex-local-adapter'
|
||||
)
|
||||
const adapter = new CodexLocalAgentAdapter({ spawn })
|
||||
|
||||
await expect(
|
||||
adapter.validateCreate({
|
||||
id: 'codex-agent',
|
||||
name: 'Codex Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
}),
|
||||
).rejects.toThrow('Codex hello probe failed')
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0]).toEqual({
|
||||
cmd: [
|
||||
'/usr/local/bin/codex',
|
||||
'exec',
|
||||
'--json',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'-',
|
||||
],
|
||||
cwd: join(homeDir, '.browseros', 'agents', 'codex-agent'),
|
||||
stdinText: 'Respond with hello.',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the local prompt and normalizes codex JSONL stdout into text events', async () => {
|
||||
const agentDir = join(homeDir, 'agents', 'codex-agent')
|
||||
const agentCwd = join(homeDir, 'workspace', 'codex-agent')
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await mkdir(agentCwd, { recursive: true })
|
||||
await writeFile(
|
||||
join(agentDir, 'AGENTS.md'),
|
||||
'# Agent Rules\nDo the task.\n',
|
||||
)
|
||||
await writeFile(join(agentDir, 'SOUL.md'), '# Soul\nStay calm.\n')
|
||||
await writeFile(join(agentDir, 'TOOLS.md'), '# Tools\nUse the workspace.\n')
|
||||
|
||||
const spawn = mock(
|
||||
(cmd: string[], options?: { cwd?: string; stdin?: unknown }) => {
|
||||
invocations.push({
|
||||
cmd,
|
||||
cwd: options?.cwd,
|
||||
stdinText: decodeStdin(options?.stdin),
|
||||
})
|
||||
return createMockProcess({
|
||||
stdoutLines: [
|
||||
JSON.stringify({
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
type: 'agent_message',
|
||||
text: 'Hello',
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'response.output_text.delta',
|
||||
delta: ' world',
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message',
|
||||
text: '!',
|
||||
}),
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
const { CodexLocalAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/codex-local-adapter'
|
||||
)
|
||||
const adapter = new CodexLocalAgentAdapter({ spawn })
|
||||
const record = createStoredAgent({
|
||||
id: 'codex-agent',
|
||||
name: 'Codex Agent',
|
||||
adapterType: 'codex_local',
|
||||
paths: {
|
||||
agentDir,
|
||||
cwd: agentCwd,
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await adapter.materialize({
|
||||
id: 'codex-agent',
|
||||
name: 'Codex Agent',
|
||||
adapterType: 'codex_local',
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
dangerouslyBypassApprovalsAndSandbox: true,
|
||||
}),
|
||||
).toEqual({
|
||||
runtimeBinding: null,
|
||||
})
|
||||
await expect(adapter.remove(record)).resolves.toBeUndefined()
|
||||
|
||||
const stream = await adapter.streamChat(record, {
|
||||
sessionKey: 'session-123',
|
||||
conversation: [
|
||||
{ role: 'user', text: 'What happened yesterday?' },
|
||||
{ role: 'assistant', text: 'We finished the migration.' },
|
||||
],
|
||||
message: 'Summarize the current state.',
|
||||
})
|
||||
|
||||
expect(invocations).toHaveLength(1)
|
||||
expect(invocations[0]?.cmd).toEqual([
|
||||
'/usr/local/bin/codex',
|
||||
'exec',
|
||||
'--json',
|
||||
'--dangerously-bypass-approvals-and-sandbox',
|
||||
'-',
|
||||
])
|
||||
expect(invocations[0]?.cwd).toBe(agentCwd)
|
||||
expect(invocations[0]?.stdinText).toContain('# Agent Rules')
|
||||
expect(invocations[0]?.stdinText).toContain('# Soul')
|
||||
expect(invocations[0]?.stdinText).toContain('# Tools')
|
||||
expect(invocations[0]?.stdinText).toContain('What happened yesterday?')
|
||||
expect(invocations[0]?.stdinText).toContain('We finished the migration.')
|
||||
expect(invocations[0]?.stdinText).toContain('Summarize the current state.')
|
||||
|
||||
expect(await readEvents(stream)).toEqual([
|
||||
{ type: 'start' },
|
||||
{ type: 'text-start', id: 'codex-agent-text' },
|
||||
{ type: 'text-delta', id: 'codex-agent-text', delta: 'Hello' },
|
||||
{ type: 'text-delta', id: 'codex-agent-text', delta: ' world' },
|
||||
{ type: 'text-delta', id: 'codex-agent-text', delta: '!' },
|
||||
{ type: 'text-end', id: 'codex-agent-text' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function createStoredAgent(
|
||||
overrides: Partial<BrowserOsStoredAgent> = {},
|
||||
): BrowserOsStoredAgent {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'agent',
|
||||
name: 'Agent',
|
||||
adapterType: 'codex_local',
|
||||
paths: {
|
||||
agentDir: '/tmp/agent',
|
||||
cwd: '/tmp/agent',
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {},
|
||||
runtimeBinding: null,
|
||||
lastValidation: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function createMockProcess(input: {
|
||||
stdoutLines?: string[]
|
||||
stderrLines?: string[]
|
||||
exitCode?: number
|
||||
}) {
|
||||
return {
|
||||
stdout: createByteStream(input.stdoutLines ?? []),
|
||||
stderr: createByteStream(input.stderrLines ?? []),
|
||||
exited: Promise.resolve(input.exitCode ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
function createByteStream(lines: string[]): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder()
|
||||
for (const line of lines) {
|
||||
controller.enqueue(encoder.encode(`${line}\n`))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function decodeStdin(stdin: unknown): string {
|
||||
if (stdin instanceof Uint8Array) {
|
||||
return new TextDecoder().decode(stdin)
|
||||
}
|
||||
return String(stdin ?? '')
|
||||
}
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<Record<string, unknown>>,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const reader = stream.getReader()
|
||||
const events: Record<string, unknown>[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import type { BrowserOsStoredAgent } from '@browseros/shared/types/browseros-agents'
|
||||
|
||||
type MutableOpenClawService = {
|
||||
getStatus: ReturnType<typeof mock>
|
||||
createAgent: ReturnType<typeof mock>
|
||||
removeAgent: ReturnType<typeof mock>
|
||||
chatStream: ReturnType<typeof mock>
|
||||
}
|
||||
|
||||
describe('OpenClawAgentAdapter', () => {
|
||||
let service: MutableOpenClawService
|
||||
|
||||
beforeEach(() => {
|
||||
service = {
|
||||
getStatus: mock(async () => ({
|
||||
status: 'running',
|
||||
podmanAvailable: true,
|
||||
machineReady: true,
|
||||
port: 18789,
|
||||
agentCount: 1,
|
||||
error: null,
|
||||
controlPlaneStatus: 'connected',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
})),
|
||||
createAgent: mock(async () => ({
|
||||
agentId: 'ops',
|
||||
name: 'ops',
|
||||
workspace: '/workspace/ops',
|
||||
model: 'openclaw/ops',
|
||||
})),
|
||||
removeAgent: mock(async () => {}),
|
||||
chatStream: mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: ' world' },
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Hello world' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('rejects create when OpenClaw is not ready', async () => {
|
||||
service.getStatus = mock(async () => ({
|
||||
status: 'starting',
|
||||
podmanAvailable: true,
|
||||
machineReady: true,
|
||||
port: 18789,
|
||||
agentCount: 0,
|
||||
error: null,
|
||||
controlPlaneStatus: 'connecting',
|
||||
lastGatewayError: null,
|
||||
lastRecoveryReason: null,
|
||||
}))
|
||||
|
||||
const { OpenClawAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/openclaw-adapter'
|
||||
)
|
||||
const adapter = new OpenClawAgentAdapter(service as never)
|
||||
|
||||
await expect(
|
||||
adapter.validateCreate({
|
||||
id: 'ops',
|
||||
name: 'Ops',
|
||||
adapterType: 'openclaw',
|
||||
}),
|
||||
).rejects.toThrow('OpenClaw must be running with a connected control plane')
|
||||
})
|
||||
|
||||
it('materializes, removes, and streams via OpenClaw', async () => {
|
||||
const { OpenClawAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/openclaw-adapter'
|
||||
)
|
||||
const adapter = new OpenClawAgentAdapter(service as never)
|
||||
|
||||
const materialized = await adapter.materialize({
|
||||
id: 'ops',
|
||||
name: 'Ops',
|
||||
adapterType: 'openclaw',
|
||||
providerType: 'openai',
|
||||
providerName: 'openai',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'secret-key',
|
||||
modelId: 'gpt-4o-mini',
|
||||
})
|
||||
|
||||
expect(service.createAgent).toHaveBeenCalledWith({
|
||||
name: 'ops',
|
||||
providerType: 'openai',
|
||||
providerName: 'openai',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
apiKey: 'secret-key',
|
||||
modelId: 'gpt-4o-mini',
|
||||
})
|
||||
expect(materialized).toEqual({
|
||||
runtimeBinding: {
|
||||
agentId: 'ops',
|
||||
workspace: '/workspace/ops',
|
||||
model: 'openclaw/ops',
|
||||
},
|
||||
adapterConfig: {
|
||||
providerType: 'openai',
|
||||
providerName: 'openai',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
modelId: 'gpt-4o-mini',
|
||||
},
|
||||
})
|
||||
|
||||
const record = createStoredAgent({
|
||||
runtimeBinding: {
|
||||
agentId: 'ops-runtime',
|
||||
},
|
||||
})
|
||||
|
||||
await adapter.remove(record)
|
||||
expect(service.removeAgent).toHaveBeenCalledWith('ops-runtime')
|
||||
|
||||
const stream = await adapter.streamChat(record, {
|
||||
sessionKey: 'session-123',
|
||||
message: 'hi',
|
||||
})
|
||||
expect(service.chatStream).toHaveBeenCalledWith(
|
||||
'ops-runtime',
|
||||
'session-123',
|
||||
'hi',
|
||||
)
|
||||
expect(await readEvents(stream)).toEqual([
|
||||
{ type: 'start' },
|
||||
{ type: 'text-start', id: 'ops-runtime-text' },
|
||||
{ type: 'text-delta', id: 'ops-runtime-text', delta: 'Hello' },
|
||||
{ type: 'text-delta', id: 'ops-runtime-text', delta: ' world' },
|
||||
{ type: 'text-end', id: 'ops-runtime-text' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves thinking, tool, and lifecycle details in normalized chat output', async () => {
|
||||
service.chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'thinking',
|
||||
data: { text: 'Inspecting context' },
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'tool-start',
|
||||
data: {
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'browser.search',
|
||||
input: { query: 'BrowserOS' },
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'tool-output',
|
||||
data: {
|
||||
toolCallId: 'call-1',
|
||||
output: { result: 'ok' },
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'tool-end',
|
||||
data: {
|
||||
toolCallId: 'call-1',
|
||||
status: 'completed',
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'lifecycle',
|
||||
data: {
|
||||
phase: 'retrieval',
|
||||
status: 'running',
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: '' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { OpenClawAgentAdapter } = await import(
|
||||
'../../../../src/api/services/agents/adapters/openclaw-adapter'
|
||||
)
|
||||
const adapter = new OpenClawAgentAdapter(service as never)
|
||||
|
||||
const stream = await adapter.streamChat(createStoredAgent(), {
|
||||
sessionKey: 'session-456',
|
||||
message: 'status',
|
||||
})
|
||||
|
||||
expect(await readEvents(stream)).toEqual([
|
||||
{ type: 'start' },
|
||||
{ type: 'text-start', id: 'ops-text' },
|
||||
{ type: 'reasoning-start', id: 'ops-reasoning' },
|
||||
{
|
||||
type: 'reasoning-delta',
|
||||
id: 'ops-reasoning',
|
||||
delta: 'Inspecting context',
|
||||
},
|
||||
{
|
||||
type: 'tool-input-start',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'browser.search',
|
||||
},
|
||||
{
|
||||
type: 'tool-input-available',
|
||||
toolCallId: 'call-1',
|
||||
toolName: 'browser.search',
|
||||
input: { query: 'BrowserOS' },
|
||||
},
|
||||
{
|
||||
type: 'tool-output-available',
|
||||
toolCallId: 'call-1',
|
||||
output: { result: 'ok' },
|
||||
},
|
||||
{
|
||||
type: 'tool-output-available',
|
||||
toolCallId: 'call-1',
|
||||
output: { status: 'completed' },
|
||||
},
|
||||
{
|
||||
type: 'reasoning-delta',
|
||||
id: 'ops-reasoning',
|
||||
delta: '{"phase":"retrieval","status":"running"}',
|
||||
},
|
||||
{ type: 'reasoning-end', id: 'ops-reasoning' },
|
||||
{ type: 'text-end', id: 'ops-text' },
|
||||
{ type: 'finish', finishReason: 'stop' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
function createStoredAgent(
|
||||
overrides: Partial<BrowserOsStoredAgent> = {},
|
||||
): BrowserOsStoredAgent {
|
||||
return {
|
||||
version: 1,
|
||||
id: 'ops',
|
||||
name: 'Ops',
|
||||
adapterType: 'openclaw',
|
||||
paths: {
|
||||
agentDir: '/tmp/agent',
|
||||
cwd: '/tmp/agent',
|
||||
contextDirs: [],
|
||||
},
|
||||
adapterConfig: {},
|
||||
runtimeBinding: {
|
||||
agentId: 'ops',
|
||||
},
|
||||
lastValidation: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function readEvents(
|
||||
stream: ReadableStream<Record<string, unknown>>,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const reader = stream.getReader()
|
||||
const events: Record<string, unknown>[] = []
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { tmpdir } from 'node:os'
|
||||
|
||||
describe('browseros agent path helpers', () => {
|
||||
let homeDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = '/tmp/browseros-agent-path-test'
|
||||
mock.module('node:os', () => ({
|
||||
homedir: () => homeDir,
|
||||
tmpdir,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('derives managed agent paths under ~/.browseros/agents/<id>', async () => {
|
||||
const {
|
||||
getAgentsDir,
|
||||
getAgentDir,
|
||||
getAgentMetadataPath,
|
||||
getAgentRuntimeDir,
|
||||
} = await import('../../src/lib/browseros-dir')
|
||||
|
||||
expect(getAgentsDir()).toBe(
|
||||
'/tmp/browseros-agent-path-test/.browseros/agents',
|
||||
)
|
||||
expect(getAgentDir('chief-of-staff')).toBe(
|
||||
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff',
|
||||
)
|
||||
expect(getAgentMetadataPath('chief-of-staff')).toBe(
|
||||
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff/agent.json',
|
||||
)
|
||||
expect(getAgentRuntimeDir('chief-of-staff')).toBe(
|
||||
'/tmp/browseros-agent-path-test/.browseros/agents/chief-of-staff/runtime',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -53,6 +53,10 @@
|
||||
"types": "./src/types/server-config.ts",
|
||||
"default": "./src/types/server-config.ts"
|
||||
},
|
||||
"./types/browseros-agents": {
|
||||
"types": "./src/types/browseros-agents.ts",
|
||||
"default": "./src/types/browseros-agents.ts"
|
||||
},
|
||||
"./types/role-aware-agents": {
|
||||
"types": "./src/types/role-aware-agents.ts",
|
||||
"default": "./src/types/role-aware-agents.ts"
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
export const PATHS = {
|
||||
DEFAULT_EXECUTION_DIR: process.cwd(),
|
||||
BROWSEROS_DIR_NAME: '.browseros',
|
||||
AGENTS_DIR_NAME: 'agents',
|
||||
AGENT_METADATA_FILE_NAME: 'agent.json',
|
||||
AGENT_RUNTIME_DIR_NAME: 'runtime',
|
||||
MEMORY_DIR_NAME: 'memory',
|
||||
SESSIONS_DIR_NAME: 'sessions',
|
||||
TOOL_OUTPUT_DIR_NAME: 'tool-output',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type BrowserOsAgentAdapterType =
|
||||
| 'openclaw'
|
||||
| 'codex_local'
|
||||
| 'claude_local'
|
||||
|
||||
export interface BrowserOsAgentPaths {
|
||||
agentDir: string
|
||||
cwd: string
|
||||
contextDirs: string[]
|
||||
}
|
||||
|
||||
export interface BrowserOsValidationState {
|
||||
status: 'ok' | 'error'
|
||||
checkedAt: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BrowserOsStoredAgent {
|
||||
version: 1
|
||||
id: string
|
||||
name: string
|
||||
adapterType: BrowserOsAgentAdapterType
|
||||
paths: BrowserOsAgentPaths
|
||||
adapterConfig: Record<string, unknown>
|
||||
runtimeBinding: Record<string, unknown> | null
|
||||
lastValidation: BrowserOsValidationState | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
Reference in New Issue
Block a user