diff --git a/tests/tools/test_browser_use_cli.py b/tests/tools/test_browser_use_cli.py index 0f6a197be9c9..08aefe3ec9a8 100644 --- a/tests/tools/test_browser_use_cli.py +++ b/tests/tools/test_browser_use_cli.py @@ -395,20 +395,68 @@ def test_provider_without_cdp_returns_error(self, monkeypatch): err = bu_cli._resolve_backend_cdp(self._env(), "t1") assert err and "no" in err.lower() and "CDP" in err - def test_named_session_skips_backend_resolution(self, tmp_path, monkeypatch): - """session= (BU_NAME cloud browser) must not consume a backend - provider session.""" + def test_named_session_composes_with_provider_backend(self, tmp_path, monkeypatch): + """session= composes with a configured provider backend: the + name keys its OWN provider browser (bu-named-), so concurrent + named sessions never share one browser (#86894).""" import tools.browser_tool as bt - def fail(task_id): - raise AssertionError("backend resolution must be skipped") + seen = [] - monkeypatch.setattr(bt, "_get_session_info", fail) - cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME"\n') + def fake_session_info(key): + seen.append(key) + return {"cdp_url": "wss://browser.example/cdp/" + key} + + monkeypatch.setattr(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: object()) + monkeypatch.setattr(bt, "_get_session_info", fake_session_info) + cli = _fake_cli(tmp_path, 'cat > /dev/null\necho "bu:$BU_NAME ws:$BU_CDP_WS"\n') monkeypatch.setattr(bu_cli, "_find_cli", lambda: [cli]) result = json.loads(bu_cli.browser_exec("print(1)", session="r7k2")) assert result["success"] is True + assert seen == ["bu-named-r7k2"] assert "bu:r7k2" in result["output"] + assert "ws:wss://browser.example/cdp/bu-named-r7k2" in result["output"] + + def test_named_session_key_stable_across_tasks(self, monkeypatch): + """The same session name maps to the same provider cache key no + matter which task calls it — that is what lets a follow-up call + reattach to the same cloud browser.""" + import tools.browser_tool as bt + + seen = [] + monkeypatch.setattr(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: object()) + monkeypatch.setattr( + bt, "_get_session_info", + lambda key: seen.append(key) or {"cdp_url": "wss://x/cdp/a"}, + ) + env1, env2 = {}, {} + assert bu_cli._resolve_backend_cdp(env1, "task-A", session_name="research") is None + assert bu_cli._resolve_backend_cdp(env2, "task-B", session_name="research") is None + assert seen == ["bu-named-research", "bu-named-research"] + + def test_named_session_direct_api_bu_cloud_still_skips_provider( + self, tmp_path, monkeypatch + ): + """Direct-API Browser Use cloud configs keep the native named-daemon + path: resolving through the provider would double-session and + double-bill.""" + import tools.browser_tool as bt + + class _BUProvider: + name = "browser-use" + + monkeypatch.setattr(bt, "_get_cdp_override", lambda: "") + monkeypatch.setattr(bt, "_get_cloud_provider", lambda: _BUProvider()) + monkeypatch.setattr( + bt, "_get_session_info", + lambda key: (_ for _ in ()).throw(AssertionError("must skip provider")), + ) + monkeypatch.setattr(bu_cli, "_read_browser_cfg", lambda: {"cloud_provider": "browser-use"}) + env = {} + assert bu_cli._resolve_backend_cdp(env, "t1", session_name="r7k2") is None + assert "BU_CDP_WS" not in env and "BU_CDP_URL" not in env class TestProviderPickerIntegration: diff --git a/tools/browser_use_cli.py b/tools/browser_use_cli.py index 15828edf354e..97c6931ab47f 100644 --- a/tools/browser_use_cli.py +++ b/tools/browser_use_cli.py @@ -399,7 +399,9 @@ def _native_screenshot_result(result: Dict[str, Any], path: str) -> Optional[Dic return None -def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: +def _resolve_backend_cdp( + env: dict, task_id: Optional[str], session_name: str = "" +) -> Optional[str]: """Point the harness at the configured browser backend's CDP endpoint. Resolution order (first hit wins): @@ -416,6 +418,12 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: 4. Nothing configured: return None; the harness attaches to local Chrome (or Browser Use cloud via BU_AUTOSPAWN for legacy configs). + ``session_name`` (the tool's ``session`` argument / BU_NAME) keys the + provider session cache when set, so every distinct name gets its OWN + cloud browser and the same name reuses one — that is what makes named + sessions actually concurrent-safe on provider backends instead of all + names sharing a single per-task browser. + Returns an error string on provider failure, None on success. """ if env.get("BU_CDP_WS") or env.get("BU_CDP_URL"): @@ -460,7 +468,11 @@ def _resolve_backend_cdp(env: dict, task_id: Optional[str]) -> Optional[str]: return None try: - session_info = _get_session_info(task_id or "browser-exec-default") + # Named sessions get their OWN provider browser, keyed by name so the + # same name reuses one browser across calls and tasks, and different + # names never collide. Unnamed calls keep the per-task key. + cache_key = f"bu-named-{session_name}" if session_name else (task_id or "browser-exec-default") + session_info = _get_session_info(cache_key) except Exception as e: return ( f"Cloud browser provider {type(provider).__name__} failed to " @@ -511,13 +523,17 @@ def browser_exec( "dashes, or underscores (e.g. 'r7k2')." ) env["BU_NAME"] = session - else: - # Route through the configured browser backend (Browserbase, - # Firecrawl, Nous gateway, CDP override, …). Explicit BU_NAME cloud - # sessions manage their own browser and skip backend resolution. - backend_err = _resolve_backend_cdp(env, task_id) - if backend_err: - return tool_error(backend_err) + # Route through the configured browser backend (Browserbase, Firecrawl, + # Nous gateway, CDP override, local Chrome, …). Named sessions compose + # with the backend: BU_NAME namespaces the harness daemon (its IPC + # socket, log, and pid), and on provider backends the name additionally + # keys its own cloud browser — so concurrent sessions stop clobbering + # each other's daemon (#86894). Browser Use direct-API cloud configs + # are the one exception: the CLI manages named cloud browsers natively, + # and _resolve_backend_cdp skips provider resolution for them. + backend_err = _resolve_backend_cdp(env, task_id, session_name=session) + if backend_err: + return tool_error(backend_err) workspace = _workspace_dir(task_id) if workspace: @@ -616,8 +632,10 @@ def browser_exec( "Batch each sub-procedure (navigate, wait, extract, act) into one call " "— do not spend a call per action — but for long extractions prefer " "several medium calls that append to workspace files over one giant " - "call, so progress survives timeouts. For a named cloud browser, pass " - "session= (never BU_NAME env syntax)." + "call, so progress survives timeouts. For an isolated concurrent " + "browser session (parallel tasks that must not share tabs), pass " + "session= (never BU_NAME env syntax) and reuse the same name on " + "every related call." ) _HEADER_VISION = ( @@ -716,7 +734,7 @@ def _dynamic_schema_overrides() -> dict: }, "session": { "type": "string", - "description": "Named cloud browser session (sets BU_NAME). Omit for the local default daemon. Use the same name you passed to start_remote_daemon().", + "description": "Named isolated browser session (sets BU_NAME): each name gets its own harness daemon — and on cloud backends its own browser — so concurrent tasks don't clobber each other. Omit for the shared default session. Reuse the same name across calls to keep working in that session (and the name passed to start_remote_daemon(), if used).", }, "timeout_s": { "type": "integer", diff --git a/website/docs/user-guide/features/browser.md b/website/docs/user-guide/features/browser.md index 9b064a6bc00f..99fb0e84c2a5 100644 --- a/website/docs/user-guide/features/browser.md +++ b/website/docs/user-guide/features/browser.md @@ -70,6 +70,8 @@ Browser Use mode uses the [Browser Use CLI 3.0](https://github.com/browser-use/b The mode is a **driver** that composes with your configured browser backend: it drives your local Chrome, a Nous-subscription cloud browser, Browserbase, Firecrawl, or Browser Use cloud browsers — whichever browser source is selected in `hermes tools` → Browser Automation. The one exception is Camofox, which has no CDP endpoint for the harness to attach to; Camofox setups automatically keep the built-in browser tools. +**Concurrent sessions:** `browser_exec` accepts a `session=` argument that isolates browser work per name on every backend. Each name gets its own harness daemon (its own IPC socket, log, and state), and on cloud backends its own browser — so parallel subagents or simultaneous chats no longer clobber a single shared connection. Omitting `session` uses the shared default daemon, which is fine for one-at-a-time browsing. + To opt out and force the built-in browser tools, use `/browser use off`, or: ```yaml