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