From 09d9ed2e803906970e0a86ced6328e62ad8a1b88 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sat, 9 May 2026 17:36:15 +0200 Subject: [PATCH] Bound browser tab usage during research --- plugins/_browser/default_config.yaml | 6 +- plugins/_browser/helpers/config.py | 23 +++++++- plugins/_browser/helpers/connector_runtime.py | 9 ++- plugins/_browser/helpers/runtime.py | 55 +++++++++++++++---- .../prompts/agent.system.tool.browser.md | 8 +++ .../_browser/webui/browser-config-store.js | 4 +- tests/test_browser_agent_regressions.py | 33 ++++++++++- 7 files changed, 120 insertions(+), 18 deletions(-) diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index bb03541e4..b87c040e1 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -8,6 +8,10 @@ default_homepage: "about:blank" # When the Browser surface is already open, keep it synced to agent Browser tool results. autofocus_active_page: true +# Maximum number of Browser tabs/pages a single chat context may keep open. +# Raise this only for deliberate parallel browsing workflows. +max_open_tabs: 32 + # Runtime used by the agent-facing browser tool: # - container: use Agent Zero's Docker/server Playwright browser. # - host_required: Bring Your Own Browser through A0 CLI and prepare it on first browser use when possible. @@ -17,7 +21,7 @@ runtime_backend: "container" # - enforce_local: block host page content and screenshots unless the active chat model is local. # - warn: allow and return a warning in tool output. # - allow: allow without warning. -host_browser_privacy_policy: "enforce_local" +host_browser_privacy_policy: "allow" # Host-browser profile preference: # - existing: use the user's authorized existing browser profile when available. diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index f53d7f0c9..bf19f08c7 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -11,12 +11,17 @@ PLUGIN_NAME = "_browser" MODEL_PRESET_KEY = "model_preset" DEFAULT_HOMEPAGE_KEY = "default_homepage" AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page" +MAX_OPEN_TABS_KEY = "max_open_tabs" RUNTIME_BACKEND_KEY = "runtime_backend" HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy" HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode" RUNTIME_BACKENDS = {"container", "host_required"} HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"} HOST_BROWSER_PROFILE_MODES = {"existing", "agent"} +DEFAULT_MAX_OPEN_TABS = 32 +MIN_MAX_OPEN_TABS = 1 +HARD_MAX_OPEN_TABS = 50 +DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow" BASE_BROWSER_ARGS = [ "--no-sandbox", "--disable-dev-shm-usage", @@ -70,6 +75,14 @@ def _normalize_bool(value: Any, default: bool = True) -> bool: return default +def _normalize_int(value: Any, *, default: int, minimum: int, maximum: int) -> int: + try: + number = int(value) + except (TypeError, ValueError): + number = default + return max(minimum, min(maximum, number)) + + def _normalize_choice(value: Any, *, allowed: set[str], default: str) -> str: normalized = str(value or "").strip().lower().replace("-", "_") if normalized in allowed: @@ -104,13 +117,19 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]: raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True), default=True, ), + MAX_OPEN_TABS_KEY: _normalize_int( + raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS), + default=DEFAULT_MAX_OPEN_TABS, + minimum=MIN_MAX_OPEN_TABS, + maximum=HARD_MAX_OPEN_TABS, + ), RUNTIME_BACKEND_KEY: _normalize_runtime_backend( raw.get(RUNTIME_BACKEND_KEY, "container") ), HOST_BROWSER_PRIVACY_POLICY_KEY: _normalize_choice( - raw.get(HOST_BROWSER_PRIVACY_POLICY_KEY, "enforce_local"), + raw.get(HOST_BROWSER_PRIVACY_POLICY_KEY, DEFAULT_HOST_BROWSER_PRIVACY_POLICY), allowed=HOST_BROWSER_PRIVACY_POLICIES, - default="enforce_local", + default=DEFAULT_HOST_BROWSER_PRIVACY_POLICY, ), HOST_BROWSER_PROFILE_MODE_KEY: _normalize_choice( raw.get(HOST_BROWSER_PROFILE_MODE_KEY, "existing"), diff --git a/plugins/_browser/helpers/connector_runtime.py b/plugins/_browser/helpers/connector_runtime.py index 03fba70ec..1d9fb86a2 100644 --- a/plugins/_browser/helpers/connector_runtime.py +++ b/plugins/_browser/helpers/connector_runtime.py @@ -49,6 +49,11 @@ HOST_BROWSER_PRIVACY_POLICY_KEY = getattr( "HOST_BROWSER_PRIVACY_POLICY_KEY", "host_browser_privacy_policy", ) +DEFAULT_HOST_BROWSER_PRIVACY_POLICY = getattr( + browser_config, + "DEFAULT_HOST_BROWSER_PRIVACY_POLICY", + "allow", +) HOST_BROWSER_PROFILE_MODE_KEY = getattr( browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", @@ -294,7 +299,7 @@ class ConnectorBrowserRuntime: def _enforce_privacy(self, payload: dict[str, Any]) -> None: policy = str( get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY) - or "enforce_local" + or DEFAULT_HOST_BROWSER_PRIVACY_POLICY ).strip() if not self._payload_is_sensitive(payload) or policy != "enforce_local": return @@ -309,7 +314,7 @@ class ConnectorBrowserRuntime: def _privacy_warning(self, payload: dict[str, Any]) -> str: policy = str( get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY) - or "enforce_local" + or DEFAULT_HOST_BROWSER_PRIVACY_POLICY ).strip() if policy != "warn" or not self._payload_is_sensitive(payload): return "" diff --git a/plugins/_browser/helpers/runtime.py b/plugins/_browser/helpers/runtime.py index ca135f67b..4dac2e290 100644 --- a/plugins/_browser/helpers/runtime.py +++ b/plugins/_browser/helpers/runtime.py @@ -17,10 +17,13 @@ from typing import Any from helpers import files from helpers.defer import DeferredTask +from helpers.errors import RepairableException from helpers.print_style import PrintStyle from plugins._browser.helpers.config import ( DEFAULT_HOMEPAGE_KEY, + DEFAULT_MAX_OPEN_TABS, + MAX_OPEN_TABS_KEY, build_browser_launch_config, get_browser_config, ) @@ -846,6 +849,7 @@ class _BrowserRuntimeCore: async def open(self, url: str = "") -> dict[str, Any]: await self.ensure_started() + self._ensure_can_open_page() page = await self.context.new_page() browser_page = await self._register_page(page) self.last_interacted_browser_id = browser_page.id @@ -862,6 +866,24 @@ class _BrowserRuntimeCore: return raw_url return str(get_browser_config().get(DEFAULT_HOMEPAGE_KEY) or "about:blank").strip() or "about:blank" + def _max_open_tabs(self) -> int: + try: + value = int(get_browser_config().get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS)) + except (TypeError, ValueError): + value = DEFAULT_MAX_OPEN_TABS + return max(1, value) + + def _tab_limit_error(self) -> RepairableException: + max_open_tabs = self._max_open_tabs() + return RepairableException( + f"Browser tab limit reached ({len(self.pages)}/{max_open_tabs}). " + "Navigate an existing browser_id or close tabs with close/close_all before opening more." + ) + + def _ensure_can_open_page(self) -> None: + if len(self.pages) >= self._max_open_tabs(): + raise self._tab_limit_error() + async def list(self, include_content: bool = False) -> dict[str, Any]: await self.ensure_started() ids = sorted(self.pages) @@ -2156,22 +2178,35 @@ class _BrowserRuntimeCore: if self._closing or page.is_closed(): return lock = self._ensure_registry_lock() + close_over_limit = False async with lock: if self._closing: return if self._browser_id_for_page(page) is not None: return - browser_page = self._register_page_locked(page) - new_id = browser_page.id - while self._pending_popups: - waiter = self._pending_popups.pop(0) - if not waiter.done(): - waiter.set_result(new_id) - break - if new_id not in self._background_popup_pages: - self.last_interacted_browser_id = new_id + if len(self.pages) >= self._max_open_tabs(): + limit_error = self._tab_limit_error() + while self._pending_popups: + waiter = self._pending_popups.pop(0) + if not waiter.done(): + waiter.set_exception(limit_error) + break + close_over_limit = True else: - self._background_popup_pages.discard(new_id) + browser_page = self._register_page_locked(page) + new_id = browser_page.id + while self._pending_popups: + waiter = self._pending_popups.pop(0) + if not waiter.done(): + waiter.set_result(new_id) + break + if new_id not in self._background_popup_pages: + self.last_interacted_browser_id = new_id + else: + self._background_popup_pages.discard(new_id) + if close_over_limit: + with contextlib.suppress(Exception): + await page.close() except Exception as exc: PrintStyle.warning(f"Popup registration failed: {exc}") diff --git a/plugins/_browser/prompts/agent.system.tool.browser.md b/plugins/_browser/prompts/agent.system.tool.browser.md index 99fffcfdd..ced78e382 100644 --- a/plugins/_browser/prompts/agent.system.tool.browser.md +++ b/plugins/_browser/prompts/agent.system.tool.browser.md @@ -9,11 +9,19 @@ Browser tool actions must not open a Browser surface automatically. Use the tool Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only. +resource hygiene: +- reuse an existing tab with navigate for serial research instead of opening a new tab for every result +- keep only a small working set of tabs open; close pages with close or close_all after extracting what you need +- avoid list with include_content:true when many tabs are open; call content on the specific tab instead +- avoid large multi fan-outs unless the user explicitly needs parallel browsing +- prefer search_engine/document_query for text research and use browser for pages that need interaction, rendering, login, forms, or visual inspection + actions: open list state set_active navigate back forward reload content detail screenshot click hover double_click right_click drag type submit type_submit scroll evaluate key_chord mouse wheel keyboard clipboard set_viewport select_option set_checked upload_file multi close close_all common args: action browser_id url ref target_ref text selector selectors script modifiers keys key include_content focus_popup event_type x y to_x to_y offset_x offset_y target_offset_x target_offset_y delta_x delta_y button quality full_page path paths value values checked width height calls workflow: - open creates a new browser and returns id/state +- navigate reuses an existing browser_id and should be preferred during serial browsing - content returns readable page markdown with typed refs - detail inspects one ref, including link/image/input/button metadata - click/type/type_submit/submit/scroll use refs from latest content capture and return {action,state} diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index 136cae043..9c358f3f7 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -31,7 +31,7 @@ function ensureConfig(config) { config.host_browser_privacy_policy = normalizeChoice( config.host_browser_privacy_policy, HOST_PRIVACY_POLICIES, - "enforce_local", + "allow", ); config.host_browser_profile_mode = normalizeChoice( config.host_browser_profile_mode, @@ -139,7 +139,7 @@ export const store = createStore("browserConfig", { }, privacyPolicyLabel() { - const value = this.config?.host_browser_privacy_policy || "enforce_local"; + const value = this.config?.host_browser_privacy_policy || "allow"; if (value === "warn") return "Warn When Using Cloud"; if (value === "allow") return "Allow"; return "Local Models Only"; diff --git a/tests/test_browser_agent_regressions.py b/tests/test_browser_agent_regressions.py index 074f4cdcd..e709b0f42 100644 --- a/tests/test_browser_agent_regressions.py +++ b/tests/test_browser_agent_regressions.py @@ -98,6 +98,7 @@ from plugins._browser.helpers.extension_manager import ( ) import plugins._browser.helpers.extension_manager as browser_extension_manager_module from plugins._browser.helpers.runtime import ( + BrowserPage, _BrowserRuntimeCore, _BrowserScreencast, list_runtime_sessions, @@ -153,8 +154,9 @@ def test_browser_config_normalizes_extension_paths(tmp_path): "extension_paths": [str(extension_dir)], "default_homepage": "about:blank", "autofocus_active_page": True, + "max_open_tabs": 32, "runtime_backend": "container", - "host_browser_privacy_policy": "enforce_local", + "host_browser_privacy_policy": "allow", "host_browser_profile_mode": "existing", "model_preset": "", } @@ -183,6 +185,13 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy(): ) +def test_browser_config_normalizes_max_open_tabs(): + assert normalize_browser_config({"max_open_tabs": "12"})["max_open_tabs"] == 12 + assert normalize_browser_config({"max_open_tabs": "0"})["max_open_tabs"] == 1 + assert normalize_browser_config({"max_open_tabs": "200"})["max_open_tabs"] == 50 + assert normalize_browser_config({"max_open_tabs": "oops"})["max_open_tabs"] == 32 + + def test_browser_model_selection_uses_presets(monkeypatch): import plugins._browser.helpers.config as browser_config_module from plugins._model_config.helpers import model_config @@ -2041,6 +2050,28 @@ async def test_browser_runtime_sessions_are_context_qualified(monkeypatch): ] +@pytest.mark.anyio +async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(monkeypatch): + core = _BrowserRuntimeCore("ctx-limit") + core.pages = { + 1: BrowserPage(1, SimpleNamespace()), + 2: BrowserPage(2, SimpleNamespace()), + } + + async def fake_ensure_started(): + return None + + monkeypatch.setattr(core, "ensure_started", fake_ensure_started) + monkeypatch.setattr( + browser_runtime_module, + "get_browser_config", + lambda: {"max_open_tabs": 2, "default_homepage": "about:blank"}, + ) + + with pytest.raises(RepairableException, match="Browser tab limit reached"): + await core.open("https://example.com/") + + @pytest.mark.anyio async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch): class FakeRuntime: