Bound browser tab usage during research

This commit is contained in:
Alessandro 2026-05-09 17:36:15 +02:00
parent bb3b41412c
commit 09d9ed2e80
7 changed files with 120 additions and 18 deletions

View file

@ -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.

View file

@ -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"),

View file

@ -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 ""

View file

@ -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}")

View file

@ -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}

View file

@ -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";

View file

@ -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: