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: