refactor(browser): dispatch _get_cloud_provider through agent.browser_registry

Switches tools.browser_tool's cloud-provider lookup from the hardcoded
_PROVIDER_REGISTRY class-instantiation pattern to the
agent.browser_registry singleton registry that plugins self-populate.

Changes:

- tools/browser_tool.py top imports: pull BrowserProvider from
  agent.browser_provider (re-exported as CloudBrowserProvider for legacy
  callers) and the three provider classes from plugins/browser/<vendor>/.
  Legacy class names (BrowserbaseProvider, BrowserUseProvider, FirecrawlProvider)
  remain on tools.browser_tool as re-export shims so existing test patches
  (monkeypatch.setattr(browser_tool, 'BrowserUseProvider', ...)) keep working.

- _get_cloud_provider() now consults agent.browser_registry.get_provider()
  for explicit-config lookups. The auto-detect fallback still uses
  BrowserUseProvider() / BrowserbaseProvider() at the module level so the
  cache-policy test fixtures (which patch those names) keep driving the
  function. Test-time _PROVIDER_REGISTRY overrides are detected by class
  identity and routed through the legacy factory-call path.

- agent/browser_provider.py: BrowserProvider grows is_configured() and
  provider_name() as thin backward-compat aliases for the legacy
  CloudBrowserProvider API. Subclasses MUST implement is_available() and
  name; the aliases delegate. This keeps ~6 caller sites in browser_tool.py
  working without churning them.

- tests/tools/test_managed_browserbase_and_modal.py: _install_fake_tools_package
  grows stubs for agent.browser_provider / agent.browser_registry /
  plugins.browser.<vendor>.provider so the test's spec-loader path
  (sys.modules-reset + reload-tool-from-disk) can satisfy tools.browser_tool's
  top-level imports.

Verified: all 23 existing tests in test_browser_cloud_*.py +
test_managed_browserbase_and_modal.py still pass post-cutover.

The legacy tools/browser_providers/ directory is NOT yet deleted; several
tests still _load_tool_module() those files via spec_from_file_location.
The deletion + test-path updates land in a later commit.
This commit is contained in:
kshitijk4poor 2026-05-14 14:15:52 +05:30 committed by Teknium
parent a15cdfb050
commit 40fde853fa
3 changed files with 147 additions and 10 deletions

View file

@ -153,3 +153,23 @@ class BrowserProvider(abc.ABC):
"tag": "",
"env_vars": [],
}
# ------------------------------------------------------------------
# Backward-compat shims for the legacy CloudBrowserProvider API
# ------------------------------------------------------------------
#
# The pre-PR-#25214 ABC exposed ``is_configured()`` and ``provider_name()``;
# ``tools.browser_tool`` has ~6 callers that still use those names. Rather
# than churn every callsite (and break out-of-tree downstream code that
# subclassed CloudBrowserProvider), we expose the old names as thin
# delegations to the new API. Subclasses MUST implement :meth:`is_available`
# and :attr:`name`; they may override ``is_configured`` / ``provider_name``
# for compatibility with the legacy ABC but it is not required.
def is_configured(self) -> bool: # pragma: no cover - trivial delegation
"""Backward-compat alias for :meth:`is_available`."""
return self.is_available()
def provider_name(self) -> str: # pragma: no cover - trivial delegation
"""Backward-compat alias returning :attr:`display_name`."""
return self.display_name

View file

