import asyncio import json import logging import math import os from typing import Generic, TypeVar import anyio try: from lmnr import Laminar # type: ignore except ImportError: Laminar = None # type: ignore from pydantic import BaseModel from browser_use.agent.views import ActionModel, ActionResult from browser_use.browser import BrowserSession from browser_use.browser.events import ( ClickCoordinateEvent, ClickElementEvent, CloseTabEvent, GetDropdownOptionsEvent, GoBackEvent, NavigateToUrlEvent, ScrollEvent, ScrollToTextEvent, SendKeysEvent, SwitchTabEvent, TypeTextEvent, UploadFileEvent, ) from browser_use.browser.views import BrowserError from browser_use.dom.service import EnhancedDOMTreeNode from browser_use.filesystem.file_system import FileSystem from browser_use.llm.base import BaseChatModel from browser_use.llm.messages import SystemMessage, UserMessage from browser_use.observability import observe_debug from browser_use.tools.registry.service import Registry from browser_use.tools.utils import get_click_description from browser_use.tools.views import ( ClickElementAction, ClickElementActionIndexOnly, CloseTabAction, DoneAction, ExtractAction, FindElementsAction, GetDropdownOptionsAction, InputTextAction, NavigateAction, NoParamsAction, SaveAsPdfAction, ScreenshotAction, ScrollAction, SearchAction, SearchPageAction, SelectDropdownOptionAction, SendKeysAction, StructuredOutputAction, SwitchTabAction, UploadFileAction, ) from browser_use.utils import create_task_with_error_handling, sanitize_surrogates, time_execution_sync logger = logging.getLogger(__name__) # Import EnhancedDOMTreeNode and rebuild event models that have forward references to it # This must be done after all imports are complete ClickElementEvent.model_rebuild() TypeTextEvent.model_rebuild() ScrollEvent.model_rebuild() UploadFileEvent.model_rebuild() Context = TypeVar('Context') T = TypeVar('T', bound=BaseModel) # Default header/footer templates for save_as_pdf, mirroring the metadata that # Chrome's own Print dialog renders by default: the date in the header and the # page URL + page numbers in the footer. Chrome injects values into elements # bearing the magic classes `date`, `title`, `url`, `pageNumber` and `totalPages`. # A font-size MUST be set explicitly — Chrome defaults header/footer text to 0px, # so omitting it renders an invisible (blank) header/footer. _DEFAULT_PDF_HEADER_TEMPLATE = ( '
' ) _DEFAULT_PDF_FOOTER_TEMPLATE = ( '
' # A flex item defaults to min-width:auto and won't shrink below its content, # so a long/unbroken URL would overflow and push the page count off-page. # min-width:0 + ellipsis lets the URL truncate while page numbers stay put. '' ' /
' ) # Global per-action timeout: last-resort guard against hung event handlers. # Individual CDP calls (Page.navigate etc.) have their own shorter timeouts, # but event-bus `await event` and `event_result()` calls have none — if a # watchdog handler blocks on a dead CDP WebSocket, the action can hang past # any agent-level watchdog. This cap ensures every action returns within a # bounded window with an ActionResult(error=...) instead of hanging silently. # # The default (180s) sits above the longest built-in inner timeout — the extract # action's page_extraction_llm.ainvoke at 120s — plus comfortable grace, so # slow-but-valid LLM-backed actions aren't truncated. Override per-call via # BROWSER_USE_ACTION_TIMEOUT_S env var or tools.act(action_timeout=...). _ACTION_TIMEOUT_FALLBACK_S = 180.0 def _parse_env_action_timeout(raw: str | None) -> float: """Parse BROWSER_USE_ACTION_TIMEOUT_S defensively. Accepts only finite positive values. Empty, non-numeric, inf, nan, or non-positive values fall back to the hardcoded default with a warning — these would otherwise make every action time out immediately (nan) or disable the hang guard entirely (inf / negative / zero). """ if raw is None or raw == '': return _ACTION_TIMEOUT_FALLBACK_S try: parsed = float(raw) except ValueError: logging.getLogger(__name__).warning( 'Invalid BROWSER_USE_ACTION_TIMEOUT_S=%r; falling back to %.0fs', raw, _ACTION_TIMEOUT_FALLBACK_S, ) return _ACTION_TIMEOUT_FALLBACK_S if not math.isfinite(parsed) or parsed <= 0: logging.getLogger(__name__).warning( 'BROWSER_USE_ACTION_TIMEOUT_S=%r is not a finite positive number; falling back to %.0fs', raw, _ACTION_TIMEOUT_FALLBACK_S, ) return _ACTION_TIMEOUT_FALLBACK_S return parsed _DEFAULT_ACTION_TIMEOUT_S = _parse_env_action_timeout(os.getenv('BROWSER_USE_ACTION_TIMEOUT_S')) def _coerce_valid_action_timeout(value: float | None) -> float: """Normalize a caller-supplied action_timeout to a finite positive value. Mirrors the env-var guard so the public `tools.act(action_timeout=...)` override path has the same defenses: nan / inf / <=0 make actions either time out immediately or never, which would silently defeat the hang guard this module exists to provide. Fall back to the env-derived default with a warning instead. """ if value is None: return _DEFAULT_ACTION_TIMEOUT_S if not math.isfinite(value) or value <= 0: logging.getLogger(__name__).warning( 'action_timeout=%r is not a finite positive number; falling back to %.0fs', value, _DEFAULT_ACTION_TIMEOUT_S, ) return _DEFAULT_ACTION_TIMEOUT_S return float(value) def _detect_sensitive_key_name(text: str, sensitive_data: dict[str, str | dict[str, str]] | None) -> str | None: """Detect which sensitive key name corresponds to the given text value.""" if not sensitive_data or not text: return None # Collect all sensitive values and their keys for domain_or_key, content in sensitive_data.items(): if isinstance(content, dict): # New format: {domain: {key: value}} for key, value in content.items(): if value and value == text: return key elif content: # Old format: {key: value} if content == text: return domain_or_key return None def handle_browser_error(e: BrowserError) -> ActionResult: if e.long_term_memory is not None: if e.short_term_memory is not None: return ActionResult( extracted_content=e.short_term_memory, error=e.long_term_memory, include_extracted_content_only_once=True ) else: return ActionResult(error=e.long_term_memory) # Fallback to original error handling if long_term_memory is None logger.warning( '⚠️ A BrowserError was raised without long_term_memory - always set long_term_memory when raising BrowserError to propagate right messages to LLM.' ) raise e # --- JS templates for search_page and find_elements --- _SEARCH_PAGE_JS_BODY = """\ try { var scope = CSS_SCOPE ? document.querySelector(CSS_SCOPE) : document.body; if (!scope) { return {error: 'CSS scope selector not found: ' + CSS_SCOPE, matches: [], total: 0}; } var walker = document.createTreeWalker(scope, NodeFilter.SHOW_TEXT); var fullText = ''; var nodeOffsets = []; while (walker.nextNode()) { var node = walker.currentNode; var text = node.textContent; if (text && text.trim()) { nodeOffsets.push({offset: fullText.length, length: text.length, node: node}); fullText += text; } } var re; try { var flags = CASE_SENSITIVE ? 'g' : 'gi'; if (IS_REGEX) { re = new RegExp(PATTERN, flags); } else { re = new RegExp(PATTERN.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), flags); } } catch (e) { return {error: 'Invalid regex pattern: ' + e.message, matches: [], total: 0}; } var matches = []; var match; var totalFound = 0; while ((match = re.exec(fullText)) !== null) { totalFound++; if (matches.length < MAX_RESULTS) { var start = Math.max(0, match.index - CONTEXT_CHARS); var end = Math.min(fullText.length, match.index + match[0].length + CONTEXT_CHARS); var context = fullText.slice(start, end); var elementPath = ''; for (var i = 0; i < nodeOffsets.length; i++) { var no = nodeOffsets[i]; if (no.offset <= match.index && no.offset + no.length > match.index) { elementPath = _getPath(no.node.parentElement); break; } } matches.push({ match_text: match[0], context: (start > 0 ? '...' : '') + context + (end < fullText.length ? '...' : ''), element_path: elementPath, char_position: match.index }); } if (match[0].length === 0) re.lastIndex++; } return {matches: matches, total: totalFound, has_more: totalFound > MAX_RESULTS}; } catch (e) { return {error: 'search_page error: ' + e.message, matches: [], total: 0}; } function _getPath(el) { var parts = []; var current = el; while (current && current !== document.body && current !== document) { var desc = current.tagName ? current.tagName.toLowerCase() : ''; if (!desc) break; if (current.id) desc += '#' + current.id; else if (current.className && typeof current.className === 'string') { var classes = current.className.trim().split(/\\s+/).slice(0, 2).join('.'); if (classes) desc += '.' + classes; } parts.unshift(desc); current = current.parentElement; } return parts.join(' > '); } """ _FIND_ELEMENTS_JS_BODY = """\ try { var elements; try { elements = document.querySelectorAll(SELECTOR); } catch (e) { return {error: 'Invalid CSS selector: ' + e.message, elements: [], total: 0}; } var total = elements.length; var limit = Math.min(total, MAX_RESULTS); var results = []; for (var i = 0; i < limit; i++) { var el = elements[i]; var item = {index: i, tag: el.tagName.toLowerCase()}; if (INCLUDE_TEXT) { var text = (el.textContent || '').trim(); item.text = text.length > 300 ? text.slice(0, 300) + '...' : text; } if (ATTRIBUTES && ATTRIBUTES.length > 0) { item.attrs = {}; for (var j = 0; j < ATTRIBUTES.length; j++) { var attrName = ATTRIBUTES[j]; var val; // Use resolved DOM property for src/href to get absolute URLs if ((attrName === 'src' || attrName === 'href') && typeof el[attrName] === 'string' && el[attrName] !== '') { val = el[attrName]; } else { val = el.getAttribute(attrName); } if (val !== null) { item.attrs[attrName] = val.length > 500 ? val.slice(0, 500) + '...' : val; } } } item.children_count = el.children.length; results.push(item); } return {elements: results, total: total, showing: limit}; } catch (e) { return {error: 'find_elements error: ' + e.message, elements: [], total: 0}; } """ def _build_search_page_js( pattern: str, regex: bool, case_sensitive: bool, context_chars: int, css_scope: str | None, max_results: int, ) -> str: """Build JS IIFE for search_page with safe parameter injection.""" params_js = ( f'var PATTERN = {json.dumps(pattern)};\n' f'var IS_REGEX = {json.dumps(regex)};\n' f'var CASE_SENSITIVE = {json.dumps(case_sensitive)};\n' f'var CONTEXT_CHARS = {json.dumps(context_chars)};\n' f'var CSS_SCOPE = {json.dumps(css_scope)};\n' f'var MAX_RESULTS = {json.dumps(max_results)};\n' ) return '(function() {\n' + params_js + _SEARCH_PAGE_JS_BODY + '\n})()' def _build_find_elements_js( selector: str, attributes: list[str] | None, max_results: int, include_text: bool, ) -> str: """Build JS IIFE for find_elements with safe parameter injection.""" params_js = ( f'var SELECTOR = {json.dumps(selector)};\n' f'var ATTRIBUTES = {json.dumps(attributes)};\n' f'var MAX_RESULTS = {json.dumps(max_results)};\n' f'var INCLUDE_TEXT = {json.dumps(include_text)};\n' ) return '(function() {\n' + params_js + _FIND_ELEMENTS_JS_BODY + '\n})()' def _format_search_results(data: dict, pattern: str) -> str: """Format search_page CDP result into human-readable text for the agent.""" if not isinstance(data, dict): return f'search_page returned unexpected result: {data}' matches = data.get('matches', []) total = data.get('total', 0) has_more = data.get('has_more', False) if total == 0: return f'No matches found for "{pattern}" on page.' lines = [f'Found {total} match{"es" if total != 1 else ""} for "{pattern}" on page:'] lines.append('') for i, m in enumerate(matches): context = m.get('context', '') path = m.get('element_path', '') loc = f' (in {path})' if path else '' lines.append(f'[{i + 1}] {context}{loc}') if has_more: lines.append(f'\n... showing {len(matches)} of {total} total matches. Increase max_results to see more.') return '\n'.join(lines) def _format_find_results(data: dict, selector: str) -> str: """Format find_elements CDP result into human-readable text for the agent.""" if not isinstance(data, dict): return f'find_elements returned unexpected result: {data}' elements = data.get('elements', []) total = data.get('total', 0) showing = data.get('showing', 0) if total == 0: return f'No elements found matching "{selector}".' lines = [f'Found {total} element{"s" if total != 1 else ""} matching "{selector}":'] lines.append('') for el in elements: idx = el.get('index', 0) tag = el.get('tag', '?') text = el.get('text', '') attrs = el.get('attrs', {}) children = el.get('children_count', 0) # Build element description parts = [f'[{idx}] <{tag}>'] if text: # Collapse whitespace for readability display_text = ' '.join(text.split()) if len(display_text) > 120: display_text = display_text[:120] + '...' parts.append(f'"{display_text}"') if attrs: attr_strs = [f'{k}="{v}"' for k, v in attrs.items()] parts.append('{' + ', '.join(attr_strs) + '}') parts.append(f'({children} children)') lines.append(' '.join(parts)) if showing < total: lines.append(f'\nShowing {showing} of {total} total elements. Increase max_results to see more.') return '\n'.join(lines) def _is_autocomplete_field(node: EnhancedDOMTreeNode) -> bool: """Detect if a node is an autocomplete/combobox field from its attributes.""" attrs = node.attributes or {} if attrs.get('role') == 'combobox': return True aria_ac = attrs.get('aria-autocomplete', '') if aria_ac and aria_ac != 'none': return True if attrs.get('list'): return True haspopup = attrs.get('aria-haspopup', '') if haspopup and haspopup != 'false' and (attrs.get('aria-controls') or attrs.get('aria-owns')): return True return False class Tools(Generic[Context]): def __init__( self, exclude_actions: list[str] | None = None, output_model: type[T] | None = None, display_files_in_done_text: bool = True, ): self.registry = Registry[Context](exclude_actions if exclude_actions is not None else []) self.display_files_in_done_text = display_files_in_done_text self._output_model: type[BaseModel] | None = output_model self._coordinate_clicking_enabled: bool = False """Register all default browser actions""" self._register_done_action(output_model) # Basic Navigation Actions @self.registry.action( '', param_model=SearchAction, terminates_sequence=True, ) async def search(params: SearchAction, browser_session: BrowserSession): import urllib.parse # Encode query for URL safety encoded_query = urllib.parse.quote_plus(params.query) # Build search URL based on search engine search_engines = { 'duckduckgo': f'https://duckduckgo.com/?q={encoded_query}', 'google': f'https://www.google.com/search?q={encoded_query}&udm=14', 'bing': f'https://www.bing.com/search?q={encoded_query}', } if params.engine.lower() not in search_engines: return ActionResult(error=f'Unsupported search engine: {params.engine}. Options: duckduckgo, google, bing') search_url = search_engines[params.engine.lower()] # Simple tab logic: use current tab by default use_new_tab = False # Dispatch navigation event try: event = browser_session.event_bus.dispatch( NavigateToUrlEvent( url=search_url, new_tab=use_new_tab, ) ) await event await event.event_result(raise_if_any=True, raise_if_none=False) memory = f"Searched {params.engine.title()} for '{params.query}'" msg = f'🔍 {memory}' logger.info(msg) return ActionResult(extracted_content=memory, long_term_memory=memory) except Exception as e: logger.error(f'Failed to search {params.engine}: {e}') return ActionResult(error=f'Failed to search {params.engine} for "{params.query}": {str(e)}') @self.registry.action( '', param_model=NavigateAction, terminates_sequence=True, ) async def navigate(params: NavigateAction, browser_session: BrowserSession): try: # Dispatch navigation event event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=params.url, new_tab=params.new_tab)) await event await event.event_result(raise_if_any=True, raise_if_none=False) # Health check: detect empty DOM for http/https pages and retry once. # Uses _root is None (truly blank) OR empty llm_representation() (no actionable # content for the LLM, e.g. SPA not yet rendered, empty body). # NOTE: llm_representation() returns a non-empty placeholder when _root is None, # so we must check _root is None separately — not rely on the repr string alone. def _page_appears_empty(s) -> bool: return s.dom_state._root is None or not s.dom_state.llm_representation().strip() if not params.new_tab: state = await browser_session.get_browser_state_summary(include_screenshot=False) url_is_http = state.url.lower().startswith(('http://', 'https://')) if url_is_http and _page_appears_empty(state): browser_session.logger.warning( f'⚠️ Empty DOM detected after navigation to {params.url}, waiting 3s and rechecking...' ) await asyncio.sleep(3.0) state = await browser_session.get_browser_state_summary(include_screenshot=False) if state.url.lower().startswith(('http://', 'https://')) and _page_appears_empty(state): # Second attempt: reload the page and wait longer browser_session.logger.warning(f'⚠️ Still empty after 3s, attempting page reload for {params.url}...') reload_event = browser_session.event_bus.dispatch(NavigateToUrlEvent(url=params.url, new_tab=False)) await reload_event await reload_event.event_result(raise_if_any=False, raise_if_none=False) await asyncio.sleep(5.0) state = await browser_session.get_browser_state_summary(include_screenshot=False) if state.url.lower().startswith(('http://', 'https://')) and state.dom_state._root is None: return ActionResult( error=f'Page loaded but returned empty content for {params.url}. ' f'The page may require JavaScript that failed to render, use anti-bot measures, ' f'or have a connection issue (e.g. tunnel/proxy error). Try a different URL or approach.' ) if params.new_tab: memory = f'Opened new tab with URL {params.url}' msg = f'🔗 Opened new tab with url {params.url}' else: memory = f'Navigated to {params.url}' msg = f'🔗 {memory}' logger.info(msg) return ActionResult(extracted_content=msg, long_term_memory=memory) except Exception as e: error_msg = str(e) # Always log the actual error first for debugging browser_session.logger.error(f'❌ Navigation failed: {error_msg}') # Check if it's specifically a RuntimeError about CDP client if isinstance(e, RuntimeError) and 'CDP client not initialized' in error_msg: browser_session.logger.error('❌ Browser connection failed - CDP client not properly initialized') return ActionResult(error=f'Browser connection error: {error_msg}') # Check for network-related errors elif any( err in error_msg for err in [ 'ERR_NAME_NOT_RESOLVED', 'ERR_INTERNET_DISCONNECTED', 'ERR_CONNECTION_REFUSED', 'ERR_TIMED_OUT', 'ERR_TUNNEL_CONNECTION_FAILED', 'net::', ] ): site_unavailable_msg = f'Navigation failed - site unavailable: {params.url}' browser_session.logger.warning(f'⚠️ {site_unavailable_msg} - {error_msg}') return ActionResult(error=site_unavailable_msg) else: # Return error in ActionResult instead of re-raising return ActionResult(error=f'Navigation failed: {str(e)}') @self.registry.action('Go back', param_model=NoParamsAction, terminates_sequence=True) async def go_back(_: NoParamsAction, browser_session: BrowserSession): try: event = browser_session.event_bus.dispatch(GoBackEvent()) await event memory = 'Navigated back' msg = f'🔙 {memory}' logger.info(msg) return ActionResult(extracted_content=memory) except Exception as e: logger.error(f'Failed to dispatch GoBackEvent: {type(e).__name__}: {e}') error_msg = f'Failed to go back: {str(e)}' return ActionResult(error=error_msg) @self.registry.action('Wait for x seconds.') async def wait(seconds: int = 3): # Cap wait time at maximum 30 seconds # Reduce the wait time by 3 seconds to account for the llm call which takes at least 3 seconds # So if the model decides to wait for 5 seconds, the llm call took at least 3 seconds, so we only need to wait for 2 seconds # Note by Mert: the above doesnt make sense because we do the LLM call right after this or this could be followed by another action after which we would like to wait # so I revert this. actual_seconds = min(max(seconds - 1, 0), 30) memory = f'Waited for {seconds} seconds' logger.info(f'🕒 waited for {seconds} second{"" if seconds == 1 else "s"}') await asyncio.sleep(actual_seconds) return ActionResult(extracted_content=memory, long_term_memory=memory) # Helper function for coordinate conversion def _convert_llm_coordinates_to_viewport(llm_x: int, llm_y: int, browser_session: BrowserSession) -> tuple[int, int]: """Convert coordinates from LLM screenshot size to original viewport size.""" if browser_session.llm_screenshot_size and browser_session._original_viewport_size: original_width, original_height = browser_session._original_viewport_size llm_width, llm_height = browser_session.llm_screenshot_size # Convert coordinates using fractions actual_x = int((llm_x / llm_width) * original_width) actual_y = int((llm_y / llm_height) * original_height) logger.info( f'🔄 Converting coordinates: LLM ({llm_x}, {llm_y}) @ {llm_width}x{llm_height} ' f'→ Viewport ({actual_x}, {actual_y}) @ {original_width}x{original_height}' ) return actual_x, actual_y return llm_x, llm_y # Element Interaction Actions async def _detect_new_tab_opened( browser_session: BrowserSession, tabs_before: set[str], ) -> str: """Detect if a click opened a new tab and automatically switch to it.""" try: # Brief delay to allow CDP Target.attachedToTarget events to propagate # and be processed by SessionManager._handle_target_attached await asyncio.sleep(0.05) tabs_after = await browser_session.get_tabs() new_tabs = [t for t in tabs_after if t.target_id not in tabs_before] if new_tabs: new_tab = new_tabs[0] new_tab_id = new_tab.target_id[-4:] # Auto-switch to the new tab so the agent can immediately interact with it try: switch_event = browser_session.event_bus.dispatch(SwitchTabEvent(target_id=new_tab.target_id)) await switch_event await switch_event.event_result(raise_if_any=False, raise_if_none=False) return f'. Automatically switched to new tab (tab_id: {new_tab_id}).' except Exception: return f'. Note: This opened a new tab (tab_id: {new_tab_id}) - switch to it if you need to interact with the new page.' except Exception: pass return '' async def _click_by_coordinate(params: ClickElementAction, browser_session: BrowserSession) -> ActionResult: # Ensure coordinates are provided (type safety) if params.coordinate_x is None or params.coordinate_y is None: return ActionResult(error='Both coordinate_x and coordinate_y must be provided') try: # Convert coordinates from LLM size to original viewport size if resizing was used actual_x, actual_y = _convert_llm_coordinates_to_viewport( params.coordinate_x, params.coordinate_y, browser_session ) # Capture tab IDs before click to detect new tabs tabs_before = {t.target_id for t in await browser_session.get_tabs()} # Highlight the coordinate being clicked (truly non-blocking) asyncio.create_task(browser_session.highlight_coordinate_click(actual_x, actual_y)) # Dispatch ClickCoordinateEvent - handler will check for safety and click event = browser_session.event_bus.dispatch( ClickCoordinateEvent(coordinate_x=actual_x, coordinate_y=actual_y, force=True) ) await event # Wait for handler to complete and get any exception or metadata click_metadata = await event.event_result(raise_if_any=True, raise_if_none=False) # Check for validation errors (only happens when force=False) if isinstance(click_metadata, dict) and 'validation_error' in click_metadata: error_msg = click_metadata['validation_error'] return ActionResult(error=error_msg) memory = f'Clicked on coordinate {params.coordinate_x}, {params.coordinate_y}' memory += await _detect_new_tab_opened(browser_session, tabs_before) logger.info(f'🖱️ {memory}') return ActionResult( extracted_content=memory, metadata={'click_x': actual_x, 'click_y': actual_y}, ) except BrowserError as e: return handle_browser_error(e) except Exception as e: error_msg = f'Failed to click at coordinates ({params.coordinate_x}, {params.coordinate_y}).' return ActionResult(error=error_msg) async def _click_by_index( params: ClickElementAction | ClickElementActionIndexOnly, browser_session: BrowserSession ) -> ActionResult: assert params.index is not None try: assert params.index != 0, ( 'Cannot click on element with index 0. If there are no interactive elements use wait(), refresh(), etc. to troubleshoot' ) # Look up the node from the selector map node = await browser_session.get_element_by_index(params.index) if node is None: msg = f'Element index {params.index} not available - page may have changed. Try refreshing browser state.' logger.warning(f'⚠️ {msg}') return ActionResult(extracted_content=msg) # Get description of clicked element element_desc = get_click_description(node) # Capture tab IDs before click to detect new tabs tabs_before = {t.target_id for t in await browser_session.get_tabs()} # Highlight the element being clicked (truly non-blocking) create_task_with_error_handling( browser_session.highlight_interaction_element(node), name='highlight_click_element', suppress_exceptions=True ) event = browser_session.event_bus.dispatch(ClickElementEvent(node=node)) await event # Wait for handler to complete and get any exception or metadata click_metadata = await event.event_result(raise_if_any=True, raise_if_none=False) # Check if result contains validation error (e.g., trying to click elements.' in error_msg: try: return await dropdown_options( params=GetDropdownOptionsAction(index=params.index), browser_session=browser_session ) except Exception as dropdown_error: logger.debug( f'Failed to get dropdown options as shortcut during click on dropdown: {type(dropdown_error).__name__}: {dropdown_error}' ) return ActionResult(error=error_msg) # Build memory with element info memory = f'Clicked {element_desc}' memory += await _detect_new_tab_opened(browser_session, tabs_before) logger.info(f'🖱️ {memory}') # Include click coordinates in metadata if available return ActionResult( extracted_content=memory, metadata=click_metadata if isinstance(click_metadata, dict) else None, ) except BrowserError as e: return handle_browser_error(e) except Exception as e: error_msg = f'Failed to click element {params.index}: {str(e)}' return ActionResult(error=error_msg) # Store click handlers for re-registration self._click_by_index = _click_by_index self._click_by_coordinate = _click_by_coordinate # Register click action (index-only by default) self._register_click_action() @self.registry.action( 'Input text into element by index. Clears existing text by default; pass text="" to clear only, or clear=False to append.', param_model=InputTextAction, ) async def input( params: InputTextAction, browser_session: BrowserSession, has_sensitive_data: bool = False, sensitive_data: dict[str, str | dict[str, str]] | None = None, ): # Look up the node from the selector map node = await browser_session.get_element_by_index(params.index) if node is None: msg = f'Element index {params.index} not available - page may have changed. Try refreshing browser state.' logger.warning(f'⚠️ {msg}') return ActionResult(extracted_content=msg) # Highlight the element being typed into (truly non-blocking) create_task_with_error_handling( browser_session.highlight_interaction_element(node), name='highlight_type_element', suppress_exceptions=True ) # Dispatch type text event with node try: # Detect which sensitive key is being used sensitive_key_name = None if has_sensitive_data and sensitive_data: sensitive_key_name = _detect_sensitive_key_name(params.text, sensitive_data) event = browser_session.event_bus.dispatch( TypeTextEvent( node=node, text=params.text, clear=params.clear, is_sensitive=has_sensitive_data, sensitive_key_name=sensitive_key_name, ) ) await event input_metadata = await event.event_result(raise_if_any=True, raise_if_none=False) # Create message with sensitive data handling if has_sensitive_data: if sensitive_key_name: msg = f'Typed {sensitive_key_name}' log_msg = f'Typed <{sensitive_key_name}>' else: msg = 'Typed sensitive data' log_msg = 'Typed ' else: msg = f"Typed '{params.text}'" log_msg = f"Typed '{params.text}'" logger.debug(log_msg) # Check for value mismatch (non-sensitive only) actual_value = None if isinstance(input_metadata, dict): actual_value = input_metadata.pop('actual_value', None) if not has_sensitive_data and actual_value is not None and actual_value != params.text: msg += f"\n⚠️ Note: the field's actual value '{actual_value}' differs from typed text '{params.text}'. The page may have reformatted or autocompleted your input." # Check for autocomplete/combobox field — add mechanical delay for dropdown if _is_autocomplete_field(node): msg += '\n💡 This is an autocomplete field. Wait for suggestions to appear, then click the correct suggestion instead of pressing Enter.' # Only delay for true JS-driven autocomplete (combobox / aria-autocomplete), # not native or loose aria-haspopup which the browser handles instantly attrs = node.attributes or {} if attrs.get('role') == 'combobox' or (attrs.get('aria-autocomplete', '') not in ('', 'none')): await asyncio.sleep(0.4) # let JS dropdown populate before next action # Include input coordinates in metadata if available return ActionResult( extracted_content=msg, long_term_memory=msg, metadata=input_metadata if isinstance(input_metadata, dict) else None, ) except BrowserError as e: return handle_browser_error(e) except Exception as e: # Log the full error for debugging logger.error(f'Failed to dispatch TypeTextEvent: {type(e).__name__}: {e}') error_msg = f'Failed to type text into element {params.index}: {e}' return ActionResult(error=error_msg) @self.registry.action( '', param_model=UploadFileAction, ) async def upload_file( params: UploadFileAction, browser_session: BrowserSession, available_file_paths: list[str], file_system: FileSystem ): # Check if file is in available_file_paths (user-provided or downloaded files) # For remote browsers (is_local=False), we allow absolute remote paths even if not tracked locally if params.path not in available_file_paths: # Also check if it's a recently downloaded file that might not be in available_file_paths yet downloaded_files = browser_session.downloaded_files if params.path not in downloaded_files: # Finally, check if it's a file in the FileSystem service. # Only rewrite to the local FileSystem path on local sessions — # on remote sessions, params.path is meant to address a file on # the remote machine, and a coincidental basename collision with # a local managed file (e.g. `/tmp/note.md` colliding with a # local `note.md`) must not silently upload the local file. if browser_session.is_local and file_system and file_system.get_dir(): # Check if the file is actually managed by the FileSystem service # The path should be just the filename for FileSystem files file_obj = file_system.get_file(params.path) if file_obj: # Construct the upload path from the FileSystem-owned basename # (file_obj.full_name), NOT from params.path. The agent-controlled # params.path may contain '..' traversal sequences that escape # data_dir when naively joined — get_file() matches by basename # so a path like '../../../note.md' would otherwise resolve to a # sibling file outside the FileSystem directory. # GHSA-j9hj-92j8-jv9h. file_system_path = str(file_system.get_dir() / file_obj.full_name) # Defense in depth: refuse any path that resolves outside data_dir. real_path = os.path.realpath(file_system_path) real_dir = os.path.realpath(str(file_system.get_dir())) if not (real_path == real_dir or real_path.startswith(real_dir + os.sep)): msg = f'Upload of {params.path!r} escapes FileSystem directory; refusing.' logger.error(f'❌ {msg}') return ActionResult(error=msg) params = UploadFileAction(index=params.index, path=file_system_path) else: msg = f'File path {params.path} is not available. To fix: The user must add this file path to the available_file_paths parameter when creating the Agent. Example: Agent(task="...", llm=llm, browser=browser, available_file_paths=["{params.path}"])' logger.error(f'❌ {msg}') return ActionResult(error=msg) else: # If browser is remote, allow passing a remote-accessible absolute path if not browser_session.is_local: pass else: msg = f'File path {params.path} is not available. To fix: The user must add this file path to the available_file_paths parameter when creating the Agent. Example: Agent(task="...", llm=llm, browser=browser, available_file_paths=["{params.path}"])' raise BrowserError(message=msg, long_term_memory=msg) # For local browsers, ensure the file exists and has content if browser_session.is_local: if not os.path.exists(params.path): msg = f'File {params.path} does not exist' return ActionResult(error=msg) file_size = os.path.getsize(params.path) if file_size == 0: msg = f'File {params.path} is empty (0 bytes). The file may not have been saved correctly.' return ActionResult(error=msg) # Get the selector map to find the node selector_map = await browser_session.get_selector_map() if params.index not in selector_map: msg = f'Element with index {params.index} does not exist.' return ActionResult(error=msg) node = selector_map[params.index] # Try to find a file input element near the selected element file_input_node = browser_session.find_file_input_near_element(node) # Highlight the file input element if found (truly non-blocking) if file_input_node: create_task_with_error_handling( browser_session.highlight_interaction_element(file_input_node), name='highlight_file_input', suppress_exceptions=True, ) # If not found near the selected element, fallback to finding the closest file input to current scroll position if file_input_node is None: logger.info( f'No file upload element found near index {params.index}, searching for closest file input to scroll position' ) # Get current scroll position cdp_session = await browser_session.get_or_create_cdp_session() try: scroll_info = await cdp_session.cdp_client.send.Runtime.evaluate( params={'expression': 'window.scrollY || window.pageYOffset || 0'}, session_id=cdp_session.session_id ) current_scroll_y = scroll_info.get('result', {}).get('value', 0) except Exception: current_scroll_y = 0 # Find all file inputs in the selector map and pick the closest one to scroll position closest_file_input = None min_distance = float('inf') for idx, element in selector_map.items(): if browser_session.is_file_input(element): # Get element's Y position if element.absolute_position: element_y = element.absolute_position.y distance = abs(element_y - current_scroll_y) if distance < min_distance: min_distance = distance closest_file_input = element if closest_file_input: file_input_node = closest_file_input logger.info(f'Found file input closest to scroll position (distance: {min_distance}px)') # Highlight the fallback file input element (truly non-blocking) create_task_with_error_handling( browser_session.highlight_interaction_element(file_input_node), name='highlight_file_input_fallback', suppress_exceptions=True, ) else: msg = 'No file upload element found on the page' logger.error(msg) raise BrowserError(msg) # TODO: figure out why this fails sometimes + add fallback hail mary, just look for any file input on page # Dispatch upload file event with the file input node try: event = browser_session.event_bus.dispatch(UploadFileEvent(node=file_input_node, file_path=params.path)) await event await event.event_result(raise_if_any=True, raise_if_none=False) msg = f'Successfully uploaded file to index {params.index}' logger.info(f'📁 {msg}') return ActionResult( extracted_content=msg, long_term_memory=f'Uploaded file {params.path} to element {params.index}', ) except Exception as e: logger.error(f'Failed to upload file: {e}') raise BrowserError(f'Failed to upload file: {e}') # Tab Management Actions @self.registry.action( 'Switch to another open tab by tab_id. Tab IDs are shown in browser state tabs list (last 4 chars of target_id). Use when you need to work with content in a different tab.', param_model=SwitchTabAction, terminates_sequence=True, ) async def switch(params: SwitchTabAction, browser_session: BrowserSession): # Simple switch tab logic try: target_id = await browser_session.get_target_id_from_tab_id(params.tab_id) event = browser_session.event_bus.dispatch(SwitchTabEvent(target_id=target_id)) await event new_target_id = await event.event_result(raise_if_any=False, raise_if_none=False) # Don't raise on errors if new_target_id: memory = f'Switched to tab #{new_target_id[-4:]}' else: memory = f'Switched to tab #{params.tab_id}' logger.info(f'🔄 {memory}') return ActionResult(extracted_content=memory, long_term_memory=memory) except Exception as e: logger.warning(f'Tab switch may have failed: {e}') memory = f'Attempted to switch to tab #{params.tab_id}' return ActionResult(extracted_content=memory, long_term_memory=memory) @self.registry.action( 'Close a tab by tab_id. Tab IDs are shown in browser state tabs list (last 4 chars of target_id). Use to clean up tabs you no longer need.', param_model=CloseTabAction, ) async def close(params: CloseTabAction, browser_session: BrowserSession): # Simple close tab logic try: target_id = await browser_session.get_target_id_from_tab_id(params.tab_id) # Dispatch close tab event - handle stale target IDs gracefully event = browser_session.event_bus.dispatch(CloseTabEvent(target_id=target_id)) await event await event.event_result(raise_if_any=False, raise_if_none=False) # Don't raise on errors memory = f'Closed tab #{params.tab_id}' logger.info(f'🗑️ {memory}') return ActionResult( extracted_content=memory, long_term_memory=memory, ) except Exception as e: # Handle stale target IDs gracefully logger.warning(f'Tab {params.tab_id} may already be closed: {e}') memory = f'Tab #{params.tab_id} closed (was already closed or invalid)' return ActionResult( extracted_content=memory, long_term_memory=memory, ) @self.registry.action( """LLM extracts structured data from page markdown. Use when: on right page, know what to extract, haven't called before on same page+query. Can't get interactive elements. Set extract_links=True for URLs. Set extract_images=True for image src URLs. Use start_from_char if previous extraction was truncated to extract data further down the page. When paginating across pages, pass already_collected with item identifiers (names/URLs) from prior pages to avoid duplicates.""", param_model=ExtractAction, ) async def extract( params: ExtractAction, browser_session: BrowserSession, page_extraction_llm: BaseChatModel, file_system: FileSystem, extraction_schema: dict | None = None, ): # Constants MAX_CHAR_LIMIT = 100000 query = params['query'] if isinstance(params, dict) else params.query extract_links = params['extract_links'] if isinstance(params, dict) else params.extract_links extract_images = params.get('extract_images', False) if isinstance(params, dict) else params.extract_images start_from_char = params['start_from_char'] if isinstance(params, dict) else params.start_from_char output_schema: dict | None = params.get('output_schema') if isinstance(params, dict) else params.output_schema already_collected: list[str] = ( params.get('already_collected', []) if isinstance(params, dict) else params.already_collected ) # Auto-enable extract_images if query contains image-related keywords _IMAGE_KEYWORDS = ['image', 'photo', 'picture', 'thumbnail', 'img url', 'image url', 'photo url', 'product image'] if not extract_images and any(kw in query.lower() for kw in _IMAGE_KEYWORDS): extract_images = True # If the LLM didn't provide an output_schema, use the agent-injected extraction_schema if output_schema is None and extraction_schema is not None: output_schema = extraction_schema # Attempt to convert output_schema to a pydantic model upfront; fall back to free-text on failure structured_model: type[BaseModel] | None = None if output_schema is not None: try: from browser_use.tools.extraction.schema_utils import schema_dict_to_pydantic_model structured_model = schema_dict_to_pydantic_model(output_schema) except (ValueError, TypeError) as exc: logger.warning(f'Invalid output_schema, falling back to free-text extraction: {exc}') output_schema = None # Extract clean markdown using the unified method try: from browser_use.dom.markdown_extractor import extract_clean_markdown content, content_stats = await extract_clean_markdown( browser_session=browser_session, extract_links=extract_links, extract_images=extract_images ) except Exception as e: raise RuntimeError(f'Could not extract clean markdown: {type(e).__name__}') # Original content length for processing final_filtered_length = content_stats['final_filtered_chars'] # Structure-aware chunking replaces naive char-based truncation from browser_use.dom.markdown_extractor import chunk_markdown_by_structure chunks = chunk_markdown_by_structure(content, max_chunk_chars=MAX_CHAR_LIMIT, start_from_char=start_from_char) if not chunks: return ActionResult( error=f'start_from_char ({start_from_char}) exceeds content length {final_filtered_length} characters.' ) chunk = chunks[0] content = chunk.content truncated = chunk.has_more # Prepend overlap context for continuation chunks (e.g. table headers) if chunk.overlap_prefix: content = chunk.overlap_prefix + '\n' + content if start_from_char > 0: content_stats['started_from_char'] = start_from_char if truncated: content_stats['truncated_at_char'] = chunk.char_offset_end content_stats['next_start_char'] = chunk.char_offset_end content_stats['chunk_index'] = chunk.chunk_index content_stats['total_chunks'] = chunk.total_chunks # Add content statistics to the result original_html_length = content_stats['original_html_chars'] initial_markdown_length = content_stats['initial_markdown_chars'] chars_filtered = content_stats['filtered_chars_removed'] stats_summary = f"""Content processed: {original_html_length:,} HTML chars → {initial_markdown_length:,} initial markdown → {final_filtered_length:,} filtered markdown""" if start_from_char > 0: stats_summary += f' (started from char {start_from_char:,})' if truncated: chunk_info = f'chunk {chunk.chunk_index + 1} of {chunk.total_chunks}, ' stats_summary += f' → {len(content):,} final chars ({chunk_info}use start_from_char={content_stats["next_start_char"]} to continue)' elif chars_filtered > 0: stats_summary += f' (filtered {chars_filtered:,} chars of noise)' # Sanitize surrogates from content to prevent UTF-8 encoding errors content = sanitize_surrogates(content) query = sanitize_surrogates(query) # --- Structured extraction path --- if structured_model is not None: assert output_schema is not None system_prompt = """ You are an expert at extracting structured data from the markdown of a webpage. You will be given a query, a JSON Schema, and the markdown of a webpage that has been filtered to remove noise and advertising content. - Extract ONLY information present in the webpage. Do not guess or fabricate values. - Your response MUST conform to the provided JSON Schema exactly. - If a required field's value cannot be found on the page, use null (if the schema allows it) or an empty string / empty array as appropriate. - If the content was truncated, extract what is available from the visible portion. - If items are provided, skip any items whose name/title/URL matches those listed — do not include duplicates. """.strip() schema_json = json.dumps(output_schema, indent=2) already_collected_section = '' if already_collected: items_str = '\n'.join(f'- {item}' for item in already_collected[:100]) already_collected_section = f'\n\n\nSkip items whose name/title/URL matches any of these already-collected identifiers:\n{items_str}\n' prompt = ( f'\n{query}\n\n\n' f'\n{schema_json}\n\n\n' f'\n{stats_summary}\n\n\n' f'\n{content}\n' + already_collected_section ) try: response = await asyncio.wait_for( page_extraction_llm.ainvoke( [SystemMessage(content=system_prompt), UserMessage(content=prompt)], output_format=structured_model, ), timeout=120.0, ) # response.completion is a pydantic model instance result_data: dict = response.completion.model_dump(mode='json') # type: ignore[union-attr] result_json = json.dumps(result_data) current_url = await browser_session.get_current_page_url() extracted_content = f'\n{current_url}\n\n\n{query}\n\n\n{result_json}\n' from browser_use.tools.extraction.views import ExtractionResult extraction_meta = ExtractionResult( data=result_data, schema_used=output_schema, is_partial=truncated, source_url=current_url, content_stats=content_stats, ) # Simple memory handling MAX_MEMORY_LENGTH = 10000 if len(extracted_content) < MAX_MEMORY_LENGTH: memory = extracted_content include_extracted_content_only_once = False else: file_name = await file_system.save_extracted_content(extracted_content) memory = f'Query: {query}\nContent in {file_name} and once in .' include_extracted_content_only_once = True logger.info(f'📄 {memory}') return ActionResult( extracted_content=extracted_content, include_extracted_content_only_once=include_extracted_content_only_once, long_term_memory=memory, metadata={'structured_extraction': True, 'extraction_result': extraction_meta.model_dump(mode='json')}, ) except Exception as e: logger.debug(f'Error in structured extraction: {e}') raise RuntimeError(str(e)) # --- Free-text extraction path (default) --- system_prompt = """ You are an expert at extracting data from the markdown of a webpage. You will be given a query and the markdown of a webpage that has been filtered to remove noise and advertising content. - You are tasked to extract information from the webpage that is relevant to the query. - You should ONLY use the information available in the webpage to answer the query. Do not make up information or provide guess from your own knowledge. - If the information relevant to the query is not available in the page, your response should mention that. - If the query asks for all items, products, etc., make sure to directly list all of them. - If the content was truncated and you need more information, note that the user can use start_from_char parameter to continue from where truncation occurred. - If items are provided, exclude any results whose name/title/URL matches those already collected — do not include duplicates. - Your output should present ALL the information relevant to the query in a concise way. - Do not answer in conversational format - directly output the relevant information or that the information is unavailable. """.strip() already_collected_section = '' if already_collected: items_str = '\n'.join(f'- {item}' for item in already_collected[:100]) already_collected_section = f'\n\n\nSkip items whose name/title/URL matches any of these already-collected identifiers:\n{items_str}\n' prompt = ( f'\n{query}\n\n\n\n{stats_summary}\n\n\n\n{content}\n' + already_collected_section ) try: response = await asyncio.wait_for( page_extraction_llm.ainvoke([SystemMessage(content=system_prompt), UserMessage(content=prompt)]), timeout=120.0, ) current_url = await browser_session.get_current_page_url() extracted_content = ( f'\n{current_url}\n\n\n{query}\n\n\n{response.completion}\n' ) # Simple memory handling MAX_MEMORY_LENGTH = 10000 if len(extracted_content) < MAX_MEMORY_LENGTH: memory = extracted_content include_extracted_content_only_once = False else: file_name = await file_system.save_extracted_content(extracted_content) memory = f'Query: {query}\nContent in {file_name} and once in .' include_extracted_content_only_once = True logger.info(f'📄 {memory}') return ActionResult( extracted_content=extracted_content, include_extracted_content_only_once=include_extracted_content_only_once, long_term_memory=memory, ) except Exception as e: logger.debug(f'Error extracting content: {e}') raise RuntimeError(str(e)) # --- Page search and exploration tools (zero LLM cost) --- @self.registry.action( """Search page text for a pattern (like grep). Zero LLM cost, instant. Returns matches with surrounding context. Use to find specific text, verify content exists, or locate data on the page. Set regex=True for regex patterns. Use css_scope to search within a specific section.""", param_model=SearchPageAction, ) async def search_page(params: SearchPageAction, browser_session: BrowserSession): js_code = _build_search_page_js( pattern=params.pattern, regex=params.regex, case_sensitive=params.case_sensitive, context_chars=params.context_chars, css_scope=params.css_scope, max_results=params.max_results, ) cdp_session = await browser_session.get_or_create_cdp_session() result = await cdp_session.cdp_client.send.Runtime.evaluate( params={'expression': js_code, 'returnByValue': True, 'awaitPromise': True}, session_id=cdp_session.session_id, ) if result.get('exceptionDetails'): error_text = result['exceptionDetails'].get('text', 'Unknown JS error') return ActionResult(error=f'search_page failed: {error_text}') data = result.get('result', {}).get('value') if data is None: return ActionResult(error='search_page returned no result') if isinstance(data, dict) and data.get('error'): return ActionResult(error=f'search_page: {data["error"]}') formatted = _format_search_results(data, params.pattern) total = data.get('total', 0) memory = f'Searched page for "{params.pattern}": {total} match{"es" if total != 1 else ""} found.' logger.info(f'🔎 {memory}') return ActionResult(extracted_content=formatted, long_term_memory=memory) @self.registry.action( """Query DOM elements by CSS selector (like find). Zero LLM cost, instant. Returns matching elements with tag, text, and attributes. Use to explore page structure, count items, get links/attributes. Use attributes=["href","src"] to extract specific attributes.""", param_model=FindElementsAction, ) async def find_elements(params: FindElementsAction, browser_session: BrowserSession): js_code = _build_find_elements_js( selector=params.selector, attributes=params.attributes, max_results=params.max_results, include_text=params.include_text, ) cdp_session = await browser_session.get_or_create_cdp_session() result = await cdp_session.cdp_client.send.Runtime.evaluate( params={'expression': js_code, 'returnByValue': True, 'awaitPromise': True}, session_id=cdp_session.session_id, ) if result.get('exceptionDetails'): error_text = result['exceptionDetails'].get('text', 'Unknown JS error') return ActionResult(error=f'find_elements failed: {error_text}') data = result.get('result', {}).get('value') if data is None: return ActionResult(error='find_elements returned no result') if isinstance(data, dict) and data.get('error'): return ActionResult(error=f'find_elements: {data["error"]}') formatted = _format_find_results(data, params.selector) total = data.get('total', 0) memory = f'Found {total} element{"s" if total != 1 else ""} matching "{params.selector}".' logger.info(f'🔍 {memory}') return ActionResult(extracted_content=formatted, long_term_memory=memory) @self.registry.action( """Scroll by pages. REQUIRED: down=True/False (True=scroll down, False=scroll up, default=True). Optional: pages=0.5-10.0 (default 1.0). Use index for scroll elements (dropdowns/custom UI). High pages (10) reaches bottom. Multi-page scrolls sequentially. Viewport-based height, fallback 1000px/page.""", param_model=ScrollAction, ) async def scroll(params: ScrollAction, browser_session: BrowserSession): try: # Look up the node from the selector map if index is provided # Special case: index 0 means scroll the whole page (root/body element) node = None if params.index is not None and params.index != 0: node = await browser_session.get_element_by_index(params.index) if node is None: # Element does not exist msg = f'Element index {params.index} not found in browser state' return ActionResult(error=msg) direction = 'down' if params.down else 'up' target = f'element {params.index}' if params.index is not None and params.index != 0 else '' # Get actual viewport height for more accurate scrolling try: cdp_session = await browser_session.get_or_create_cdp_session() metrics = await cdp_session.cdp_client.send.Page.getLayoutMetrics(session_id=cdp_session.session_id) # Use cssVisualViewport for the most accurate representation css_viewport = metrics.get('cssVisualViewport', {}) css_layout_viewport = metrics.get('cssLayoutViewport', {}) # Get viewport height, prioritizing cssVisualViewport viewport_height = int(css_viewport.get('clientHeight') or css_layout_viewport.get('clientHeight', 1000)) logger.debug(f'Detected viewport height: {viewport_height}px') except Exception as e: viewport_height = 1000 # Fallback to 1000px logger.debug(f'Failed to get viewport height, using fallback 1000px: {e}') # For multiple pages (>=1.0), scroll one page at a time to ensure each scroll completes if params.pages >= 1.0: import asyncio num_full_pages = int(params.pages) remaining_fraction = params.pages - num_full_pages completed_scrolls = 0 # Scroll one page at a time for i in range(num_full_pages): try: pixels = viewport_height # Use actual viewport height if not params.down: pixels = -pixels event = browser_session.event_bus.dispatch( ScrollEvent(direction=direction, amount=abs(pixels), node=node) ) await event await event.event_result(raise_if_any=True, raise_if_none=False) completed_scrolls += 1 # Small delay to ensure scroll completes before next one await asyncio.sleep(0.15) except Exception as e: logger.warning(f'Scroll {i + 1}/{num_full_pages} failed: {e}') # Continue with remaining scrolls even if one fails # Handle fractional page if present if remaining_fraction > 0: try: pixels = int(remaining_fraction * viewport_height) if not params.down: pixels = -pixels event = browser_session.event_bus.dispatch( ScrollEvent(direction=direction, amount=abs(pixels), node=node) ) await event await event.event_result(raise_if_any=True, raise_if_none=False) completed_scrolls += remaining_fraction except Exception as e: logger.warning(f'Fractional scroll failed: {e}') if params.pages == 1.0: long_term_memory = f'Scrolled {direction} {target} {viewport_height}px'.replace(' ', ' ') else: long_term_memory = f'Scrolled {direction} {target} {completed_scrolls:.1f} pages'.replace(' ', ' ') else: # For fractional pages <1.0, do single scroll pixels = int(params.pages * viewport_height) event = browser_session.event_bus.dispatch( ScrollEvent(direction='down' if params.down else 'up', amount=pixels, node=node) ) await event await event.event_result(raise_if_any=True, raise_if_none=False) long_term_memory = f'Scrolled {direction} {target} {params.pages} pages'.replace(' ', ' ') msg = f'🔍 {long_term_memory}' logger.info(msg) return ActionResult(extracted_content=msg, long_term_memory=long_term_memory) except Exception as e: logger.error(f'Failed to dispatch ScrollEvent: {type(e).__name__}: {e}') error_msg = 'Failed to execute scroll action.' return ActionResult(error=error_msg) @self.registry.action( '', param_model=SendKeysAction, ) async def send_keys(params: SendKeysAction, browser_session: BrowserSession): # Dispatch send keys event try: event = browser_session.event_bus.dispatch(SendKeysEvent(keys=params.keys)) await event await event.event_result(raise_if_any=True, raise_if_none=False) memory = f'Sent keys: {params.keys}' msg = f'⌨️ {memory}' logger.info(msg) return ActionResult(extracted_content=memory, long_term_memory=memory) except Exception as e: logger.error(f'Failed to dispatch SendKeysEvent: {type(e).__name__}: {e}') error_msg = f'Failed to send keys: {str(e)}' return ActionResult(error=error_msg) @self.registry.action('Scroll to text.') async def find_text(text: str, browser_session: BrowserSession): # type: ignore # Dispatch scroll to text event event = browser_session.event_bus.dispatch(ScrollToTextEvent(text=text)) try: # The handler returns None on success or raises an exception if text not found await event.event_result(raise_if_any=True, raise_if_none=False) memory = f'Scrolled to text: {text}' msg = f'🔍 {memory}' logger.info(msg) return ActionResult(extracted_content=memory, long_term_memory=memory) except Exception as e: # Text not found msg = f"Text '{text}' not found or not visible on page" logger.info(msg) return ActionResult( extracted_content=msg, long_term_memory=f"Tried scrolling to text '{text}' but it was not found", ) @self.registry.action( 'Take a screenshot of the current viewport. If file_name is provided, saves to that file and returns the path. ' 'Otherwise, screenshot is included in the next browser_state observation.', param_model=ScreenshotAction, ) async def screenshot( params: ScreenshotAction, browser_session: BrowserSession, file_system: FileSystem, ): """Take screenshot, optionally saving to file.""" if params.file_name: # Save screenshot to file file_name = params.file_name if not file_name.lower().endswith('.png'): file_name = f'{file_name}.png' file_name = FileSystem.sanitize_filename(file_name) screenshot_bytes = await browser_session.take_screenshot(full_page=False) file_path = file_system.get_dir() / file_name file_path.write_bytes(screenshot_bytes) result = f'Screenshot saved to {file_name}' logger.info(f'📸 {result}. Full path: {file_path}') return ActionResult( extracted_content=result, long_term_memory=f'{result}. Full path: {file_path}', attachments=[str(file_path)], ) else: # Flag for next observation memory = 'Requested screenshot for next observation' logger.info(f'📸 {memory}') return ActionResult( extracted_content=memory, metadata={'include_screenshot': True}, ) # PDF Actions @self.registry.action( 'Save the current page as a PDF file. Returns the file path of the saved PDF. ' 'Use this to capture the full page content (including content below the fold) as a printable document.', param_model=SaveAsPdfAction, ) async def save_as_pdf( params: SaveAsPdfAction, browser_session: BrowserSession, file_system: FileSystem, ): """Save the current page as a PDF using CDP Page.printToPDF.""" import base64 import re # Paper format dimensions in inches (width, height) paper_sizes: dict[str, tuple[float, float]] = { 'letter': (8.5, 11), 'legal': (8.5, 14), 'a4': (8.27, 11.69), 'a3': (11.69, 16.54), 'tabloid': (11, 17), } paper_key = params.paper_format.lower() if paper_key not in paper_sizes: paper_key = 'letter' paper_width, paper_height = paper_sizes[paper_key] cdp_session = await browser_session.get_or_create_cdp_session(focus=True) from cdp_use.cdp.page import PrintToPDFParameters pdf_params: PrintToPDFParameters = { 'printBackground': params.print_background, 'landscape': params.landscape, 'scale': params.scale, 'paperWidth': paper_width, 'paperHeight': paper_height, 'preferCSSPageSize': True, } if params.display_header_footer: # Chrome clips the header/footer unless the page leaves vertical room for # them, so set explicit margins. preferCSSPageSize only governs page size, # not margins, so these still apply. The horizontal margins keep the body # aligned with the header/footer content (which is padded to match). pdf_params.update( { 'displayHeaderFooter': True, 'headerTemplate': params.header_template if params.header_template is not None else _DEFAULT_PDF_HEADER_TEMPLATE, 'footerTemplate': params.footer_template if params.footer_template is not None else _DEFAULT_PDF_FOOTER_TEMPLATE, 'marginTop': 0.5, 'marginBottom': 0.5, 'marginLeft': 0.4, 'marginRight': 0.4, } ) result = await asyncio.wait_for( cdp_session.cdp_client.send.Page.printToPDF( params=pdf_params, session_id=cdp_session.session_id, ), timeout=30.0, ) pdf_data = result.get('data') assert pdf_data, 'CDP Page.printToPDF returned no data' pdf_bytes = base64.b64decode(pdf_data) # Determine filename if params.file_name: file_name = params.file_name else: try: page_title = await asyncio.wait_for(browser_session.get_current_page_title(), timeout=2.0) safe_title = re.sub(r'[^\w\s-]', '', page_title).strip()[:50] file_name = safe_title if safe_title else 'page' except Exception: file_name = 'page' if not file_name.lower().endswith('.pdf'): file_name = f'{file_name}.pdf' file_name = FileSystem.sanitize_filename(file_name) file_path = file_system.get_dir() / file_name # Handle duplicate filenames if file_path.exists(): base, ext = os.path.splitext(file_name) counter = 1 while (file_system.get_dir() / f'{base} ({counter}){ext}').exists(): counter += 1 file_name = f'{base} ({counter}){ext}' file_path = file_system.get_dir() / file_name async with await anyio.open_file(file_path, 'wb') as f: await f.write(pdf_bytes) file_size = file_path.stat().st_size msg = f'Saved page as PDF: {file_name} ({file_size:,} bytes)' logger.info(f'📄 {msg}. Full path: {file_path}') return ActionResult( extracted_content=msg, long_term_memory=f'{msg}. Full path: {file_path}', attachments=[str(file_path)], ) # Dropdown Actions @self.registry.action( '', param_model=GetDropdownOptionsAction, ) async def dropdown_options(params: GetDropdownOptionsAction, browser_session: BrowserSession): """Get all options from a native dropdown or ARIA menu""" # Look up the node from the selector map node = await browser_session.get_element_by_index(params.index) if node is None: msg = f'Element index {params.index} not available - page may have changed. Try refreshing browser state.' logger.warning(f'⚠️ {msg}') return ActionResult(extracted_content=msg) # Dispatch GetDropdownOptionsEvent to the event handler event = browser_session.event_bus.dispatch(GetDropdownOptionsEvent(node=node)) dropdown_data = await event.event_result(timeout=3.0, raise_if_none=True, raise_if_any=True) if not dropdown_data: raise ValueError('Failed to get dropdown options - no data returned') # Use structured memory from the handler return ActionResult( extracted_content=dropdown_data['short_term_memory'], long_term_memory=dropdown_data['long_term_memory'], include_extracted_content_only_once=True, ) @self.registry.action( 'Set the option of a