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:
Nikhil
2026-03-03 10:18:44 -08:00
committed by GitHub
parent f449162699
commit 36b9a78d56
4 changed files with 76 additions and 45 deletions

View File

@@ -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'

View File

@@ -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',

View File

@@ -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', {

View File

@@ -141,7 +141,7 @@
},
"apps/server": {
"name": "@browseros/server",
"version": "0.0.63",
"version": "0.0.64",
"bin": {
"browseros-server": "./src/index.ts",
},