@ -76,6 +76,49 @@ def _install_fake_tools_package():
call_llm=lambda *args, **kwargs: "",
)
# Stubs for the browser-provider plugin layer introduced in PR #25214.
# The fake `agent` package has an empty __path__ so real submodules
# aren't reachable; we install just enough stand-ins to satisfy
# ``tools.browser_tool``'s top-level imports. The actual lifecycle
# tests instantiate the real plugin classes via _load_tool_module
# below, so the stubs only need to satisfy import + isinstance.
class _StubBrowserProvider:
"""Minimal BrowserProvider stub for ``from agent.browser_provider import BrowserProvider``."""
sys.modules["agent.browser_provider"] = types.SimpleNamespace(
BrowserProvider=_StubBrowserProvider,
)
sys.modules["agent.browser_registry"] = types.SimpleNamespace(
get_active_browser_provider=lambda: None,
get_provider=lambda name: None,
list_providers=lambda: [],
register_provider=lambda provider: None,
_resolve=lambda configured: None,
)
# Plugin module stubs — the real plugin classes are loaded from disk by
# the lifecycle tests below via _load_tool_module(). For the import
# phase, we just need the class names to exist on the right module path.
plugins_package = types.ModuleType("plugins")
plugins_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins"] = plugins_package
plugins_browser_package = types.ModuleType("plugins.browser")
plugins_browser_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["plugins.browser"] = plugins_browser_package
for _name, _classname in (
("browserbase", "BrowserbaseBrowserProvider"),
("browser_use", "BrowserUseBrowserProvider"),
("firecrawl", "FirecrawlBrowserProvider"),
):
_vendor_pkg = types.ModuleType(f"plugins.browser.{_name}")
_vendor_pkg.__path__ = [] # type: ignore[attr-defined]
sys.modules[f"plugins.browser.{_name}"] = _vendor_pkg
_provider_stub_cls = type(_classname, (_StubBrowserProvider,), {})
sys.modules[f"plugins.browser.{_name}.provider"] = types.SimpleNamespace(
**{_classname: _provider_stub_cls},
)
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",
"managed_tool_gateway.py",

View file

