mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-17 02:25:57 +00:00
fix: move ChatV2Service to API services layer and add resolvePageIds (#394)
* feat: move ChatV2Service to api/services layer and add resolvePageIds Move ChatV2Service from agent/tool-loop/ to api/services/ where it belongs as a service-layer concern. Add resolvePageIds() to convert Chrome tab IDs to internal page IDs before they reach the agent, fixing undefined pageId issues in browser automation tools. Clean up server.ts by removing the USE_TOOL_AGENT flag, SessionManager, and old chat route import — both /chat and /chat-v2 now directly use createChatV2Routes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address review comments for chat-v2-service - Fix TOCTOU race: derive isNewSession inside the creation block instead of separate has()/get() calls - Log warning when resolvePageIds can't map a tab ID - Deduplicate tab IDs with Set before resolving - Remove redundant null check on session in onFinish - Add license header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: update bun.lock * fix: skip resolvePageIds for scheduled tasks to prevent pageId corruption Scheduled tasks build browserContext with internal page IDs from browser.newPage(), not Chrome tab IDs. The unconditional second resolvePageIds() call was passing these internal IDs to resolveTabIds() which expects Chrome tab IDs, causing the lookup to fail and overwrite correct pageIds with undefined. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { ChatV2Service } from '../../agent/tool-loop/service'
|
||||
import { SessionStore } from '../../agent/tool-loop/session-store'
|
||||
import type { Browser } from '../../browser/browser'
|
||||
import { KlavisClient } from '../../lib/clients/klavis/klavis-client'
|
||||
@@ -11,6 +10,7 @@ import type { RateLimiter } from '../../lib/rate-limiter/rate-limiter'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import { createBrowserosRateLimitMiddleware } from '../middleware/rate-limit'
|
||||
import { ChatV2Service } from '../services/chat-v2-service'
|
||||
import { ChatRequestSchema } from '../types'
|
||||
import { ConversationIdParamSchema } from '../utils/validation'
|
||||
|
||||
|
||||
@@ -14,10 +14,8 @@ import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status'
|
||||
import { HttpAgentError } from '../agent/errors'
|
||||
import { SessionManager } from '../agent/session'
|
||||
import { logger } from '../lib/logger'
|
||||
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createChatV2Routes } from './routes/chat-v2'
|
||||
import { createGraphRoutes } from './routes/graph'
|
||||
import { createHealthRoute } from './routes/health'
|
||||
@@ -53,8 +51,6 @@ async function assertPortAvailable(port: number): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
const USE_TOOL_AGENT = true
|
||||
|
||||
export async function createHttpServer(config: HttpServerConfig) {
|
||||
const {
|
||||
port,
|
||||
@@ -69,7 +65,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
} = config
|
||||
|
||||
const { onShutdown } = config
|
||||
const sessionManager = new SessionManager()
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
@@ -91,22 +86,13 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
)
|
||||
.route(
|
||||
'/chat',
|
||||
USE_TOOL_AGENT
|
||||
? createChatV2Routes({
|
||||
browser,
|
||||
registry,
|
||||
executionDir,
|
||||
browserosId,
|
||||
rateLimiter,
|
||||
})
|
||||
: createChatRoutes({
|
||||
port,
|
||||
executionDir,
|
||||
browserosId,
|
||||
rateLimiter,
|
||||
sessionManager,
|
||||
browser,
|
||||
}),
|
||||
createChatV2Routes({
|
||||
browser,
|
||||
registry,
|
||||
executionDir,
|
||||
browserosId,
|
||||
rateLimiter,
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
'/chat-v2',
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { createAgentUIStreamResponse, type UIMessage } from 'ai'
|
||||
import type { ChatRequest } from '../../api/types'
|
||||
import { AiSdkAgent } from '../../agent/tool-loop/ai-sdk-agent'
|
||||
import { formatUserMessage } from '../../agent/tool-loop/format-message'
|
||||
import type { SessionStore } from '../../agent/tool-loop/session-store'
|
||||
import type { ResolvedAgentConfig } from '../../agent/types'
|
||||
import type { Browser } from '../../browser/browser'
|
||||
import type { KlavisClient } from '../../lib/clients/klavis/klavis-client'
|
||||
import { resolveLLMConfig } from '../../lib/clients/llm/config'
|
||||
import { logger } from '../../lib/logger'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import type { ResolvedAgentConfig } from '../types'
|
||||
import { AiSdkAgent } from './ai-sdk-agent'
|
||||
import { formatUserMessage } from './format-message'
|
||||
import type { SessionStore } from './session-store'
|
||||
import type { BrowserContext, ChatRequest } from '../types'
|
||||
|
||||
export interface ChatV2ServiceDeps {
|
||||
sessionStore: SessionStore
|
||||
@@ -30,13 +36,10 @@ export class ChatV2Service {
|
||||
): Promise<Response> {
|
||||
const { sessionStore } = this.deps
|
||||
|
||||
// Resolve LLM provider config (handles BROWSEROS gateway lookup)
|
||||
const llmConfig = await resolveLLMConfig(request, this.deps.browserosId)
|
||||
|
||||
// Resolve session working directory
|
||||
const sessionExecutionDir = await this.resolveSessionDir(request)
|
||||
|
||||
// Build full agent config
|
||||
const agentConfig: ResolvedAgentConfig = {
|
||||
conversationId: request.conversationId,
|
||||
provider: llmConfig.provider,
|
||||
@@ -57,15 +60,13 @@ export class ChatV2Service {
|
||||
isScheduledTask: request.isScheduledTask,
|
||||
}
|
||||
|
||||
// Get or create agent session
|
||||
const isNewSession = !sessionStore.has(request.conversationId)
|
||||
let session = sessionStore.get(request.conversationId)
|
||||
let isNewSession = false
|
||||
|
||||
if (!session) {
|
||||
// For scheduled tasks, create a hidden window so automation
|
||||
// doesn't interfere with the user's visible browser.
|
||||
isNewSession = true
|
||||
let hiddenWindowId: number | undefined
|
||||
let browserContext = request.browserContext
|
||||
let browserContext = await this.resolvePageIds(request.browserContext)
|
||||
if (request.isScheduledTask) {
|
||||
try {
|
||||
const win = await this.deps.browser.createWindow({ hidden: true })
|
||||
@@ -107,7 +108,6 @@ export class ChatV2Service {
|
||||
sessionStore.set(request.conversationId, session)
|
||||
}
|
||||
|
||||
// Inject previous conversation as history for resumed sessions
|
||||
if (isNewSession && request.previousConversation?.length) {
|
||||
for (const msg of request.previousConversation) {
|
||||
session.agent.messages.push({
|
||||
@@ -122,24 +122,27 @@ export class ChatV2Service {
|
||||
})
|
||||
}
|
||||
|
||||
// Scheduled tasks use the session's browser context (hidden window with
|
||||
// correct pageId/windowId). Normal messages use the request's browser
|
||||
// context so that freshly selected tabs are always included.
|
||||
const messageContext = request.isScheduledTask
|
||||
? (session.browserContext ?? request.browserContext)
|
||||
: request.browserContext
|
||||
const userContent = formatUserMessage(request.message, messageContext)
|
||||
// Scheduled tasks already have correct internal pageIds from browser.newPage();
|
||||
// calling resolvePageIds would pass those to resolveTabIds (which expects Chrome
|
||||
// tab IDs), corrupting them back to undefined.
|
||||
const resolvedMessageContext = request.isScheduledTask
|
||||
? messageContext
|
||||
: await this.resolvePageIds(messageContext)
|
||||
const userContent = formatUserMessage(
|
||||
request.message,
|
||||
resolvedMessageContext,
|
||||
)
|
||||
session.agent.appendUserMessage(userContent)
|
||||
|
||||
// Stream the agent response
|
||||
return createAgentUIStreamResponse({
|
||||
agent: session.agent.toolLoopAgent,
|
||||
uiMessages: session.agent.messages,
|
||||
abortSignal,
|
||||
onFinish: async ({ messages }: { messages: UIMessage[] }) => {
|
||||
if (session) {
|
||||
session.agent.messages = messages
|
||||
}
|
||||
session.agent.messages = messages
|
||||
logger.info('Agent execution complete', {
|
||||
conversationId: request.conversationId,
|
||||
totalMessages: messages.length,
|
||||
@@ -167,6 +170,48 @@ export class ChatV2Service {
|
||||
return { deleted, sessionCount: this.deps.sessionStore.count() }
|
||||
}
|
||||
|
||||
// Browser context arrives with Chrome tab IDs, but tools expect internal page IDs.
|
||||
// Resolve the mapping upfront so the agent's first navigation doesn't fail.
|
||||
private async resolvePageIds(
|
||||
browserContext?: BrowserContext,
|
||||
): Promise<BrowserContext | undefined> {
|
||||
if (!browserContext) return undefined
|
||||
|
||||
const tabIdSet = new Set<number>()
|
||||
if (browserContext.activeTab) tabIdSet.add(browserContext.activeTab.id)
|
||||
if (browserContext.selectedTabs) {
|
||||
for (const tab of browserContext.selectedTabs) tabIdSet.add(tab.id)
|
||||
}
|
||||
if (browserContext.tabs) {
|
||||
for (const tab of browserContext.tabs) tabIdSet.add(tab.id)
|
||||
}
|
||||
|
||||
if (tabIdSet.size === 0) return browserContext
|
||||
|
||||
const tabToPage = await this.deps.browser.resolveTabIds([...tabIdSet])
|
||||
|
||||
const addPageId = (tab: { id: number; url?: string; title?: string }) => {
|
||||
const pageId = tabToPage.get(tab.id)
|
||||
if (pageId === undefined) {
|
||||
logger.warn('Could not resolve page ID for tab', { tabId: tab.id })
|
||||
}
|
||||
return { ...tab, pageId }
|
||||
}
|
||||
|
||||
logger.debug('Resolved tab IDs to page IDs', {
|
||||
mapping: Object.fromEntries(tabToPage),
|
||||
})
|
||||
|
||||
return {
|
||||
...browserContext,
|
||||
activeTab: browserContext.activeTab
|
||||
? addPageId(browserContext.activeTab)
|
||||
: undefined,
|
||||
selectedTabs: browserContext.selectedTabs?.map(addPageId),
|
||||
tabs: browserContext.tabs?.map(addPageId),
|
||||
}
|
||||
}
|
||||
|
||||
private closeHiddenWindow(windowId: number, conversationId: string): void {
|
||||
this.deps.browser.closeWindow(windowId).catch((error) => {
|
||||
logger.warn('Failed to close hidden window', {
|
||||
Reference in New Issue
Block a user