From b5cc8e2951b40878e8ff2f6b1450d765e91eea72 Mon Sep 17 00:00:00 2001 From: openhands Date: Sun, 15 Mar 2026 20:51:04 +0000 Subject: [PATCH 1/4] fix(tools): return browser timeout as observation Catch browser action timeouts at the browser tool boundary so a hung browser call returns a BrowserObservation error instead of bubbling up as a fatal conversation TimeoutError. Also format empty browser exceptions more clearly to avoid blank 'Browser operation failed:' messages. Co-authored-by: openhands --- .../openhands/tools/browser_use/impl.py | 48 ++++++++++++++++--- .../browser_use/test_browser_executor.py | 23 ++++++++- 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/openhands-tools/openhands/tools/browser_use/impl.py b/openhands-tools/openhands/tools/browser_use/impl.py index 0eb7dc3120..e155a31013 100644 --- a/openhands-tools/openhands/tools/browser_use/impl.py +++ b/openhands-tools/openhands/tools/browser_use/impl.py @@ -2,6 +2,7 @@ from __future__ import annotations +import builtins import functools import json import logging @@ -26,7 +27,10 @@ BrowserObservation, ) from openhands.tools.browser_use.server import CustomBrowserUseServer -from openhands.tools.utils.timeout import TimeoutError, run_with_timeout +from openhands.tools.utils.timeout import ( + TimeoutError as ToolTimeoutError, + run_with_timeout, +) F = TypeVar("F", bound=Callable[..., Coroutine[Any, Any, Any]]) @@ -86,6 +90,25 @@ async def wrapper(self: BrowserToolExecutor, *args: Any, **kwargs: Any) -> Any: logger = get_logger(__name__) +DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS = 300.0 + + +def _format_browser_operation_error( + error: BaseException, timeout_seconds: float | None = None +) -> str: + error_detail = str(error).strip() + if not error_detail: + if isinstance(error, builtins.TimeoutError): + if timeout_seconds is None: + error_detail = "Operation timed out" + else: + error_detail = ( + f"Operation timed out after {int(timeout_seconds)} seconds" + ) + else: + error_detail = error.__class__.__name__ + return f"Browser operation failed: {error_detail}" + def _install_chromium() -> bool: """Attempt to install Chromium via uvx playwright install.""" @@ -286,7 +309,7 @@ def init_logic(): try: run_with_timeout(init_logic, init_timeout_seconds) - except TimeoutError: + except ToolTimeoutError: raise Exception( f"Browser tool initialization timed out after {init_timeout_seconds}s" ) @@ -302,9 +325,20 @@ def __call__( conversation: LocalConversation | None = None, # noqa: ARG002 ): """Submit an action to run in the background loop and wait for result.""" - return self._async_executor.run_async( - self._execute_action, action, timeout=300.0 - ) + try: + return self._async_executor.run_async( + self._execute_action, + action, + timeout=DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS, + ) + except builtins.TimeoutError as error: + return BrowserObservation.from_text( + text=_format_browser_operation_error( + error, timeout_seconds=DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS + ), + is_error=True, + full_output_save_dir=self.full_output_save_dir, + ) async def _execute_action(self, action): """Execute browser action asynchronously.""" @@ -372,8 +406,8 @@ async def _execute_action(self, action): is_error=False, full_output_save_dir=self.full_output_save_dir, ) - except Exception as e: - error_msg = f"Browser operation failed: {str(e)}" + except Exception as error: + error_msg = _format_browser_operation_error(error) logging.error(error_msg, exc_info=True) return BrowserObservation.from_text( text=error_msg, diff --git a/tests/tools/browser_use/test_browser_executor.py b/tests/tools/browser_use/test_browser_executor.py index 342e350ce9..60bded08ba 100644 --- a/tests/tools/browser_use/test_browser_executor.py +++ b/tests/tools/browser_use/test_browser_executor.py @@ -1,5 +1,6 @@ """Tests for BrowserToolExecutor integration logic.""" +import builtins from unittest.mock import AsyncMock, patch from openhands.tools.browser_use.definition import ( @@ -8,7 +9,10 @@ BrowserNavigateAction, BrowserObservation, ) -from openhands.tools.browser_use.impl import BrowserToolExecutor +from openhands.tools.browser_use.impl import ( + DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS, + BrowserToolExecutor, +) from .conftest import ( assert_browser_observation_error, @@ -123,6 +127,23 @@ def test_browser_executor_async_execution(mock_browser_executor): mock_execute.assert_called_once_with(action) +def test_browser_executor_timeout_wrapping(mock_browser_executor): + """Test that browser action timeouts return BrowserObservation errors.""" + with patch.object( + mock_browser_executor._async_executor, + "run_async", + side_effect=builtins.TimeoutError(), + ): + action = BrowserNavigateAction(url="https://example.com") + result = mock_browser_executor(action) + + assert_browser_observation_error(result, "Browser operation failed") + assert ( + f"timed out after {int(DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS)} seconds" + in result.text + ) + + async def test_browser_executor_initialization_lazy(mock_browser_executor): """Test that browser session initialization is lazy.""" assert mock_browser_executor._initialized is False From 4010efb7a04159930d0587a8505d6602a1a1fa8f Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 16 Mar 2026 13:37:04 +0000 Subject: [PATCH 2/4] fix(tools): add live browser timeout demo Co-authored-by: openhands --- .../45_browser_timeout_observation.py | 197 ++++++++++++++++++ .../openhands/tools/browser_use/impl.py | 11 +- tests/examples/test_examples.py | 1 + .../browser_use/test_browser_executor.py | 111 +++++++++- 4 files changed, 314 insertions(+), 6 deletions(-) create mode 100644 examples/01_standalone_sdk/45_browser_timeout_observation.py diff --git a/examples/01_standalone_sdk/45_browser_timeout_observation.py b/examples/01_standalone_sdk/45_browser_timeout_observation.py new file mode 100644 index 0000000000..40a2d9d04a --- /dev/null +++ b/examples/01_standalone_sdk/45_browser_timeout_observation.py @@ -0,0 +1,197 @@ +"""Demonstrate browser timeouts surfacing as normal observations. + +This example starts a local web service with a slow endpoint and registers a +minimal browser-style executor that performs a live HTTP request to that +service. A deterministic in-process LLM asks the agent to call +`browser_navigate` and then finish, so the example stays fully self-contained +and can be run without external model credentials. +""" + +import asyncio +import json +import threading +import time +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast +from unittest.mock import MagicMock +from urllib.request import urlopen + +from litellm.types.utils import ModelResponse + +from openhands.sdk import Agent, Conversation, Event, LLMConvertibleEvent +from openhands.sdk.llm import LLM, LLMResponse, Message, MessageToolCall, TextContent +from openhands.sdk.llm.utils.metrics import MetricsSnapshot, TokenUsage +from openhands.sdk.tool import Tool, register_tool +from openhands.sdk.utils.async_executor import AsyncExecutor +from openhands.tools.browser_use.definition import BrowserNavigateTool +from openhands.tools.browser_use.impl import BrowserToolExecutor + + +if TYPE_CHECKING: + from collections.abc import Iterator + + +ACTION_TIMEOUT_SECONDS = 2 +SLOW_RESPONSE_SECONDS = 8 + + +class _ThreadedSlowServer(ThreadingHTTPServer): + daemon_threads = True + + +class SlowHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + time.sleep(SLOW_RESPONSE_SECONDS) + body = b"slow response" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): # noqa: A003 + _ = (format, args) + return + + +class SlowServiceBrowserExecutor(BrowserToolExecutor): + """A lightweight browser executor for timeout observation demos.""" + + def __init__(self, action_timeout_seconds: float): + self._server = cast(Any, SimpleNamespace(_is_recording=False)) + self._config = {} + self._initialized = True + self._async_executor = AsyncExecutor() + self._cleanup_initiated = False + self._action_timeout_seconds = action_timeout_seconds + self.full_output_save_dir = None + + async def navigate(self, url: str, new_tab: bool = False) -> str: + del new_tab + return await asyncio.to_thread(self._fetch_url, url) + + def close(self) -> None: + return + + @staticmethod + def _fetch_url(url: str) -> str: + with urlopen(url, timeout=30) as response: + return response.read().decode() + + +class DemoLLM(LLM): + def __init__(self, slow_url: str): + super().__init__(model="demo-model", usage_id="demo-llm") + self._slow_url = slow_url + self._call_count = 0 + + def completion(self, *, messages, tools=None, **kwargs) -> LLMResponse: # type: ignore[override] + del messages, tools, kwargs + self._call_count += 1 + + if self._call_count == 1: + tool_call = MessageToolCall( + id="call-browser", + name=BrowserNavigateTool.name, + arguments=json.dumps({"url": self._slow_url}), + origin="completion", + ) + message = Message( + role="assistant", + content=[TextContent(text="I'll check the slow page.")], + tool_calls=[tool_call], + ) + else: + tool_call = MessageToolCall( + id="call-finish", + name="finish", + arguments=json.dumps( + { + "message": ( + "The slow web service timed out, but the browser tool " + "returned a normal error observation instead of " + "crashing the conversation." + ) + } + ), + origin="completion", + ) + message = Message( + role="assistant", + content=[TextContent(text="The timeout was handled cleanly.")], + tool_calls=[tool_call], + ) + + return LLMResponse( + message=message, + metrics=MetricsSnapshot( + model_name="demo-model", + accumulated_cost=0.0, + max_budget_per_task=0.0, + accumulated_token_usage=TokenUsage(model="demo-model"), + ), + raw_response=MagicMock(spec=ModelResponse, id=f"demo-{self._call_count}"), + ) + + +@contextmanager +def run_demo_server() -> "Iterator[str]": + server = _ThreadedSlowServer(("127.0.0.1", 0), SlowHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + host = server.server_address[0] + port = server.server_address[1] + yield f"http://{host}:{port}" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +llm_messages = [] + + +def conversation_callback(event: Event): + if isinstance(event, LLMConvertibleEvent): + llm_messages.append(event.to_llm_message()) + + +with run_demo_server() as slow_url: + executor = SlowServiceBrowserExecutor(action_timeout_seconds=ACTION_TIMEOUT_SECONDS) + browser_navigate_tool = BrowserNavigateTool.create(executor)[0] + register_tool(BrowserNavigateTool.name, browser_navigate_tool) + + tools = [Tool(name=BrowserNavigateTool.name)] + agent = Agent(llm=DemoLLM(slow_url), tools=tools) + + conversation = Conversation( + agent=agent, + callbacks=[conversation_callback], + max_iteration_per_run=4, + ) + + print("=" * 80) + print("Browser timeout observation example") + print("=" * 80) + print(f"Slow URL: {slow_url}") + print(f"Browser action timeout: {ACTION_TIMEOUT_SECONDS} seconds") + + conversation.send_message( + "Use browser_navigate to open the slow web service. Do not retry. After " + "the tool returns, explain what happened in one sentence." + ) + conversation.run() + + print("\nConversation completed without a fatal timeout.\n") + print("Collected LLM messages:") + for index, message in enumerate(llm_messages): + print(f"Message {index}: {str(message)[:400]}") + + cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost + print(f"Conversation ID: {conversation.id}") + print(f"EXAMPLE_COST: {cost}") + conversation.close() diff --git a/openhands-tools/openhands/tools/browser_use/impl.py b/openhands-tools/openhands/tools/browser_use/impl.py index e155a31013..a192dc60be 100644 --- a/openhands-tools/openhands/tools/browser_use/impl.py +++ b/openhands-tools/openhands/tools/browser_use/impl.py @@ -162,6 +162,7 @@ class BrowserToolExecutor(ToolExecutor[BrowserAction, BrowserObservation]): _initialized: bool _async_executor: AsyncExecutor _cleanup_initiated: bool + _action_timeout_seconds: float def check_chromium_available(self) -> str | None: """Check if a Chromium/Chrome binary is available. @@ -252,6 +253,7 @@ def __init__( allowed_domains: list[str] | None = None, session_timeout_minutes: int = 30, init_timeout_seconds: int = 30, + action_timeout_seconds: float = DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS, full_output_save_dir: str | None = None, inject_scripts: list[str] | None = None, **config, @@ -263,6 +265,7 @@ def __init__( allowed_domains: List of allowed domains for browser operations session_timeout_minutes: Browser session timeout in minutes init_timeout_seconds: Timeout for browser initialization in seconds + action_timeout_seconds: Timeout for each browser action in seconds full_output_save_dir: Absolute path to directory to save full output logs and files, used when truncation is needed. inject_scripts: List of JavaScript code strings to inject into every @@ -314,10 +317,14 @@ def init_logic(): f"Browser tool initialization timed out after {init_timeout_seconds}s" ) + if action_timeout_seconds <= 0: + raise ValueError("action_timeout_seconds must be greater than 0") + self.full_output_save_dir: str | None = full_output_save_dir self._initialized = False self._async_executor = AsyncExecutor() self._cleanup_initiated = False + self._action_timeout_seconds = action_timeout_seconds def __call__( self, @@ -329,12 +336,12 @@ def __call__( return self._async_executor.run_async( self._execute_action, action, - timeout=DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS, + timeout=self._action_timeout_seconds, ) except builtins.TimeoutError as error: return BrowserObservation.from_text( text=_format_browser_operation_error( - error, timeout_seconds=DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS + error, timeout_seconds=self._action_timeout_seconds ), is_error=True, full_output_save_dir=self.full_output_save_dir, diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py index c28f15a2a9..925713c495 100644 --- a/tests/examples/test_examples.py +++ b/tests/examples/test_examples.py @@ -49,6 +49,7 @@ "examples/01_standalone_sdk/08_mcp_with_oauth.py", "examples/01_standalone_sdk/15_browser_use.py", "examples/01_standalone_sdk/16_llm_security_analyzer.py", + "examples/01_standalone_sdk/45_browser_timeout_observation.py", "examples/01_standalone_sdk/27_observability_laminar.py", "examples/01_standalone_sdk/35_subscription_login.py", # Requires interactive input() which fails in CI with EOFError diff --git a/tests/tools/browser_use/test_browser_executor.py b/tests/tools/browser_use/test_browser_executor.py index 60bded08ba..8c6a57693f 100644 --- a/tests/tools/browser_use/test_browser_executor.py +++ b/tests/tools/browser_use/test_browser_executor.py @@ -1,8 +1,18 @@ """Tests for BrowserToolExecutor integration logic.""" +import asyncio import builtins +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from types import SimpleNamespace +from typing import Any, cast from unittest.mock import AsyncMock, patch +from urllib.request import urlopen +import pytest + +from openhands.sdk.utils.async_executor import AsyncExecutor from openhands.tools.browser_use.definition import ( BrowserClickAction, BrowserGetStateAction, @@ -20,6 +30,69 @@ ) +class _ThreadedSlowServer(ThreadingHTTPServer): + daemon_threads = True + + +class SlowServiceBrowserExecutor(BrowserToolExecutor): + """Minimal browser executor that blocks on a live HTTP request.""" + + def __init__(self, action_timeout_seconds: float): + self._server = cast(Any, SimpleNamespace(_is_recording=False)) + self._config = {} + self._initialized = True + self._async_executor = AsyncExecutor() + self._cleanup_initiated = False + self._action_timeout_seconds = action_timeout_seconds + self.full_output_save_dir = None + + async def navigate(self, url: str, new_tab: bool = False) -> str: + del new_tab + return await asyncio.to_thread(self._fetch_url, url) + + def close(self) -> None: + return + + @staticmethod + def _fetch_url(url: str) -> str: + with urlopen(url, timeout=30) as response: + return response.read().decode() + + +@pytest.fixture +def slow_service(): + """Serve an endpoint that stays pending long enough to trigger a timeout.""" + request_started = threading.Event() + + class SlowHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 + request_started.set() + time.sleep(5) + body = b"slow response" + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, format, *args): # noqa: A003 + _ = (format, args) + return + + server = _ThreadedSlowServer(("127.0.0.1", 0), SlowHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + try: + host = server.server_address[0] + port = server.server_address[1] + yield f"http://{host}:{port}", request_started + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_browser_executor_initialization(): """Test that BrowserToolExecutor initializes correctly.""" executor = BrowserToolExecutor() @@ -29,6 +102,7 @@ def test_browser_executor_initialization(): assert executor._initialized is False assert executor._server is not None assert executor._async_executor is not None + assert executor._action_timeout_seconds == DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS def test_browser_executor_config_passing(): @@ -37,12 +111,27 @@ def test_browser_executor_config_passing(): session_timeout_minutes=60, headless=False, allowed_domains=["example.com", "test.com"], + action_timeout_seconds=12.5, custom_param="value", ) assert executor._config["headless"] is False assert executor._config["allowed_domains"] == ["example.com", "test.com"] assert executor._config["custom_param"] == "value" + assert executor._action_timeout_seconds == 12.5 + + +def test_browser_executor_rejects_non_positive_action_timeout(): + """Test that BrowserToolExecutor validates action timeouts.""" + with patch("openhands.tools.browser_use.impl.run_with_timeout"): + with patch.object(BrowserToolExecutor, "_ensure_chromium_available"): + with patch("openhands.tools.browser_use.impl.CustomBrowserUseServer"): + with patch("openhands.tools.browser_use.impl.AsyncExecutor"): + with pytest.raises( + ValueError, + match="action_timeout_seconds must be greater than 0", + ): + BrowserToolExecutor(action_timeout_seconds=0) @patch("openhands.tools.browser_use.impl.BrowserToolExecutor.navigate") @@ -127,8 +216,25 @@ def test_browser_executor_async_execution(mock_browser_executor): mock_execute.assert_called_once_with(action) +def test_browser_executor_timeout_wrapping_live_service(slow_service): + """Test that a live slow service timeout becomes a BrowserObservation.""" + slow_url, request_started = slow_service + executor = SlowServiceBrowserExecutor(action_timeout_seconds=1) + + try: + result = executor(BrowserNavigateAction(url=slow_url)) + finally: + executor.close() + + assert request_started.wait(timeout=1), "The slow service was never queried" + assert_browser_observation_error(result, "Browser operation failed") + assert "timed out after 1 seconds" in result.text + + def test_browser_executor_timeout_wrapping(mock_browser_executor): """Test that browser action timeouts return BrowserObservation errors.""" + mock_browser_executor._action_timeout_seconds = 7 + with patch.object( mock_browser_executor._async_executor, "run_async", @@ -138,10 +244,7 @@ def test_browser_executor_timeout_wrapping(mock_browser_executor): result = mock_browser_executor(action) assert_browser_observation_error(result, "Browser operation failed") - assert ( - f"timed out after {int(DEFAULT_BROWSER_ACTION_TIMEOUT_SECONDS)} seconds" - in result.text - ) + assert "timed out after 7 seconds" in result.text async def test_browser_executor_initialization_lazy(mock_browser_executor): From 9fbd4417fff9f2f04c3cbee0286a41371edda904 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 02:32:26 +0000 Subject: [PATCH 3/4] fix(ci): remove undocumented browser timeout example Co-authored-by: openhands --- .../45_browser_timeout_observation.py | 197 ------------------ tests/examples/test_examples.py | 1 - 2 files changed, 198 deletions(-) delete mode 100644 examples/01_standalone_sdk/45_browser_timeout_observation.py diff --git a/examples/01_standalone_sdk/45_browser_timeout_observation.py b/examples/01_standalone_sdk/45_browser_timeout_observation.py deleted file mode 100644 index 40a2d9d04a..0000000000 --- a/examples/01_standalone_sdk/45_browser_timeout_observation.py +++ /dev/null @@ -1,197 +0,0 @@ -"""Demonstrate browser timeouts surfacing as normal observations. - -This example starts a local web service with a slow endpoint and registers a -minimal browser-style executor that performs a live HTTP request to that -service. A deterministic in-process LLM asks the agent to call -`browser_navigate` and then finish, so the example stays fully self-contained -and can be run without external model credentials. -""" - -import asyncio -import json -import threading -import time -from contextlib import contextmanager -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from types import SimpleNamespace -from typing import TYPE_CHECKING, Any, cast -from unittest.mock import MagicMock -from urllib.request import urlopen - -from litellm.types.utils import ModelResponse - -from openhands.sdk import Agent, Conversation, Event, LLMConvertibleEvent -from openhands.sdk.llm import LLM, LLMResponse, Message, MessageToolCall, TextContent -from openhands.sdk.llm.utils.metrics import MetricsSnapshot, TokenUsage -from openhands.sdk.tool import Tool, register_tool -from openhands.sdk.utils.async_executor import AsyncExecutor -from openhands.tools.browser_use.definition import BrowserNavigateTool -from openhands.tools.browser_use.impl import BrowserToolExecutor - - -if TYPE_CHECKING: - from collections.abc import Iterator - - -ACTION_TIMEOUT_SECONDS = 2 -SLOW_RESPONSE_SECONDS = 8 - - -class _ThreadedSlowServer(ThreadingHTTPServer): - daemon_threads = True - - -class SlowHandler(BaseHTTPRequestHandler): - def do_GET(self): # noqa: N802 - time.sleep(SLOW_RESPONSE_SECONDS) - body = b"slow response" - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, format, *args): # noqa: A003 - _ = (format, args) - return - - -class SlowServiceBrowserExecutor(BrowserToolExecutor): - """A lightweight browser executor for timeout observation demos.""" - - def __init__(self, action_timeout_seconds: float): - self._server = cast(Any, SimpleNamespace(_is_recording=False)) - self._config = {} - self._initialized = True - self._async_executor = AsyncExecutor() - self._cleanup_initiated = False - self._action_timeout_seconds = action_timeout_seconds - self.full_output_save_dir = None - - async def navigate(self, url: str, new_tab: bool = False) -> str: - del new_tab - return await asyncio.to_thread(self._fetch_url, url) - - def close(self) -> None: - return - - @staticmethod - def _fetch_url(url: str) -> str: - with urlopen(url, timeout=30) as response: - return response.read().decode() - - -class DemoLLM(LLM): - def __init__(self, slow_url: str): - super().__init__(model="demo-model", usage_id="demo-llm") - self._slow_url = slow_url - self._call_count = 0 - - def completion(self, *, messages, tools=None, **kwargs) -> LLMResponse: # type: ignore[override] - del messages, tools, kwargs - self._call_count += 1 - - if self._call_count == 1: - tool_call = MessageToolCall( - id="call-browser", - name=BrowserNavigateTool.name, - arguments=json.dumps({"url": self._slow_url}), - origin="completion", - ) - message = Message( - role="assistant", - content=[TextContent(text="I'll check the slow page.")], - tool_calls=[tool_call], - ) - else: - tool_call = MessageToolCall( - id="call-finish", - name="finish", - arguments=json.dumps( - { - "message": ( - "The slow web service timed out, but the browser tool " - "returned a normal error observation instead of " - "crashing the conversation." - ) - } - ), - origin="completion", - ) - message = Message( - role="assistant", - content=[TextContent(text="The timeout was handled cleanly.")], - tool_calls=[tool_call], - ) - - return LLMResponse( - message=message, - metrics=MetricsSnapshot( - model_name="demo-model", - accumulated_cost=0.0, - max_budget_per_task=0.0, - accumulated_token_usage=TokenUsage(model="demo-model"), - ), - raw_response=MagicMock(spec=ModelResponse, id=f"demo-{self._call_count}"), - ) - - -@contextmanager -def run_demo_server() -> "Iterator[str]": - server = _ThreadedSlowServer(("127.0.0.1", 0), SlowHandler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - - try: - host = server.server_address[0] - port = server.server_address[1] - yield f"http://{host}:{port}" - finally: - server.shutdown() - thread.join(timeout=5) - server.server_close() - - -llm_messages = [] - - -def conversation_callback(event: Event): - if isinstance(event, LLMConvertibleEvent): - llm_messages.append(event.to_llm_message()) - - -with run_demo_server() as slow_url: - executor = SlowServiceBrowserExecutor(action_timeout_seconds=ACTION_TIMEOUT_SECONDS) - browser_navigate_tool = BrowserNavigateTool.create(executor)[0] - register_tool(BrowserNavigateTool.name, browser_navigate_tool) - - tools = [Tool(name=BrowserNavigateTool.name)] - agent = Agent(llm=DemoLLM(slow_url), tools=tools) - - conversation = Conversation( - agent=agent, - callbacks=[conversation_callback], - max_iteration_per_run=4, - ) - - print("=" * 80) - print("Browser timeout observation example") - print("=" * 80) - print(f"Slow URL: {slow_url}") - print(f"Browser action timeout: {ACTION_TIMEOUT_SECONDS} seconds") - - conversation.send_message( - "Use browser_navigate to open the slow web service. Do not retry. After " - "the tool returns, explain what happened in one sentence." - ) - conversation.run() - - print("\nConversation completed without a fatal timeout.\n") - print("Collected LLM messages:") - for index, message in enumerate(llm_messages): - print(f"Message {index}: {str(message)[:400]}") - - cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost - print(f"Conversation ID: {conversation.id}") - print(f"EXAMPLE_COST: {cost}") - conversation.close() diff --git a/tests/examples/test_examples.py b/tests/examples/test_examples.py index 925713c495..c28f15a2a9 100644 --- a/tests/examples/test_examples.py +++ b/tests/examples/test_examples.py @@ -49,7 +49,6 @@ "examples/01_standalone_sdk/08_mcp_with_oauth.py", "examples/01_standalone_sdk/15_browser_use.py", "examples/01_standalone_sdk/16_llm_security_analyzer.py", - "examples/01_standalone_sdk/45_browser_timeout_observation.py", "examples/01_standalone_sdk/27_observability_laminar.py", "examples/01_standalone_sdk/35_subscription_login.py", # Requires interactive input() which fails in CI with EOFError From 34db45a4b033285fbfb9a7cb05bfb38d50b45106 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 19 Mar 2026 22:03:20 +0000 Subject: [PATCH 4/4] refactor(tools): simplify browser error formatting Co-authored-by: openhands --- .../openhands/tools/browser_use/impl.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/openhands-tools/openhands/tools/browser_use/impl.py b/openhands-tools/openhands/tools/browser_use/impl.py index a192dc60be..4099623f43 100644 --- a/openhands-tools/openhands/tools/browser_use/impl.py +++ b/openhands-tools/openhands/tools/browser_use/impl.py @@ -96,17 +96,16 @@ async def wrapper(self: BrowserToolExecutor, *args: Any, **kwargs: Any) -> Any: def _format_browser_operation_error( error: BaseException, timeout_seconds: float | None = None ) -> str: - error_detail = str(error).strip() - if not error_detail: - if isinstance(error, builtins.TimeoutError): - if timeout_seconds is None: - error_detail = "Operation timed out" - else: - error_detail = ( - f"Operation timed out after {int(timeout_seconds)} seconds" - ) - else: - error_detail = error.__class__.__name__ + if error_detail := str(error).strip(): + pass + elif isinstance(error, builtins.TimeoutError): + error_detail = ( + f"Operation timed out after {int(timeout_seconds)} seconds" + if timeout_seconds is not None + else "Operation timed out" + ) + else: + error_detail = error.__class__.__name__ return f"Browser operation failed: {error_detail}"