Bound browser tab usage during research

Alessandro committed May 9, 2026 at 17:36 UTC 09d9ed2e803906970e0a86ced6328e62ad8a1b88
7 files changed +120 -18
plugins/_browser/default_config.yaml
+5 -1
@@ -8,6 +8,10 @@ default_homepage: "about:blank"
8 # When the Browser surface is already open, keep it synced to agent Browser tool results.
9 autofocus_active_page: true
10
11 +# Maximum number of Browser tabs/pages a single chat context may keep open.
12 +# Raise this only for deliberate parallel browsing workflows.
13 +max_open_tabs: 32
14 +
15 # Runtime used by the agent-facing browser tool:
16 # - container: use Agent Zero's Docker/server Playwright browser.
17 # - 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"
21 # - enforce_local: block host page content and screenshots unless the active chat model is local.
22 # - warn: allow and return a warning in tool output.
23 # - allow: allow without warning.
20 -host_browser_privacy_policy: "enforce_local"
24 +host_browser_privacy_policy: "allow"
25
26 # Host-browser profile preference:
27 # - existing: use the user's authorized existing browser profile when available.
plugins/_browser/helpers/config.py
+21 -2
@@ -11,12 +11,17 @@ PLUGIN_NAME = "_browser"
11 MODEL_PRESET_KEY = "model_preset"
12 DEFAULT_HOMEPAGE_KEY = "default_homepage"
13 AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
14 +MAX_OPEN_TABS_KEY = "max_open_tabs"
15 RUNTIME_BACKEND_KEY = "runtime_backend"
16 HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
17 HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
18 RUNTIME_BACKENDS = {"container", "host_required"}
19 HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
20 HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
21 +DEFAULT_MAX_OPEN_TABS = 32
22 +MIN_MAX_OPEN_TABS = 1
23 +HARD_MAX_OPEN_TABS = 50
24 +DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow"
25 BASE_BROWSER_ARGS = [
26 "--no-sandbox",
27 "--disable-dev-shm-usage",
@@ -70,6 +75,14 @@ def _normalize_bool(value: Any, default: bool = True) -> bool:
75 return default
76
77
78 +def _normalize_int(value: Any, *, default: int, minimum: int, maximum: int) -> int:
79 + try:
80 + number = int(value)
81 + except (TypeError, ValueError):
82 + number = default
83 + return max(minimum, min(maximum, number))
84 +
85 +
86 def _normalize_choice(value: Any, *, allowed: set[str], default: str) -> str:
87 normalized = str(value or "").strip().lower().replace("-", "_")
88 if normalized in allowed:
@@ -104,13 +117,19 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
117 raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
118 default=True,
119 ),
120 + MAX_OPEN_TABS_KEY: _normalize_int(
121 + raw.get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS),
122 + default=DEFAULT_MAX_OPEN_TABS,
123 + minimum=MIN_MAX_OPEN_TABS,
124 + maximum=HARD_MAX_OPEN_TABS,
125 + ),
126 RUNTIME_BACKEND_KEY: _normalize_runtime_backend(
127 raw.get(RUNTIME_BACKEND_KEY, "container")
128 ),
129 HOST_BROWSER_PRIVACY_POLICY_KEY: _normalize_choice(
111 - raw.get(HOST_BROWSER_PRIVACY_POLICY_KEY, "enforce_local"),
130 + raw.get(HOST_BROWSER_PRIVACY_POLICY_KEY, DEFAULT_HOST_BROWSER_PRIVACY_POLICY),
131 allowed=HOST_BROWSER_PRIVACY_POLICIES,
113 - default="enforce_local",
132 + default=DEFAULT_HOST_BROWSER_PRIVACY_POLICY,
133 ),
134 HOST_BROWSER_PROFILE_MODE_KEY: _normalize_choice(
135 raw.get(HOST_BROWSER_PROFILE_MODE_KEY, "existing"),
plugins/_browser/helpers/connector_runtime.py
+7 -2
@@ -49,6 +49,11 @@ HOST_BROWSER_PRIVACY_POLICY_KEY = getattr(
49 "HOST_BROWSER_PRIVACY_POLICY_KEY",
50 "host_browser_privacy_policy",
51 )
52 +DEFAULT_HOST_BROWSER_PRIVACY_POLICY = getattr(
53 + browser_config,
54 + "DEFAULT_HOST_BROWSER_PRIVACY_POLICY",
55 + "allow",
56 +)
57 HOST_BROWSER_PROFILE_MODE_KEY = getattr(
58 browser_config,
59 "HOST_BROWSER_PROFILE_MODE_KEY",
@@ -294,7 +299,7 @@ class ConnectorBrowserRuntime:
299 def _enforce_privacy(self, payload: dict[str, Any]) -> None:
300 policy = str(
301 get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY)
297 - or "enforce_local"
302 + or DEFAULT_HOST_BROWSER_PRIVACY_POLICY
303 ).strip()
304 if not self._payload_is_sensitive(payload) or policy != "enforce_local":
305 return
@@ -309,7 +314,7 @@ class ConnectorBrowserRuntime:
314 def _privacy_warning(self, payload: dict[str, Any]) -> str:
315 policy = str(
316 get_browser_config(agent=self.agent).get(HOST_BROWSER_PRIVACY_POLICY_KEY)
312 - or "enforce_local"
317 + or DEFAULT_HOST_BROWSER_PRIVACY_POLICY
318 ).strip()
319 if policy != "warn" or not self._payload_is_sensitive(payload):
320 return ""
plugins/_browser/helpers/runtime.py
+45 -10
@@ -17,10 +17,13 @@ from typing import Any
17
18 from helpers import files
19 from helpers.defer import DeferredTask
20 +from helpers.errors import RepairableException
21 from helpers.print_style import PrintStyle
22
23 from plugins._browser.helpers.config import (
24 DEFAULT_HOMEPAGE_KEY,
25 + DEFAULT_MAX_OPEN_TABS,
26 + MAX_OPEN_TABS_KEY,
27 build_browser_launch_config,
28 get_browser_config,
29 )
@@ -846,6 +849,7 @@ class _BrowserRuntimeCore:
849
850 async def open(self, url: str = "") -> dict[str, Any]:
851 await self.ensure_started()
852 + self._ensure_can_open_page()
853 page = await self.context.new_page()
854 browser_page = await self._register_page(page)
855 self.last_interacted_browser_id = browser_page.id
@@ -862,6 +866,24 @@ class _BrowserRuntimeCore:
866 return raw_url
867 return str(get_browser_config().get(DEFAULT_HOMEPAGE_KEY) or "about:blank").strip() or "about:blank"
868
869 + def _max_open_tabs(self) -> int:
870 + try:
871 + value = int(get_browser_config().get(MAX_OPEN_TABS_KEY, DEFAULT_MAX_OPEN_TABS))
872 + except (TypeError, ValueError):
873 + value = DEFAULT_MAX_OPEN_TABS
874 + return max(1, value)
875 +
876 + def _tab_limit_error(self) -> RepairableException:
877 + max_open_tabs = self._max_open_tabs()
878 + return RepairableException(
879 + f"Browser tab limit reached ({len(self.pages)}/{max_open_tabs}). "
880 + "Navigate an existing browser_id or close tabs with close/close_all before opening more."
881 + )
882 +
883 + def _ensure_can_open_page(self) -> None:
884 + if len(self.pages) >= self._max_open_tabs():
885 + raise self._tab_limit_error()
886 +
887 async def list(self, include_content: bool = False) -> dict[str, Any]:
888 await self.ensure_started()
889 ids = sorted(self.pages)
@@ -2156,22 +2178,35 @@ class _BrowserRuntimeCore:
2178 if self._closing or page.is_closed():
2179 return
2180 lock = self._ensure_registry_lock()
2181 + close_over_limit = False
2182 async with lock:
2183 if self._closing:
2184 return
2185 if self._browser_id_for_page(page) is not None:
2186 return
2164 - browser_page = self._register_page_locked(page)
2165 - new_id = browser_page.id
2166 - while self._pending_popups:
2167 - waiter = self._pending_popups.pop(0)
2168 - if not waiter.done():
2169 - waiter.set_result(new_id)
2170 - break
2171 - if new_id not in self._background_popup_pages:
2172 - self.last_interacted_browser_id = new_id
2187 + if len(self.pages) >= self._max_open_tabs():
2188 + limit_error = self._tab_limit_error()
2189 + while self._pending_popups:
2190 + waiter = self._pending_popups.pop(0)
2191 + if not waiter.done():
2192 + waiter.set_exception(limit_error)
2193 + break
2194 + close_over_limit = True
2195 else:
2174 - self._background_popup_pages.discard(new_id)
2196 + browser_page = self._register_page_locked(page)
2197 + new_id = browser_page.id
2198 + while self._pending_popups:
2199 + waiter = self._pending_popups.pop(0)
2200 + if not waiter.done():
2201 + waiter.set_result(new_id)
2202 + break
2203 + if new_id not in self._background_popup_pages:
2204 + self.last_interacted_browser_id = new_id
2205 + else:
2206 + self._background_popup_pages.discard(new_id)
2207 + if close_over_limit:
2208 + with contextlib.suppress(Exception):
2209 + await page.close()
2210 except Exception as exc:
2211 PrintStyle.warning(f"Popup registration failed: {exc}")
2212
plugins/_browser/prompts/agent.system.tool.browser.md
+8
@@ -9,11 +9,19 @@ Browser tool actions must not open a Browser surface automatically. Use the tool
9
10 Browser does not automatically load screenshots or surface images into model context. Screenshots are explicit only.
11
12 +resource hygiene:
13 +- reuse an existing tab with navigate for serial research instead of opening a new tab for every result
14 +- keep only a small working set of tabs open; close pages with close or close_all after extracting what you need
15 +- avoid list with include_content:true when many tabs are open; call content on the specific tab instead
16 +- avoid large multi fan-outs unless the user explicitly needs parallel browsing
17 +- prefer search_engine/document_query for text research and use browser for pages that need interaction, rendering, login, forms, or visual inspection
18 +
19 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
20 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
21
22 workflow:
23 - open creates a new browser and returns id/state
24 +- navigate reuses an existing browser_id and should be preferred during serial browsing
25 - content returns readable page markdown with typed refs
26 - detail inspects one ref, including link/image/input/button metadata
27 - click/type/type_submit/submit/scroll use refs from latest content capture and return {action,state}
plugins/_browser/webui/browser-config-store.js
+2 -2
@@ -31,7 +31,7 @@ function ensureConfig(config) {
31 config.host_browser_privacy_policy = normalizeChoice(
32 config.host_browser_privacy_policy,
33 HOST_PRIVACY_POLICIES,
34 - "enforce_local",
34 + "allow",
35 );
36 config.host_browser_profile_mode = normalizeChoice(
37 config.host_browser_profile_mode,
@@ -139,7 +139,7 @@ export const store = createStore("browserConfig", {
139 },
140
141 privacyPolicyLabel() {
142 - const value = this.config?.host_browser_privacy_policy || "enforce_local";
142 + const value = this.config?.host_browser_privacy_policy || "allow";
143 if (value === "warn") return "Warn When Using Cloud";
144 if (value === "allow") return "Allow";
145 return "Local Models Only";
tests/test_browser_agent_regressions.py
+32 -1
@@ -98,6 +98,7 @@ from plugins._browser.helpers.extension_manager import (
98 )
99 import plugins._browser.helpers.extension_manager as browser_extension_manager_module
100 from plugins._browser.helpers.runtime import (
101 + BrowserPage,
102 _BrowserRuntimeCore,
103 _BrowserScreencast,
104 list_runtime_sessions,
@@ -153,8 +154,9 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
154 "extension_paths": [str(extension_dir)],
155 "default_homepage": "about:blank",
156 "autofocus_active_page": True,
157 + "max_open_tabs": 32,
158 "runtime_backend": "container",
157 - "host_browser_privacy_policy": "enforce_local",
159 + "host_browser_privacy_policy": "allow",
160 "host_browser_profile_mode": "existing",
161 "model_preset": "",
162 }
@@ -183,6 +185,13 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
185 )
186
187
188 +def test_browser_config_normalizes_max_open_tabs():
189 + assert normalize_browser_config({"max_open_tabs": "12"})["max_open_tabs"] == 12
190 + assert normalize_browser_config({"max_open_tabs": "0"})["max_open_tabs"] == 1
191 + assert normalize_browser_config({"max_open_tabs": "200"})["max_open_tabs"] == 50
192 + assert normalize_browser_config({"max_open_tabs": "oops"})["max_open_tabs"] == 32
193 +
194 +
195 def test_browser_model_selection_uses_presets(monkeypatch):
196 import plugins._browser.helpers.config as browser_config_module
197 from plugins._model_config.helpers import model_config
@@ -2041,6 +2050,28 @@ async def test_browser_runtime_sessions_are_context_qualified(monkeypatch):
2050 ]
2051
2052
2053 +@pytest.mark.anyio
2054 +async def test_browser_runtime_refuses_new_tabs_when_context_limit_is_reached(monkeypatch):
2055 + core = _BrowserRuntimeCore("ctx-limit")
2056 + core.pages = {
2057 + 1: BrowserPage(1, SimpleNamespace()),
2058 + 2: BrowserPage(2, SimpleNamespace()),
2059 + }
2060 +
2061 + async def fake_ensure_started():
2062 + return None
2063 +
2064 + monkeypatch.setattr(core, "ensure_started", fake_ensure_started)
2065 + monkeypatch.setattr(
2066 + browser_runtime_module,
2067 + "get_browser_config",
2068 + lambda: {"max_open_tabs": 2, "default_homepage": "about:blank"},
2069 + )
2070 +
2071 + with pytest.raises(RepairableException, match="Browser tab limit reached"):
2072 + await core.open("https://example.com/")
2073 +
2074 +
2075 @pytest.mark.anyio
2076 async def test_browser_viewer_command_returns_tabs_from_all_contexts(monkeypatch):
2077 class FakeRuntime: