Reduce browser startup and rendering overhead
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

Avoid starting Chromium for empty tab listings while preserving saved-tab
restoration. Honor navigation readiness without redundant load waits or
fixed pauses, and let WebUI navigation return at document commit.

Skip automatic history screenshots for inspection, evaluation, and batches
containing only those actions. Read page metadata in one evaluation and
report the actual loading state. Reuse the native viewer display size when
no resize is requested, preventing page zoom from enlarging and clipping
frames during navigation.

Update browser documentation and regression coverage. Validated 194 browser
regression/output tests and 29 host-browser tests in separate runs, plus
live WebUI navigation, pointer input, screenshots, and repeated reloads.
Local test-page medians improved from 474 to 83 ms for navigation and from
82 to 19 ms for content reads.
This commit is contained in:
Alessandro 2026-09-23 23:38:37 +02:00
parent 45b74ab7cf
commit 50db781a4d
7 changed files with 137 additions and 58 deletions

View file

@ -90,6 +90,10 @@ Many Browser steps also keep a small history screenshot. That means an older
chat can show the page as it looked when the agent worked on it, not just the
latest page frame.
Reading page content, listing tabs, inspecting state or elements, and running
JavaScript skip automatic history screenshots to keep these tasks quick.
Ask for a screenshot when you need a visual record.
## Annotate Pages
Annotate mode lets you mark a page element or region and send a targeted comment back into the chat. This is useful for UI review: you can point at the exact thing that needs to change instead of describing it from memory.

View file

@ -24,6 +24,7 @@
- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory.
- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest.
- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
- Reuse the private display's native dimensions when interactive-viewer requests omit a size. Page CSS viewport dimensions change with zoom and must not resize the display during navigation or tab switching.
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
- Complete tab switching when the viewer subscription succeeds and reuses the interactive viewer URL; the shared iframe does not reload for each tab. New viewer URLs and fallback frames retain their load/render completion path.
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
@ -40,6 +41,8 @@
- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists.
- Empty viewer subscriptions and closing the last tab clear the address, page state, viewer, and switching flags. Context changes invalidate pending surface, subscription, and command results before awaiting session refresh; stale completions must not restore another chat or clear the current startup state. Structured subscription errors release switching state just like transport failures.
- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down.
- Listing tabs must not cold-start Chromium when there are no saved tabs to restore. Preserve per-chat/shared and legacy-session restoration when saved tabs exist.
- Navigation waits only for the requested readiness: agent tools default to `domcontentloaded`, while WebUI navigation/new tabs and restored tabs use `commit`. Do not follow navigation with another load wait or fixed settle delay; retain settling for interaction actions. Read title, back-navigation availability, and loading state in one page evaluation.
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic host selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
- Browser Settings must refresh connected host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
@ -50,6 +53,7 @@
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end.
- Open-browser runtime state (tab listing, last interacted browser, page content) is per-loop dynamic context: it must flow through the `message_loop_prompts_after` extension into `loop_data.extras_temporary` (rendered as message extras), never into the system prompt, so the system prompt stays cache-stable across turns. The extension must check the profile Browser tool policy first and inject nothing when the tool is blocked.
- Browser tool results serialize minified JSON for agent history; any JSON beautification for display stays plugin-local in the WebUI tool handler (`browser-tool-handler.js`), never in shared `helpers/tool.py` where it would apply to every tool.
- History screenshot capture skips `list`, `state`, `content`, `detail`, `evaluate`, closing actions, and batches containing only those actions. Preserve history previews for other actions and reuse explicit screenshot results without capturing again.
- `evaluate` requires a non-empty `script` string in single and multi calls. Reject missing input instead of evaluating `undefined`; preserve legitimate JavaScript null/undefined results.
- `evaluate_timeout_seconds` is a Browser setting (default 30, finite range 0.1–60 seconds, invalid values rejected), resolved for the requesting chat/project; never accept a model-facing timeout override. It bounds user evaluation and result-state collection. Serialize evaluates per page and interrupt with CDP `Runtime.terminateExecution`. Preserve the document when the original request settles within a 250 ms response grace; reload to commit only for unresolved execution (such as awaited promises). Report any reload or fallback closure in the timeout error. Recovery, fallback page closure, and protocol detachment each have a 5-second bound; report recovery failure rather than success. Never reset the shared browser for an evaluate timeout. Caller cancellation must reach the Browser worker and await its cleanup without stopping the shared thread.
- Host evaluate requires `evaluate_timeout_v1`. Core supplies the settings deadline, dispatches multi calls through the same per-browser grouping as Internal Browser, and waits for `cancel_evaluate` acknowledgement on caller/transport cancellation. The companion connector owns browser-side interruption/recovery and rejects Safari evaluate. A lost acknowledgement is an unconfirmed cancellation error, never proof that JavaScript stopped.