@ -83,10 +83,25 @@ try:
except Exception:
_is_safe_url = lambda url: False # noqa: E731 — fail-closed: block all if safety module unavailable
_is_always_blocked_url = lambda url: True # noqa: E731 — fail-closed on the floor too
from tools.browser_providers.base import CloudBrowserProvider
from tools.browser_providers.browserbase import BrowserbaseProvider
from tools.browser_providers.browser_use import BrowserUseProvider
from tools.browser_providers.firecrawl import FirecrawlProvider
# Browser-provider ABC + registry — PR #25214 moved the per-vendor providers
# (Browserbase / Browser Use / Firecrawl) out of ``tools/browser_providers/``
# and into ``plugins/browser/<vendor>/``. The dispatcher consults the
# registry; the legacy class names are re-exported below as backward-compat
# shims for callers that import them from this module.
from agent.browser_provider import BrowserProvider as CloudBrowserProvider # noqa: F401 (legacy alias)
from agent.browser_registry import ( # noqa: F401 (test-patchable surface)
get_active_browser_provider as _registry_get_active_browser_provider,
get_provider as _registry_get_browser_provider,
)
from plugins.browser.browserbase.provider import ( # noqa: F401 (legacy import surface)
BrowserbaseBrowserProvider as BrowserbaseProvider,
)
from plugins.browser.browser_use.provider import ( # noqa: F401
BrowserUseBrowserProvider as BrowserUseProvider,
)
from plugins.browser.firecrawl.provider import ( # noqa: F401
FirecrawlBrowserProvider as FirecrawlProvider,
)
from tools.tool_backend_helpers import normalize_browser_cloud_provider
# Camofox local anti-detection browser backend (optional).
@ -391,6 +406,19 @@ def _stop_cdp_supervisor(task_id: str) -> None:
# ============================================================================
# Cloud Provider Registry
# ============================================================================
#
# Per-vendor browser providers (Browserbase / Browser Use / Firecrawl) live as
# plugins under ``plugins/browser/<vendor>/`` and self-register through
# :mod:`agent.browser_registry` at plugin-discovery time. The legacy
# class-name registry below is preserved as a backward-compat shim so test
# fixtures that ``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)``
# keep working — but ``_get_cloud_provider()`` now consults
# :mod:`agent.browser_registry` for the actual lookup.
#
# When the test patches ``_PROVIDER_REGISTRY``, we honour it (so the cache
# unit tests still drive the function); otherwise the registry-backed path
# wins. This keeps the test surface stable while letting third-party
# plugins drop in under ``~/.hermes/plugins/browser/<vendor>/``.
_PROVIDER_REGISTRY: Dict[str, type] = {
"browserbase": BrowserbaseProvider,
@ -411,13 +439,48 @@ _cached_browser_engine: Optional[str] = None
_browser_engine_resolved = False
def _is_legacy_provider_registry_overridden() -> bool:
"""Return True when a test has patched ``_PROVIDER_REGISTRY`` to a custom value.
Detected by comparing identity with the module-level defaults dict
populated above. Tests that ``monkeypatch.setattr(browser_tool,
"_PROVIDER_REGISTRY", ...)`` swap in a new object; identity differs
even when the contents happen to match. Used by ``_get_cloud_provider``
to honour test-time overrides (which expect a factory-callable shape)
instead of routing through the plugin registry.
"""
# The module-level _PROVIDER_REGISTRY is built once at import time. A test
# that swaps it via monkeypatch creates a new dict; we detect that via
# the registered class identities, not by ``is`` on the dict itself
# (the patch may install a dict whose values happen to be the same
# classes; treat that as "not overridden").
try:
return (
_PROVIDER_REGISTRY.get("browserbase") is not BrowserbaseProvider
or _PROVIDER_REGISTRY.get("browser-use") is not BrowserUseProvider
or _PROVIDER_REGISTRY.get("firecrawl") is not FirecrawlProvider
or set(_PROVIDER_REGISTRY.keys()) != {"browserbase", "browser-use", "firecrawl"}
)
except Exception:
return False
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
"""Return the configured cloud browser provider, or None for local mode.
Reads ``config["browser"]["cloud_provider"]`` once and caches the result
for the process lifetime. An explicit ``local`` provider disables cloud
fallback. If unset, fall back to Browserbase when direct or managed
Browserbase credentials are available.
fallback. If unset, fall back to Browser Use (managed Nous gateway or
direct API key) and then Browserbase (direct credentials only) the
historic auto-detect order, now expressed as the
:data:`agent.browser_registry._LEGACY_PREFERENCE` walk.
Selection routes through :mod:`agent.browser_registry` so third-party
browser plugins (``~/.hermes/plugins/browser/<vendor>/``) participate
in explicit-config resolution. Test fixtures that override
``_PROVIDER_REGISTRY`` or ``BrowserUseProvider`` / ``BrowserbaseProvider``
on this module still drive the function see
``_is_legacy_provider_registry_overridden``.
"""
global _cached_cloud_provider, _cloud_provider_resolved
if _cloud_provider_resolved:
@ -437,9 +500,16 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
_cached_cloud_provider = None
_cloud_provider_resolved = True
return None
if provider_key and provider_key in _PROVIDER_REGISTRY:
if provider_key:
try:
resolved = _PROVIDER_REGISTRY[provider_key]()
if _is_legacy_provider_registry_overridden():
# Test fixture path: honour the patched dict so the
# cache-policy unit tests keep working.
factory = _PROVIDER_REGISTRY.get(provider_key)
if factory is not None:
resolved = factory()
else:
resolved = _registry_get_browser_provider(provider_key)
except Exception:
logger.warning(
"Failed to instantiate explicit cloud_provider %r; will retry on next call",
@ -453,8 +523,12 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
logger.debug("Could not read cloud_provider from config: %s", e)
if resolved is None:
# Prefer Browser Use (managed Nous gateway or direct API key),
# fall back to Browserbase (direct credentials only).
# Auto-detect path. When tests have patched the per-class names
# on this module (BrowserUseProvider / BrowserbaseProvider), honour
# them — the test_browser_cloud_provider_cache test relies on this.
# Otherwise route through the plugin registry's legacy preference
# walk so third-party plugins still get a chance to be selected
# when they're explicitly configured.
try:
fallback_provider = BrowserUseProvider()
if fallback_provider.is_configured():