diff --git a/packages/browseros-agent/apps/server/src/browser/backends/cdp.ts b/packages/browseros-agent/apps/server/src/browser/backends/cdp.ts index bb1702c68..2a91f85ff 100644 --- a/packages/browseros-agent/apps/server/src/browser/backends/cdp.ts +++ b/packages/browseros-agent/apps/server/src/browser/backends/cdp.ts @@ -36,6 +36,10 @@ class CdpBackend implements ICdpBackend { private reconnecting = false private reconnectRequested = false private eventHandlers = new Map void)[]>() + private sessionEventHandlers = new Map< + string, + ((params: unknown, sessionId: string) => void)[] + >() private sessionCache = new Map() private keepaliveTimer: ReturnType | null = null private preferredDiscoveryHost: LoopbackDiscoveryHost | null = null @@ -432,6 +436,26 @@ class CdpBackend implements ICdpBackend { } } + onSessionEvent( + event: string, + handler: (params: unknown, sessionId: string) => void, + ): () => void { + if (!this.sessionEventHandlers.has(event)) { + this.sessionEventHandlers.set(event, []) + } + const handlers = this.sessionEventHandlers.get(event) + if (handlers) { + handlers.push(handler) + } + return () => { + const list = this.sessionEventHandlers.get(event) + if (list) { + const idx = list.indexOf(handler) + if (idx !== -1) list.splice(idx, 1) + } + } + } + private handleMessage(data: string): void { const message = JSON.parse(data) as { id?: number @@ -439,8 +463,10 @@ class CdpBackend implements ICdpBackend { params?: unknown result?: unknown error?: { message: string; code: number } + sessionId?: string } + // Route responses to pending requests if (message.id !== undefined) { const pending = this.pending.get(message.id) if (pending) { @@ -453,12 +479,23 @@ class CdpBackend implements ICdpBackend { } } } else if (message.method) { + // Dispatch to global event handlers const handlers = this.eventHandlers.get(message.method) if (handlers) { for (const handler of handlers) { handler(message.params) } } + + // Dispatch to session-aware handlers when sessionId is present + if (message.sessionId) { + const sessionHandlers = this.sessionEventHandlers.get(message.method) + if (sessionHandlers) { + for (const handler of sessionHandlers) { + handler(message.params, message.sessionId) + } + } + } } } } diff --git a/packages/browseros-agent/apps/server/src/browser/backends/types.ts b/packages/browseros-agent/apps/server/src/browser/backends/types.ts index 2e04803c7..669e03ad6 100644 --- a/packages/browseros-agent/apps/server/src/browser/backends/types.ts +++ b/packages/browseros-agent/apps/server/src/browser/backends/types.ts @@ -6,6 +6,10 @@ export interface CdpBackend extends ProtocolApi { isConnected(): boolean getTargets(): Promise session(sessionId: string): ProtocolApi + onSessionEvent( + event: string, + handler: (params: unknown, sessionId: string) => void, + ): () => void } export interface ControllerBackend { diff --git a/packages/browseros-agent/apps/server/src/browser/browser.ts b/packages/browseros-agent/apps/server/src/browser/browser.ts index ba1ec593c..b2178e8a7 100644 --- a/packages/browseros-agent/apps/server/src/browser/browser.ts +++ b/packages/browseros-agent/apps/server/src/browser/browser.ts @@ -3,6 +3,11 @@ import { logger } from '../lib/logger' import type { CdpBackend, ControllerBackend } from './backends/types' import type { BookmarkNode } from './bookmarks' import * as bookmarks from './bookmarks' +import { + ConsoleCollector, + type GetConsoleLogsOptions, + type GetConsoleLogsResult, +} from './console-collector' import { buildContentMarkdownExpression, type ContentMarkdownOptions, @@ -84,6 +89,7 @@ export class Browser { private cdp: CdpBackend // biome-ignore lint/correctness/noUnusedPrivateClassMembers: kept for later removal private controller: ControllerBackend + private consoleCollector: ConsoleCollector private pages = new Map() private sessions = new Map() private nextPageId = 1 @@ -91,6 +97,7 @@ export class Browser { constructor(cdp: CdpBackend, controller: ControllerBackend) { this.cdp = cdp this.controller = controller + this.consoleCollector = new ConsoleCollector(cdp) this.setupEventHandlers() } @@ -123,11 +130,14 @@ export class Browser { throw new Error( `Unknown page ${page}. Use list_pages to see available pages.`, ) - const sessionId = await this.attachToPage(info.targetId) + const sessionId = await this.attachToPage(info.targetId, page) return this.cdp.session(sessionId) } - private async attachToPage(targetId: string): Promise { + private async attachToPage( + targetId: string, + pageId: number, + ): Promise { const cached = this.sessions.get(targetId) if (cached) return cached @@ -143,10 +153,13 @@ export class Browser { session.Page.enable(), session.DOM.enable(), session.Runtime.enable(), + session.Log.enable(), session.Accessibility.enable(), ]) this.sessions.set(targetId, sessionId) + this.consoleCollector.attach(pageId, sessionId) + return sessionId } @@ -204,6 +217,7 @@ export class Browser { for (const [pageId, info] of this.pages) { if (!seenTargetIds.has(info.targetId)) { + this.consoleCollector.detach(pageId) this.pages.delete(pageId) } } @@ -290,6 +304,7 @@ export class Browser { `Unknown page ${page}. Use list_pages to see available pages.`, ) await this.cdp.Browser.closeTab({ tabId: info.tabId }) + this.consoleCollector.detach(page) this.pages.delete(page) this.sessions.delete(info.targetId) } @@ -1236,4 +1251,14 @@ export class Browser { async closeTabGroup(groupId: string): Promise { return tabGroups.closeTabGroup(this.cdp, groupId) } + + // --- Console --- + + async getConsoleLogs( + page: number, + opts?: GetConsoleLogsOptions, + ): Promise { + await this.resolveSession(page) + return this.consoleCollector.getLogs(page, opts) + } } diff --git a/packages/browseros-agent/apps/server/src/browser/console-collector.ts b/packages/browseros-agent/apps/server/src/browser/console-collector.ts new file mode 100644 index 000000000..76d7b0b3b --- /dev/null +++ b/packages/browseros-agent/apps/server/src/browser/console-collector.ts @@ -0,0 +1,219 @@ +import type { EntryAddedEvent } from '@browseros/cdp-protocol/domains/log' +import type { + ConsoleAPICalledEvent, + ExceptionThrownEvent, + RemoteObject, +} from '@browseros/cdp-protocol/domains/runtime' +import { CONTENT_LIMITS } from '@browseros/shared/constants/limits' +import type { CdpBackend } from './backends/types' + +export type ConsoleLevel = 'error' | 'warning' | 'info' | 'debug' + +export interface ConsoleEntry { + source: 'console' | 'exception' | 'browser' + level: ConsoleLevel + text: string + url?: string + lineNumber?: number + timestamp: number +} + +export interface GetConsoleLogsOptions { + level?: ConsoleLevel + search?: string + limit?: number + clear?: boolean +} + +export interface GetConsoleLogsResult { + entries: ConsoleEntry[] + totalCount: number +} + +// Lower number = higher severity +const LEVEL_PRIORITY: Record = { + error: 0, + warning: 1, + info: 2, + debug: 3, +} + +const CONSOLE_TYPE_TO_LEVEL: Record = { + error: 'error', + assert: 'error', + warning: 'warning', + log: 'info', + info: 'info', + dir: 'info', + dirxml: 'info', + table: 'info', + count: 'info', + timeEnd: 'info', + debug: 'debug', + trace: 'debug', + clear: 'debug', + startGroup: 'debug', + startGroupCollapsed: 'debug', + endGroup: 'debug', + profile: 'debug', + profileEnd: 'debug', +} + +const LOG_LEVEL_MAP: Record = { + error: 'error', + warning: 'warning', + info: 'info', + verbose: 'debug', +} + +export class ConsoleCollector { + private readonly buffers = new Map() + private readonly sessionToPage = new Map() + private readonly pageToSession = new Map() + private readonly maxEntries = CONTENT_LIMITS.CONSOLE_BUFFER_MAX_ENTRIES + + constructor(cdp: CdpBackend) { + // Single handler per event type — O(1) routing via sessionToPage lookup + cdp.onSessionEvent('Runtime.consoleAPICalled', (params, sessionId) => { + const pageId = this.sessionToPage.get(sessionId) + if (pageId === undefined) return + this.handleConsoleAPI(pageId, params as ConsoleAPICalledEvent) + }) + + cdp.onSessionEvent('Runtime.exceptionThrown', (params, sessionId) => { + const pageId = this.sessionToPage.get(sessionId) + if (pageId === undefined) return + this.handleException(pageId, params as ExceptionThrownEvent) + }) + + cdp.onSessionEvent('Log.entryAdded', (params, sessionId) => { + const pageId = this.sessionToPage.get(sessionId) + if (pageId === undefined) return + this.handleLogEntry(pageId, params as EntryAddedEvent) + }) + + // Clear buffer on main-frame navigation + cdp.onSessionEvent('Page.frameNavigated', (params, sessionId) => { + const pageId = this.sessionToPage.get(sessionId) + if (pageId === undefined) return + const frame = (params as { frame: { parentId?: string } }).frame + if (!frame.parentId) { + this.buffers.set(pageId, []) + } + }) + } + + attach(pageId: number, sessionId: string): void { + if (!this.buffers.has(pageId)) { + this.buffers.set(pageId, []) + } + // Clean up old session mapping if session changed (re-attach after detach) + const oldSession = this.pageToSession.get(pageId) + if (oldSession && oldSession !== sessionId) { + this.sessionToPage.delete(oldSession) + } + this.sessionToPage.set(sessionId, pageId) + this.pageToSession.set(pageId, sessionId) + } + + detach(pageId: number): void { + const sessionId = this.pageToSession.get(pageId) + if (sessionId) this.sessionToPage.delete(sessionId) + this.pageToSession.delete(pageId) + this.buffers.delete(pageId) + } + + getLogs(pageId: number, opts?: GetConsoleLogsOptions): GetConsoleLogsResult { + const buffer = this.buffers.get(pageId) ?? [] + const levelThreshold = LEVEL_PRIORITY[opts?.level ?? 'info'] + + // Filter by level + let filtered = buffer.filter( + (e) => LEVEL_PRIORITY[e.level] <= levelThreshold, + ) + + // Filter by search text + if (opts?.search) { + const term = opts.search.toLowerCase() + filtered = filtered.filter((e) => e.text.toLowerCase().includes(term)) + } + + // Return most recent entries up to limit + const totalCount = filtered.length + const limit = Math.min( + opts?.limit ?? CONTENT_LIMITS.CONSOLE_DEFAULT_LIMIT, + CONTENT_LIMITS.CONSOLE_MAX_LIMIT, + ) + const entries = filtered.slice(-limit) + + if (opts?.clear) { + this.buffers.set(pageId, []) + } + + return { entries, totalCount } + } + + private addEntry(pageId: number, entry: ConsoleEntry): void { + const buffer = this.buffers.get(pageId) + if (!buffer) return + + // FIFO eviction when buffer is full + if (buffer.length >= this.maxEntries) { + buffer.shift() + } + buffer.push(entry) + } + + private handleConsoleAPI(pageId: number, event: ConsoleAPICalledEvent): void { + const level = CONSOLE_TYPE_TO_LEVEL[event.type] ?? 'info' + const text = serializeArgs(event.args) + const frame = event.stackTrace?.callFrames[0] + + this.addEntry(pageId, { + source: 'console', + level, + text, + url: frame?.url, + lineNumber: frame?.lineNumber, + timestamp: event.timestamp, + }) + } + + private handleException(pageId: number, event: ExceptionThrownEvent): void { + const details = event.exceptionDetails + const text = details.exception?.description ?? details.text + + this.addEntry(pageId, { + source: 'exception', + level: 'error', + text, + url: details.url ?? details.stackTrace?.callFrames[0]?.url, + lineNumber: details.lineNumber, + timestamp: event.timestamp, + }) + } + + private handleLogEntry(pageId: number, event: EntryAddedEvent): void { + const entry = event.entry + const level = LOG_LEVEL_MAP[entry.level] ?? 'info' + + this.addEntry(pageId, { + source: 'browser', + level, + text: entry.text, + url: entry.url, + lineNumber: entry.lineNumber, + timestamp: entry.timestamp, + }) + } +} + +function serializeArgs(args: RemoteObject[]): string { + return args + .map((arg) => { + if (arg.type === 'string') return arg.value as string + if (arg.value !== undefined) return String(arg.value) + return arg.description ?? `[${arg.type}]` + }) + .join(' ') +} diff --git a/packages/browseros-agent/apps/server/src/tools/console.ts b/packages/browseros-agent/apps/server/src/tools/console.ts new file mode 100644 index 000000000..ce5bbf4e3 --- /dev/null +++ b/packages/browseros-agent/apps/server/src/tools/console.ts @@ -0,0 +1,95 @@ +import { CONTENT_LIMITS } from '@browseros/shared/constants/limits' +import { z } from 'zod' +import type { ConsoleLevel } from '../browser/console-collector' +import { defineTool } from './framework' + +const pageParam = z.number().describe('Page ID (from list_pages)') + +export const get_console_logs = defineTool({ + name: 'get_console_logs', + description: + 'Get browser console output (logs, warnings, errors, exceptions) for a page. Use to debug JavaScript errors, failed network requests, or unexpected page behavior.', + input: z.object({ + page: pageParam, + level: z + .enum(['error', 'warning', 'info', 'debug']) + .default('info') + .describe( + 'Minimum severity level. "error" = errors only, "warning" = errors + warnings, "info" = errors + warnings + logs (default), "debug" = everything', + ), + search: z + .string() + .optional() + .describe('Filter entries containing this text (case-insensitive)'), + limit: z + .number() + .min(1) + .max(CONTENT_LIMITS.CONSOLE_MAX_LIMIT) + .optional() + .describe( + `Max entries to return (default ${CONTENT_LIMITS.CONSOLE_DEFAULT_LIMIT}, max ${CONTENT_LIMITS.CONSOLE_MAX_LIMIT}). Returns most recent entries.`, + ), + clear: z + .boolean() + .default(false) + .describe('Clear the console buffer after reading'), + }), + output: z.object({ + entries: z.array( + z.object({ + source: z.enum(['console', 'exception', 'browser']), + level: z.enum(['error', 'warning', 'info', 'debug']), + text: z.string(), + url: z.string().optional(), + lineNumber: z.number().optional(), + timestamp: z.number(), + }), + ), + totalCount: z.number(), + returnedCount: z.number(), + }), + handler: async (args, ctx, response) => { + const result = await ctx.browser.getConsoleLogs(args.page, { + level: args.level as ConsoleLevel, + search: args.search, + limit: args.limit, + clear: args.clear, + }) + + // Empty results + if (result.entries.length === 0) { + response.text( + result.totalCount === 0 + ? `No console output for page ${args.page}.` + : `No entries match the filter (${result.totalCount} total entries in buffer).`, + ) + response.data({ + entries: [], + totalCount: result.totalCount, + returnedCount: 0, + }) + return + } + + // Format each entry as [level] text — url:line + const lines = result.entries.map((e) => { + const location = e.url + ? ` — ${e.url}${e.lineNumber !== undefined ? `:${e.lineNumber}` : ''}` + : '' + return `[${e.level}] ${e.text}${location}` + }) + + // Build header with count info + const header = + result.entries.length < result.totalCount + ? `Console logs for page ${args.page} (showing ${result.entries.length} of ${result.totalCount}, level ≥ ${args.level}):` + : `Console logs for page ${args.page} (${result.entries.length} entries, level ≥ ${args.level}):` + + response.text(`${header}\n\n${lines.join('\n')}`) + response.data({ + entries: result.entries, + totalCount: result.totalCount, + returnedCount: result.entries.length, + }) + }, +}) diff --git a/packages/browseros-agent/apps/server/src/tools/registry.ts b/packages/browseros-agent/apps/server/src/tools/registry.ts index f017b6041..427c0472a 100644 --- a/packages/browseros-agent/apps/server/src/tools/registry.ts +++ b/packages/browseros-agent/apps/server/src/tools/registry.ts @@ -7,6 +7,7 @@ import { update_bookmark, } from './bookmarks' import { browseros_info } from './browseros-info' +import { get_console_logs } from './console' import { get_dom, search_dom } from './dom' import { delete_history_range, @@ -80,7 +81,7 @@ export const registry = createRegistry([ close_page, // wait_for, // temporarily disabled - // Observation (8) + // Observation (9) take_snapshot, take_enhanced_snapshot, get_page_content, @@ -89,6 +90,7 @@ export const registry = createRegistry([ search_dom, take_screenshot, evaluate_script, + get_console_logs, // Input (14) click, diff --git a/packages/browseros-agent/packages/shared/src/constants/limits.ts b/packages/browseros-agent/packages/shared/src/constants/limits.ts index e9ec727a7..28bb1da3b 100644 --- a/packages/browseros-agent/packages/shared/src/constants/limits.ts +++ b/packages/browseros-agent/packages/shared/src/constants/limits.ts @@ -82,4 +82,7 @@ export const CONTENT_LIMITS = { BODY_CONTEXT_SIZE: 10_000, MAX_QUEUE_SIZE: 1_000, CONSOLE_META_CHAR: 1_000, + CONSOLE_BUFFER_MAX_ENTRIES: 500, + CONSOLE_DEFAULT_LIMIT: 50, + CONSOLE_MAX_LIMIT: 200, } as const