mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
2 Commits
fix/browse
...
fix/acp-2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
830f15d24e | ||
|
|
1420495ea9 |
@@ -23,9 +23,9 @@ export interface BrowserOSChatHistoryToolCall {
|
||||
toolName: string
|
||||
label: string
|
||||
subject?: string
|
||||
status: 'completed' | 'failed'
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { buildToolLabel } from '../../../lib/tool-labels'
|
||||
import type { HarnessAgentHistoryPage } from '../agents/agent-harness-types'
|
||||
import type {
|
||||
AgentHistoryPageResponse,
|
||||
BrowserOSChatHistoryItem,
|
||||
BrowserOSChatHistoryToolCall,
|
||||
} from './claw-chat-types'
|
||||
|
||||
export function mapHarnessHistoryPage(
|
||||
page: HarnessAgentHistoryPage,
|
||||
): AgentHistoryPageResponse {
|
||||
const items: BrowserOSChatHistoryItem[] = page.items.map((item, index) => {
|
||||
const toolCalls = item.toolCalls?.map(
|
||||
(tool): BrowserOSChatHistoryToolCall => {
|
||||
const input = asRecord(tool.input)
|
||||
const { label, subject } = buildToolLabel(tool.toolName, input)
|
||||
return {
|
||||
toolName: tool.toolName,
|
||||
label,
|
||||
status: tool.status,
|
||||
...(tool.toolCallId ? { toolCallId: tool.toolCallId } : {}),
|
||||
...(subject ? { subject } : {}),
|
||||
...(tool.input !== undefined ? { input: tool.input } : {}),
|
||||
...(tool.output !== undefined ? { output: tool.output } : {}),
|
||||
...(tool.error ? { error: tool.error } : {}),
|
||||
...(tool.durationMs != null ? { durationMs: tool.durationMs } : {}),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
text: item.text,
|
||||
timestamp: item.createdAt,
|
||||
messageSeq: index + 1,
|
||||
sessionKey: 'main',
|
||||
source: 'user-chat',
|
||||
...(item.reasoning ? { reasoning: item.reasoning } : {}),
|
||||
...(toolCalls && toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
}
|
||||
})
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { mapHarnessHistoryPage } from './harness-history-mapper'
|
||||
|
||||
describe('mapHarnessHistoryPage', () => {
|
||||
it('maps rich harness history into chat history items', () => {
|
||||
const page = mapHarnessHistoryPage({
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
items: [
|
||||
{
|
||||
id: 'agent:agent-1:main:1',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: 'Done.',
|
||||
createdAt: 1000,
|
||||
reasoning: { text: 'checking state' },
|
||||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read_file',
|
||||
status: 'completed',
|
||||
input: { path: 'src/index.ts' },
|
||||
output: 'file contents',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(page.items).toEqual([
|
||||
{
|
||||
id: 'agent:agent-1:main:1',
|
||||
role: 'assistant',
|
||||
text: 'Done.',
|
||||
timestamp: 1000,
|
||||
messageSeq: 1,
|
||||
sessionKey: 'main',
|
||||
source: 'user-chat',
|
||||
reasoning: { text: 'checking state' },
|
||||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read_file',
|
||||
label: 'Read file',
|
||||
subject: 'index.ts',
|
||||
status: 'completed',
|
||||
input: { path: 'src/index.ts' },
|
||||
output: 'file contents',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,8 @@
|
||||
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'
|
||||
import type { AgentHistoryPageResponse } from './claw-chat-types'
|
||||
import { mapHarnessHistoryPage } from './harness-history-mapper'
|
||||
|
||||
const HISTORY_QUERY_KEY = 'harness-agent-history'
|
||||
|
||||
@@ -30,39 +27,3 @@ export function useHarnessChatHistory(agentId: string, enabled = true) {
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,19 +62,36 @@ export interface CreateHarnessAgentInput {
|
||||
reasoningEffort?: string
|
||||
}
|
||||
|
||||
export interface HarnessTranscriptEntry {
|
||||
export interface HarnessHistoryReasoning {
|
||||
text: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface HarnessHistoryToolCall {
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface HarnessHistoryEntry {
|
||||
id: string
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
createdAt: number
|
||||
reasoning?: HarnessHistoryReasoning
|
||||
toolCalls?: HarnessHistoryToolCall[]
|
||||
}
|
||||
|
||||
export interface HarnessAgentHistoryPage {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
items: HarnessTranscriptEntry[]
|
||||
items: HarnessHistoryEntry[]
|
||||
}
|
||||
|
||||
export function mapHarnessAgentToEntry(agent: HarnessAgent): AgentEntry {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type CreateAgentInput,
|
||||
FileAgentStore,
|
||||
} from '../../../lib/agents/file-agent-store'
|
||||
import { FileTranscriptStore } from '../../../lib/agents/file-transcript-store'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentRuntime,
|
||||
@@ -19,19 +18,16 @@ import type {
|
||||
|
||||
export class AgentHarnessService {
|
||||
private readonly agentStore: FileAgentStore
|
||||
private readonly transcriptStore: FileTranscriptStore
|
||||
private readonly runtime: AgentRuntime
|
||||
|
||||
constructor(
|
||||
deps: {
|
||||
agentStore?: FileAgentStore
|
||||
transcriptStore?: FileTranscriptStore
|
||||
runtime?: AgentRuntime
|
||||
browserosServerPort?: number
|
||||
} = {},
|
||||
) {
|
||||
this.agentStore = deps.agentStore ?? new FileAgentStore()
|
||||
this.transcriptStore = deps.transcriptStore ?? new FileTranscriptStore()
|
||||
this.runtime =
|
||||
deps.runtime ??
|
||||
new AcpxRuntime({ browserosServerPort: deps.browserosServerPort })
|
||||
@@ -55,14 +51,7 @@ export class AgentHarnessService {
|
||||
|
||||
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',
|
||||
}),
|
||||
}
|
||||
return this.runtime.getHistory({ agent, sessionId: 'main' })
|
||||
}
|
||||
|
||||
async send(input: {
|
||||
@@ -71,13 +60,7 @@ export class AgentHarnessService {
|
||||
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({
|
||||
return this.runtime.send({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
sessionKey: agent.sessionKey,
|
||||
@@ -85,7 +68,6 @@ export class AgentHarnessService {
|
||||
permissionMode: agent.permissionMode,
|
||||
signal: input.signal,
|
||||
})
|
||||
return this.persistAssistantTranscript(agent, runtimeStream)
|
||||
}
|
||||
|
||||
private async requireAgent(agentId: string): Promise<AgentDefinition> {
|
||||
@@ -95,54 +77,6 @@ export class AgentHarnessService {
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type AcpRuntimeOptions,
|
||||
type AcpRuntimeTurn,
|
||||
type AcpRuntimeTurnResult,
|
||||
type AcpSessionRecord,
|
||||
type AcpRuntime as AcpxCoreRuntime,
|
||||
createAcpRuntime,
|
||||
createAgentRegistry,
|
||||
@@ -19,6 +20,11 @@ import {
|
||||
} from 'acpx/runtime'
|
||||
import { getBrowserosDir } from '../browseros-dir'
|
||||
import { logger } from '../logger'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentHistoryEntry,
|
||||
AgentHistoryToolCall,
|
||||
} from './agent-types'
|
||||
import type {
|
||||
AgentHistoryPage,
|
||||
AgentPromptInput,
|
||||
@@ -48,6 +54,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
private readonly runtimeFactory: (
|
||||
options: AcpRuntimeOptions,
|
||||
) => AcpxCoreRuntime
|
||||
private readonly sessionStore: ReturnType<typeof createRuntimeStore>
|
||||
private readonly runtimes = new Map<string, AcpxCoreRuntime>()
|
||||
|
||||
constructor(options: AcpxRuntimeOptions = {}) {
|
||||
@@ -58,6 +65,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
join(getBrowserosDir(), 'agents', 'acpx')
|
||||
this.browserosServerPort =
|
||||
options.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
this.sessionStore = createRuntimeStore({ stateDir: this.stateDir })
|
||||
this.runtimeFactory = options.runtimeFactory ?? createAcpRuntime
|
||||
}
|
||||
|
||||
@@ -75,7 +83,11 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
agent: AgentPromptInput['agent']
|
||||
sessionId: 'main'
|
||||
}): Promise<AgentHistoryPage> {
|
||||
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
|
||||
const record = await this.sessionStore.load(input.agent.sessionKey)
|
||||
if (!record) {
|
||||
return { agentId: input.agent.id, sessionId: input.sessionId, items: [] }
|
||||
}
|
||||
return mapAcpxSessionRecordToHistory(input.agent, input.sessionId, record)
|
||||
}
|
||||
|
||||
async send(
|
||||
@@ -113,7 +125,7 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
|
||||
const runtime = this.runtimeFactory({
|
||||
cwd: input.cwd,
|
||||
sessionStore: createRuntimeStore({ stateDir: this.stateDir }),
|
||||
sessionStore: this.sessionStore,
|
||||
agentRegistry: createBrowserosAgentRegistry(input.permissionMode),
|
||||
mcpServers: createBrowserosMcpServers(this.browserosServerPort),
|
||||
permissionMode: input.permissionMode,
|
||||
@@ -131,6 +143,165 @@ export class AcpxRuntime implements AgentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
type AcpxSessionMessage = AcpSessionRecord['messages'][number]
|
||||
type AcpxUserContent = Extract<
|
||||
Exclude<AcpxSessionMessage, 'Resume'>,
|
||||
{ User: unknown }
|
||||
>['User']['content'][number]
|
||||
type AcpxAgentMessage = Extract<
|
||||
Exclude<AcpxSessionMessage, 'Resume'>,
|
||||
{ Agent: unknown }
|
||||
>['Agent']
|
||||
type AcpxAgentContent = AcpxAgentMessage['content'][number]
|
||||
type AcpxToolUse = Extract<AcpxAgentContent, { ToolUse: unknown }>['ToolUse']
|
||||
type AcpxToolResult = AcpxAgentMessage['tool_results'][string]
|
||||
|
||||
function mapAcpxSessionRecordToHistory(
|
||||
agent: AgentDefinition,
|
||||
sessionId: 'main',
|
||||
record: AcpSessionRecord,
|
||||
): AgentHistoryPage {
|
||||
const createdAt = parseRecordTimestamp(record)
|
||||
const items = record.messages.flatMap(
|
||||
(message, index): AgentHistoryEntry[] => {
|
||||
if (message === 'Resume') return []
|
||||
const id = `${record.acpxRecordId}:${index}`
|
||||
const messageCreatedAt = createdAt + index
|
||||
|
||||
if ('User' in message) {
|
||||
const text = message.User.content
|
||||
.map(userContentToText)
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
.trim()
|
||||
if (!text) return []
|
||||
return [
|
||||
{
|
||||
id,
|
||||
agentId: agent.id,
|
||||
sessionId,
|
||||
role: 'user',
|
||||
text,
|
||||
createdAt: messageCreatedAt,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const entry = mapAgentMessageToHistoryEntry({
|
||||
id,
|
||||
agentId: agent.id,
|
||||
sessionId,
|
||||
createdAt: messageCreatedAt,
|
||||
message: message.Agent,
|
||||
})
|
||||
return entry ? [entry] : []
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
agentId: agent.id,
|
||||
sessionId,
|
||||
items,
|
||||
}
|
||||
}
|
||||
|
||||
function mapAgentMessageToHistoryEntry(input: {
|
||||
id: string
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
createdAt: number
|
||||
message: AcpxAgentMessage
|
||||
}): AgentHistoryEntry | null {
|
||||
const textParts: string[] = []
|
||||
const reasoningParts: string[] = []
|
||||
const toolCalls: AgentHistoryToolCall[] = []
|
||||
|
||||
for (const content of input.message.content) {
|
||||
if ('Text' in content) {
|
||||
textParts.push(content.Text)
|
||||
} else if ('Thinking' in content) {
|
||||
reasoningParts.push(content.Thinking.text)
|
||||
} else if ('RedactedThinking' in content) {
|
||||
reasoningParts.push('[redacted_thinking]')
|
||||
} else if ('ToolUse' in content) {
|
||||
toolCalls.push(
|
||||
mapToolUseToHistoryToolCall(
|
||||
content.ToolUse,
|
||||
input.message.tool_results[content.ToolUse.id],
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const text = textParts.join('').trim()
|
||||
const reasoningText = reasoningParts.join('\n\n').trim()
|
||||
if (!text && !reasoningText && toolCalls.length === 0) return null
|
||||
|
||||
return {
|
||||
id: input.id,
|
||||
agentId: input.agentId,
|
||||
sessionId: input.sessionId,
|
||||
role: 'assistant',
|
||||
text,
|
||||
createdAt: input.createdAt,
|
||||
...(reasoningText ? { reasoning: { text: reasoningText } } : {}),
|
||||
...(toolCalls.length > 0 ? { toolCalls } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapToolUseToHistoryToolCall(
|
||||
tool: AcpxToolUse,
|
||||
result: AcpxToolResult | undefined,
|
||||
): AgentHistoryToolCall {
|
||||
const resultValue = result ? toolResultValue(result) : undefined
|
||||
const status = result?.is_error
|
||||
? 'failed'
|
||||
: result || tool.is_input_complete
|
||||
? 'completed'
|
||||
: 'running'
|
||||
|
||||
return {
|
||||
toolCallId: tool.id,
|
||||
toolName: result?.tool_name ?? tool.name,
|
||||
status,
|
||||
input: tool.input,
|
||||
...(result?.is_error
|
||||
? { error: stringifyToolError(resultValue) }
|
||||
: resultValue !== undefined
|
||||
? { output: resultValue }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function userContentToText(content: AcpxUserContent): string {
|
||||
if ('Text' in content) return content.Text
|
||||
if ('Mention' in content) return content.Mention.content
|
||||
if ('Image' in content) return content.Image.source ? '[image]' : ''
|
||||
return ''
|
||||
}
|
||||
|
||||
function toolResultValue(result: AcpxToolResult): unknown {
|
||||
if (result.output != null) return result.output
|
||||
if ('Text' in result.content) return result.content.Text
|
||||
if ('Image' in result.content) return result.content.Image.source
|
||||
return undefined
|
||||
}
|
||||
|
||||
function stringifyToolError(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (value === undefined) return 'Tool call failed'
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return 'Tool call failed'
|
||||
}
|
||||
}
|
||||
|
||||
function parseRecordTimestamp(record: AcpSessionRecord): number {
|
||||
const parsed = Date.parse(record.updated_at || record.lastUsedAt)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function createAcpxEventStream(
|
||||
runtime: AcpxCoreRuntime,
|
||||
input: AgentPromptInput,
|
||||
|
||||
@@ -38,11 +38,28 @@ export interface AgentAdapterDescriptor {
|
||||
}>
|
||||
}
|
||||
|
||||
export interface AgentTranscriptEntry {
|
||||
export interface AgentHistoryReasoning {
|
||||
text: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface AgentHistoryToolCall {
|
||||
toolCallId?: string
|
||||
toolName: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface AgentHistoryEntry {
|
||||
id: string
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
role: 'user' | 'assistant'
|
||||
text: string
|
||||
createdAt: number
|
||||
reasoning?: AgentHistoryReasoning
|
||||
toolCalls?: AgentHistoryToolCall[]
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* @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'
|
||||
)
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
|
||||
import type {
|
||||
AgentDefinition,
|
||||
AgentHistoryEntry,
|
||||
AgentPermissionMode,
|
||||
AgentTranscriptEntry,
|
||||
} from './agent-types'
|
||||
|
||||
export interface AgentStatus {
|
||||
@@ -24,7 +24,7 @@ export interface AgentSession {
|
||||
export interface AgentHistoryPage {
|
||||
agentId: string
|
||||
sessionId: 'main'
|
||||
items: AgentTranscriptEntry[]
|
||||
items: AgentHistoryEntry[]
|
||||
}
|
||||
|
||||
export type AgentStreamEvent =
|
||||
|
||||
@@ -5,12 +5,8 @@
|
||||
|
||||
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 { AgentDefinition } 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,
|
||||
@@ -19,48 +15,8 @@ import type {
|
||||
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 agentStore = createAgentStore(agents)
|
||||
const runtime: AgentRuntime = {
|
||||
async status() {
|
||||
return { state: 'ready' }
|
||||
@@ -89,7 +45,6 @@ describe('AgentHarnessService', () => {
|
||||
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: agentStore as FileAgentStore,
|
||||
transcriptStore: transcriptStore as FileTranscriptStore,
|
||||
runtime,
|
||||
})
|
||||
|
||||
@@ -99,11 +54,12 @@ describe('AgentHarnessService', () => {
|
||||
modelId: 'gpt-5.5',
|
||||
reasoningEffort: 'medium',
|
||||
})
|
||||
const stream = await service.send({
|
||||
agentId: agent.id,
|
||||
message: 'hello',
|
||||
})
|
||||
await stream.pipeTo(new WritableStream())
|
||||
const events = await collectStream(
|
||||
await service.send({
|
||||
agentId: agent.id,
|
||||
message: 'hello',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(runtimeInputs[0]).toMatchObject({
|
||||
agent,
|
||||
@@ -112,13 +68,13 @@ describe('AgentHarnessService', () => {
|
||||
message: 'hello',
|
||||
permissionMode: 'approve-all',
|
||||
})
|
||||
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'answer' },
|
||||
expect(events).toEqual([
|
||||
{ type: 'text_delta', text: 'answer', stream: 'output' },
|
||||
{ type: 'done', stopReason: 'end_turn' },
|
||||
])
|
||||
})
|
||||
|
||||
it('flushes partial assistant text when the response stream is cancelled', async () => {
|
||||
it('reads history from the runtime', async () => {
|
||||
const agent: AgentDefinition = {
|
||||
id: 'agent-1',
|
||||
name: 'Review bot',
|
||||
@@ -130,35 +86,7 @@ describe('AgentHarnessService', () => {
|
||||
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 runtimeInputs: unknown[] = []
|
||||
const runtime: AgentRuntime = {
|
||||
async status() {
|
||||
return { state: 'ready' }
|
||||
@@ -166,39 +94,95 @@ describe('AgentHarnessService', () => {
|
||||
async listSessions() {
|
||||
return []
|
||||
},
|
||||
async getHistory() {
|
||||
return { agentId: agent.id, sessionId: 'main', items: [] }
|
||||
async getHistory(input) {
|
||||
runtimeInputs.push(input)
|
||||
return {
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
items: [
|
||||
{
|
||||
id: 'agent:agent-1:main:1',
|
||||
agentId: agent.id,
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: 'Done.',
|
||||
createdAt: 1000,
|
||||
reasoning: { text: 'checking state' },
|
||||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read_file',
|
||||
status: 'completed',
|
||||
input: { path: 'src/index.ts' },
|
||||
output: 'file contents',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
async send() {
|
||||
return new ReadableStream<AgentStreamEvent>({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text_delta',
|
||||
text: 'partial answer',
|
||||
stream: 'output',
|
||||
})
|
||||
},
|
||||
})
|
||||
return new ReadableStream<AgentStreamEvent>()
|
||||
},
|
||||
}
|
||||
const service = new AgentHarnessService({
|
||||
agentStore: agentStore as FileAgentStore,
|
||||
transcriptStore: transcriptStore as FileTranscriptStore,
|
||||
agentStore: createAgentStore([agent]) as FileAgentStore,
|
||||
runtime,
|
||||
})
|
||||
|
||||
const reader = (
|
||||
await service.send({
|
||||
agentId: agent.id,
|
||||
message: 'hello',
|
||||
})
|
||||
).getReader()
|
||||
await reader.read()
|
||||
await reader.cancel()
|
||||
const history = await service.getHistory(agent.id)
|
||||
|
||||
expect(transcripts.map(({ role, text }) => ({ role, text }))).toEqual([
|
||||
{ role: 'user', text: 'hello' },
|
||||
{ role: 'assistant', text: 'partial answer' },
|
||||
])
|
||||
expect(runtimeInputs).toEqual([{ agent, sessionId: 'main' }])
|
||||
expect(history.items[0]).toMatchObject({
|
||||
role: 'assistant',
|
||||
reasoning: { text: 'checking state' },
|
||||
toolCalls: [{ toolName: 'read_file' }],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createAgentStore(agents: AgentDefinition[]) {
|
||||
return {
|
||||
async list() {
|
||||
return agents
|
||||
},
|
||||
async get(id: string) {
|
||||
return agents.find((agent) => agent.id === id) ?? null
|
||||
},
|
||||
async create(input) {
|
||||
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 delete() {
|
||||
return true
|
||||
},
|
||||
} satisfies Partial<FileAgentStore>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ import type {
|
||||
AcpRuntimeEvent,
|
||||
AcpRuntimeHandle,
|
||||
AcpRuntimeOptions,
|
||||
AcpSessionRecord,
|
||||
AcpRuntime as AcpxCoreRuntime,
|
||||
} from 'acpx/runtime'
|
||||
import { createRuntimeStore } 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'
|
||||
@@ -112,6 +114,121 @@ describe('AcpxRuntime', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('maps persisted acpx session records into rich history entries', async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), 'browseros-acpx-runtime-'))
|
||||
const stateDir = await mkdtemp(join(tmpdir(), 'browseros-acpx-state-'))
|
||||
tempDirs.push(cwd, stateDir)
|
||||
const timestamp = '2026-04-28T20:00:00.000Z'
|
||||
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 record: AcpSessionRecord = {
|
||||
schema: 'acpx.session.v1',
|
||||
acpxRecordId: agent.sessionKey,
|
||||
acpSessionId: 'sid-1',
|
||||
agentSessionId: 'inner-1',
|
||||
agentCommand: 'codex --acp',
|
||||
cwd,
|
||||
name: agent.sessionKey,
|
||||
createdAt: timestamp,
|
||||
lastUsedAt: timestamp,
|
||||
lastSeq: 0,
|
||||
eventLog: {
|
||||
active_path: '',
|
||||
segment_count: 0,
|
||||
max_segment_bytes: 0,
|
||||
max_segments: 0,
|
||||
},
|
||||
closed: false,
|
||||
messages: [
|
||||
{
|
||||
User: {
|
||||
id: 'user-1',
|
||||
content: [{ Text: 'inspect history' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
Agent: {
|
||||
content: [
|
||||
{ Thinking: { text: 'checking state', signature: null } },
|
||||
{
|
||||
ToolUse: {
|
||||
id: 'tool-1',
|
||||
name: 'read_file',
|
||||
raw_input: '{"path":"src/index.ts"}',
|
||||
input: { path: 'src/index.ts' },
|
||||
is_input_complete: true,
|
||||
thought_signature: null,
|
||||
},
|
||||
},
|
||||
{ Text: 'Done.' },
|
||||
],
|
||||
tool_results: {
|
||||
'tool-1': {
|
||||
tool_use_id: 'tool-1',
|
||||
tool_name: 'read_file',
|
||||
is_error: false,
|
||||
content: { Text: 'file contents' },
|
||||
output: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
updated_at: timestamp,
|
||||
cumulative_token_usage: {},
|
||||
request_token_usage: {},
|
||||
acpx: {},
|
||||
}
|
||||
await createRuntimeStore({ stateDir }).save(record)
|
||||
|
||||
const history = await new AcpxRuntime({ cwd, stateDir }).getHistory({
|
||||
agent,
|
||||
sessionId: 'main',
|
||||
})
|
||||
|
||||
expect(history).toEqual({
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
items: [
|
||||
{
|
||||
id: 'agent:agent-1:main:0',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'user',
|
||||
text: 'inspect history',
|
||||
createdAt: Date.parse(timestamp),
|
||||
},
|
||||
{
|
||||
id: 'agent:agent-1:main:1',
|
||||
agentId: 'agent-1',
|
||||
sessionId: 'main',
|
||||
role: 'assistant',
|
||||
text: 'Done.',
|
||||
createdAt: Date.parse(timestamp) + 1,
|
||||
reasoning: { text: 'checking state' },
|
||||
toolCalls: [
|
||||
{
|
||||
toolCallId: 'tool-1',
|
||||
toolName: 'read_file',
|
||||
status: 'completed',
|
||||
input: { path: 'src/index.ts' },
|
||||
output: 'file contents',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('continues the turn when runtime config control is unavailable', async () => {
|
||||
const calls: Array<{ method: string; input: unknown }> = []
|
||||
const runtime = new AcpxRuntime({
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* @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' },
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user