mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-05-19 16:31:30 +00:00
Bound browser tab usage during research
This commit is contained in:
parent
bb3b41412c
commit
09d9ed2e80
7 changed files with 120 additions and 18 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue