mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
15 Commits
fix/dev-se
...
feat/check
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5359ec4680 | ||
|
|
a56a29d21a | ||
|
|
9b6e2efcbc | ||
|
|
685d539712 | ||
|
|
18e0a58e90 | ||
|
|
2eef2c6d8e | ||
|
|
120afc6d5e | ||
|
|
2441f71d0f | ||
|
|
1e01120587 | ||
|
|
8f38e77955 | ||
|
|
76ac30efef | ||
|
|
7fffd81242 | ||
|
|
c1ae563493 | ||
|
|
a1bb7600c7 | ||
|
|
6c26a3ac84 |
@@ -12,10 +12,12 @@ import { ClawChat } from './ClawChat'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
} from './claw-chat-types'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import { useClawChatHistory } from './useClawChatHistory'
|
||||
import { useHarnessChatHistory } from './useHarnessChatHistory'
|
||||
import { useOutboundQueue } from './useOutboundQueue'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
@@ -132,7 +134,7 @@ function AgentRailList({
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getModelDisplayName(entry.model) ?? 'OpenClaw agent'
|
||||
const modelName = getAgentEntryMeta(entry)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -167,6 +169,13 @@ function AgentRailList({
|
||||
)
|
||||
}
|
||||
|
||||
function getAgentEntryMeta(agent: AgentEntry | undefined): string {
|
||||
if (agent?.source === 'agent-harness') {
|
||||
return getModelDisplayName(agent.model) ?? 'ACP agent'
|
||||
}
|
||||
return getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(status: string | undefined): string {
|
||||
if (status === 'running') return 'Ready'
|
||||
if (status === 'starting') return 'Connecting'
|
||||
@@ -198,35 +207,66 @@ function AgentConversationController({
|
||||
const [streamSessionKey, setStreamSessionKey] = useState<string | null>(null)
|
||||
const agent = agents.find((entry) => entry.agentId === agentId)
|
||||
const agentName = agent?.name || agentId || 'Agent'
|
||||
// Single source of truth: the history endpoint resolves the session itself
|
||||
// when sessionKey is null. Once a chat creates a new session, streamSessionKey
|
||||
// overrides it and the history queryKey rotates to refetch for that session.
|
||||
const historyQuery = useClawChatHistory({
|
||||
const isAgentHarnessAgent = agent?.source === 'agent-harness'
|
||||
const clawHistoryQuery = useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey: streamSessionKey,
|
||||
enabled: Boolean(agent) && !isAgentHarnessAgent,
|
||||
})
|
||||
const harnessHistoryQuery = useHarnessChatHistory(
|
||||
agentId,
|
||||
Boolean(agent) && isAgentHarnessAgent,
|
||||
)
|
||||
|
||||
const historyMessages = useMemo(
|
||||
() => flattenHistoryPages(historyQuery.data?.pages ?? []),
|
||||
[historyQuery.data?.pages],
|
||||
() =>
|
||||
flattenHistoryPages(
|
||||
isAgentHarnessAgent
|
||||
? harnessHistoryQuery.data
|
||||
? [harnessHistoryQuery.data]
|
||||
: []
|
||||
: (clawHistoryQuery.data?.pages ?? []),
|
||||
),
|
||||
[
|
||||
clawHistoryQuery.data?.pages,
|
||||
harnessHistoryQuery.data,
|
||||
isAgentHarnessAgent,
|
||||
],
|
||||
)
|
||||
const chatHistory = useMemo(
|
||||
() => buildChatHistoryFromClawMessages(historyMessages),
|
||||
[historyMessages],
|
||||
)
|
||||
const resolvedSessionKey =
|
||||
streamSessionKey ?? historyQuery.data?.pages?.[0]?.sessionKey ?? null
|
||||
streamSessionKey ??
|
||||
(isAgentHarnessAgent
|
||||
? null
|
||||
: (clawHistoryQuery.data?.pages?.[0]?.sessionKey ?? null))
|
||||
|
||||
const { turns, streaming } = useAgentConversation(agentId, {
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
runtime: isAgentHarnessAgent ? 'agent-harness' : 'openclaw',
|
||||
sessionKey: resolvedSessionKey,
|
||||
history: chatHistory,
|
||||
onComplete: () => {
|
||||
if (isAgentHarnessAgent) {
|
||||
void harnessHistoryQuery.refetch()
|
||||
}
|
||||
},
|
||||
onSessionKeyChange: (sessionKey) => {
|
||||
setStreamSessionKey(sessionKey)
|
||||
},
|
||||
})
|
||||
const visibleTurns = useMemo(
|
||||
() =>
|
||||
isAgentHarnessAgent
|
||||
? filterTurnsPersistedInHistory(turns, historyMessages)
|
||||
: turns,
|
||||
[historyMessages, isAgentHarnessAgent, turns],
|
||||
)
|
||||
const outboundQueue = useOutboundQueue({
|
||||
agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
enabled: Boolean(agent) && !isAgentHarnessAgent,
|
||||
})
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
@@ -238,6 +278,7 @@ function AgentConversationController({
|
||||
// signal we have without exposing per-turn SSE.
|
||||
const previousSendingIdsRef = useRef<Set<string>>(new Set())
|
||||
useEffect(() => {
|
||||
if (isAgentHarnessAgent) return
|
||||
const currentSending = new Set(
|
||||
outboundQueue.queue
|
||||
.filter((item) => item.status === 'sending')
|
||||
@@ -248,27 +289,38 @@ function AgentConversationController({
|
||||
)
|
||||
previousSendingIdsRef.current = currentSending
|
||||
if (dropped.length > 0) {
|
||||
void historyQuery.refetch()
|
||||
void clawHistoryQuery.refetch()
|
||||
}
|
||||
}, [outboundQueue.queue, historyQuery])
|
||||
}, [clawHistoryQuery, isAgentHarnessAgent, outboundQueue.queue])
|
||||
|
||||
const disabled = status?.status !== 'running'
|
||||
const disabled =
|
||||
!agent || (!isAgentHarnessAgent && status?.status !== 'running')
|
||||
// Two-part gate: cover both "still fetching" AND "just got enabled but
|
||||
// hasn't started fetching yet". When `enabled` flips true (baseUrl
|
||||
// resolves), there's a render frame where React Query reports
|
||||
// isLoading=false but hasn't run the queryFn yet — `isFetched` is still
|
||||
// false. Without this we render EmptyState during that one frame.
|
||||
const isInitialLoading =
|
||||
historyQuery.isLoading || (!historyQuery.isFetched && !historyQuery.isError)
|
||||
!isAgentHarnessAgent &&
|
||||
(clawHistoryQuery.isLoading ||
|
||||
(!clawHistoryQuery.isFetched && !clawHistoryQuery.isError))
|
||||
|
||||
const historyReady = historyQuery.isFetched || historyQuery.isError
|
||||
const historyReady =
|
||||
(isAgentHarnessAgent &&
|
||||
(harnessHistoryQuery.isFetched || harnessHistoryQuery.isError)) ||
|
||||
(!isAgentHarnessAgent &&
|
||||
(clawHistoryQuery.isFetched || clawHistoryQuery.isError))
|
||||
const initialMessageKey = initialMessage
|
||||
? `${agentId}:${initialMessage}`
|
||||
: null
|
||||
const error = historyQuery.error ?? null
|
||||
const error = isAgentHarnessAgent
|
||||
? (harnessHistoryQuery.error ?? null)
|
||||
: (clawHistoryQuery.error ?? null)
|
||||
|
||||
const enqueueRef = useRef(outboundQueue.enqueue)
|
||||
enqueueRef.current = outboundQueue.enqueue
|
||||
const sendRef = useRef(send)
|
||||
sendRef.current = send
|
||||
|
||||
useEffect(() => {
|
||||
const query = initialMessage?.trim()
|
||||
@@ -277,11 +329,6 @@ function AgentConversationController({
|
||||
return
|
||||
}
|
||||
|
||||
// The initial-message handoff (home composer → conversation page via
|
||||
// ?q=) goes through the outbound queue too, so it inherits the same
|
||||
// single-flight serialization. We no longer need to gate on
|
||||
// `streaming` — the queue worker drains as soon as the agent is
|
||||
// free.
|
||||
if (
|
||||
!query ||
|
||||
initialMessageSentRef.current === initialMessageKey ||
|
||||
@@ -293,8 +340,18 @@ function AgentConversationController({
|
||||
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
onInitialMessageConsumedRef.current()
|
||||
enqueueRef.current({ text: query })
|
||||
}, [disabled, historyReady, initialMessage, initialMessageKey])
|
||||
if (isAgentHarnessAgent) {
|
||||
void sendRef.current({ text: query })
|
||||
} else {
|
||||
enqueueRef.current({ text: query })
|
||||
}
|
||||
}, [
|
||||
disabled,
|
||||
historyReady,
|
||||
initialMessage,
|
||||
initialMessageKey,
|
||||
isAgentHarnessAgent,
|
||||
])
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
@@ -305,17 +362,29 @@ function AgentConversationController({
|
||||
<ClawChat
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={turns}
|
||||
turns={visibleTurns}
|
||||
streaming={streaming}
|
||||
isInitialLoading={isInitialLoading}
|
||||
isInitialLoading={
|
||||
isAgentHarnessAgent ? harnessHistoryQuery.isLoading : isInitialLoading
|
||||
}
|
||||
error={error}
|
||||
hasNextPage={Boolean(historyQuery.hasNextPage)}
|
||||
isFetchingNextPage={historyQuery.isFetchingNextPage}
|
||||
hasNextPage={
|
||||
isAgentHarnessAgent ? false : Boolean(clawHistoryQuery.hasNextPage)
|
||||
}
|
||||
isFetchingNextPage={
|
||||
isAgentHarnessAgent ? false : clawHistoryQuery.isFetchingNextPage
|
||||
}
|
||||
onFetchNextPage={() => {
|
||||
void historyQuery.fetchNextPage()
|
||||
if (!isAgentHarnessAgent) {
|
||||
void clawHistoryQuery.fetchNextPage()
|
||||
}
|
||||
}}
|
||||
onRetry={() => {
|
||||
void historyQuery.refetch()
|
||||
if (isAgentHarnessAgent) {
|
||||
void harnessHistoryQuery.refetch()
|
||||
} else {
|
||||
void clawHistoryQuery.refetch()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -327,27 +396,40 @@ function AgentConversationController({
|
||||
selectedAgentId={agentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={(input) => {
|
||||
outboundQueue.enqueue({
|
||||
text: input.text,
|
||||
attachments: input.attachments.map((a) => a.payload),
|
||||
attachmentPreviews: input.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
mediaType: a.mediaType,
|
||||
name: a.name,
|
||||
dataUrl: a.dataUrl,
|
||||
})),
|
||||
history: chatHistory,
|
||||
})
|
||||
const attachments = input.attachments.map((a) => a.payload)
|
||||
const attachmentPreviews = input.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
kind: a.kind,
|
||||
mediaType: a.mediaType,
|
||||
name: a.name,
|
||||
dataUrl: a.dataUrl,
|
||||
}))
|
||||
if (isAgentHarnessAgent) {
|
||||
void send({ text: input.text, attachments, attachmentPreviews })
|
||||
} else {
|
||||
outboundQueue.enqueue({
|
||||
text: input.text,
|
||||
attachments,
|
||||
attachmentPreviews,
|
||||
history: chatHistory,
|
||||
})
|
||||
}
|
||||
}}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
streaming={streaming}
|
||||
disabled={disabled}
|
||||
status={status?.status}
|
||||
status={isAgentHarnessAgent ? 'running' : status?.status}
|
||||
attachmentsEnabled={!isAgentHarnessAgent}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
outboundQueue={outboundQueue.queue}
|
||||
onCancelQueued={outboundQueue.cancel}
|
||||
onRetryQueued={outboundQueue.retry}
|
||||
outboundQueue={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.queue
|
||||
}
|
||||
onCancelQueued={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.cancel
|
||||
}
|
||||
onRetryQueued={
|
||||
isAgentHarnessAgent ? undefined : outboundQueue.retry
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,7 +458,7 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
const agentMeta = getAgentEntryMeta(agent)
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
@@ -389,7 +471,10 @@ export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status)
|
||||
const statusCopy =
|
||||
agent?.source === 'agent-harness'
|
||||
? 'Ready'
|
||||
: getConversationStatusCopy(status?.status)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ArrowRight, Bot, Plus, Settings2 } from 'lucide-react'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -13,34 +13,6 @@ import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import { buildAgentCardData } from './useAgentCardData'
|
||||
import { useAgentDashboard } from './useAgentDashboard'
|
||||
|
||||
function AgentCommandSetupState({
|
||||
onOpenAgents,
|
||||
}: {
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">Set up your first agent</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Connect OpenClaw and create an agent before using the new tab as
|
||||
your workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
@@ -63,33 +35,6 @@ function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
)
|
||||
}
|
||||
|
||||
function OpenClawUnavailableState({
|
||||
onOpenAgents,
|
||||
}: {
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Settings2 className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">OpenClaw is unavailable</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Review your agent setup to restart the gateway or reconnect the
|
||||
local service.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function RecentThreads({
|
||||
activeAgentId,
|
||||
agents,
|
||||
@@ -134,10 +79,9 @@ function RecentThreads({
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const { agents, status } = useAgentCommandData()
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const { data: dashboard } = useAgentDashboard(status?.status === 'running')
|
||||
const cardData = buildAgentCardData(agents, status?.status, dashboard?.agents)
|
||||
const cardData = buildAgentCardData(agents, status?.status, undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
@@ -157,11 +101,6 @@ export const AgentCommandHome: FC = () => {
|
||||
|
||||
const handleSend = (input: { text: string }) => {
|
||||
if (!selectedAgentId) return
|
||||
// Home composer navigates to the conversation page with the prompt in
|
||||
// the query string. Attachments are dropped at this boundary in v1 —
|
||||
// the conversation page (where staging UX is most useful anyway) is
|
||||
// where users can attach. A future iteration can stash staged files
|
||||
// in chrome.storage.session and replay them on first mount there.
|
||||
navigate(
|
||||
`/home/agents/${selectedAgentId}?q=${encodeURIComponent(input.text)}`,
|
||||
)
|
||||
@@ -171,71 +110,65 @@ 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 selectedAgentReady = selectedAgent
|
||||
? selectedAgent.source === 'agent-harness' || status?.status === 'running'
|
||||
: false
|
||||
const selectedAgentStatus =
|
||||
selectedAgent?.source === 'agent-harness' ? 'running' : status?.status
|
||||
const selectedCard =
|
||||
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||
{isSetup ? (
|
||||
shouldShowUnavailableState ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
) : cardData.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{cardData.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={!selectedAgentReady}
|
||||
status={selectedAgentStatus}
|
||||
attachmentsEnabled={false}
|
||||
placeholder={
|
||||
selectedAgentReady
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
: 'Agent runtime is not running...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
agents={cardData}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)
|
||||
<Separator />
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
agents={cardData}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ interface ConversationInputProps {
|
||||
disabled?: boolean
|
||||
status?: string
|
||||
placeholder?: string
|
||||
attachmentsEnabled?: boolean
|
||||
variant?: 'home' | 'conversation'
|
||||
// Outbound queue: when present, the composer renders the queue strip
|
||||
// above the textarea and lets the user keep sending while a previous
|
||||
@@ -155,6 +156,7 @@ function ContextControls({
|
||||
status,
|
||||
onAttachClick,
|
||||
attachDisabled,
|
||||
attachmentsEnabled,
|
||||
}: {
|
||||
agents: AgentEntry[]
|
||||
onCreateAgent?: () => void
|
||||
@@ -166,6 +168,7 @@ function ContextControls({
|
||||
status?: string
|
||||
onAttachClick: () => void
|
||||
attachDisabled: boolean
|
||||
attachmentsEnabled: boolean
|
||||
}) {
|
||||
const { supports } = useCapabilities()
|
||||
const { selectedFolder } = useWorkspace()
|
||||
@@ -229,7 +232,7 @@ function ContextControls({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onAttachClick}
|
||||
disabled={attachDisabled}
|
||||
disabled={attachDisabled || !attachmentsEnabled}
|
||||
title="Attach files"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-3 py-1.5 font-medium text-sm transition-all',
|
||||
@@ -306,6 +309,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
disabled,
|
||||
status,
|
||||
placeholder,
|
||||
attachmentsEnabled = true,
|
||||
variant = 'conversation',
|
||||
outboundQueue,
|
||||
onCancelQueued,
|
||||
@@ -328,6 +332,10 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
|
||||
const stageFiles = async (files: File[]) => {
|
||||
if (files.length === 0) return
|
||||
if (!attachmentsEnabled) {
|
||||
setAttachmentError('Attachments are not supported for this agent yet.')
|
||||
return
|
||||
}
|
||||
setIsStaging(true)
|
||||
setAttachmentError(null)
|
||||
try {
|
||||
@@ -369,6 +377,12 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
}, [voice.transcript, voice.isTranscribing, voice])
|
||||
|
||||
useEffect(() => {
|
||||
if (attachmentsEnabled) return
|
||||
setAttachments([])
|
||||
setAttachmentError(null)
|
||||
}, [attachmentsEnabled])
|
||||
|
||||
const toggleTab = (tab: chrome.tabs.Tab) => {
|
||||
setSelectedTabs((prev) => {
|
||||
const isSelected = prev.some((selected) => selected.id === tab.id)
|
||||
@@ -435,6 +449,10 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}
|
||||
|
||||
const openFilePicker = () => {
|
||||
if (!attachmentsEnabled) {
|
||||
setAttachmentError('Attachments are not supported for this agent yet.')
|
||||
return
|
||||
}
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
@@ -565,6 +583,7 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
status={status}
|
||||
onAttachClick={openFilePicker}
|
||||
attachDisabled={attachments.length >= 10 || isStaging || !!disabled}
|
||||
attachmentsEnabled={attachmentsEnabled}
|
||||
/>
|
||||
{isDragOver ? (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-[inherit] bg-background/80 font-medium text-foreground text-sm backdrop-blur-sm">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { FC } from 'react'
|
||||
import { Outlet, useOutletContext } from 'react-router'
|
||||
import { useHarnessAgents } from '@/entrypoints/app/agents/useAgents'
|
||||
import type {
|
||||
AgentEntry,
|
||||
OpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type OpenClawStatus,
|
||||
useOpenClawAgents,
|
||||
useOpenClawStatus,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
@@ -16,16 +19,24 @@ interface AgentCommandContextValue {
|
||||
|
||||
export const AgentCommandLayout: FC = () => {
|
||||
const { status, loading: statusLoading } = useOpenClawStatus(5000)
|
||||
const { agents, loading: agentsLoading } = useOpenClawAgents(
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected',
|
||||
)
|
||||
const openClawEnabled =
|
||||
status?.status === 'running' && status.controlPlaneStatus === 'connected'
|
||||
const { agents: openClawAgents, loading: openClawAgentsLoading } =
|
||||
useOpenClawAgents(openClawEnabled)
|
||||
const { agents: harnessAgents, loading: harnessAgentsLoading } =
|
||||
useHarnessAgents()
|
||||
const visibleOpenClawAgents = openClawEnabled ? openClawAgents : []
|
||||
const agents = [...visibleOpenClawAgents, ...harnessAgents]
|
||||
|
||||
return (
|
||||
<Outlet
|
||||
context={
|
||||
{
|
||||
agents,
|
||||
agentsLoading,
|
||||
agentsLoading:
|
||||
harnessAgentsLoading ||
|
||||
statusLoading ||
|
||||
(openClawEnabled && openClawAgentsLoading),
|
||||
status,
|
||||
statusLoading,
|
||||
} satisfies AgentCommandContextValue
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mapAgentHarnessToolStatus } from './agent-stream-events'
|
||||
|
||||
describe('mapAgentHarnessToolStatus', () => {
|
||||
it('normalizes ACP tool statuses for the chat renderer', () => {
|
||||
expect(mapAgentHarnessToolStatus('running')).toBe('running')
|
||||
expect(mapAgentHarnessToolStatus('completed')).toBe('completed')
|
||||
expect(mapAgentHarnessToolStatus('failed')).toBe('error')
|
||||
expect(mapAgentHarnessToolStatus('incomplete')).toBe('running')
|
||||
expect(mapAgentHarnessToolStatus(undefined)).toBe('running')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ToolEntry } from '@/lib/agent-conversations/types'
|
||||
|
||||
export function mapAgentHarnessToolStatus(
|
||||
status: string | undefined,
|
||||
): ToolEntry['status'] {
|
||||
if (!status) return 'running'
|
||||
const normalized = status.toLowerCase()
|
||||
if (['error', 'failed', 'failure', 'denied'].includes(normalized)) {
|
||||
return 'error'
|
||||
}
|
||||
if (
|
||||
['complete', 'completed', 'done', 'success', 'succeeded'].includes(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
return 'completed'
|
||||
}
|
||||
return 'running'
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import {
|
||||
type AgentHistoryPageResponse,
|
||||
type BrowserOSChatHistoryItem,
|
||||
buildChatHistoryFromClawMessages,
|
||||
filterTurnsPersistedInHistory,
|
||||
flattenHistoryPages,
|
||||
mapHistoryItemToClawMessage,
|
||||
} from './claw-chat-types'
|
||||
@@ -118,4 +120,64 @@ describe('claw-chat-types', () => {
|
||||
{ role: 'assistant', content: 'Assistant answer' },
|
||||
])
|
||||
})
|
||||
|
||||
it('hides completed live turns once harness history contains the same turn', () => {
|
||||
const turn: AgentConversationTurn = {
|
||||
id: 'live-turn',
|
||||
userText: 'hello',
|
||||
parts: [{ kind: 'text', text: 'hi there' }],
|
||||
done: true,
|
||||
timestamp: 1_000,
|
||||
}
|
||||
|
||||
const visible = filterTurnsPersistedInHistory(
|
||||
[turn],
|
||||
[
|
||||
{
|
||||
id: 'history-user',
|
||||
role: 'user',
|
||||
sessionKey: 'main',
|
||||
timestamp: 1_050,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
{
|
||||
id: 'history-assistant',
|
||||
role: 'assistant',
|
||||
sessionKey: 'main',
|
||||
timestamp: 1_100,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: 'hi there' }],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(visible).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps completed live turns until matching assistant history arrives', () => {
|
||||
const turn: AgentConversationTurn = {
|
||||
id: 'live-turn',
|
||||
userText: 'hello',
|
||||
parts: [{ kind: 'text', text: 'hi there' }],
|
||||
done: true,
|
||||
timestamp: 1_000,
|
||||
}
|
||||
|
||||
const visible = filterTurnsPersistedInHistory(
|
||||
[turn],
|
||||
[
|
||||
{
|
||||
id: 'history-user',
|
||||
role: 'user',
|
||||
sessionKey: 'main',
|
||||
timestamp: 1_050,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
expect(visible).toEqual([turn])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
@@ -221,3 +222,66 @@ export function buildChatHistoryFromClawMessages(
|
||||
Boolean(message),
|
||||
)
|
||||
}
|
||||
|
||||
const TURN_HISTORY_MATCH_WINDOW_MS = 5_000
|
||||
|
||||
export function filterTurnsPersistedInHistory(
|
||||
turns: AgentConversationTurn[],
|
||||
historyMessages: ClawChatMessage[],
|
||||
): AgentConversationTurn[] {
|
||||
return turns.filter(
|
||||
(turn) => !isTurnPersistedInHistory(turn, historyMessages),
|
||||
)
|
||||
}
|
||||
|
||||
function isTurnPersistedInHistory(
|
||||
turn: AgentConversationTurn,
|
||||
historyMessages: ClawChatMessage[],
|
||||
): boolean {
|
||||
if (!turn.done) return false
|
||||
|
||||
const assistantText = getTurnAssistantText(turn)
|
||||
if (!assistantText) return false
|
||||
|
||||
const minTimestamp = turn.timestamp - TURN_HISTORY_MATCH_WINDOW_MS
|
||||
const userText = turn.userText.trim()
|
||||
const userPersisted =
|
||||
!userText ||
|
||||
historyMessages.some(
|
||||
(message) =>
|
||||
message.role === 'user' &&
|
||||
isHistoryMessageAfter(message, minTimestamp) &&
|
||||
getClawMessageText(message) === userText,
|
||||
)
|
||||
const assistantPersisted = historyMessages.some(
|
||||
(message) =>
|
||||
message.role === 'assistant' &&
|
||||
isHistoryMessageAfter(message, minTimestamp) &&
|
||||
getClawMessageText(message) === assistantText,
|
||||
)
|
||||
|
||||
return userPersisted && assistantPersisted
|
||||
}
|
||||
|
||||
function isHistoryMessageAfter(
|
||||
message: ClawChatMessage,
|
||||
minTimestamp: number,
|
||||
): boolean {
|
||||
return message.timestamp == null || message.timestamp >= minTimestamp
|
||||
}
|
||||
|
||||
function getTurnAssistantText(turn: AgentConversationTurn): string {
|
||||
return turn.parts
|
||||
.filter((part) => part.kind === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function getClawMessageText(message: ClawChatMessage): string {
|
||||
return message.parts
|
||||
.filter((part) => part.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
@@ -39,7 +39,10 @@ export function buildAgentCardData(
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
model: getModelDisplayName(agent.model),
|
||||
status: resolveAgentStatus(status, overview?.status),
|
||||
status:
|
||||
agent.source === 'agent-harness'
|
||||
? 'idle'
|
||||
: resolveAgentStatus(status, overview?.status),
|
||||
lastMessage: overview?.latestMessage?.slice(0, 200) ?? undefined,
|
||||
lastMessageTimestamp: overview?.latestMessageAt ?? undefined,
|
||||
activitySummary: overview?.activitySummary ?? undefined,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
type AgentHarnessStreamEvent,
|
||||
chatWithHarnessAgent,
|
||||
} from '@/entrypoints/app/agents/useAgents'
|
||||
import {
|
||||
chatWithAgent,
|
||||
type OpenClawChatHistoryMessage,
|
||||
@@ -7,11 +11,13 @@ import {
|
||||
import type {
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
ToolEntry,
|
||||
UserAttachmentPreview,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import type { ServerAttachmentPayload } from '@/lib/attachments'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { buildToolLabel } from '@/lib/tool-labels'
|
||||
import { mapAgentHarnessToolStatus } from './agent-stream-events'
|
||||
|
||||
export interface SendInput {
|
||||
text: string
|
||||
@@ -23,8 +29,10 @@ export interface SendInput {
|
||||
}
|
||||
|
||||
interface UseAgentConversationOptions {
|
||||
runtime?: 'openclaw' | 'agent-harness'
|
||||
sessionKey?: string | null
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
onComplete?: () => void
|
||||
onSessionKeyChange?: (sessionKey: string) => void
|
||||
}
|
||||
|
||||
@@ -39,6 +47,7 @@ export function useAgentConversation(
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
const onCompleteRef = useRef(options.onComplete)
|
||||
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,6 +58,10 @@ export function useAgentConversation(
|
||||
historyRef.current = options.history ?? []
|
||||
}, [options.history])
|
||||
|
||||
useEffect(() => {
|
||||
onCompleteRef.current = options.onComplete
|
||||
}, [options.onComplete])
|
||||
|
||||
useEffect(() => {
|
||||
onSessionKeyChangeRef.current = options.onSessionKeyChange
|
||||
}, [options.onSessionKeyChange])
|
||||
@@ -72,34 +85,12 @@ export function useAgentConversation(
|
||||
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 }]
|
||||
})
|
||||
appendTextDelta((event.data.text as string) ?? '')
|
||||
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 }]
|
||||
})
|
||||
appendThinkingDelta((event.data.text as string) ?? '')
|
||||
break
|
||||
}
|
||||
|
||||
@@ -155,16 +146,7 @@ export function useAgentConversation(
|
||||
}
|
||||
|
||||
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 }]
|
||||
})
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -173,15 +155,127 @@ export function useAgentConversation(
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
appendErrorText(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const appendTextDelta = (delta: 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 }]
|
||||
})
|
||||
}
|
||||
|
||||
const appendThinkingDelta = (delta: 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 }]
|
||||
})
|
||||
}
|
||||
|
||||
const appendErrorText = (message: string) => {
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${message}` },
|
||||
])
|
||||
}
|
||||
|
||||
const markCurrentTurnDone = () => {
|
||||
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 }]
|
||||
})
|
||||
}
|
||||
|
||||
const upsertAgentHarnessTool = (event: AgentHarnessStreamEvent) => {
|
||||
if (event.type !== 'tool_call') return
|
||||
const rawName = event.title || event.rawType || 'tool call'
|
||||
const { label, subject } = buildToolLabel(
|
||||
rawName,
|
||||
event.text ? { description: event.text } : undefined,
|
||||
)
|
||||
const tool: ToolEntry = {
|
||||
id: event.id ?? crypto.randomUUID(),
|
||||
name: rawName,
|
||||
label,
|
||||
subject,
|
||||
status: mapAgentHarnessToolStatus(event.status),
|
||||
}
|
||||
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((existing) => existing.id === tool.id)
|
||||
) {
|
||||
const tools = part.tools.map((existing) =>
|
||||
existing.id === tool.id ? { ...existing, ...tool } : existing,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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] }]
|
||||
})
|
||||
}
|
||||
|
||||
const processAgentHarnessStreamEvent = (event: AgentHarnessStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text_delta':
|
||||
if (event.stream === 'thought') {
|
||||
appendThinkingDelta(event.text)
|
||||
} else {
|
||||
appendTextDelta(event.text)
|
||||
}
|
||||
break
|
||||
case 'tool_call':
|
||||
upsertAgentHarnessTool(event)
|
||||
break
|
||||
case 'done':
|
||||
markCurrentTurnDone()
|
||||
break
|
||||
case 'error':
|
||||
appendErrorText(event.message)
|
||||
break
|
||||
case 'status':
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const send = async (input: string | SendInput) => {
|
||||
const normalized: SendInput =
|
||||
typeof input === 'string' ? { text: input } : input
|
||||
@@ -210,15 +304,20 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
trimmed,
|
||||
sessionKeyRef.current || undefined,
|
||||
historyRef.current,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
)
|
||||
const responseSessionKey = response.headers.get('X-Session-Key')
|
||||
const response =
|
||||
options.runtime === 'agent-harness'
|
||||
? await chatWithHarnessAgent(agentId, trimmed, abortController.signal)
|
||||
: await chatWithAgent(
|
||||
agentId,
|
||||
trimmed,
|
||||
sessionKeyRef.current || undefined,
|
||||
historyRef.current,
|
||||
abortController.signal,
|
||||
attachments,
|
||||
)
|
||||
const responseSessionKey =
|
||||
response.headers.get('X-Session-Key') ??
|
||||
response.headers.get('X-Session-Id')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
@@ -231,11 +330,19 @@ export function useAgentConversation(
|
||||
])
|
||||
return
|
||||
}
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
if (options.runtime === 'agent-harness') {
|
||||
await consumeSSEStream<AgentHarnessStreamEvent>(
|
||||
response,
|
||||
processAgentHarnessStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} else {
|
||||
await consumeSSEStream<OpenClawStreamEvent>(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
@@ -247,6 +354,7 @@ export function useAgentConversation(
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
onCompleteRef.current?.()
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { HarnessAgentHistoryPage } from '@/entrypoints/app/agents/agent-harness-types'
|
||||
import { fetchHarnessAgentHistory } from '@/entrypoints/app/agents/useAgents'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type {
|
||||
AgentHistoryPageResponse,
|
||||
BrowserOSChatHistoryItem,
|
||||
} from './claw-chat-types'
|
||||
|
||||
const HISTORY_QUERY_KEY = 'harness-agent-history'
|
||||
|
||||
export function useHarnessChatHistory(agentId: string, enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentHistoryPageResponse, Error>({
|
||||
queryKey: [HISTORY_QUERY_KEY, baseUrl, agentId, 'main'],
|
||||
queryFn: async () => {
|
||||
return mapHarnessHistoryPage(await fetchHarnessAgentHistory(agentId))
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
|
||||
function mapHarnessHistoryPage(
|
||||
page: HarnessAgentHistoryPage,
|
||||
): AgentHistoryPageResponse {
|
||||
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => ({
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
text: item.text,
|
||||
timestamp: item.createdAt,
|
||||
messageSeq: index + 1,
|
||||
sessionKey: 'main',
|
||||
source: 'user-chat',
|
||||
}))
|
||||
const updatedAt =
|
||||
page.items.length > 0
|
||||
? Math.max(...page.items.map((item) => item.createdAt))
|
||||
: Date.now()
|
||||
|
||||
return {
|
||||
agentId: page.agentId,
|
||||
sessionKey: 'main',
|
||||
session: {
|
||||
key: 'main',
|
||||
updatedAt,
|
||||
sessionId: 'main',
|
||||
agentId: page.agentId,
|
||||
kind: 'agent-harness',
|
||||
source: 'user-chat',
|
||||
},
|
||||
items,
|
||||
page: {
|
||||
hasMore: false,
|
||||
limit: items.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ export interface OutboundQueueApi {
|
||||
interface UseOutboundQueueOptions {
|
||||
agentId: string | null | undefined
|
||||
sessionKey?: string | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
interface ServerQueuedItem {
|
||||
@@ -68,7 +69,7 @@ function makeId(): string {
|
||||
export function useOutboundQueue(
|
||||
options: UseOutboundQueueOptions,
|
||||
): OutboundQueueApi {
|
||||
const { agentId, sessionKey } = options
|
||||
const { agentId, enabled = true, sessionKey } = options
|
||||
const { baseUrl } = useAgentServerUrl()
|
||||
const sessionKeyRef = useRef<string | null | undefined>(sessionKey)
|
||||
sessionKeyRef.current = sessionKey
|
||||
@@ -85,7 +86,7 @@ export function useOutboundQueue(
|
||||
const previewMapRef = useRef<Map<string, UserAttachmentPreview[]>>(new Map())
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseUrl || !agentId) {
|
||||
if (!enabled || !baseUrl || !agentId) {
|
||||
setItems([])
|
||||
everSeenByServerRef.current = new Set()
|
||||
previewMapRef.current = new Map()
|
||||
@@ -136,11 +137,11 @@ export function useOutboundQueue(
|
||||
cancelled = true
|
||||
source.close()
|
||||
}
|
||||
}, [baseUrl, agentId])
|
||||
}, [baseUrl, agentId, enabled])
|
||||
|
||||
const enqueue = useCallback(
|
||||
(input: OutboundQueueEnqueueInput) => {
|
||||
if (!baseUrl || !agentId) return
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
const trimmed = input.text.trim()
|
||||
const attachments = input.attachments ?? []
|
||||
if (!trimmed && attachments.length === 0) return
|
||||
@@ -215,7 +216,7 @@ export function useOutboundQueue(
|
||||
}
|
||||
})()
|
||||
},
|
||||
[baseUrl, agentId],
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
const cancel = useCallback(
|
||||
@@ -226,13 +227,13 @@ export function useOutboundQueue(
|
||||
setItems((prev) => prev.filter((item) => item.id !== id))
|
||||
return
|
||||
}
|
||||
if (!baseUrl || !agentId) return
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
void fetch(
|
||||
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}`,
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => {})
|
||||
},
|
||||
[baseUrl, agentId],
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
const retry = useCallback(
|
||||
@@ -249,13 +250,13 @@ export function useOutboundQueue(
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!baseUrl || !agentId) return
|
||||
if (!enabled || !baseUrl || !agentId) return
|
||||
void fetch(
|
||||
`${baseUrl}/claw/agents/${encodeURIComponent(agentId)}/queue/${encodeURIComponent(id)}/retry`,
|
||||
{ method: 'POST' },
|
||||
).catch(() => {})
|
||||
},
|
||||
[baseUrl, agentId],
|
||||
[baseUrl, agentId, enabled],
|
||||
)
|
||||
|
||||
return { queue: items, enqueue, cancel, retry }
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Bot, Cpu, Loader2, MessageSquare, Plus, Trash2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import type { AgentListItem } from './agents-page-types'
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AgentListItem[]
|
||||
loading: boolean
|
||||
deletingAgentKey: string | null
|
||||
onChatAgent: (agent: AgentListItem) => void
|
||||
onCreateAgent: () => void
|
||||
onDeleteAgent: (agent: AgentListItem) => void
|
||||
}
|
||||
|
||||
export const AgentList: FC<AgentListProps> = ({
|
||||
agents,
|
||||
loading,
|
||||
deletingAgentKey,
|
||||
onChatAgent,
|
||||
onCreateAgent,
|
||||
onDeleteAgent,
|
||||
}) => {
|
||||
if (loading && agents.length === 0) {
|
||||
return (
|
||||
<div className="flex h-36 items-center justify-center rounded-lg border border-border/70">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex h-48 flex-col items-center justify-center gap-4 text-center">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-medium text-base">No agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Create an OpenClaw, Claude Code, or Codex agent.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onCreateAgent}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.key} className="rounded-lg border-border/70">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
{agent.source === 'openclaw' ? (
|
||||
<Cpu className="size-5" />
|
||||
) : (
|
||||
<Bot className="size-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">
|
||||
{agent.name}
|
||||
</CardTitle>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
|
||||
<Badge variant="outline" className="rounded-md">
|
||||
{agent.runtimeLabel}
|
||||
</Badge>
|
||||
<span>{agent.modelLabel}</span>
|
||||
<Badge variant="outline" className="rounded-md">
|
||||
main
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 truncate font-mono text-muted-foreground text-xs">
|
||||
{agent.detail}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChatAgent(agent)}
|
||||
disabled={!agent.canChat}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-4" />
|
||||
Chat
|
||||
</Button>
|
||||
{agent.canDelete ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete agent"
|
||||
onClick={() => onDeleteAgent(agent)}
|
||||
disabled={deletingAgentKey === agent.key}
|
||||
>
|
||||
{deletingAgentKey === agent.key ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,261 @@
|
||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime, ProviderOption } from './agents-page-types'
|
||||
import { ProviderSelector } from './OpenClawControls'
|
||||
import {
|
||||
type OpenClawCliProvider,
|
||||
type OpenClawCliProviderAuthStatus,
|
||||
OpenClawCliProviderStatusPanel,
|
||||
} from './openclaw-cli-providers'
|
||||
|
||||
interface NewAgentDialogProps {
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
canManageOpenClaw: boolean
|
||||
createError: string | null
|
||||
createRuntime: CreateAgentRuntime
|
||||
creating: boolean
|
||||
defaultProviderId: string
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
name: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
selectedCliProvider: OpenClawCliProvider | undefined
|
||||
selectedProviderId: string
|
||||
cliAuthError: Error | null
|
||||
cliAuthLoading: boolean
|
||||
cliAuthStatus: OpenClawCliProviderAuthStatus | undefined
|
||||
onConnectCliProvider: () => void
|
||||
onCreate: () => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
onRuntimeChange: (runtime: CreateAgentRuntime) => void
|
||||
onHarnessAdapterChange: (adapter: HarnessAgentAdapter) => void
|
||||
onHarnessModelChange: (modelId: string) => void
|
||||
onHarnessReasoningChange: (reasoningEffort: string) => void
|
||||
onNameChange: (name: string) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
}
|
||||
|
||||
export const NewAgentDialog: FC<NewAgentDialogProps> = ({
|
||||
adapters,
|
||||
canManageOpenClaw,
|
||||
createError,
|
||||
createRuntime,
|
||||
creating,
|
||||
defaultProviderId,
|
||||
harnessAdapterId,
|
||||
harnessModelId,
|
||||
harnessReasoningEffort,
|
||||
name,
|
||||
open,
|
||||
providers,
|
||||
selectedCliProvider,
|
||||
selectedProviderId,
|
||||
cliAuthError,
|
||||
cliAuthLoading,
|
||||
cliAuthStatus,
|
||||
onConnectCliProvider,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
onRuntimeChange,
|
||||
onHarnessAdapterChange,
|
||||
onHarnessModelChange,
|
||||
onHarnessReasoningChange,
|
||||
onNameChange,
|
||||
onProviderChange,
|
||||
}) => {
|
||||
const selectedHarnessAdapter =
|
||||
adapters.find((adapter) => adapter.id === harnessAdapterId) ?? adapters[0]
|
||||
const isHarnessRuntime = createRuntime !== 'openclaw'
|
||||
const openClawBlocked = createRuntime === 'openclaw' && !canManageOpenClaw
|
||||
const cliBlocked =
|
||||
createRuntime === 'openclaw' &&
|
||||
!!selectedCliProvider &&
|
||||
!cliAuthStatus?.loggedIn
|
||||
const canCreate =
|
||||
Boolean(name.trim()) &&
|
||||
!creating &&
|
||||
!openClawBlocked &&
|
||||
!cliBlocked &&
|
||||
(createRuntime === 'openclaw'
|
||||
? providers.length > 0
|
||||
: Boolean(selectedHarnessAdapter))
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Agent</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
{createError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Create failed</AlertTitle>
|
||||
<AlertDescription>{createError}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-name">Name</Label>
|
||||
<Input
|
||||
id="agent-name"
|
||||
value={name}
|
||||
onChange={(event) => onNameChange(event.target.value)}
|
||||
placeholder={
|
||||
createRuntime === 'openclaw' ? 'research-agent' : 'Review bot'
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && canCreate) onCreate()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="agent-runtime">Adapter</Label>
|
||||
<Select
|
||||
value={createRuntime}
|
||||
onValueChange={(value) => {
|
||||
if (
|
||||
value === 'openclaw' ||
|
||||
value === 'claude' ||
|
||||
value === 'codex'
|
||||
) {
|
||||
onRuntimeChange(value)
|
||||
if (value !== 'openclaw') onHarnessAdapterChange(value)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="agent-runtime">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openclaw">OpenClaw</SelectItem>
|
||||
{adapters.map((adapter) => (
|
||||
<SelectItem key={adapter.id} value={adapter.id}>
|
||||
{adapter.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{createRuntime === 'openclaw' ? (
|
||||
<>
|
||||
{openClawBlocked ? (
|
||||
<Alert>
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>OpenClaw is not ready</AlertTitle>
|
||||
<AlertDescription>
|
||||
Start or set up the OpenClaw gateway before creating an
|
||||
OpenClaw agent.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<ProviderSelector
|
||||
providers={providers}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={selectedProviderId}
|
||||
onSelect={onProviderChange}
|
||||
hideApiKeyHint={!!selectedCliProvider}
|
||||
/>
|
||||
|
||||
{selectedCliProvider ? (
|
||||
<OpenClawCliProviderStatusPanel
|
||||
provider={selectedCliProvider}
|
||||
status={cliAuthStatus}
|
||||
loading={cliAuthLoading}
|
||||
fetchError={cliAuthError}
|
||||
onConnect={onConnectCliProvider}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isHarnessRuntime ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-model">Model</Label>
|
||||
<Select
|
||||
value={harnessModelId}
|
||||
onValueChange={onHarnessModelChange}
|
||||
>
|
||||
<SelectTrigger id="harness-model">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedHarnessAdapter?.models ?? []).map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="harness-effort">Reasoning</Label>
|
||||
<Select
|
||||
value={harnessReasoningEffort}
|
||||
onValueChange={onHarnessReasoningChange}
|
||||
>
|
||||
<SelectTrigger id="harness-effort">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(selectedHarnessAdapter?.reasoningEfforts ?? []).map(
|
||||
(effort) => (
|
||||
<SelectItem key={effort.id} value={effort.id}>
|
||||
{effort.label}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button disabled={!canCreate} onClick={onCreate}>
|
||||
{creating ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
Cpu,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
WifiOff,
|
||||
Wrench,
|
||||
} from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ProviderOption } from './agents-page-types'
|
||||
import {
|
||||
CONTROL_PLANE_COPY,
|
||||
FALLBACK_CONTROL_PLANE_COPY,
|
||||
} from './agents-page-types'
|
||||
import type { getControlPlaneCopy } from './agents-page-utils'
|
||||
import type { OpenClawStatus } from './useOpenClaw'
|
||||
|
||||
const StatusBadge: FC<{ status: OpenClawStatus['status'] }> = ({ status }) => {
|
||||
const variants: Record<
|
||||
OpenClawStatus['status'],
|
||||
{
|
||||
variant: 'default' | 'secondary' | 'outline' | 'destructive'
|
||||
label: string
|
||||
}
|
||||
> = {
|
||||
running: { variant: 'default', label: 'Running' },
|
||||
starting: { variant: 'secondary', label: 'Starting...' },
|
||||
stopped: { variant: 'outline', label: 'Stopped' },
|
||||
error: { variant: 'destructive', label: 'Error' },
|
||||
uninitialized: { variant: 'outline', label: 'Not Set Up' },
|
||||
}
|
||||
const current = variants[status] ?? {
|
||||
variant: 'outline' as const,
|
||||
label: 'Unknown',
|
||||
}
|
||||
return <Badge variant={current.variant}>{current.label}</Badge>
|
||||
}
|
||||
|
||||
const ControlPlaneBadge: FC<{
|
||||
status: OpenClawStatus['controlPlaneStatus']
|
||||
}> = ({ status }) => {
|
||||
const current = CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
|
||||
return <Badge variant={current.badgeVariant}>{current.badgeLabel}</Badge>
|
||||
}
|
||||
|
||||
interface ProviderSelectorProps {
|
||||
providers: ProviderOption[]
|
||||
defaultProviderId: string
|
||||
selectedId: string
|
||||
onSelect: (id: string) => void
|
||||
hideApiKeyHint?: boolean
|
||||
}
|
||||
|
||||
export const ProviderSelector: FC<ProviderSelectorProps> = ({
|
||||
providers,
|
||||
defaultProviderId,
|
||||
selectedId,
|
||||
onSelect,
|
||||
hideApiKeyHint,
|
||||
}) => {
|
||||
if (providers.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium text-sm">LLM Provider</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No compatible LLM providers configured.{' '}
|
||||
<a href="#/settings/ai" className="underline">
|
||||
Add one in AI settings
|
||||
</a>{' '}
|
||||
first.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider-select">LLM Provider</Label>
|
||||
<Select value={selectedId} onValueChange={onSelect}>
|
||||
<SelectTrigger id="provider-select">
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name} - {provider.modelId}
|
||||
{provider.id === defaultProviderId ? ' (default)' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!hideApiKeyHint && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Uses your existing API key from BrowserOS settings. The key is passed
|
||||
to the container and never leaves your machine.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentsPageHeaderProps {
|
||||
actionInProgress: boolean
|
||||
controlPlaneBusy: boolean
|
||||
reconnecting: boolean
|
||||
status: OpenClawStatus | null
|
||||
onCreateAgent: () => void
|
||||
onOpenTerminal: () => void
|
||||
onReconnect: () => void
|
||||
onRefresh: () => void
|
||||
onRestart: () => void
|
||||
onStop: () => void
|
||||
}
|
||||
|
||||
export const AgentsPageHeader: FC<AgentsPageHeaderProps> = ({
|
||||
actionInProgress,
|
||||
controlPlaneBusy,
|
||||
reconnecting,
|
||||
status,
|
||||
onCreateAgent,
|
||||
onOpenTerminal,
|
||||
onReconnect,
|
||||
onRefresh,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) => (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="font-semibold text-2xl tracking-normal">Agents</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
OpenClaw, Claude Code, and Codex agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{status ? (
|
||||
<>
|
||||
<StatusBadge status={status.status} />
|
||||
{status.status !== 'uninitialized' && (
|
||||
<ControlPlaneBadge status={status.controlPlaneStatus} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{status?.status === 'running' &&
|
||||
status.controlPlaneStatus !== 'connected' ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={actionInProgress || controlPlaneBusy}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{status?.status === 'running' ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
title="Restart gateway"
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
disabled={actionInProgress}
|
||||
title="Stop gateway"
|
||||
>
|
||||
<Square className="size-4" />
|
||||
</Button>
|
||||
<Button variant="outline" onClick={onOpenTerminal}>
|
||||
<TerminalSquare className="mr-2 size-4" />
|
||||
Terminal
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button variant="ghost" size="icon" onClick={onRefresh} title="Refresh">
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button onClick={onCreateAgent}>
|
||||
<Plus className="mr-2 size-4" />
|
||||
New Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export function LifecycleAlert({ message }: { message: string }) {
|
||||
return (
|
||||
<Alert>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<AlertTitle>{message}</AlertTitle>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
export function InlineErrorAlert({
|
||||
message,
|
||||
onDismiss,
|
||||
}: {
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
}) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Agent action failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{message}</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="outline" size="sm" onClick={onDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
interface ControlPlaneAlertProps {
|
||||
actionInProgress: boolean
|
||||
controlPlaneBusy: boolean
|
||||
controlPlaneCopy: ReturnType<typeof getControlPlaneCopy>
|
||||
reconnecting: boolean
|
||||
recoveryDetail: string | null
|
||||
status: OpenClawStatus
|
||||
onReconnect: () => void
|
||||
onRestart: () => void
|
||||
}
|
||||
|
||||
export const ControlPlaneAlert: FC<ControlPlaneAlertProps> = ({
|
||||
actionInProgress,
|
||||
controlPlaneBusy,
|
||||
controlPlaneCopy,
|
||||
reconnecting,
|
||||
recoveryDetail,
|
||||
status,
|
||||
onReconnect,
|
||||
onRestart,
|
||||
}) => (
|
||||
<Alert
|
||||
variant={status.controlPlaneStatus === 'failed' ? 'destructive' : 'default'}
|
||||
>
|
||||
{status.controlPlaneStatus === 'failed' ? (
|
||||
<ShieldAlert className="size-4" />
|
||||
) : status.controlPlaneStatus === 'recovering' ? (
|
||||
<Wrench className="size-4" />
|
||||
) : (
|
||||
<WifiOff className="size-4" />
|
||||
)}
|
||||
<AlertTitle>{controlPlaneCopy.title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>{controlPlaneCopy.description}</p>
|
||||
{recoveryDetail ? <p>{recoveryDetail}</p> : null}
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onReconnect}
|
||||
disabled={actionInProgress || controlPlaneBusy}
|
||||
>
|
||||
{reconnecting ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
)}
|
||||
Retry Connection
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
|
||||
interface GatewayStateCardsProps {
|
||||
actionInProgress: boolean
|
||||
status: OpenClawStatus | null
|
||||
onOpenSetup: () => void
|
||||
onRestart: () => void
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
export const GatewayStateCards: FC<GatewayStateCardsProps> = ({
|
||||
actionInProgress,
|
||||
status,
|
||||
onOpenSetup,
|
||||
onRestart,
|
||||
onStart,
|
||||
}) => (
|
||||
<>
|
||||
{status?.status === 'uninitialized' ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Set Up OpenClaw</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.podmanAvailable
|
||||
? 'Create a local BrowserOS VM to run autonomous agents with full tool access.'
|
||||
: 'BrowserOS VM runtime is unavailable on this system.'}
|
||||
</p>
|
||||
</div>
|
||||
{status.podmanAvailable ? (
|
||||
<Button onClick={onOpenSetup}>Set Up Now</Button>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{status?.status === 'stopped' ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<Cpu className="size-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Stopped</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
The OpenClaw gateway is not running.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{status?.status === 'error' ? (
|
||||
<Card className="border-destructive">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<AlertCircle className="size-12 text-destructive" />
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold text-lg">Gateway Error</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{status.error ?? status.lastGatewayError}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={onStart} disabled={actionInProgress}>
|
||||
Start Gateway
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onRestart}
|
||||
disabled={actionInProgress}
|
||||
>
|
||||
Restart Gateway
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import type { ProviderOption } from './agents-page-types'
|
||||
import { ProviderSelector } from './OpenClawControls'
|
||||
import type { OpenClawCliProvider } from './openclaw-cli-providers'
|
||||
|
||||
interface SetupOpenClawDialogProps {
|
||||
defaultProviderId: string
|
||||
open: boolean
|
||||
providers: ProviderOption[]
|
||||
selectedProviderId: string
|
||||
selectedCliProvider: OpenClawCliProvider | undefined
|
||||
settingUp: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onProviderChange: (providerId: string) => void
|
||||
onSetup: () => void
|
||||
}
|
||||
|
||||
export const SetupOpenClawDialog: FC<SetupOpenClawDialogProps> = ({
|
||||
defaultProviderId,
|
||||
open,
|
||||
providers,
|
||||
selectedProviderId,
|
||||
selectedCliProvider,
|
||||
settingUp,
|
||||
onOpenChange,
|
||||
onProviderChange,
|
||||
onSetup,
|
||||
}) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Up OpenClaw</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<ProviderSelector
|
||||
providers={providers}
|
||||
defaultProviderId={defaultProviderId}
|
||||
selectedId={selectedProviderId}
|
||||
onSelect={onProviderChange}
|
||||
hideApiKeyHint={!!selectedCliProvider}
|
||||
/>
|
||||
|
||||
{selectedCliProvider ? (
|
||||
<p className="rounded-md border border-border bg-muted/30 px-3 py-2 text-muted-foreground text-xs">
|
||||
{selectedCliProvider.description}. Clicking{' '}
|
||||
<span className="font-medium">Set Up & Start</span> starts the
|
||||
gateway and opens a terminal to sign in.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={onSetup}
|
||||
disabled={settingUp || providers.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
{settingUp ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
Setting up...
|
||||
</>
|
||||
) : (
|
||||
'Set Up & Start'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
export function buildAgentApiUrl(baseUrl: string, path: string): string {
|
||||
const normalizedPath = path === '/' ? '' : path
|
||||
return `${baseUrl}/agents${normalizedPath}`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AgentEntry } from './useOpenClaw'
|
||||
|
||||
export type HarnessAgentAdapter = 'claude' | 'codex'
|
||||
|
||||
export type AgentHarnessStreamEvent =
|
||||
| {
|
||||
type: 'text_delta'
|
||||
text: string
|
||||
stream: 'output' | 'thought'
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'tool_call'
|
||||
text: string
|
||||
title: string
|
||||
id?: string
|
||||
status?: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'status'
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
stopReason?: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
message: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface HarnessAgent {
|
||||
id: string
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
permissionMode: 'approve-all'
|
||||
sessionKey: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface HarnessAdapterDescriptor {
|
||||
id: HarnessAgentAdapter
|
||||
name: string
|
||||
defaultModelId: string
|
||||
defaultReasoningEffort: string
|
||||
modelControl: 'runtime-supported' | 'best-effort'
|
||||
models: Array<{ id: string; label: string; recommended?: boolean }>
|
||||
reasoningEfforts: Array<{ id: string; label: string; recommended?: boolean }>
|
||||
}
|
||||
|
||||
export interface CreateHarnessAgentInput {
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
export interface HarnessTranscriptEntry {
|
||||
id: string
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface HarnessAgentHistoryPage {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
items: HarnessTranscriptEntry[]
|
||||
}
|
||||
|
||||
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
workspace: `${agent.adapter}:main`,
|
||||
model: agent.modelId,
|
||||
source: 'agent-harness',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { NavigateFunction } from 'react-router'
|
||||
import {
|
||||
AGENT_CREATED_EVENT,
|
||||
AGENT_DELETED_EVENT,
|
||||
} from '@/lib/constants/analyticsEvents'
|
||||
import { track } from '@/lib/metrics/track'
|
||||
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||
import type {
|
||||
AgentListItem,
|
||||
CreateAgentRuntime,
|
||||
ProviderOption,
|
||||
} from './agents-page-types'
|
||||
import { findOpenClawCliProviderById } from './openclaw-cli-providers'
|
||||
import type {
|
||||
AgentEntry,
|
||||
OpenClawAgentMutationInput,
|
||||
OpenClawSetupInput,
|
||||
} from './useOpenClaw'
|
||||
|
||||
export interface AgentPageActionInput {
|
||||
createProviderId: string
|
||||
createRuntime: CreateAgentRuntime
|
||||
harnessModelId: string
|
||||
harnessReasoningEffort: string
|
||||
navigate: NavigateFunction
|
||||
newName: string
|
||||
selectableOpenClawProviders: ProviderOption[]
|
||||
setupProviderId: string
|
||||
createHarnessAgent: (input: {
|
||||
name: string
|
||||
adapter: HarnessAgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}) => Promise<HarnessAgent>
|
||||
createOpenClawAgent: (
|
||||
input: OpenClawAgentMutationInput,
|
||||
) => Promise<{ agent: AgentEntry }>
|
||||
deleteHarnessAgent: (agentId: string) => Promise<unknown>
|
||||
deleteOpenClawAgent: (agentId: string) => Promise<unknown>
|
||||
setCliAuthModalOpen: (open: boolean) => void
|
||||
setCreateError: (error: string | null) => void
|
||||
setCreateOpen: (open: boolean) => void
|
||||
setDeletingAgentKey: (key: string | null) => void
|
||||
setNewName: (name: string) => void
|
||||
setPageError: (error: string | null) => void
|
||||
setSetupOpen: (open: boolean) => void
|
||||
setupOpenClaw: (input: OpenClawSetupInput) => Promise<unknown>
|
||||
}
|
||||
|
||||
export function createAgentPageActions(input: AgentPageActionInput) {
|
||||
const runWithPageErrorHandling = async (fn: () => Promise<unknown>) => {
|
||||
input.setPageError(null)
|
||||
try {
|
||||
await fn()
|
||||
} catch (err) {
|
||||
input.setPageError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetup = async () => {
|
||||
const option = input.selectableOpenClawProviders.find(
|
||||
(item) => item.id === input.setupProviderId,
|
||||
)
|
||||
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
|
||||
const llmOption = !isCli && option ? option : undefined
|
||||
|
||||
await runWithPageErrorHandling(async () => {
|
||||
await input.setupOpenClaw({
|
||||
providerType: option?.type,
|
||||
providerName: isCli ? undefined : option?.name,
|
||||
baseUrl: llmOption?.baseUrl,
|
||||
apiKey: llmOption?.apiKey,
|
||||
modelId: option?.modelId,
|
||||
})
|
||||
input.setSetupOpen(false)
|
||||
if (isCli) input.setCliAuthModalOpen(true)
|
||||
})
|
||||
}
|
||||
|
||||
const handleOpenClawCreate = async () => {
|
||||
if (!input.newName.trim()) return
|
||||
const option = input.selectableOpenClawProviders.find(
|
||||
(item) => item.id === input.createProviderId,
|
||||
)
|
||||
const normalizedName = input.newName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
const isCli = !!option && !!findOpenClawCliProviderById(option.type)
|
||||
const llmOption = !isCli && option ? option : undefined
|
||||
|
||||
input.setCreateError(null)
|
||||
try {
|
||||
const result = await input.createOpenClawAgent({
|
||||
name: normalizedName,
|
||||
providerType: option?.type,
|
||||
providerName: isCli ? undefined : option?.name,
|
||||
baseUrl: llmOption?.baseUrl,
|
||||
apiKey: llmOption?.apiKey,
|
||||
modelId: option?.modelId,
|
||||
})
|
||||
input.setCreateOpen(false)
|
||||
input.setNewName('')
|
||||
track(AGENT_CREATED_EVENT, {
|
||||
runtime: 'openclaw',
|
||||
provider_type: option?.type,
|
||||
})
|
||||
input.navigate(`/agents/${result.agent.agentId}`)
|
||||
} catch (err) {
|
||||
input.setCreateError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarnessCreate = async () => {
|
||||
if (!input.newName.trim()) return
|
||||
|
||||
input.setCreateError(null)
|
||||
try {
|
||||
const agent = await input.createHarnessAgent({
|
||||
name: input.newName.trim(),
|
||||
adapter: input.createRuntime as HarnessAgentAdapter,
|
||||
modelId: input.harnessModelId || undefined,
|
||||
reasoningEffort: input.harnessReasoningEffort || undefined,
|
||||
})
|
||||
input.setCreateOpen(false)
|
||||
input.setNewName('')
|
||||
track(AGENT_CREATED_EVENT, {
|
||||
runtime: input.createRuntime,
|
||||
model_id: input.harnessModelId || undefined,
|
||||
reasoning_effort: input.harnessReasoningEffort || undefined,
|
||||
})
|
||||
input.navigate(`/agents/${agent.id}`)
|
||||
} catch (err) {
|
||||
input.setCreateError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
const createByRuntime: Record<CreateAgentRuntime, () => Promise<void>> = {
|
||||
openclaw: handleOpenClawCreate,
|
||||
claude: handleHarnessCreate,
|
||||
codex: handleHarnessCreate,
|
||||
}
|
||||
void createByRuntime[input.createRuntime]()
|
||||
}
|
||||
|
||||
const handleDelete = async (agent: AgentListItem) => {
|
||||
input.setDeletingAgentKey(agent.key)
|
||||
await runWithPageErrorHandling(async () => {
|
||||
const deleteBySource: Record<
|
||||
AgentListItem['source'],
|
||||
(agentId: string) => Promise<unknown>
|
||||
> = {
|
||||
openclaw: (agentId) => input.deleteOpenClawAgent(agentId),
|
||||
'agent-harness': (agentId) => input.deleteHarnessAgent(agentId),
|
||||
}
|
||||
await deleteBySource[agent.source](agent.agentId)
|
||||
track(AGENT_DELETED_EVENT, {
|
||||
runtime: agent.source,
|
||||
agent_id: agent.agentId,
|
||||
})
|
||||
})
|
||||
input.setDeletingAgentKey(null)
|
||||
}
|
||||
|
||||
return {
|
||||
handleCreate,
|
||||
handleDelete,
|
||||
handleSetup,
|
||||
runWithPageErrorHandling,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useMemo } from 'react'
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import type {
|
||||
HarnessAdapterDescriptor,
|
||||
HarnessAgentAdapter,
|
||||
} from './agent-harness-types'
|
||||
import type { CreateAgentRuntime } from './agents-page-types'
|
||||
import { toProviderOptions } from './agents-page-utils'
|
||||
import {
|
||||
buildOpenClawCliProviderOptions,
|
||||
findOpenClawCliProviderById,
|
||||
useOpenClawCliProviderAuthStatus,
|
||||
} from './openclaw-cli-providers'
|
||||
|
||||
export function useDefaultAgentName(
|
||||
createOpen: boolean,
|
||||
setNewName: Dispatch<SetStateAction<string>>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
setNewName((current) => current || 'agent')
|
||||
}, [createOpen, setNewName])
|
||||
}
|
||||
|
||||
export function useHarnessAgentDefaults(input: {
|
||||
adapters: HarnessAdapterDescriptor[]
|
||||
createOpen: boolean
|
||||
harnessAdapterId: HarnessAgentAdapter
|
||||
setHarnessAdapterId: Dispatch<SetStateAction<HarnessAgentAdapter>>
|
||||
setHarnessModelId: Dispatch<SetStateAction<string>>
|
||||
setHarnessReasoningEffort: Dispatch<SetStateAction<string>>
|
||||
}): void {
|
||||
const {
|
||||
adapters,
|
||||
createOpen,
|
||||
harnessAdapterId,
|
||||
setHarnessAdapterId,
|
||||
setHarnessModelId,
|
||||
setHarnessReasoningEffort,
|
||||
} = input
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
const adapter =
|
||||
adapters.find((entry) => entry.id === harnessAdapterId) ?? adapters[0]
|
||||
if (!adapter) return
|
||||
setHarnessAdapterId(adapter.id)
|
||||
setHarnessModelId((current) => current || adapter.defaultModelId)
|
||||
setHarnessReasoningEffort(
|
||||
(current) => current || adapter.defaultReasoningEffort,
|
||||
)
|
||||
}, [
|
||||
adapters,
|
||||
createOpen,
|
||||
harnessAdapterId,
|
||||
setHarnessAdapterId,
|
||||
setHarnessModelId,
|
||||
setHarnessReasoningEffort,
|
||||
])
|
||||
}
|
||||
|
||||
export function useOpenClawProviderSelection(input: {
|
||||
providers: LlmProviderConfig[]
|
||||
defaultProviderId: string
|
||||
createOpen: boolean
|
||||
createRuntime: CreateAgentRuntime
|
||||
createProviderId: string
|
||||
setCreateProviderId: Dispatch<SetStateAction<string>>
|
||||
setupOpen: boolean
|
||||
setupProviderId: string
|
||||
setSetupProviderId: Dispatch<SetStateAction<string>>
|
||||
cliAuthModalOpen: boolean
|
||||
setCliAuthModalOpen: Dispatch<SetStateAction<boolean>>
|
||||
}) {
|
||||
const {
|
||||
providers,
|
||||
defaultProviderId,
|
||||
createOpen,
|
||||
createRuntime,
|
||||
createProviderId,
|
||||
setCreateProviderId,
|
||||
setupOpen,
|
||||
setupProviderId,
|
||||
setSetupProviderId,
|
||||
cliAuthModalOpen,
|
||||
setCliAuthModalOpen,
|
||||
} = input
|
||||
const cliProviderOptions = useMemo(
|
||||
() => buildOpenClawCliProviderOptions(),
|
||||
[],
|
||||
)
|
||||
const selectableOpenClawProviders = useMemo(
|
||||
() => toProviderOptions(providers, cliProviderOptions),
|
||||
[providers, cliProviderOptions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableOpenClawProviders.length === 0) return
|
||||
const fallbackId =
|
||||
selectableOpenClawProviders.find(
|
||||
(provider) => provider.id === defaultProviderId,
|
||||
)?.id ?? selectableOpenClawProviders[0].id
|
||||
|
||||
if (createOpen && !createProviderId) {
|
||||
setCreateProviderId(fallbackId)
|
||||
}
|
||||
}, [
|
||||
createOpen,
|
||||
createProviderId,
|
||||
defaultProviderId,
|
||||
selectableOpenClawProviders,
|
||||
setCreateProviderId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectableOpenClawProviders.length === 0) return
|
||||
const fallbackId =
|
||||
selectableOpenClawProviders.find(
|
||||
(provider) => provider.id === defaultProviderId,
|
||||
)?.id ?? selectableOpenClawProviders[0].id
|
||||
|
||||
if (setupOpen && !setupProviderId) {
|
||||
setSetupProviderId(fallbackId)
|
||||
}
|
||||
}, [
|
||||
defaultProviderId,
|
||||
selectableOpenClawProviders,
|
||||
setSetupProviderId,
|
||||
setupOpen,
|
||||
setupProviderId,
|
||||
])
|
||||
|
||||
const selectedCreateOption = selectableOpenClawProviders.find(
|
||||
(provider) => provider.id === createProviderId,
|
||||
)
|
||||
const selectedCliProvider = selectedCreateOption
|
||||
? findOpenClawCliProviderById(selectedCreateOption.type)
|
||||
: undefined
|
||||
const selectedSetupOption = selectableOpenClawProviders.find(
|
||||
(provider) => provider.id === setupProviderId,
|
||||
)
|
||||
const selectedSetupCliProvider = selectedSetupOption
|
||||
? findOpenClawCliProviderById(selectedSetupOption.type)
|
||||
: undefined
|
||||
const activeCliProvider =
|
||||
(setupOpen && selectedSetupCliProvider) ||
|
||||
(createOpen && createRuntime === 'openclaw' && selectedCliProvider) ||
|
||||
undefined
|
||||
const {
|
||||
data: cliAuthStatus,
|
||||
isLoading: cliAuthLoading,
|
||||
error: cliAuthError,
|
||||
} = useOpenClawCliProviderAuthStatus(
|
||||
activeCliProvider?.id ?? '',
|
||||
!!activeCliProvider,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (cliAuthModalOpen && cliAuthStatus?.loggedIn) {
|
||||
setCliAuthModalOpen(false)
|
||||
}
|
||||
}, [cliAuthModalOpen, cliAuthStatus?.loggedIn, setCliAuthModalOpen])
|
||||
|
||||
return {
|
||||
selectableOpenClawProviders,
|
||||
selectedCliProvider,
|
||||
selectedSetupCliProvider,
|
||||
authTerminalProvider: selectedSetupCliProvider ?? selectedCliProvider,
|
||||
cliAuthStatus,
|
||||
cliAuthLoading,
|
||||
cliAuthError,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { HarnessAgentAdapter } from './agent-harness-types'
|
||||
import type { GatewayLifecycleAction, OpenClawStatus } from './useOpenClaw'
|
||||
|
||||
export type CreateAgentRuntime = 'openclaw' | HarnessAgentAdapter
|
||||
|
||||
export interface ProviderOption {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
modelId: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface AgentListItem {
|
||||
key: string
|
||||
agentId: string
|
||||
name: string
|
||||
source: 'openclaw' | 'agent-harness'
|
||||
runtimeLabel: string
|
||||
modelLabel: string
|
||||
detail: string
|
||||
canChat: boolean
|
||||
canDelete: boolean
|
||||
}
|
||||
|
||||
export interface GatewayUiState {
|
||||
canManageAgents: boolean
|
||||
controlPlaneDegraded: boolean
|
||||
controlPlaneBusy: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_HARNESS_ADAPTER: HarnessAgentAdapter = 'claude'
|
||||
export const DEFAULT_CREATE_RUNTIME: CreateAgentRuntime = 'openclaw'
|
||||
|
||||
export const LIFECYCLE_BANNER_COPY: Record<GatewayLifecycleAction, string> = {
|
||||
setup: 'Setting up OpenClaw...',
|
||||
start: 'Starting gateway...',
|
||||
stop: 'Stopping gateway...',
|
||||
restart: 'Restarting gateway...',
|
||||
reconnect: 'Restoring gateway connection...',
|
||||
}
|
||||
|
||||
export const CONTROL_PLANE_COPY: Record<
|
||||
OpenClawStatus['controlPlaneStatus'],
|
||||
{
|
||||
badgeVariant: 'default' | 'secondary' | 'outline' | 'destructive'
|
||||
badgeLabel: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
> = {
|
||||
connected: {
|
||||
badgeVariant: 'default',
|
||||
badgeLabel: 'Control Plane Ready',
|
||||
title: 'Gateway Connected',
|
||||
description: 'OpenClaw can create, manage, and chat with agents normally.',
|
||||
},
|
||||
connecting: {
|
||||
badgeVariant: 'secondary',
|
||||
badgeLabel: 'Connecting',
|
||||
title: 'Connecting to Gateway',
|
||||
description:
|
||||
'BrowserOS is establishing the OpenClaw control channel for agent operations.',
|
||||
},
|
||||
reconnecting: {
|
||||
badgeVariant: 'secondary',
|
||||
badgeLabel: 'Reconnecting',
|
||||
title: 'Reconnecting Control Plane',
|
||||
description:
|
||||
'The gateway process is up, but BrowserOS is restoring the control channel.',
|
||||
},
|
||||
recovering: {
|
||||
badgeVariant: 'secondary',
|
||||
badgeLabel: 'Recovering',
|
||||
title: 'Recovering Gateway Connection',
|
||||
description:
|
||||
'BrowserOS detected a control-plane fault and is trying a safe recovery path.',
|
||||
},
|
||||
disconnected: {
|
||||
badgeVariant: 'outline',
|
||||
badgeLabel: 'Disconnected',
|
||||
title: 'Gateway Disconnected',
|
||||
description: 'The gateway process is not available to BrowserOS right now.',
|
||||
},
|
||||
failed: {
|
||||
badgeVariant: 'destructive',
|
||||
badgeLabel: 'Needs Attention',
|
||||
title: 'Gateway Recovery Failed',
|
||||
description:
|
||||
'BrowserOS could not restore the OpenClaw control channel automatically.',
|
||||
},
|
||||
}
|
||||
|
||||
export const FALLBACK_CONTROL_PLANE_COPY = {
|
||||
badgeVariant: 'outline' as const,
|
||||
badgeLabel: 'Unknown',
|
||||
title: 'Gateway State Unknown',
|
||||
description:
|
||||
'BrowserOS received a gateway status it does not recognize yet. Refreshing or reconnecting should restore a known state.',
|
||||
}
|
||||
|
||||
export const RECOVERY_REASON_COPY: Record<
|
||||
NonNullable<OpenClawStatus['lastRecoveryReason']>,
|
||||
string
|
||||
> = {
|
||||
transient_disconnect:
|
||||
'The control channel dropped briefly and BrowserOS is retrying it.',
|
||||
signature_expired:
|
||||
'The gateway rejected the signed device handshake because its clock drifted.',
|
||||
pairing_required:
|
||||
'The gateway asked BrowserOS to approve its local device identity again.',
|
||||
token_mismatch:
|
||||
'BrowserOS had to reload the gateway token before reconnecting.',
|
||||
container_not_ready:
|
||||
'The OpenClaw gateway process is not ready yet, so control-plane recovery cannot start.',
|
||||
unknown:
|
||||
'BrowserOS hit an unexpected gateway error and could not classify it cleanly.',
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { LlmProviderConfig } from '@/lib/llm-providers/types'
|
||||
import type { HarnessAgent, HarnessAgentAdapter } from './agent-harness-types'
|
||||
import {
|
||||
type AgentListItem,
|
||||
CONTROL_PLANE_COPY,
|
||||
FALLBACK_CONTROL_PLANE_COPY,
|
||||
type GatewayUiState,
|
||||
LIFECYCLE_BANNER_COPY,
|
||||
type ProviderOption,
|
||||
RECOVERY_REASON_COPY,
|
||||
} from './agents-page-types'
|
||||
import { getOpenClawSupportedProviders } from './openclaw-supported-providers'
|
||||
import {
|
||||
type AgentEntry,
|
||||
type GatewayLifecycleAction,
|
||||
getModelDisplayName,
|
||||
type OpenClawStatus,
|
||||
} from './useOpenClaw'
|
||||
|
||||
export function getControlPlaneCopy(
|
||||
status: OpenClawStatus['controlPlaneStatus'],
|
||||
) {
|
||||
return CONTROL_PLANE_COPY[status] ?? FALLBACK_CONTROL_PLANE_COPY
|
||||
}
|
||||
|
||||
export function getRecoveryDetail(status: OpenClawStatus): string | null {
|
||||
if (!status.lastRecoveryReason && !status.lastGatewayError) return null
|
||||
|
||||
const detail = status.lastRecoveryReason
|
||||
? RECOVERY_REASON_COPY[status.lastRecoveryReason]
|
||||
: null
|
||||
|
||||
if (status.lastGatewayError && detail) {
|
||||
return `${detail} Latest gateway error: ${status.lastGatewayError}`
|
||||
}
|
||||
|
||||
return status.lastGatewayError ?? detail
|
||||
}
|
||||
|
||||
export function formatHarnessAdapter(adapter: HarnessAgentAdapter): string {
|
||||
return adapter === 'claude' ? 'Claude Code' : 'Codex'
|
||||
}
|
||||
|
||||
export function toProviderOptions(
|
||||
providers: LlmProviderConfig[],
|
||||
cliProviders: ProviderOption[],
|
||||
): ProviderOption[] {
|
||||
return [...getOpenClawSupportedProviders(providers), ...cliProviders]
|
||||
}
|
||||
|
||||
export function toOpenClawListItem(
|
||||
agent: AgentEntry,
|
||||
canManageAgents: boolean,
|
||||
): AgentListItem {
|
||||
return {
|
||||
key: `openclaw:${agent.agentId}`,
|
||||
agentId: agent.agentId,
|
||||
name: agent.name,
|
||||
source: 'openclaw',
|
||||
runtimeLabel: 'OpenClaw',
|
||||
modelLabel: getModelDisplayName(agent.model) ?? 'default',
|
||||
detail: agent.workspace,
|
||||
canChat: canManageAgents,
|
||||
canDelete: canManageAgents && agent.agentId !== 'main',
|
||||
}
|
||||
}
|
||||
|
||||
export function toHarnessListItem(agent: HarnessAgent): AgentListItem {
|
||||
return {
|
||||
key: `agent-harness:${agent.id}`,
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
source: 'agent-harness',
|
||||
runtimeLabel: formatHarnessAdapter(agent.adapter),
|
||||
modelLabel: agent.modelId ?? 'default',
|
||||
detail: `${agent.adapter}:main`,
|
||||
canChat: true,
|
||||
canDelete: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGatewayUiState(
|
||||
status: OpenClawStatus | null,
|
||||
): GatewayUiState {
|
||||
if (!status) {
|
||||
return {
|
||||
canManageAgents: false,
|
||||
controlPlaneDegraded: false,
|
||||
controlPlaneBusy: false,
|
||||
}
|
||||
}
|
||||
|
||||
const controlPlaneBusy =
|
||||
status.controlPlaneStatus === 'connecting' ||
|
||||
status.controlPlaneStatus === 'reconnecting' ||
|
||||
status.controlPlaneStatus === 'recovering'
|
||||
|
||||
return {
|
||||
canManageAgents:
|
||||
status.status === 'running' && status.controlPlaneStatus === 'connected',
|
||||
controlPlaneBusy,
|
||||
controlPlaneDegraded:
|
||||
status.status === 'running' && status.controlPlaneStatus !== 'connected',
|
||||
}
|
||||
}
|
||||
|
||||
export function getLifecycleBanner(
|
||||
action: GatewayLifecycleAction | null,
|
||||
): string | null {
|
||||
return action ? LIFECYCLE_BANNER_COPY[action] : null
|
||||
}
|
||||
|
||||
export function canManageOpenClawAgents(
|
||||
state: GatewayUiState,
|
||||
lifecyclePending: boolean,
|
||||
): boolean {
|
||||
return state.canManageAgents && !lifecyclePending
|
||||
}
|
||||
|
||||
export function shouldShowControlPlaneDegraded(
|
||||
state: GatewayUiState,
|
||||
lifecyclePending: boolean,
|
||||
): boolean {
|
||||
return state.controlPlaneDegraded && !lifecyclePending
|
||||
}
|
||||
|
||||
export function getControlPlaneCopyForStatus(status: OpenClawStatus | null) {
|
||||
return status
|
||||
? getControlPlaneCopy(status.controlPlaneStatus)
|
||||
: FALLBACK_CONTROL_PLANE_COPY
|
||||
}
|
||||
|
||||
export function getVisibleOpenClawAgents(
|
||||
enabled: boolean,
|
||||
agents: AgentEntry[],
|
||||
): AgentEntry[] {
|
||||
return enabled ? agents : []
|
||||
}
|
||||
|
||||
export function getAgentsLoading(input: {
|
||||
statusLoading: boolean
|
||||
adaptersLoading: boolean
|
||||
harnessAgentsLoading: boolean
|
||||
openClawAgentsEnabled: boolean
|
||||
openClawAgentsLoading: boolean
|
||||
}): boolean {
|
||||
return (
|
||||
input.statusLoading ||
|
||||
input.adaptersLoading ||
|
||||
input.harnessAgentsLoading ||
|
||||
(input.openClawAgentsEnabled && input.openClawAgentsLoading)
|
||||
)
|
||||
}
|
||||
|
||||
export function getInlineError(input: {
|
||||
lifecyclePending: boolean
|
||||
pageError: string | null
|
||||
statusError: Error | null
|
||||
openClawAgentsError: Error | null
|
||||
adaptersError: Error | null
|
||||
harnessAgentsError: Error | null
|
||||
}): string | null {
|
||||
if (input.lifecyclePending) return null
|
||||
return (
|
||||
input.pageError ??
|
||||
input.statusError?.message ??
|
||||
input.openClawAgentsError?.message ??
|
||||
input.adaptersError?.message ??
|
||||
input.harnessAgentsError?.message ??
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { buildAgentApiUrl } from './agent-api-url'
|
||||
import { mapHarnessAgentToEntry } from './agent-harness-types'
|
||||
|
||||
describe('mapHarnessAgentToEntry', () => {
|
||||
it('maps created harness agents into chat-compatible entries', () => {
|
||||
expect(
|
||||
mapHarnessAgentToEntry({
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}),
|
||||
).toEqual({
|
||||
agentId: 'agent-1',
|
||||
name: 'Review bot',
|
||||
workspace: 'codex:main',
|
||||
model: 'gpt-5.5',
|
||||
source: 'agent-harness',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildAgentApiUrl', () => {
|
||||
it('does not add a trailing slash for the harness root route', () => {
|
||||
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/')).toBe(
|
||||
'http://127.0.0.1:9105/agents',
|
||||
)
|
||||
expect(buildAgentApiUrl('http://127.0.0.1:9105', '/adapters')).toBe(
|
||||
'http://127.0.0.1:9105/agents/adapters',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import { buildAgentApiUrl } from './agent-api-url'
|
||||
import {
|
||||
type AgentHarnessStreamEvent,
|
||||
type CreateHarnessAgentInput,
|
||||
type HarnessAdapterDescriptor,
|
||||
type HarnessAgent,
|
||||
type HarnessAgentHistoryPage,
|
||||
mapHarnessAgentToEntry,
|
||||
} from './agent-harness-types'
|
||||
|
||||
export type { AgentHarnessStreamEvent }
|
||||
|
||||
const AGENT_QUERY_KEYS = {
|
||||
adapters: 'agent-harness-adapters',
|
||||
agents: 'agent-harness-agents',
|
||||
} as const
|
||||
|
||||
async function agentsFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const res = await fetch(buildAgentApiUrl(baseUrl, path), init)
|
||||
if (!res.ok) {
|
||||
let message = `Request failed with status ${res.status}`
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function useAgentAdapters(enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<HarnessAdapterDescriptor[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.adapters, baseUrl],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<{ adapters: HarnessAdapterDescriptor[] }>(
|
||||
baseUrl as string,
|
||||
'/adapters',
|
||||
)
|
||||
return data.adapters ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
adapters: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useHarnessAgents(enabled = true) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<HarnessAgent[], Error>({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents, baseUrl],
|
||||
queryFn: async () => {
|
||||
const data = await agentsFetch<{ agents: HarnessAgent[] }>(
|
||||
baseUrl as string,
|
||||
'/',
|
||||
)
|
||||
return data.agents ?? []
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && enabled,
|
||||
})
|
||||
|
||||
return {
|
||||
agents: (query.data ?? []).map(mapHarnessAgentToEntry),
|
||||
harnessAgents: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCreateHarnessAgent() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (input: CreateHarnessAgentInput) => {
|
||||
if (!baseUrl || urlLoading) {
|
||||
throw new Error('BrowserOS agent server URL is not ready')
|
||||
}
|
||||
const data = await agentsFetch<{ agent: HarnessAgent }>(baseUrl, '/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
return data.agent
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteHarnessAgent() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (agentId: string) => {
|
||||
if (!baseUrl || urlLoading) {
|
||||
throw new Error('BrowserOS agent server URL is not ready')
|
||||
}
|
||||
return agentsFetch<{ success: boolean }>(
|
||||
baseUrl,
|
||||
`/${encodeURIComponent(agentId)}`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [AGENT_QUERY_KEYS.agents],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function chatWithHarnessAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/agents/${encodeURIComponent(agentId)}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchHarnessAgentHistory(
|
||||
agentId: string,
|
||||
): Promise<HarnessAgentHistoryPage> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return agentsFetch<HarnessAgentHistoryPage>(
|
||||
baseUrl,
|
||||
`/${encodeURIComponent(agentId)}/sessions/main/history`,
|
||||
)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface AgentEntry {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
source?: 'openclaw' | 'agent-harness'
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
@@ -98,7 +99,10 @@ async function fetchOpenClawStatus(baseUrl: string): Promise<OpenClawStatus> {
|
||||
|
||||
async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
const data = await clawFetch<{ agents: AgentEntry[] }>(baseUrl, '/agents')
|
||||
return data.agents ?? []
|
||||
return (data.agents ?? []).map((agent) => ({
|
||||
...agent,
|
||||
source: 'openclaw',
|
||||
}))
|
||||
}
|
||||
|
||||
async function invalidateOpenClawQueries(
|
||||
|
||||
@@ -75,6 +75,12 @@ export const MCP_EXTERNAL_ACCESS_DISABLED_EVENT =
|
||||
/** @public */
|
||||
export const MCP_SERVER_RESTARTED_EVENT = 'settings.mcp_server.restarted'
|
||||
|
||||
/** @public */
|
||||
export const AGENT_CREATED_EVENT = 'agents.agent.created'
|
||||
|
||||
/** @public */
|
||||
export const AGENT_DELETED_EVENT = 'agents.agent.deleted'
|
||||
|
||||
/** @public */
|
||||
export const NEW_SCHEDULED_TASK_CREATED_EVENT =
|
||||
'settings.scheduled_task.created'
|
||||
|
||||
@@ -82,7 +82,6 @@
|
||||
"@ai-sdk/openai-compatible": "^2.0.30",
|
||||
"@ai-sdk/provider": "^3.0.8",
|
||||
"@browseros-ai/agent-sdk": "workspace:*",
|
||||
"@huggingface/transformers": "^3.4.0",
|
||||
"@browseros/cdp-protocol": "workspace:*",
|
||||
"@browseros/shared": "workspace:*",
|
||||
"@google/gemini-cli-core": "^0.16.0",
|
||||
@@ -90,9 +89,11 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^1.19.6",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@huggingface/transformers": "^3.4.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@sentry/bun": "^10.31.0",
|
||||
"acpx": "0.6.1",
|
||||
"ai": "^6.0.94",
|
||||
"chrome-devtools-frontend": "^1.0.1577886",
|
||||
"commander": "^14.0.1",
|
||||
|
||||
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
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { AGENT_HARNESS_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { type Context, Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import {
|
||||
AGENT_ADAPTER_CATALOG,
|
||||
isAgentAdapter,
|
||||
isSupportedAgentModel,
|
||||
isSupportedReasoningEffort,
|
||||
} from '../../lib/agents/agent-catalog'
|
||||
import type {
|
||||
AgentAdapter,
|
||||
AgentDefinition,
|
||||
} from '../../lib/agents/agent-types'
|
||||
import type { AgentHistoryPage, AgentStreamEvent } from '../../lib/agents/types'
|
||||
import {
|
||||
AgentHarnessService,
|
||||
UnknownAgentError,
|
||||
} from '../services/agents/agent-harness-service'
|
||||
import type { Env } from '../types'
|
||||
|
||||
type AgentRouteService = {
|
||||
listAgents(): Promise<AgentDefinition[]>
|
||||
createAgent(input: {
|
||||
name: string
|
||||
adapter: AgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}): Promise<AgentDefinition>
|
||||
getAgent(agentId: string): Promise<AgentDefinition | null>
|
||||
deleteAgent(agentId: string): Promise<boolean>
|
||||
getHistory(agentId: string): Promise<AgentHistoryPage>
|
||||
send(input: {
|
||||
agentId: string
|
||||
message: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<ReadableStream<AgentStreamEvent>>
|
||||
}
|
||||
|
||||
type AgentRouteDeps = {
|
||||
service?: AgentRouteService
|
||||
}
|
||||
|
||||
export function createAgentRoutes(deps: AgentRouteDeps = {}) {
|
||||
const service = deps.service ?? new AgentHarnessService()
|
||||
|
||||
return new Hono<Env>()
|
||||
.get('/adapters', (c) => c.json({ adapters: AGENT_ADAPTER_CATALOG }))
|
||||
.get('/', async (c) => c.json({ agents: await service.listAgents() }))
|
||||
.post('/', async (c) => {
|
||||
const parsed = await parseCreateAgentBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
try {
|
||||
return c.json({ agent: await service.createAgent(parsed) })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId', async (c) => {
|
||||
try {
|
||||
const agent = await service.getAgent(c.req.param('agentId'))
|
||||
if (!agent) return c.json({ error: 'Unknown agent' }, 404)
|
||||
return c.json({ agent })
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.delete('/:agentId', async (c) => {
|
||||
try {
|
||||
return c.json({
|
||||
success: await service.deleteAgent(c.req.param('agentId')),
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.get('/:agentId/sessions/main/history', async (c) => {
|
||||
try {
|
||||
return c.json(await service.getHistory(c.req.param('agentId')))
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
})
|
||||
.post('/:agentId/chat', async (c) => {
|
||||
const agentId = c.req.param('agentId')
|
||||
const parsed = await parseChatBody(c)
|
||||
if ('error' in parsed) return c.json({ error: parsed.error }, 400)
|
||||
|
||||
let eventStream: ReadableStream<AgentStreamEvent>
|
||||
try {
|
||||
eventStream = await service.send({
|
||||
agentId,
|
||||
message: parsed.message,
|
||||
signal: c.req.raw.signal,
|
||||
})
|
||||
} catch (err) {
|
||||
return handleAgentRouteError(c, err)
|
||||
}
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Id', 'main')
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
let completed = false
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(encoder.encode(`data: ${JSON.stringify(value)}\n\n`))
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
completed = true
|
||||
} finally {
|
||||
if (completed) {
|
||||
reader.releaseLock()
|
||||
} else {
|
||||
await reader.cancel('BrowserOS HTTP stream ended').catch(() => {})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function parseCreateAgentBody(c: Context<Env>): Promise<
|
||||
| {
|
||||
name: string
|
||||
adapter: AgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
| { error: string }
|
||||
> {
|
||||
const body = await readJsonBody(c)
|
||||
if ('error' in body) return body
|
||||
const record = body.value
|
||||
const name = typeof record.name === 'string' ? record.name.trim() : ''
|
||||
if (!name) return { error: 'Name is required' }
|
||||
if (name.length > AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS) {
|
||||
return {
|
||||
error: `Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`,
|
||||
}
|
||||
}
|
||||
if (!isAgentAdapter(record.adapter)) {
|
||||
return { error: 'Invalid adapter' }
|
||||
}
|
||||
|
||||
const modelId =
|
||||
typeof record.modelId === 'string' && record.modelId.trim()
|
||||
? record.modelId.trim()
|
||||
: undefined
|
||||
const reasoningEffort =
|
||||
typeof record.reasoningEffort === 'string' && record.reasoningEffort.trim()
|
||||
? record.reasoningEffort.trim()
|
||||
: undefined
|
||||
|
||||
if (!isSupportedAgentModel(record.adapter, modelId)) {
|
||||
return { error: 'Invalid modelId' }
|
||||
}
|
||||
if (!isSupportedReasoningEffort(record.adapter, reasoningEffort)) {
|
||||
return { error: 'Invalid reasoningEffort' }
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
adapter: record.adapter,
|
||||
modelId,
|
||||
reasoningEffort,
|
||||
}
|
||||
}
|
||||
|
||||
async function parseChatBody(
|
||||
c: Context<Env>,
|
||||
): Promise<{ message: string } | { error: string }> {
|
||||
const body = await readJsonBody(c)
|
||||
if ('error' in body) return body
|
||||
const message =
|
||||
typeof body.value.message === 'string' ? body.value.message.trim() : ''
|
||||
return message ? { message } : { error: 'Message is required' }
|
||||
}
|
||||
|
||||
async function readJsonBody(
|
||||
c: Context<Env>,
|
||||
): Promise<{ value: Record<string, unknown> } | { error: string }> {
|
||||
let body: unknown
|
||||
try {
|
||||
body = await c.req.json()
|
||||
} catch {
|
||||
return { error: 'Invalid JSON body' }
|
||||
}
|
||||
|
||||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||||
return { error: 'JSON object body is required' }
|
||||
}
|
||||
return { value: body as Record<string, unknown> }
|
||||
}
|
||||
|
||||
function handleAgentRouteError(c: Context<Env>, err: unknown) {
|
||||
if (err instanceof UnknownAgentError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
|
||||
import { createAclRoutes } from './routes/acl'
|
||||
import { createAgentRoutes } from './routes/agents'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
@@ -128,6 +129,10 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createMonitoringRoutes())
|
||||
|
||||
const agentRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createAgentRoutes())
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
@@ -202,6 +207,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
browserosId,
|
||||
}),
|
||||
)
|
||||
.route('/agents', agentRoutes)
|
||||
.route('/claw', clawRoutes)
|
||||
|
||||
// Error handler
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { AcpxRuntime } from '../../../lib/agents/acpx-runtime'
|
||||
import type { AgentDefinition } from '../../../lib/agents/agent-types'
|
||||
import {
|
||||
type CreateAgentInput,
|
||||
FileAgentStore,
|
||||
} from '../../../lib/agents/file-agent-store'
|
||||
import { FileTranscriptStore } from '../../../lib/agents/file-transcript-store'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../lib/agents/types'
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: FileAgentStore
|
||||
private readonly transcriptStore: FileTranscriptStore
|
||||
private readonly runtime: AgentRuntime
|
||||
|
||||
constructor(
|
||||
deps: {
|
||||
agentStore?: FileAgentStore
|
||||
transcriptStore?: FileTranscriptStore
|
||||
runtime?: AgentRuntime
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new FileAgentStore()
|
||||
this.transcriptStore = deps.transcriptStore ?? new FileTranscriptStore()
|
||||
this.runtime = deps.runtime ?? new AcpxRuntime()
|
||||
}
|
||||
|
||||
listAgents(): Promise<AgentDefinition[]> {
|
||||
return this.agentStore.list()
|
||||
}
|
||||
|
||||
createAgent(input: CreateAgentInput): Promise<AgentDefinition> {
|
||||
return this.agentStore.create(input)
|
||||
}
|
||||
|
||||
deleteAgent(agentId: string): Promise<boolean> {
|
||||
return this.agentStore.delete(agentId)
|
||||
}
|
||||
|
||||
getAgent(agentId: string): Promise<AgentDefinition | null> {
|
||||
return this.agentStore.get(agentId)
|
||||
}
|
||||
|
||||
async getHistory(agentId: string): Promise<AgentHistoryPage> {
|
||||
const agent = await this.requireAgent(agentId)
|
||||
return {
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
items: await this.transcriptStore.list({
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async send(input: {
|
||||
agentId: string
|
||||
message: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
const agent = await this.requireAgent(input.agentId)
|
||||
await this.transcriptStore.append({
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
role: 'user',
|
||||
text: input.message,
|
||||
})
|
||||
const runtimeStream = await this.runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: input.message,
|
||||
permissionMode: agent.permissionMode,
|
||||
signal: input.signal,
|
||||
})
|
||||
return this.persistAssistantTranscript(agent, runtimeStream)
|
||||
}
|
||||
|
||||
private async requireAgent(agentId: string): Promise<AgentDefinition> {
|
||||
const agent = await this.agentStore.get(agentId)
|
||||
if (!agent) {
|
||||
throw new UnknownAgentError(agentId)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
private persistAssistantTranscript(
|
||||
agent: AgentDefinition,
|
||||
stream: ReadableStream<AgentStreamEvent>,
|
||||
): ReadableStream<AgentStreamEvent> {
|
||||
let reader: ReadableStreamDefaultReader<AgentStreamEvent> | null = null
|
||||
let assistantText = ''
|
||||
let transcriptFlushed = false
|
||||
|
||||
const flushAssistantTranscript = async () => {
|
||||
if (transcriptFlushed || !assistantText.trim()) return
|
||||
transcriptFlushed = true
|
||||
await this.transcriptStore.append({
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: assistantText,
|
||||
})
|
||||
}
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start: async (controller) => {
|
||||
reader = stream.getReader()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value.type === 'text_delta' && value.stream === 'output') {
|
||||
assistantText += value.text
|
||||
} else if (value.type === 'done' && !assistantText && value.text) {
|
||||
assistantText = value.text
|
||||
}
|
||||
controller.enqueue(value)
|
||||
}
|
||||
await flushAssistantTranscript()
|
||||
controller.close()
|
||||
} catch (err) {
|
||||
controller.error(err)
|
||||
} finally {
|
||||
reader?.releaseLock()
|
||||
}
|
||||
},
|
||||
cancel: async () => {
|
||||
await flushAssistantTranscript()
|
||||
await reader?.cancel('BrowserOS stream cancelled')
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class UnknownAgentError extends Error {
|
||||
constructor(readonly agentId: string) {
|
||||
super(`Unknown agent: ${agentId}`)
|
||||
this.name = 'UnknownAgentError'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
type AcpRuntimeEvent,
|
||||
type AcpRuntimeHandle,
|
||||
type AcpRuntimeOptions,
|
||||
type AcpRuntimeTurn,
|
||||
type AcpRuntimeTurnResult,
|
||||
type AcpRuntime as AcpxCoreRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
createRuntimeStore,
|
||||
} from 'acpx/runtime'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentPromptInput,
|
||||
AgentRuntime,
|
||||
AgentSession,
|
||||
AgentStatus,
|
||||
AgentStreamEvent,
|
||||
} from './types'
|
||||
|
||||
type AcpxRuntimeOptions = {
|
||||
cwd?: string
|
||||
stateDir?: string
|
||||
runtimeFactory?: (options: AcpRuntimeOptions) => AcpxCoreRuntime
|
||||
}
|
||||
|
||||
export class AcpxRuntime implements AgentRuntime {
|
||||
private readonly cwd: string
|
||||
private readonly stateDir: string
|
||||
private readonly runtimeFactory: (
|
||||
options: AcpRuntimeOptions,
|
||||
) => AcpxCoreRuntime
|
||||
private readonly runtimes = new Map<string, AcpxCoreRuntime>()
|
||||
|
||||
constructor(options: AcpxRuntimeOptions = {}) {
|
||||
this.cwd = options.cwd ?? process.cwd()
|
||||
this.stateDir =
|
||||
options.stateDir ??
|
||||
process.env.BROWSEROS_ACPX_STATE_DIR ??
|
||||
join(getBrowserosDir(), 'agents', 'acpx')
|
||||
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
|
||||
}
|
||||
|
||||
async status(): Promise<AgentStatus> {
|
||||
return { state: 'unknown', message: 'acpx status is checked on send' }
|
||||
}
|
||||
|
||||
async listSessions(
|
||||
input: AgentPromptInput['agent'],
|
||||
): Promise<AgentSession[]> {
|
||||
return [{ agentId: input.id, id: 'main', updatedAt: input.updatedAt }]
|
||||
}
|
||||
|
||||
async getHistory(input: {
|
||||
agent: AgentPromptInput['agent']
|
||||
sessionId: 'main'
|
||||
}): Promise<AgentHistoryPage> {
|
||||
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
|
||||
}
|
||||
|
||||
async send(
|
||||
input: AgentPromptInput,
|
||||
): Promise<ReadableStream<AgentStreamEvent>> {
|
||||
logger.info('Agent harness acpx send requested', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: input.sessionKey,
|
||||
cwd: this.cwd,
|
||||
stateDir: this.stateDir,
|
||||
permissionMode: input.permissionMode,
|
||||
modelId: input.agent.modelId,
|
||||
reasoningEffort: input.agent.reasoningEffort,
|
||||
messageLength: input.message.length,
|
||||
})
|
||||
const runtime = this.getRuntime({
|
||||
cwd: this.cwd,
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: 'fail',
|
||||
})
|
||||
|
||||
return createAcpxEventStream(runtime, input, this.cwd)
|
||||
}
|
||||
|
||||
private getRuntime(input: {
|
||||
cwd: string
|
||||
permissionMode: AcpRuntimeOptions['permissionMode']
|
||||
nonInteractivePermissions: AcpRuntimeOptions['nonInteractivePermissions']
|
||||
}): AcpxCoreRuntime {
|
||||
const key = JSON.stringify(input)
|
||||
const existing = this.runtimes.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
const runtime = this.runtimeFactory({
|
||||
cwd: input.cwd,
|
||||
sessionStore: createRuntimeStore({ stateDir: this.stateDir }),
|
||||
agentRegistry: createAgentRegistry(),
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
})
|
||||
this.runtimes.set(key, runtime)
|
||||
logger.debug('Agent harness acpx runtime created', {
|
||||
cwd: input.cwd,
|
||||
stateDir: this.stateDir,
|
||||
permissionMode: input.permissionMode,
|
||||
nonInteractivePermissions: input.nonInteractivePermissions,
|
||||
})
|
||||
return runtime
|
||||
}
|
||||
}
|
||||
|
||||
function createAcpxEventStream(
|
||||
runtime: AcpxCoreRuntime,
|
||||
input: AgentPromptInput,
|
||||
cwd: string,
|
||||
): ReadableStream<AgentStreamEvent> {
|
||||
let activeTurn: AcpRuntimeTurn | null = null
|
||||
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
const run = async () => {
|
||||
const handle = await runtime.ensureSession({
|
||||
sessionKey: input.sessionKey,
|
||||
agent: input.agent.adapter,
|
||||
mode: 'persistent',
|
||||
cwd,
|
||||
})
|
||||
logger.info('Agent harness acpx session ensured', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionKey: input.sessionKey,
|
||||
backendSessionId: handle.backendSessionId,
|
||||
agentSessionId: handle.agentSessionId,
|
||||
acpxRecordId: handle.acpxRecordId,
|
||||
cwd,
|
||||
})
|
||||
|
||||
for (const event of await applyRuntimeControls(
|
||||
runtime,
|
||||
handle,
|
||||
input,
|
||||
)) {
|
||||
controller.enqueue(event)
|
||||
}
|
||||
|
||||
const turn = runtime.startTurn({
|
||||
handle,
|
||||
text: input.message,
|
||||
mode: 'prompt',
|
||||
requestId: crypto.randomUUID(),
|
||||
timeoutMs: input.timeoutMs,
|
||||
signal: input.signal,
|
||||
})
|
||||
activeTurn = turn
|
||||
for await (const event of turn.events) {
|
||||
controller.enqueue(mapRuntimeEvent(event))
|
||||
}
|
||||
controller.enqueue(mapTurnResult(await turn.result))
|
||||
logger.info('Agent harness acpx turn completed', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionKey: input.sessionKey,
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
|
||||
void run().catch((err) => {
|
||||
logger.error('Agent harness acpx turn failed', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionKey: input.sessionKey,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
controller.close()
|
||||
})
|
||||
},
|
||||
cancel() {
|
||||
void activeTurn?.cancel({ reason: 'BrowserOS stream cancelled' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function applyRuntimeControls(
|
||||
runtime: AcpxCoreRuntime,
|
||||
handle: AcpRuntimeHandle,
|
||||
input: AgentPromptInput,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = []
|
||||
if (input.agent.modelId && input.agent.modelId !== 'default') {
|
||||
events.push({
|
||||
type: 'status',
|
||||
text: 'Requested model is stored on the BrowserOS agent, but this acpx/runtime version does not expose public model control. Using adapter default.',
|
||||
})
|
||||
}
|
||||
if (!input.agent.reasoningEffort) return events
|
||||
|
||||
const key = input.agent.adapter === 'codex' ? 'reasoning_effort' : 'effort'
|
||||
if (!runtime.setConfigOption) {
|
||||
events.push({
|
||||
type: 'status',
|
||||
text: `Requested ${key}=${input.agent.reasoningEffort}, but this acpx/runtime version does not expose config control.`,
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
try {
|
||||
await runtime.setConfigOption({
|
||||
handle,
|
||||
key,
|
||||
value: input.agent.reasoningEffort,
|
||||
})
|
||||
logger.debug('Agent harness acpx config applied', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionKey: input.sessionKey,
|
||||
key,
|
||||
value: input.agent.reasoningEffort,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn('Agent harness acpx config unavailable', {
|
||||
agentId: input.agent.id,
|
||||
adapter: input.agent.adapter,
|
||||
sessionKey: input.sessionKey,
|
||||
key,
|
||||
value: input.agent.reasoningEffort,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
events.push({
|
||||
type: 'status',
|
||||
text: `Could not apply ${key}=${input.agent.reasoningEffort}; continuing with the adapter default. ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
})
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
function mapRuntimeEvent(event: AcpRuntimeEvent): AgentStreamEvent {
|
||||
switch (event.type) {
|
||||
case 'text_delta':
|
||||
return {
|
||||
type: 'text_delta',
|
||||
text: event.text,
|
||||
stream: event.stream ?? 'output',
|
||||
rawType: event.tag,
|
||||
}
|
||||
case 'tool_call':
|
||||
return {
|
||||
type: 'tool_call',
|
||||
text: event.text,
|
||||
title: event.title ?? 'tool call',
|
||||
id: event.toolCallId,
|
||||
status: event.status,
|
||||
rawType: event.tag,
|
||||
}
|
||||
case 'status':
|
||||
return {
|
||||
type: 'status',
|
||||
text: event.text,
|
||||
rawType: event.tag,
|
||||
}
|
||||
case 'done':
|
||||
return {
|
||||
type: 'done',
|
||||
stopReason: event.stopReason,
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
type: 'error',
|
||||
message: event.message,
|
||||
code: event.code,
|
||||
}
|
||||
default: {
|
||||
const exhaustive: never = event
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapTurnResult(result: AcpRuntimeTurnResult): AgentStreamEvent {
|
||||
switch (result.status) {
|
||||
case 'completed':
|
||||
return { type: 'done', stopReason: result.stopReason }
|
||||
case 'cancelled':
|
||||
return { type: 'done', stopReason: result.stopReason ?? 'cancelled' }
|
||||
case 'failed':
|
||||
return {
|
||||
type: 'error',
|
||||
message: result.error.message,
|
||||
code: result.error.code,
|
||||
}
|
||||
default: {
|
||||
const exhaustive: never = result
|
||||
return exhaustive
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type { AgentAdapter, AgentAdapterDescriptor } from './agent-types'
|
||||
|
||||
export const AGENT_ADAPTER_CATALOG: AgentAdapterDescriptor[] = [
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
defaultModelId: 'haiku',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
models: [
|
||||
{ id: 'opus', label: 'Opus' },
|
||||
{ id: 'sonnet', label: 'Sonnet' },
|
||||
{ id: 'haiku', label: 'Haiku', recommended: true },
|
||||
],
|
||||
reasoningEfforts: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra high' },
|
||||
{ id: 'max', label: 'Max' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
defaultModelId: 'gpt-5.5',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
models: [{ id: 'gpt-5.5', label: 'GPT-5.5', recommended: true }],
|
||||
reasoningEfforts: [
|
||||
{ id: 'low', label: 'Low' },
|
||||
{ id: 'medium', label: 'Medium', recommended: true },
|
||||
{ id: 'high', label: 'High' },
|
||||
{ id: 'xhigh', label: 'Extra high' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export function getAgentAdapterDescriptor(
|
||||
adapter: AgentAdapter,
|
||||
): AgentAdapterDescriptor | null {
|
||||
return AGENT_ADAPTER_CATALOG.find((entry) => entry.id === adapter) ?? null
|
||||
}
|
||||
|
||||
export function isAgentAdapter(value: unknown): value is AgentAdapter {
|
||||
return value === 'claude' || value === 'codex'
|
||||
}
|
||||
|
||||
export function resolveDefaultModelId(adapter: AgentAdapter): string {
|
||||
return getAgentAdapterDescriptor(adapter)?.defaultModelId ?? 'default'
|
||||
}
|
||||
|
||||
export function resolveDefaultReasoningEffort(adapter: AgentAdapter): string {
|
||||
return getAgentAdapterDescriptor(adapter)?.defaultReasoningEffort ?? 'medium'
|
||||
}
|
||||
|
||||
export function isSupportedAgentModel(
|
||||
adapter: AgentAdapter,
|
||||
modelId: string | undefined,
|
||||
): boolean {
|
||||
if (!modelId || modelId === 'default') return true
|
||||
const descriptor = getAgentAdapterDescriptor(adapter)
|
||||
return Boolean(descriptor?.models.some((model) => model.id === modelId))
|
||||
}
|
||||
|
||||
export function isSupportedReasoningEffort(
|
||||
adapter: AgentAdapter,
|
||||
effort: string | undefined,
|
||||
): boolean {
|
||||
if (!effort) return true
|
||||
const descriptor = getAgentAdapterDescriptor(adapter)
|
||||
return Boolean(
|
||||
descriptor?.reasoningEfforts.some((option) => option.id === effort),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type AgentAdapter = 'claude' | 'codex'
|
||||
|
||||
export type AgentPermissionMode = 'approve-all'
|
||||
|
||||
export interface AgentDefinition {
|
||||
id: string
|
||||
name: string
|
||||
adapter: AgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
permissionMode: AgentPermissionMode
|
||||
sessionKey: string
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AgentAdapterDescriptor {
|
||||
id: AgentAdapter
|
||||
name: string
|
||||
defaultModelId: string
|
||||
defaultReasoningEffort: string
|
||||
modelControl: 'runtime-supported' | 'best-effort'
|
||||
models: Array<{
|
||||
id: string
|
||||
label: string
|
||||
recommended?: boolean
|
||||
}>
|
||||
reasoningEfforts: Array<{
|
||||
id: string
|
||||
label: string
|
||||
recommended?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AgentTranscriptEntry {
|
||||
id: string
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
createdAt: number
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import {
|
||||
resolveDefaultModelId,
|
||||
resolveDefaultReasoningEffort,
|
||||
} from './agent-catalog'
|
||||
import type { AgentAdapter, AgentDefinition } from './agent-types'
|
||||
|
||||
interface AgentStoreFile {
|
||||
version: 1
|
||||
agents: AgentDefinition[]
|
||||
}
|
||||
|
||||
export interface CreateAgentInput {
|
||||
name: string
|
||||
adapter: AgentAdapter
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
export class FileAgentStore {
|
||||
private readonly filePath: string
|
||||
private writeQueue: Promise<unknown> = Promise.resolve()
|
||||
|
||||
constructor(options: { filePath?: string } = {}) {
|
||||
this.filePath =
|
||||
options.filePath ??
|
||||
join(getBrowserosDir(), 'agents', 'harness', 'agents.json')
|
||||
}
|
||||
|
||||
async list(): Promise<AgentDefinition[]> {
|
||||
const file = await this.read()
|
||||
const agents = [...file.agents].sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
logger.debug('Agent harness store listed agents', {
|
||||
count: agents.length,
|
||||
filePath: this.filePath,
|
||||
})
|
||||
return agents
|
||||
}
|
||||
|
||||
async get(id: string): Promise<AgentDefinition | null> {
|
||||
const file = await this.read()
|
||||
const agent = file.agents.find((entry) => entry.id === id) ?? null
|
||||
logger.debug('Agent harness store loaded agent', {
|
||||
agentId: id,
|
||||
found: Boolean(agent),
|
||||
adapter: agent?.adapter,
|
||||
filePath: this.filePath,
|
||||
})
|
||||
return agent
|
||||
}
|
||||
|
||||
async create(input: CreateAgentInput): Promise<AgentDefinition> {
|
||||
return this.withWriteLock(async () => {
|
||||
const now = Date.now()
|
||||
const id = randomUUID()
|
||||
const agent: AgentDefinition = {
|
||||
id,
|
||||
name: input.name.trim(),
|
||||
adapter: input.adapter,
|
||||
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
|
||||
reasoningEffort:
|
||||
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: `agent:${id}:main`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
const file = await this.read()
|
||||
await this.write({ ...file, agents: [...file.agents, agent] })
|
||||
logger.info('Agent harness store created agent', {
|
||||
agentId: agent.id,
|
||||
name: agent.name,
|
||||
adapter: agent.adapter,
|
||||
modelId: agent.modelId,
|
||||
reasoningEffort: agent.reasoningEffort,
|
||||
sessionKey: agent.sessionKey,
|
||||
filePath: this.filePath,
|
||||
})
|
||||
return agent
|
||||
})
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
return this.withWriteLock(async () => {
|
||||
const file = await this.read()
|
||||
const agents = file.agents.filter((agent) => agent.id !== id)
|
||||
if (agents.length === file.agents.length) return false
|
||||
await this.write({ ...file, agents })
|
||||
logger.info('Agent harness store deleted agent', {
|
||||
agentId: id,
|
||||
filePath: this.filePath,
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
private async read(): Promise<AgentStoreFile> {
|
||||
try {
|
||||
const raw = await readFile(this.filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as AgentStoreFile
|
||||
if (parsed.version !== 1 || !Array.isArray(parsed.agents)) {
|
||||
return emptyStoreFile()
|
||||
}
|
||||
return parsed
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) return emptyStoreFile()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async write(file: AgentStoreFile): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true })
|
||||
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8')
|
||||
await rename(tmpPath, this.filePath)
|
||||
}
|
||||
|
||||
private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const result = this.writeQueue.then(fn, fn)
|
||||
this.writeQueue = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
function emptyStoreFile(): AgentStoreFile {
|
||||
return { version: 1, agents: [] }
|
||||
}
|
||||
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import type { AgentTranscriptEntry } from './agent-types'
|
||||
|
||||
export interface TranscriptListInput {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
}
|
||||
|
||||
export interface TranscriptAppendInput {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
}
|
||||
|
||||
export class FileTranscriptStore {
|
||||
private readonly rootDir: string
|
||||
|
||||
constructor(options: { rootDir?: string } = {}) {
|
||||
this.rootDir =
|
||||
options.rootDir ??
|
||||
join(getBrowserosDir(), 'agents', 'harness', 'transcripts')
|
||||
}
|
||||
|
||||
async append(input: TranscriptAppendInput): Promise<AgentTranscriptEntry> {
|
||||
const entry: AgentTranscriptEntry = {
|
||||
id: randomUUID(),
|
||||
agentId: input.agentId,
|
||||
sessionId: input.sessionId,
|
||||
role: input.role,
|
||||
text: input.text,
|
||||
createdAt: Date.now(),
|
||||
}
|
||||
const filePath = this.pathFor(input)
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await appendFile(filePath, `${JSON.stringify(entry)}\n`, 'utf8')
|
||||
logger.debug('Agent harness transcript appended entry', {
|
||||
agentId: entry.agentId,
|
||||
sessionId: entry.sessionId,
|
||||
role: entry.role,
|
||||
textLength: entry.text.length,
|
||||
filePath,
|
||||
})
|
||||
return entry
|
||||
}
|
||||
|
||||
async list(input: TranscriptListInput): Promise<AgentTranscriptEntry[]> {
|
||||
try {
|
||||
const raw = await readFile(this.pathFor(input), 'utf8')
|
||||
const entries = raw
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => this.parseLine(line, input))
|
||||
.filter((entry): entry is AgentTranscriptEntry => entry !== null)
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
logger.debug('Agent harness transcript listed entries', {
|
||||
agentId: input.agentId,
|
||||
sessionId: input.sessionId,
|
||||
count: entries.length,
|
||||
filePath: this.pathFor(input),
|
||||
})
|
||||
return entries
|
||||
} catch (err) {
|
||||
if (isNotFoundError(err)) {
|
||||
logger.debug('Agent harness transcript file missing', {
|
||||
agentId: input.agentId,
|
||||
sessionId: input.sessionId,
|
||||
filePath: this.pathFor(input),
|
||||
})
|
||||
return []
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private pathFor(input: TranscriptListInput): string {
|
||||
return join(this.rootDir, input.agentId, `${input.sessionId}.jsonl`)
|
||||
}
|
||||
|
||||
private parseLine(
|
||||
line: string,
|
||||
input: TranscriptListInput,
|
||||
): AgentTranscriptEntry | null {
|
||||
try {
|
||||
return JSON.parse(line) as AgentTranscriptEntry
|
||||
} catch (err) {
|
||||
logger.warn('Agent harness transcript skipped malformed line', {
|
||||
agentId: input.agentId,
|
||||
sessionId: input.sessionId,
|
||||
filePath: this.pathFor(input),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isNotFoundError(err: unknown): boolean {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'code' in err &&
|
||||
err.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
84
packages/browseros-agent/apps/server/src/lib/agents/types.ts
Normal file
84
packages/browseros-agent/apps/server/src/lib/agents/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentPermissionMode,
|
||||
AgentTranscriptEntry,
|
||||
} from './agent-types'
|
||||
|
||||
export interface AgentStatus {
|
||||
state: 'ready' | 'unknown' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
agentId: string
|
||||
id: 'main'
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface AgentHistoryPage {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
items: AgentTranscriptEntry[]
|
||||
}
|
||||
|
||||
export type AgentStreamEvent =
|
||||
| {
|
||||
type: 'text_delta'
|
||||
text: string
|
||||
stream: 'output' | 'thought'
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'tool_call'
|
||||
text: string
|
||||
title: string
|
||||
id?: string
|
||||
status?: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'status'
|
||||
text: string
|
||||
rawType?: string
|
||||
}
|
||||
| {
|
||||
type: 'done'
|
||||
text?: string
|
||||
stopReason?: string
|
||||
}
|
||||
| {
|
||||
type: 'error'
|
||||
message: string
|
||||
code?: string
|
||||
}
|
||||
|
||||
export interface AgentPromptInput {
|
||||
agent: AgentDefinition
|
||||
sessionId: 'main'
|
||||
sessionKey: string
|
||||
message: string
|
||||
permissionMode: AgentPermissionMode
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface AgentRuntime {
|
||||
status(agent: AgentDefinition): Promise<AgentStatus>
|
||||
listSessions(agent: AgentDefinition): Promise<AgentSession[]>
|
||||
getHistory(input: {
|
||||
agent: AgentDefinition
|
||||
sessionId: 'main'
|
||||
}): Promise<AgentHistoryPage>
|
||||
send(input: AgentPromptInput): Promise<ReadableStream<AgentStreamEvent>>
|
||||
cancel?(input: {
|
||||
agent: AgentDefinition
|
||||
sessionId: 'main'
|
||||
reason?: string
|
||||
}): Promise<void>
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { AGENT_HARNESS_LIMITS } from '@browseros/shared/constants/limits'
|
||||
import { Hono } from 'hono'
|
||||
import { createAgentRoutes } from '../../../src/api/routes/agents'
|
||||
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
|
||||
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
|
||||
|
||||
describe('createAgentRoutes', () => {
|
||||
it('creates and lists harness agents', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const route = createMountedRoutes(agents)
|
||||
const created = await route.request('/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
expect(await created.json()).toMatchObject({
|
||||
agent: { name: 'Review bot', adapter: 'codex' },
|
||||
})
|
||||
|
||||
const list = await route.request('/agents')
|
||||
expect(await list.json()).toMatchObject({
|
||||
agents: [{ name: 'Review bot', adapter: 'codex' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('streams chat for an agent main session', async () => {
|
||||
const route = createMountedRoutes([
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
},
|
||||
])
|
||||
|
||||
const response = await route.request('/agents/agent-1/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'hi' }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('X-Session-Id')).toBe('main')
|
||||
expect(await response.text()).toContain('data: [DONE]')
|
||||
})
|
||||
|
||||
it('rejects overlong agent names', async () => {
|
||||
const route = createMountedRoutes([])
|
||||
const response = await route.request('/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'a'.repeat(AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS + 1),
|
||||
adapter: 'codex',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: `Name must be ${AGENT_HARNESS_LIMITS.AGENT_NAME_MAX_CHARS} characters or fewer`,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createMountedRoutes(agents: AgentDefinition[]) {
|
||||
return new Hono().route(
|
||||
'/agents',
|
||||
createAgentRoutes({ service: createFakeService(agents) }),
|
||||
)
|
||||
}
|
||||
|
||||
function createFakeService(agents: AgentDefinition[]) {
|
||||
return {
|
||||
async listAgents() {
|
||||
return agents
|
||||
},
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
adapter: 'claude' | 'codex'
|
||||
modelId?: string
|
||||
reasoningEffort?: string
|
||||
}) {
|
||||
const agent: AgentDefinition = {
|
||||
id: `agent-${agents.length + 1}`,
|
||||
name: input.name,
|
||||
adapter: input.adapter,
|
||||
modelId: input.modelId,
|
||||
reasoningEffort: input.reasoningEffort,
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: `agent:agent-${agents.length + 1}:main`,
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
agents.push(agent)
|
||||
return agent
|
||||
},
|
||||
async getAgent(agentId: string) {
|
||||
return agents.find((agent) => agent.id === agentId) ?? null
|
||||
},
|
||||
async deleteAgent(agentId: string) {
|
||||
const index = agents.findIndex((agent) => agent.id === agentId)
|
||||
if (index < 0) return false
|
||||
agents.splice(index, 1)
|
||||
return true
|
||||
},
|
||||
async getHistory(agentId: string) {
|
||||
return {
|
||||
agentId,
|
||||
sessionId: 'main' as const,
|
||||
items: [],
|
||||
}
|
||||
},
|
||||
async send() {
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'Hello',
|
||||
stream: 'output',
|
||||
})
|
||||
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentTranscriptEntry,
|
||||
} from '../../../../src/lib/agents/agent-types'
|
||||
import type { FileAgentStore } from '../../../../src/lib/agents/file-agent-store'
|
||||
import type { FileTranscriptStore } from '../../../../src/lib/agents/file-transcript-store'
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentStreamEvent,
|
||||
} from '../../../../src/lib/agents/types'
|
||||
|
||||
describe('AgentHarnessService', () => {
|
||||
it('creates named agents and sends prompts through the main session', async () => {
|
||||
const agents: AgentDefinition[] = []
|
||||
const transcripts: AgentTranscriptEntry[] = []
|
||||
const runtimeInputs: unknown[] = []
|
||||
const agentStore = {
|
||||
async list() {
|
||||
return agents
|
||||
},
|
||||
async get(id: string) {
|
||||
return agents.find((agent) => agent.id === id) ?? null
|
||||
},
|
||||
async create(input) {
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: input.name,
|
||||
adapter: input.adapter,
|
||||
modelId: input.modelId,
|
||||
reasoningEffort: input.reasoningEffort,
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
agents.push(agent)
|
||||
return agent
|
||||
},
|
||||
async delete() {
|
||||
return true
|
||||
},
|
||||
} satisfies Partial<FileAgentStore>
|
||||
const transcriptStore = {
|
||||
async append(input) {
|
||||
const entry: AgentTranscriptEntry = {
|
||||
id: String(transcripts.length + 1),
|
||||
createdAt: 1000 + transcripts.length,
|
||||
...input,
|
||||
}
|
||||
transcripts.push(entry)
|
||||
return entry
|
||||
},
|
||||
async list() {
|
||||
return transcripts
|
||||
},
|
||||
} satisfies Partial<FileTranscriptStore>
|
||||
const runtime: AgentRuntime = {
|
||||
async status() {
|
||||
return { state: 'ready' }
|
||||
},
|
||||
async listSessions() {
|
||||
return []
|
||||
},
|
||||
async getHistory() {
|
||||
return { agentId: 'agent-1', sessionId: 'main', items: [] }
|
||||
},
|
||||
async send(input) {
|
||||
runtimeInputs.push(input)
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'answer',
|
||||
stream: 'output',
|
||||
})
|
||||
controller.enqueue({ type: 'done', stopReason: 'end_turn' })
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: agentStore as FileAgentStore,
|
||||
transcriptStore: transcriptStore as FileTranscriptStore,
|
||||
runtime,
|
||||
})
|
||||
|
||||
const agent = await service.createAgent({
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
})
|
||||
const stream = await service.send({
|
||||
agentId: agent.id,
|
||||
message: 'hello',
|
||||
})
|
||||
await stream.pipeTo(new WritableStream())
|
||||
|
||||
expect(runtimeInputs[0]).toMatchObject({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
message: 'hello',
|
||||
permissionMode: 'approve-all',
|
||||
})
|
||||
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'answer' },
|
||||
])
|
||||
})
|
||||
|
||||
it('flushes partial assistant text when the response stream is cancelled', async () => {
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
const transcripts: AgentTranscriptEntry[] = []
|
||||
const agentStore = {
|
||||
async list() {
|
||||
return [agent]
|
||||
},
|
||||
async get(id: string) {
|
||||
return id === agent.id ? agent : null
|
||||
},
|
||||
async create() {
|
||||
return agent
|
||||
},
|
||||
async delete() {
|
||||
return true
|
||||
},
|
||||
} satisfies Partial<FileAgentStore>
|
||||
const transcriptStore = {
|
||||
async append(input) {
|
||||
const entry: AgentTranscriptEntry = {
|
||||
id: String(transcripts.length + 1),
|
||||
createdAt: 1000 + transcripts.length,
|
||||
...input,
|
||||
}
|
||||
transcripts.push(entry)
|
||||
return entry
|
||||
},
|
||||
async list() {
|
||||
return transcripts
|
||||
},
|
||||
} satisfies Partial<FileTranscriptStore>
|
||||
const runtime: AgentRuntime = {
|
||||
async status() {
|
||||
return { state: 'ready' }
|
||||
},
|
||||
async listSessions() {
|
||||
return []
|
||||
},
|
||||
async getHistory() {
|
||||
return { agentId: agent.id, sessionId: 'main', items: [] }
|
||||
},
|
||||
async send() {
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'partial answer',
|
||||
stream: 'output',
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: agentStore as FileAgentStore,
|
||||
transcriptStore: transcriptStore as FileTranscriptStore,
|
||||
runtime,
|
||||
})
|
||||
|
||||
const reader = (
|
||||
await service.send({
|
||||
agentId: agent.id,
|
||||
message: 'hello',
|
||||
})
|
||||
).getReader()
|
||||
await reader.read()
|
||||
await reader.cancel()
|
||||
|
||||
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'partial answer' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type {
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeOptions,
|
||||
AcpRuntime as AcpxCoreRuntime,
|
||||
} from 'acpx/runtime'
|
||||
import { AcpxRuntime } from '../../../src/lib/agents/acpx-runtime'
|
||||
import type { AgentDefinition } from '../../../src/lib/agents/agent-types'
|
||||
import type { AgentStreamEvent } from '../../../src/lib/agents/types'
|
||||
|
||||
describe('AcpxRuntime', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('uses acpx/runtime to ensure a session and stream a turn', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtimeFactory = (options: AcpRuntimeOptions): AcpxCoreRuntime => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
}
|
||||
|
||||
const runtime = new AcpxRuntime({ cwd, stateDir, runtimeFactory })
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
const stream = await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'say hello',
|
||||
permissionMode: 'approve-all',
|
||||
})
|
||||
|
||||
const events = await collectStream(stream)
|
||||
|
||||
expect(calls.map((call) => call.method)).toEqual([
|
||||
'createRuntime',
|
||||
'ensureSession',
|
||||
'setConfigOption',
|
||||
'startTurn',
|
||||
])
|
||||
expect(calls[0]?.input).toMatchObject({
|
||||
cwd,
|
||||
permissionMode: 'approve-all',
|
||||
nonInteractivePermissions: 'fail',
|
||||
})
|
||||
expect(calls[1]?.input).toEqual({
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
agent: 'codex',
|
||||
mode: 'persistent',
|
||||
cwd,
|
||||
})
|
||||
expect(calls[2]?.input).toMatchObject({
|
||||
key: 'reasoning_effort',
|
||||
value: 'medium',
|
||||
})
|
||||
expect(calls[3]?.input).toMatchObject({
|
||||
text: 'say hello',
|
||||
mode: 'prompt',
|
||||
})
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: 'status',
|
||||
text: 'Requested model is stored on the BrowserOS agent, but this acpx/runtime version does not expose public model control. Using adapter default.',
|
||||
},
|
||||
{
|
||||
type: 'text_delta',
|
||||
text: 'Hello from fake runtime',
|
||||
stream: 'output',
|
||||
rawType: 'agent_message_chunk',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
text: 'Run tests (completed)',
|
||||
title: 'Run tests',
|
||||
id: 'tool-1',
|
||||
status: 'completed',
|
||||
rawType: 'tool_call_update',
|
||||
},
|
||||
{
|
||||
type: 'done',
|
||||
stopReason: 'end_turn',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('continues the turn when runtime config control is unavailable', async () => {
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
runtimeFactory: () => createFakeAcpRuntime(calls, { failConfig: true }),
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Claude bot',
|
||||
adapter: 'claude',
|
||||
modelId: 'haiku',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
const events = await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'say hello',
|
||||
permissionMode: 'approve-all',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
'status',
|
||||
'status',
|
||||
'text_delta',
|
||||
'tool_call',
|
||||
'done',
|
||||
])
|
||||
expect(events[1]).toMatchObject({
|
||||
type: 'status',
|
||||
text: expect.stringContaining('Could not apply effort=medium'),
|
||||
})
|
||||
})
|
||||
|
||||
it('reuses cached runtime instances across per-turn timeouts', async () => {
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
cwd: '/tmp/browseros-acpx-runtime',
|
||||
stateDir: '/tmp/browseros-acpx-state',
|
||||
runtimeFactory: (options) => {
|
||||
calls.push({ method: 'createRuntime', input: options })
|
||||
return createFakeAcpRuntime(calls)
|
||||
},
|
||||
})
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Codex bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: 'agent:agent-1:main',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
}
|
||||
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'first',
|
||||
permissionMode: 'approve-all',
|
||||
timeoutMs: 1_000,
|
||||
}),
|
||||
)
|
||||
await collectStream(
|
||||
await runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
message: 'second',
|
||||
permissionMode: 'approve-all',
|
||||
timeoutMs: 2_000,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
calls.filter((call) => call.method === 'createRuntime'),
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
calls
|
||||
.filter((call) => call.method === 'startTurn')
|
||||
.map((call) => (call.input as { timeoutMs?: number }).timeoutMs),
|
||||
).toEqual([1_000, 2_000])
|
||||
})
|
||||
})
|
||||
|
||||
function createFakeAcpRuntime(
|
||||
calls: Array<{ method: string; input: unknown }>,
|
||||
options: { failConfig?: boolean } = {},
|
||||
): AcpxCoreRuntime {
|
||||
return {
|
||||
async ensureSession(input) {
|
||||
calls.push({ method: 'ensureSession', input })
|
||||
return {
|
||||
sessionKey: input.sessionKey,
|
||||
backend: 'acpx',
|
||||
runtimeSessionName: 'encoded-runtime-state',
|
||||
cwd: input.cwd,
|
||||
acpxRecordId: 'record-1',
|
||||
} satisfies AcpRuntimeHandle
|
||||
},
|
||||
startTurn(input) {
|
||||
calls.push({ method: 'startTurn', input })
|
||||
return {
|
||||
requestId: input.requestId,
|
||||
events: iterableEvents([
|
||||
{
|
||||
type: 'text_delta',
|
||||
text: 'Hello from fake runtime',
|
||||
stream: 'output',
|
||||
tag: 'agent_message_chunk',
|
||||
},
|
||||
{
|
||||
type: 'tool_call',
|
||||
text: 'Run tests (completed)',
|
||||
title: 'Run tests',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'completed',
|
||||
tag: 'tool_call_update',
|
||||
},
|
||||
]),
|
||||
result: Promise.resolve({
|
||||
status: 'completed',
|
||||
stopReason: 'end_turn',
|
||||
}),
|
||||
async cancel() {},
|
||||
async closeStream() {},
|
||||
}
|
||||
},
|
||||
async *runTurn() {},
|
||||
async setConfigOption(input) {
|
||||
calls.push({ method: 'setConfigOption', input })
|
||||
if (options.failConfig) {
|
||||
throw new Error('config key is not supported')
|
||||
}
|
||||
},
|
||||
async cancel() {},
|
||||
async close() {},
|
||||
}
|
||||
}
|
||||
|
||||
async function* iterableEvents(events: AcpRuntimeEvent[]) {
|
||||
for (const event of events) yield event
|
||||
}
|
||||
|
||||
async function collectStream(
|
||||
stream: ReadableStream<AgentStreamEvent>,
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const reader = stream.getReader()
|
||||
const events: AgentStreamEvent[] = []
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
events.push(value)
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
return events
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
AGENT_ADAPTER_CATALOG,
|
||||
getAgentAdapterDescriptor,
|
||||
isSupportedAgentModel,
|
||||
isSupportedReasoningEffort,
|
||||
} from '../../../src/lib/agents/agent-catalog'
|
||||
|
||||
describe('AGENT_ADAPTER_CATALOG', () => {
|
||||
it('exposes Claude and Codex adapters with model and effort options', () => {
|
||||
expect(AGENT_ADAPTER_CATALOG.map((adapter) => adapter.id)).toEqual([
|
||||
'claude',
|
||||
'codex',
|
||||
])
|
||||
|
||||
expect(getAgentAdapterDescriptor('claude')).toMatchObject({
|
||||
id: 'claude',
|
||||
name: 'Claude Code',
|
||||
defaultModelId: 'haiku',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
})
|
||||
|
||||
expect(getAgentAdapterDescriptor('codex')).toMatchObject({
|
||||
id: 'codex',
|
||||
name: 'Codex',
|
||||
defaultModelId: 'gpt-5.5',
|
||||
defaultReasoningEffort: 'medium',
|
||||
modelControl: 'best-effort',
|
||||
})
|
||||
|
||||
expect(isSupportedAgentModel('claude', 'haiku')).toBe(true)
|
||||
expect(isSupportedAgentModel('codex', 'gpt-5.5')).toBe(true)
|
||||
expect(isSupportedReasoningEffort('codex', 'xhigh')).toBe(true)
|
||||
expect(isSupportedReasoningEffort('claude', 'banana')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { FileAgentStore } from '../../../src/lib/agents/file-agent-store'
|
||||
|
||||
describe('FileAgentStore', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('creates, lists, loads, and deletes named agents', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
|
||||
tempDirs.push(dir)
|
||||
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
|
||||
|
||||
const agent = await store.create({
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
})
|
||||
|
||||
expect(agent).toMatchObject({
|
||||
name: 'Review bot',
|
||||
adapter: 'codex',
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
permissionMode: 'approve-all',
|
||||
sessionKey: `agent:${agent.id}:main`,
|
||||
})
|
||||
expect(await store.list()).toEqual([agent])
|
||||
expect(await store.get(agent.id)).toEqual(agent)
|
||||
|
||||
await store.delete(agent.id)
|
||||
expect(await store.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('serializes concurrent creates without dropping agents', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
|
||||
tempDirs.push(dir)
|
||||
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
|
||||
|
||||
const created = await Promise.all(
|
||||
Array.from({ length: 10 }, (_, index) =>
|
||||
store.create({
|
||||
name: `Agent ${index}`,
|
||||
adapter: index % 2 === 0 ? 'codex' : 'claude',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const listed = await store.list()
|
||||
expect(listed).toHaveLength(created.length)
|
||||
expect(new Set(listed.map((agent) => agent.id)).size).toBe(created.length)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { FileTranscriptStore } from '../../../src/lib/agents/file-transcript-store'
|
||||
|
||||
describe('FileTranscriptStore', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
)
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('appends and lists main-session transcript entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
|
||||
tempDirs.push(dir)
|
||||
const store = new FileTranscriptStore({ rootDir: dir })
|
||||
|
||||
await store.append({
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'user',
|
||||
text: 'hello',
|
||||
})
|
||||
await store.append({
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: 'hi',
|
||||
})
|
||||
|
||||
expect(
|
||||
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
|
||||
({ role, text }) => ({ role, text }),
|
||||
),
|
||||
).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'hi' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips malformed JSONL lines when listing transcript entries', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'browseros-transcripts-'))
|
||||
tempDirs.push(dir)
|
||||
const store = new FileTranscriptStore({ rootDir: dir })
|
||||
const agentDir = join(dir, 'agent-1')
|
||||
await mkdir(agentDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(agentDir, 'main.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
id: '1',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'user',
|
||||
text: 'hello',
|
||||
createdAt: 1,
|
||||
}),
|
||||
'{bad json',
|
||||
JSON.stringify({
|
||||
id: '2',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: 'hi',
|
||||
createdAt: 2,
|
||||
}),
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(
|
||||
(await store.list({ agentId: 'agent-1', sessionId: 'main' })).map(
|
||||
({ role, text }) => ({ role, text }),
|
||||
),
|
||||
).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'hi' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -182,6 +182,7 @@
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@openrouter/ai-sdk-provider": "^2.2.3",
|
||||
"@sentry/bun": "^10.31.0",
|
||||
"acpx": "0.6.1",
|
||||
"ai": "^6.0.94",
|
||||
"chrome-devtools-frontend": "^1.0.1577886",
|
||||
"commander": "^14.0.1",
|
||||
@@ -270,6 +271,8 @@
|
||||
|
||||
"@1natsu/wait-element": ["@1natsu/wait-element@4.1.2", "", { "dependencies": { "defu": "^6.1.4", "many-keys-map": "^2.0.1" } }, "sha512-qWxSJD+Q5b8bKOvESFifvfZ92DuMsY+03SBNjTO34ipJLP6mZ9yK4bQz/vlh48aEQXoJfaZBqUwKL5BdI5iiWw=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.20.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.63", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.46", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNOaIaOXWFZFWbB0xM1l/bQYo7XwTkpdHbrA6n9A2U1c4/DcLF/+Rwc3vZF6MHPVSjoYVG0qxIa7jh39rKftYA=="],
|
||||
|
||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw=="],
|
||||
@@ -498,6 +501,10 @@
|
||||
|
||||
"@chevrotain/utils": ["@chevrotain/utils@11.0.3", "", {}, "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="],
|
||||
|
||||
"@clack/core": ["@clack/core@1.2.0", "", { "dependencies": { "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg=="],
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@1.2.0", "", { "dependencies": { "@clack/core": "1.2.0", "fast-string-width": "^1.1.0", "fast-wrap-ansi": "^0.1.3", "sisteransi": "^1.0.5" } }, "sha512-4jmztR9fMqPMjz6H/UZXj0zEmE43ha1euENwkckKKel4XpSfokExPo5AiVStdHSAlHekz4d0CA/r45Ok1E4D3w=="],
|
||||
|
||||
"@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="],
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
@@ -2046,6 +2053,8 @@
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"acpx": ["acpx@0.6.1", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.20.0", "commander": "^14.0.3", "skillflag": "^0.1.4", "tsx": "^4.21.0", "zod": "^4.3.6" }, "bin": { "acpx": "dist/cli.js" } }, "sha512-qxZPbm3SKq0UqQ0sOJ0M4iTLkF9AR7+I+JE/L/UeMUU1vW5N4nUVkZHytoHTBAu7nrej6THNzCPgrIZfv9T3AA=="],
|
||||
|
||||
"adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
@@ -2244,7 +2253,7 @@
|
||||
|
||||
"chrome-devtools-frontend": ["chrome-devtools-frontend@1.0.1577886", "", {}, "sha512-B9hY3o/0RuVCDWNYh9YnkEbRrPUMCY+NaOgBxvZRzGvqbGSMNckkVSdO67SwWR8bm4fo/qplXbUj0cSr229V6w=="],
|
||||
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.21.0", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-d+iqrRmcwpRFV3Q4DRCF2LCoq+WCRU3GhISKQ9v8g+1C2Uh8upj3urkjxNO4QIjhBMIYei/VQ1OQLFceby80Og=="],
|
||||
"chrome-devtools-mcp": ["chrome-devtools-mcp@0.23.0", "", { "bin": { "chrome-devtools-mcp": "build/src/bin/chrome-devtools-mcp.js", "chrome-devtools": "build/src/bin/chrome-devtools.js" } }, "sha512-VISjVEzdJGJo5hwMfZsGZTX7uQx6P8t3/pfvG/YR7g6dHS78R5j2WB9RZ2H4omFAEzScYtrNN5laykvGAHWx1g=="],
|
||||
|
||||
"chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="],
|
||||
|
||||
@@ -2672,8 +2681,14 @@
|
||||
|
||||
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
|
||||
|
||||
"fast-string-truncated-width": ["fast-string-truncated-width@1.2.1", "", {}, "sha512-Q9acT/+Uu3GwGj+5w/zsGuQjh9O1TyywhIwAxHudtWrgF09nHOPrvTLhQevPbttcxjr/SNN7mJmfOw/B1bXgow=="],
|
||||
|
||||
"fast-string-width": ["fast-string-width@1.1.0", "", { "dependencies": { "fast-string-truncated-width": "^1.2.0" } }, "sha512-O3fwIVIH5gKB38QNbdg+3760ZmGz0SZMgvwJbA1b2TGXceKE6A2cOlfogh1iw8lr049zPyd7YADHy+B7U4W9bQ=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"fast-wrap-ansi": ["fast-wrap-ansi@0.1.6", "", { "dependencies": { "fast-string-width": "^1.1.0" } }, "sha512-HlUwET7a5gqjURj70D5jl7aC3Zmy4weA1SHUfM0JFI0Ptq987NH2TwbBFLoERhfwk+E+eaq4EK3jXoT+R3yp3w=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="],
|
||||
@@ -2776,6 +2791,8 @@
|
||||
|
||||
"get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="],
|
||||
|
||||
"get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="],
|
||||
|
||||
"get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="],
|
||||
|
||||
"get-value": ["get-value@2.0.6", "", {}, "sha512-Ln0UQDlxH1BapMu3GPtf7CuYNwRZf2gwCuPqbyG6pB8WfmFpzqcy4xtAaAMUhnNqjMKTiCPZG2oMT3YSx8U2NA=="],
|
||||
@@ -3946,6 +3963,8 @@
|
||||
|
||||
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"responselike": ["responselike@4.0.2", "", { "dependencies": { "lowercase-keys": "^3.0.0" } }, "sha512-cGk8IbWEAnaCpdAt1BHzJ3Ahz5ewDJa0KseTsE3qIRMJ3C698W8psM7byCeWVpd/Ha7FUYzuRVzXoKoM6nRUbA=="],
|
||||
|
||||
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
|
||||
@@ -4054,6 +4073,8 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"skillflag": ["skillflag@0.1.4", "", { "dependencies": { "@clack/prompts": "^1.0.1", "tar-stream": "^3.1.7" }, "bin": { "skillflag": "dist/bin/skillflag.js", "skill-install": "dist/bin/skill-install.js" } }, "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA=="],
|
||||
|
||||
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||
|
||||
"slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="],
|
||||
@@ -4246,6 +4267,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="],
|
||||
@@ -4912,6 +4935,10 @@
|
||||
|
||||
"@types/request/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="],
|
||||
|
||||
"acpx/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
|
||||
"acpx/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"antd/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||
|
||||
"antd/rc-collapse": ["rc-collapse@3.9.0", "", { "dependencies": { "@babel/runtime": "^7.10.1", "classnames": "2.x", "rc-motion": "^2.3.4", "rc-util": "^5.27.0" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA=="],
|
||||
|
||||
@@ -80,3 +80,7 @@ export const CONTENT_LIMITS = {
|
||||
CONSOLE_DEFAULT_LIMIT: 50,
|
||||
CONSOLE_MAX_LIMIT: 200,
|
||||
} as const
|
||||
|
||||
export const AGENT_HARNESS_LIMITS = {
|
||||
AGENT_NAME_MAX_CHARS: 80,
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user