View file

@ -92,7 +92,7 @@ class WsBrowser(WsHandler):
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if runtime and not browsers and create_browser:
opened = await runtime.call("open", "")
opened = await runtime.call("open", "", wait_until="commit")
listing = await runtime.call("list")
browsers = listing.get("browsers") or []
if opened.get("id"):
@ -264,7 +264,7 @@ class WsBrowser(WsHandler):
try:
if command == "open":
result = await runtime.call("open", data.get("url") or "")
result = await runtime.call("open", data.get("url") or "", wait_until="commit")
elif command == "navigate":
result = await runtime.call(
"navigate",

View file

@ -1302,7 +1302,9 @@ class _BrowserRuntimeCore:
except OSError as exc:
PrintStyle.warning(f"Could not force-stop orphaned Chromium process {pid}: {exc}")
async def open(self, url: str = "") -> dict[str, Any]:
async def open(
self, url: str = "", *, wait_until: str = "domcontentloaded"
) -> dict[str, Any]:
await self.ensure_started()
self._ensure_can_open_page()
context_id = self.current_context_id
@ -1314,9 +1316,7 @@ class _BrowserRuntimeCore:
self.last_interacted_browser_id = browser_page.id
target_url = self._initial_url(url)
if target_url and target_url != "about:blank":
await self._goto(page, normalize_url(target_url))
else:
await self._settle(page)
await self._goto(page, normalize_url(target_url), wait_until=wait_until)
self._persist_browser_tabs()
return {"id": browser_page.id, "state": await self._state(browser_page.id)}
@ -1346,7 +1346,8 @@ class _BrowserRuntimeCore:
raise self._tab_limit_error()
async def list(self, include_content: bool = False) -> dict[str, Any]:
await self.ensure_started()
if self.context or has_restorable_browser_tabs(self.current_context_id):
await self.ensure_started()
ids = self._context_browser_ids()
if not ids:
return {
@ -1377,7 +1378,8 @@ class _BrowserRuntimeCore:
}
async def list_all(self) -> dict[str, Any]:
await self.ensure_started()
if self.context or has_restorable_browser_tabs(self.current_context_id):
await self.ensure_started()
browser_ids = sorted(self.pages)
return {
"browsers": await asyncio.gather(
@ -1644,7 +1646,6 @@ class _BrowserRuntimeCore:
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.go_back(wait_until=wait_until, timeout=10000)
await self._settle(page, short=wait_until == "commit")
self._maybe_promote(resolved_id)
self._persist_browser_tabs()
return await self._state(resolved_id)
@ -1659,7 +1660,6 @@ class _BrowserRuntimeCore:
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.go_forward(wait_until=wait_until, timeout=10000)
await self._settle(page, short=wait_until == "commit")
self._maybe_promote(resolved_id)
self._persist_browser_tabs()
return await self._state(resolved_id)
@ -1674,7 +1674,6 @@ class _BrowserRuntimeCore:
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
await page.reload(wait_until=wait_until, timeout=15000)
await self._settle(page, short=wait_until == "commit")
self._maybe_promote(resolved_id)
self._persist_browser_tabs()
return await self._state(resolved_id)
@ -2241,11 +2240,7 @@ class _BrowserRuntimeCore:
await self.ensure_started()
resolved_id = self._resolve_browser_id(browser_id)
page = self._page(resolved_id)
current_viewport = await self._page_viewport(page)
viewer = self.interactive_view.ensure_viewer(
width or int(current_viewport.get("width") or DEFAULT_VIEWPORT["width"]),
height or int(current_viewport.get("height") or DEFAULT_VIEWPORT["height"]),
)
viewer = self.interactive_view.ensure_viewer(width, height)
if not viewer.get("available"):
return viewer
@ -2825,7 +2820,6 @@ class _BrowserRuntimeCore:
PrintStyle.warning(f"Browser navigation timed out waiting for {wait_until}: {url}")
except PlaywrightError as exc:
PrintStyle.warning(f"Browser navigation showed a native error page for {url}: {exc}")
await self._settle(page, short=wait_until == "commit")
async def _settle(self, page: Any, short: bool = False) -> None:
from patchright.async_api import Error as PlaywrightError
@ -2846,24 +2840,21 @@ class _BrowserRuntimeCore:
raise KeyError(f"Browser {browser_id} is not open.")
page = browser_page.page
try:
title = await page.title()
except Exception:
title = ""
try:
history_length = await page.evaluate(
"() => globalThis.history?.length || 0",
state = await page.evaluate(
"() => ({title: document.title, canGoBack: history.length > 1, "
"loading: document.readyState !== 'complete'})",
isolated_context=False,
)
except Exception:
history_length = 0
state = {}
return {
"id": browser_page.id,
"context_id": self._page_context_id(browser_page),
"currentUrl": page.url,
"title": title,
"canGoBack": bool(history_length and int(history_length) > 1),
"title": state.get("title", ""),
"canGoBack": bool(state.get("canGoBack")),
"canGoForward": False,
"loading": False,
"loading": bool(state.get("loading")),
}
def _register_page_locked(

View file

@ -15,7 +15,9 @@ from plugins._browser.helpers.selector import get_tool_runtime
HISTORY_SCREENSHOT_QUALITY = 62
HISTORY_SCREENSHOT_ACTION_DENYLIST = {"close", "close_all"}
HISTORY_SCREENSHOT_ACTION_DENYLIST = {
"close", "close_all", "list", "state", "content", "detail", "evaluate",
}
async def get_runtime(context_id: str, create: bool = True, agent: Any | None = None):
@ -315,7 +317,7 @@ class Browser(Tool):
message=f"Unknown browser action: {action}",
break_loop=False,
)
await self._record_history_screenshot(runtime, action, result, browser_id)
await self._record_history_screenshot(runtime, action, result, browser_id, calls=calls)
except Exception as exc:
return Response(message=f"Browser {action} failed: {exc}", break_loop=False)
@ -429,11 +431,19 @@ class Browser(Tool):
action: str,
result: Any,
requested_browser_id: int | str | None = None,
*,
calls: list[dict[str, Any]] | None = None,
) -> None:
if not getattr(self, "log", None):
return
if action in HISTORY_SCREENSHOT_ACTION_DENYLIST:
return
if action == "multi" and calls and all(
str(call.get("action") or "").strip().lower().replace("-", "_")
in HISTORY_SCREENSHOT_ACTION_DENYLIST
for call in calls
):
return
screenshot = result if action == "screenshot" and isinstance(result, dict) else None
if not self._screenshot_has_reference(screenshot):

View file

@ -2106,7 +2106,7 @@ def test_browser_navigation_errors_stay_inside_native_browser_page():
assert "Error as PlaywrightError" in runtime
assert "except PlaywrightError as exc:" in runtime
assert "Browser navigation showed a native error page" in runtime
assert "await self._settle(page)" in runtime
assert "await page.goto(url, wait_until=wait_until, timeout=30000)" in runtime
assert "except (PlaywrightError, PlaywrightTimeoutError):" in runtime
@ -2930,9 +2930,10 @@ def test_browser_interactive_views_use_isolated_loopback_sessions(monkeypatch, t
@pytest.mark.anyio
async def test_browser_interactive_viewer_reuses_the_automated_page():
@pytest.mark.parametrize("width,height", [(1200, 700), (0, 0)])
async def test_browser_interactive_viewer_reuses_the_automated_page(width, height):
class FakePage:
viewport_size = {"width": 1024, "height": 768}
viewport_size = {"width": 1800, "height": 1050}
class FakeInteractiveView:
def __init__(self):
@ -2944,8 +2945,8 @@ async def test_browser_interactive_viewer_reuses_the_automated_page():
"available": True,
"token": "browser-token",
"url": "/desktop/session/browser-token/",
"width": width,
"height": height,
"width": width or 1200,
"height": height or 700,
}
page = FakePage()
@ -2971,10 +2972,10 @@ async def test_browser_interactive_viewer_reuses_the_automated_page():
core._stop_screencasts_for_browser = stop_screencasts
core.set_viewport = set_viewport
result = await core.interactive_viewer(7, width=1200, height=700)
result = await core.interactive_viewer(7, width=width, height=height)
assert core.pages[7].page is page
assert interactive_view.ensure_calls == [(1200, 700)]
assert interactive_view.ensure_calls == [(width, height)]
assert stopped == [7]
assert viewport_calls == [(7, 1200, 700, {"resize_interactive": True})]
assert core.last_interacted_browser_id == 7
@ -3718,7 +3719,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
def __init__(self) -> None:
self.opened = False
async def call(self, method, *args):
async def call(self, method, *args, **kwargs):
if method == "list":
if self.opened:
return {
@ -3727,6 +3728,7 @@ async def test_browser_viewer_subscribe_can_create_blank_tab_when_requested(monk
}
return {"browsers": [], "last_interacted_browser_id": None}
if method == "open":
assert kwargs == {"wait_until": "commit"}
self.opened = True
return {"id": 1, "state": {"id": 1, "currentUrl": "about:blank"}}
raise AssertionError(method)
@ -4071,11 +4073,8 @@ async def test_shared_browser_runtime_keeps_tabs_context_scoped():
self.url = url
self.closed = False
async def title(self):
return self.url
async def evaluate(self, script, **kwargs):
return 1
return {"title": self.url, "canGoBack": False, "loading": False}
async def close(self):
self.closed = True
@ -4679,11 +4678,8 @@ async def test_browser_runtime_screenshot_file_defaults_to_chat_scoped_artifact(
Path(kwargs["path"]).write_bytes(b"image-bytes")
return b"image-bytes"
async def title(self):
return "Blank"
async def evaluate(self, script, payload=None, **kwargs):
return 1
return {"title": "Blank", "canGoBack": False, "loading": False}
core = _BrowserRuntimeCore(browser_runtime_module.SHARED_RUNTIME_ID)
core.context = object()
@ -4821,10 +4817,7 @@ async def test_browser_runtime_ref_point_resolution_applies_offsets():
"rect": {"x": 10, "y": 20, "width": 100, "height": 40},
"selector": "#target",
}
return 1
async def title(self):
return "Blank"
return {"title": "Blank", "canGoBack": False, "loading": False}
core = _BrowserRuntimeCore("ctx")
core.context = object()
@ -4892,10 +4885,7 @@ async def test_browser_runtime_clipboard_paste_uses_dom_bridge():
"changed": True,
"default_prevented": False,
}
return 1
async def title(self):
return "Blank"
return {"title": "Blank", "canGoBack": False, "loading": False}
page = FakePage()
core = _BrowserRuntimeCore("ctx")
@ -4941,10 +4931,7 @@ async def test_browser_runtime_clipboard_paste_falls_back_to_keyboard_insert_tex
"changed": False,
"default_prevented": False,
}
return 1
async def title(self):
return "Blank"
return {"title": "Blank", "canGoBack": False, "loading": False}
page = FakePage()
core = _BrowserRuntimeCore("ctx")

View file

@ -3,7 +3,7 @@ import json
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, Mock
import pytest
@ -16,6 +16,89 @@ from plugins._browser.tools.browser import Browser
PROJECT_ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["list", "list_all"])
@pytest.mark.parametrize("restorable", [False, True])
async def test_listing_only_starts_cold_browser_when_tabs_need_restoring(monkeypatch, method, restorable):
core = _BrowserRuntimeCore("listing-test")
started = AsyncMock()
monkeypatch.setattr(core, "ensure_started", started)
monkeypatch.setattr(runtime_module, "has_restorable_browser_tabs", lambda context_id: restorable)
assert (await getattr(core, method)())["browsers"] == []
assert started.await_count == int(restorable)
@pytest.mark.asyncio
@pytest.mark.parametrize("wait_until", ["commit", "domcontentloaded"])
async def test_navigation_uses_requested_readiness_without_extra_settling(monkeypatch, wait_until):
core = _BrowserRuntimeCore("navigation-test")
page = SimpleNamespace(
goto=AsyncMock(), go_back=AsyncMock(), go_forward=AsyncMock(), reload=AsyncMock(),
)
core.pages[1] = BrowserPage(1, page, "navigation-test")
core.context = SimpleNamespace(new_page=AsyncMock(return_value=page))
monkeypatch.setattr(core, "ensure_started", AsyncMock())
monkeypatch.setattr(core, "_state", AsyncMock(return_value={"id": 1}))
monkeypatch.setattr(core, "_register_page", AsyncMock(return_value=core.pages[1]))
monkeypatch.setattr(core, "_persist_browser_tabs", lambda: None)
monkeypatch.setattr(core, "_settle", AsyncMock(side_effect=AssertionError("Redundant wait")))
monkeypatch.setattr(runtime_module, "get_browser_config", lambda: {})
await core.open("https://example.com", wait_until=wait_until)
await core.navigate(1, "https://example.com", wait_until=wait_until)
await core.back(1, wait_until=wait_until)
await core.forward(1, wait_until=wait_until)
await core.reload(1, wait_until=wait_until)
for method in (page.goto, page.go_back, page.go_forward, page.reload):
assert all(call.kwargs["wait_until"] == wait_until for call in method.await_args_list)
core._settle.assert_not_awaited()
@pytest.mark.asyncio
async def test_browser_state_reads_metadata_once_and_reports_loading(monkeypatch):
core = _BrowserRuntimeCore("state-test")
page = SimpleNamespace(url="https://example.com", evaluate=AsyncMock(return_value={
"title": "Example", "canGoBack": True, "loading": True,
}))
core.pages[1] = BrowserPage(1, page, "state-test")
state = await core._state(1)
assert state == {
"id": 1, "context_id": "state-test", "currentUrl": page.url,
"title": "Example", "canGoBack": True, "canGoForward": False, "loading": True,
}
assert page.evaluate.await_count == 1
page.evaluate.side_effect = RuntimeError("Execution context destroyed during navigation")
assert (await core._state(1))["currentUrl"] == page.url
@pytest.mark.asyncio
@pytest.mark.parametrize("action,calls,capture", [
*[(action, None, False) for action in ("list", "state", "content", "detail", "evaluate", "close", "close_all")],
("multi", [{"action": "content"}, {"action": "evaluate", "script": "document.title"}], False),
("multi", [{"action": "content"}, {"action": "click", "ref": 1}], True),
("screenshot", None, True),
])
async def test_browser_inspection_skips_history_capture_but_preserves_visual_actions(monkeypatch, action, calls, capture):
from plugins._browser.tools import browser as tool_module
screenshot = {"browser_id": 1, "ephemeral_ref": "a0-ephemeral-image://test"}
runtime = SimpleNamespace(call=AsyncMock(side_effect=lambda method, *a, **kw:
screenshot if method == "screenshot_file" else {}))
monkeypatch.setattr(tool_module, "get_runtime", AsyncMock(return_value=runtime))
monkeypatch.setattr(tool_module, "activate_browser_model", lambda agent: None)
tool = object.__new__(Browser)
tool.agent = SimpleNamespace(context=SimpleNamespace(id="test"))
tool.method = ""
tool.log = SimpleNamespace(update=Mock())
response = await tool.execute(action=action, browser_id=1, ref=1, script="document.title", calls=calls)
assert not response.message.startswith("Browser "), response.message
assert sum(call.args[0] == "screenshot_file" for call in runtime.call.await_args_list) == int(capture)
assert tool.log.update.call_count == int(capture)
def test_format_result_minifies_tool_json() -> None:
result = {"browsers": [{"id": "b1", "title": "Example"}]}