mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: switch managed browser provider from Browserbase to Browser Use (#5750)
* feat: switch managed browser provider from Browserbase to Browser Use
The Nous subscription tool gateway now routes browser automation through
Browser Use instead of Browserbase. This commit:
- Adds managed Nous gateway support to BrowserUseProvider (idempotency
keys, X-BB-API-Key auth header, external_call_id persistence)
- Removes managed gateway support from BrowserbaseProvider (now
direct-only via BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID)
- Updates browser_tool.py fallback: prefers Browser Use over Browserbase
- Updates nous_subscription.py: gateway vendor 'browser-use', auto-config
sets cloud_provider='browser-use' for new subscribers
- Updates tools_config.py: Nous Subscription entry now uses Browser Use
- Updates setup.py, cli.py, status.py, prompt_builder.py display strings
- Updates all affected tests to match new behavior
Browserbase remains fully functional for users with direct API credentials.
The change only affects the managed/subscription path.
* chore: remove redundant Browser Use hint from system prompt
* fix: upgrade Browser Use provider to v3 API
- Base URL: api/v2 -> api/v3 (v2 is legacy)
- Unified all endpoints to use native Browser Use paths:
- POST /browsers (create session, returns cdpUrl)
- PATCH /browsers/{id} with {action: stop} (close session)
- Removed managed-mode branching that used Browserbase-style
/v1/sessions paths — v3 gateway now supports /browsers directly
- Removed unused managed_mode variable in close_session
* fix(browser-use): use X-Browser-Use-API-Key header for managed mode
The managed gateway expects X-Browser-Use-API-Key, not X-BB-API-Key
(which is a Browserbase-specific header). Using the wrong header caused
a 401 AUTH_ERROR on every managed-mode browser session create.
Simplified _headers() to always use X-Browser-Use-API-Key regardless
of direct vs managed mode.
* fix(nous_subscription): browserbase explicit provider is direct-only
Since managed Nous gateway now routes through Browser Use, the
browserbase explicit provider path should not check managed_browser_available
(which resolves against the browser-use gateway). Simplified to direct-only
with managed=False.
* fix(browser-use): port missing improvements from PR #5605
- CDP URL normalization: resolve HTTP discovery URLs to websocket after
cloud provider create_session() (prevents agent-browser failures)
- Managed session payload: send timeout=5 and proxyCountryCode=us for
gateway-backed sessions (prevents billing overruns)
- Update prompt builder, browser_close schema, and module docstring to
replace remaining Browserbase references with Browser Use
- Dynamic /browser status detection via _get_cloud_provider() instead
of hardcoded env var checks (future-proof for new providers)
- Rename post_setup key from 'browserbase' to 'agent_browser'
- Update setup hint to mention Browser Use alongside Browserbase
- Add tests: CDP normalization, browserbase direct-only guard,
managed browser-use gateway, direct browserbase fallback
---------
Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
This commit is contained in:
parent
8b861b77c1
commit
b2f477a30b
16 changed files with 429 additions and 258 deletions
|
|
@ -773,13 +773,13 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
"# Nous Subscription",
|
"# Nous Subscription",
|
||||||
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browserbase) by default. Modal execution is optional.",
|
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browser Use) by default. Modal execution is optional.",
|
||||||
"Current capability status:",
|
"Current capability status:",
|
||||||
]
|
]
|
||||||
lines.extend(_status_line(feature) for feature in features.items())
|
lines.extend(_status_line(feature) for feature in features.items())
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys.",
|
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys.",
|
||||||
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
||||||
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
||||||
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
||||||
|
|
|
||||||
17
cli.py
17
cli.py
|
|
@ -5037,13 +5037,13 @@ class HermesCLI:
|
||||||
pass
|
pass
|
||||||
print()
|
print()
|
||||||
print("🌐 Browser disconnected from live Chrome")
|
print("🌐 Browser disconnected from live Chrome")
|
||||||
print(" Browser tools reverted to default mode (local headless or Browserbase)")
|
print(" Browser tools reverted to default mode (local headless or cloud provider)")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if hasattr(self, '_pending_input'):
|
if hasattr(self, '_pending_input'):
|
||||||
self._pending_input.put(
|
self._pending_input.put(
|
||||||
"[System note: The user has disconnected the browser tools from their live Chrome. "
|
"[System note: The user has disconnected the browser tools from their live Chrome. "
|
||||||
"Browser tools are back to default mode (headless local browser or Browserbase cloud).]"
|
"Browser tools are back to default mode (headless local browser or cloud provider).]"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
print()
|
print()
|
||||||
|
|
@ -5070,10 +5070,17 @@ class HermesCLI:
|
||||||
print(" Status: ✓ reachable")
|
print(" Status: ✓ reachable")
|
||||||
except (OSError, Exception):
|
except (OSError, Exception):
|
||||||
print(" Status: ⚠ not reachable (Chrome may not be running)")
|
print(" Status: ⚠ not reachable (Chrome may not be running)")
|
||||||
elif os.environ.get("BROWSERBASE_API_KEY"):
|
|
||||||
print("🌐 Browser: Browserbase (cloud)")
|
|
||||||
else:
|
else:
|
||||||
print("🌐 Browser: local headless Chromium (agent-browser)")
|
try:
|
||||||
|
from tools.browser_tool import _get_cloud_provider
|
||||||
|
provider = _get_cloud_provider()
|
||||||
|
except Exception:
|
||||||
|
provider = None
|
||||||
|
|
||||||
|
if provider is not None:
|
||||||
|
print(f"🌐 Browser: {provider.provider_name()} (cloud)")
|
||||||
|
else:
|
||||||
|
print("🌐 Browser: local headless Chromium (agent-browser)")
|
||||||
print()
|
print()
|
||||||
print(" /browser connect — connect to your live Chrome")
|
print(" /browser connect — connect to your live Chrome")
|
||||||
print(" /browser disconnect — revert to default")
|
print(" /browser disconnect — revert to default")
|
||||||
|
|
|
||||||
|
|
@ -167,20 +167,20 @@ def _resolve_browser_feature_state(
|
||||||
if browser_provider_explicit:
|
if browser_provider_explicit:
|
||||||
current_provider = browser_provider or "local"
|
current_provider = browser_provider or "local"
|
||||||
if current_provider == "browserbase":
|
if current_provider == "browserbase":
|
||||||
provider_available = managed_browser_available or direct_browserbase
|
available = bool(browser_local_available and direct_browserbase)
|
||||||
|
active = bool(browser_tool_enabled and available)
|
||||||
|
return current_provider, available, active, False
|
||||||
|
if current_provider == "browser-use":
|
||||||
|
provider_available = managed_browser_available or direct_browser_use
|
||||||
available = bool(browser_local_available and provider_available)
|
available = bool(browser_local_available and provider_available)
|
||||||
managed = bool(
|
managed = bool(
|
||||||
browser_tool_enabled
|
browser_tool_enabled
|
||||||
and browser_local_available
|
and browser_local_available
|
||||||
and managed_browser_available
|
and managed_browser_available
|
||||||
and not direct_browserbase
|
and not direct_browser_use
|
||||||
)
|
)
|
||||||
active = bool(browser_tool_enabled and available)
|
active = bool(browser_tool_enabled and available)
|
||||||
return current_provider, available, active, managed
|
return current_provider, available, active, managed
|
||||||
if current_provider == "browser-use":
|
|
||||||
available = bool(browser_local_available and direct_browser_use)
|
|
||||||
active = bool(browser_tool_enabled and available)
|
|
||||||
return current_provider, available, active, False
|
|
||||||
if current_provider == "firecrawl":
|
if current_provider == "firecrawl":
|
||||||
available = bool(browser_local_available and direct_firecrawl)
|
available = bool(browser_local_available and direct_firecrawl)
|
||||||
active = bool(browser_tool_enabled and available)
|
active = bool(browser_tool_enabled and available)
|
||||||
|
|
@ -193,16 +193,21 @@ def _resolve_browser_feature_state(
|
||||||
active = bool(browser_tool_enabled and available)
|
active = bool(browser_tool_enabled and available)
|
||||||
return current_provider, available, active, False
|
return current_provider, available, active, False
|
||||||
|
|
||||||
if managed_browser_available or direct_browserbase:
|
if managed_browser_available or direct_browser_use:
|
||||||
available = bool(browser_local_available)
|
available = bool(browser_local_available)
|
||||||
managed = bool(
|
managed = bool(
|
||||||
browser_tool_enabled
|
browser_tool_enabled
|
||||||
and browser_local_available
|
and browser_local_available
|
||||||
and managed_browser_available
|
and managed_browser_available
|
||||||
and not direct_browserbase
|
and not direct_browser_use
|
||||||
)
|
)
|
||||||
active = bool(browser_tool_enabled and available)
|
active = bool(browser_tool_enabled and available)
|
||||||
return "browserbase", available, active, managed
|
return "browser-use", available, active, managed
|
||||||
|
|
||||||
|
if direct_browserbase:
|
||||||
|
available = bool(browser_local_available)
|
||||||
|
active = bool(browser_tool_enabled and available)
|
||||||
|
return "browserbase", available, active, False
|
||||||
|
|
||||||
available = bool(browser_local_available)
|
available = bool(browser_local_available)
|
||||||
active = bool(browser_tool_enabled and available)
|
active = bool(browser_tool_enabled and available)
|
||||||
|
|
@ -266,7 +271,7 @@ def get_nous_subscription_features(
|
||||||
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase")
|
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use")
|
||||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||||
modal_state = resolve_modal_backend_state(
|
modal_state = resolve_modal_backend_state(
|
||||||
modal_mode,
|
modal_mode,
|
||||||
|
|
@ -512,10 +517,10 @@ def apply_nous_managed_defaults(
|
||||||
changed.add("tts")
|
changed.add("tts")
|
||||||
|
|
||||||
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
|
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
|
||||||
get_env_value("BROWSERBASE_API_KEY")
|
get_env_value("BROWSER_USE_API_KEY")
|
||||||
or get_env_value("BROWSER_USE_API_KEY")
|
or get_env_value("BROWSERBASE_API_KEY")
|
||||||
):
|
):
|
||||||
browser_cfg["cloud_provider"] = "browserbase"
|
browser_cfg["cloud_provider"] = "browser-use"
|
||||||
changed.add("browser")
|
changed.add("browser")
|
||||||
|
|
||||||
if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"):
|
if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"):
|
||||||
|
|
|
||||||
|
|
@ -660,14 +660,14 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
|
# Browser tools (local Chromium, Camofox, Browserbase, Browser Use, or Firecrawl)
|
||||||
browser_provider = subscription_features.browser.current_provider
|
browser_provider = subscription_features.browser.current_provider
|
||||||
if subscription_features.browser.managed_by_nous:
|
if subscription_features.browser.managed_by_nous:
|
||||||
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
|
tool_status.append(("Browser Automation (Nous Browser Use)", True, None))
|
||||||
elif subscription_features.browser.available:
|
elif subscription_features.browser.available:
|
||||||
label = "Browser Automation"
|
label = "Browser Automation"
|
||||||
if browser_provider:
|
if browser_provider:
|
||||||
label = f"Browser Automation ({browser_provider})"
|
label = f"Browser Automation ({browser_provider})"
|
||||||
tool_status.append((label, True, None))
|
tool_status.append((label, True, None))
|
||||||
else:
|
else:
|
||||||
missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browserbase"
|
missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browser Use or Browserbase"
|
||||||
if browser_provider == "Browserbase":
|
if browser_provider == "Browserbase":
|
||||||
missing_browser_hint = (
|
missing_browser_hint = (
|
||||||
"npm install -g agent-browser and set "
|
"npm install -g agent-browser and set "
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@ def show_status(args):
|
||||||
"MiniMax-CN": "MINIMAX_CN_API_KEY",
|
"MiniMax-CN": "MINIMAX_CN_API_KEY",
|
||||||
"Firecrawl": "FIRECRAWL_API_KEY",
|
"Firecrawl": "FIRECRAWL_API_KEY",
|
||||||
"Tavily": "TAVILY_API_KEY",
|
"Tavily": "TAVILY_API_KEY",
|
||||||
"Browserbase": "BROWSERBASE_API_KEY", # Optional — local browser works without this
|
"Browser Use": "BROWSER_USE_API_KEY", # Optional — local browser works without this
|
||||||
|
"Browserbase": "BROWSERBASE_API_KEY", # Optional — direct credentials only
|
||||||
"FAL": "FAL_KEY",
|
"FAL": "FAL_KEY",
|
||||||
"Tinker": "TINKER_API_KEY",
|
"Tinker": "TINKER_API_KEY",
|
||||||
"WandB": "WANDB_API_KEY",
|
"WandB": "WANDB_API_KEY",
|
||||||
|
|
|
||||||
|
|
@ -280,21 +280,21 @@ TOOL_CATEGORIES = {
|
||||||
"icon": "🌐",
|
"icon": "🌐",
|
||||||
"providers": [
|
"providers": [
|
||||||
{
|
{
|
||||||
"name": "Nous Subscription (Browserbase cloud)",
|
"name": "Nous Subscription (Browser Use cloud)",
|
||||||
"tag": "Managed Browserbase billed to your subscription",
|
"tag": "Managed Browser Use billed to your subscription",
|
||||||
"env_vars": [],
|
"env_vars": [],
|
||||||
"browser_provider": "browserbase",
|
"browser_provider": "browser-use",
|
||||||
"requires_nous_auth": True,
|
"requires_nous_auth": True,
|
||||||
"managed_nous_feature": "browser",
|
"managed_nous_feature": "browser",
|
||||||
"override_env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
|
"override_env_vars": ["BROWSER_USE_API_KEY"],
|
||||||
"post_setup": "browserbase",
|
"post_setup": "agent_browser",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Local Browser",
|
"name": "Local Browser",
|
||||||
"tag": "Free headless Chromium (no API key needed)",
|
"tag": "Free headless Chromium (no API key needed)",
|
||||||
"env_vars": [],
|
"env_vars": [],
|
||||||
"browser_provider": "local",
|
"browser_provider": "local",
|
||||||
"post_setup": "browserbase", # Same npm install for agent-browser
|
"post_setup": "agent_browser",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Browserbase",
|
"name": "Browserbase",
|
||||||
|
|
@ -304,7 +304,7 @@ TOOL_CATEGORIES = {
|
||||||
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
{"key": "BROWSERBASE_PROJECT_ID", "prompt": "Browserbase project ID"},
|
||||||
],
|
],
|
||||||
"browser_provider": "browserbase",
|
"browser_provider": "browserbase",
|
||||||
"post_setup": "browserbase",
|
"post_setup": "agent_browser",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Browser Use",
|
"name": "Browser Use",
|
||||||
|
|
@ -313,7 +313,7 @@ TOOL_CATEGORIES = {
|
||||||
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
|
{"key": "BROWSER_USE_API_KEY", "prompt": "Browser Use API key", "url": "https://browser-use.com"},
|
||||||
],
|
],
|
||||||
"browser_provider": "browser-use",
|
"browser_provider": "browser-use",
|
||||||
"post_setup": "browserbase",
|
"post_setup": "agent_browser",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Firecrawl",
|
"name": "Firecrawl",
|
||||||
|
|
@ -322,7 +322,7 @@ TOOL_CATEGORIES = {
|
||||||
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
{"key": "FIRECRAWL_API_KEY", "prompt": "Firecrawl API key", "url": "https://firecrawl.dev"},
|
||||||
],
|
],
|
||||||
"browser_provider": "firecrawl",
|
"browser_provider": "firecrawl",
|
||||||
"post_setup": "browserbase",
|
"post_setup": "agent_browser",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Camofox",
|
"name": "Camofox",
|
||||||
|
|
@ -381,7 +381,7 @@ TOOLSET_ENV_REQUIREMENTS = {
|
||||||
def _run_post_setup(post_setup_key: str):
|
def _run_post_setup(post_setup_key: str):
|
||||||
"""Run post-setup hooks for tools that need extra installation steps."""
|
"""Run post-setup hooks for tools that need extra installation steps."""
|
||||||
import shutil
|
import shutil
|
||||||
if post_setup_key == "browserbase":
|
if post_setup_key in ("agent_browser", "browserbase"):
|
||||||
node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
|
node_modules = PROJECT_ROOT / "node_modules" / "agent-browser"
|
||||||
if not node_modules.exists() and shutil.which("npm"):
|
if not node_modules.exists() and shutil.which("npm"):
|
||||||
_print_info(" Installing Node.js dependencies for browser tools...")
|
_print_info(" Installing Node.js dependencies for browser tools...")
|
||||||
|
|
|
||||||
|
|
@ -423,7 +423,7 @@ class TestBuildNousSubscriptionPrompt:
|
||||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
|
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -431,9 +431,9 @@ class TestBuildNousSubscriptionPrompt:
|
||||||
|
|
||||||
prompt = build_nous_subscription_prompt({"web_search", "browser_navigate"})
|
prompt = build_nous_subscription_prompt({"web_search", "browser_navigate"})
|
||||||
|
|
||||||
assert "Browserbase" in prompt
|
assert "Browser Use" in prompt
|
||||||
assert "Modal execution is optional" in prompt
|
assert "Modal execution is optional" in prompt
|
||||||
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys" in prompt
|
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys" in prompt
|
||||||
|
|
||||||
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
|
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
|
||||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,62 @@ def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monke
|
||||||
assert features.modal.direct_override is False
|
assert features.modal.direct_override is False
|
||||||
|
|
||||||
|
|
||||||
def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase(monkeypatch):
|
def test_get_nous_subscription_features_marks_browser_use_as_managed_when_gateway_ready(monkeypatch):
|
||||||
|
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||||
|
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||||
|
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||||
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
|
||||||
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||||
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ns,
|
||||||
|
"is_managed_tool_gateway_ready",
|
||||||
|
lambda vendor: vendor == "browser-use",
|
||||||
|
)
|
||||||
|
|
||||||
|
features = ns.get_nous_subscription_features(
|
||||||
|
{"browser": {"cloud_provider": "browser-use"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert features.browser.available is True
|
||||||
|
assert features.browser.active is True
|
||||||
|
assert features.browser.managed_by_nous is True
|
||||||
|
assert features.browser.direct_override is False
|
||||||
|
assert features.browser.current_provider == "Browser Use"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nous_subscription_features_uses_direct_browserbase_when_no_managed_gateway(monkeypatch):
|
||||||
|
"""When direct Browserbase keys are set and no managed gateway is available,
|
||||||
|
the unconfigured fallback should pick Browserbase as a direct provider."""
|
||||||
|
env = {
|
||||||
|
"BROWSERBASE_API_KEY": "bb-key",
|
||||||
|
"BROWSERBASE_PROJECT_ID": "bb-project",
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||||
|
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||||
|
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||||
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||||
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: True)
|
||||||
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||||
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ns,
|
||||||
|
"is_managed_tool_gateway_ready",
|
||||||
|
lambda vendor: False, # No managed gateway available
|
||||||
|
)
|
||||||
|
|
||||||
|
features = ns.get_nous_subscription_features({})
|
||||||
|
|
||||||
|
assert features.browser.available is True
|
||||||
|
assert features.browser.active is True
|
||||||
|
assert features.browser.managed_by_nous is False
|
||||||
|
assert features.browser.direct_override is True
|
||||||
|
assert features.browser.current_provider == "Browserbase"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_nous_subscription_features_prefers_camofox_over_managed_browser_use(monkeypatch):
|
||||||
env = {"CAMOFOX_URL": "http://localhost:9377"}
|
env = {"CAMOFOX_URL": "http://localhost:9377"}
|
||||||
|
|
||||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||||
|
|
@ -57,11 +112,11 @@ def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
ns,
|
ns,
|
||||||
"is_managed_tool_gateway_ready",
|
"is_managed_tool_gateway_ready",
|
||||||
lambda vendor: vendor == "browserbase",
|
lambda vendor: vendor == "browser-use",
|
||||||
)
|
)
|
||||||
|
|
||||||
features = ns.get_nous_subscription_features(
|
features = ns.get_nous_subscription_features(
|
||||||
{"browser": {"cloud_provider": "browserbase"}}
|
{"browser": {"cloud_provider": "browser-use"}}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert features.browser.available is True
|
assert features.browser.available is True
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
|
||||||
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||||
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||||
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||||
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
|
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"),
|
||||||
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -330,7 +330,7 @@ def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||||
|
|
||||||
assert config["web"]["backend"] == "firecrawl"
|
assert config["web"]["backend"] == "firecrawl"
|
||||||
assert config["tts"]["provider"] == "openai"
|
assert config["tts"]["provider"] == "openai"
|
||||||
assert config["browser"]["cloud_provider"] == "browserbase"
|
assert config["browser"]["cloud_provider"] == "browser-use"
|
||||||
assert configured == []
|
assert configured == []
|
||||||
|
|
||||||
# ── Platform / toolset consistency ────────────────────────────────────────────
|
# ── Platform / toolset consistency ────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -45,3 +45,35 @@ class TestResolveCdpOverride:
|
||||||
|
|
||||||
with patch("tools.browser_tool.requests.get", side_effect=RuntimeError("boom")):
|
with patch("tools.browser_tool.requests.get", side_effect=RuntimeError("boom")):
|
||||||
assert _resolve_cdp_override(HTTP_URL) == HTTP_URL
|
assert _resolve_cdp_override(HTTP_URL) == HTTP_URL
|
||||||
|
|
||||||
|
def test_normalizes_provider_returned_http_cdp_url_when_creating_session(self, monkeypatch):
|
||||||
|
import tools.browser_tool as browser_tool
|
||||||
|
|
||||||
|
provider = Mock()
|
||||||
|
provider.create_session.return_value = {
|
||||||
|
"session_name": "cloud-session",
|
||||||
|
"bb_session_id": "bu_123",
|
||||||
|
"cdp_url": "https://cdp.browser-use.example/session",
|
||||||
|
"features": {"browser_use": True},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = Mock()
|
||||||
|
response.raise_for_status.return_value = None
|
||||||
|
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
|
||||||
|
|
||||||
|
monkeypatch.setattr(browser_tool, "_active_sessions", {})
|
||||||
|
monkeypatch.setattr(browser_tool, "_session_last_activity", {})
|
||||||
|
monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
|
||||||
|
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda task_id: None)
|
||||||
|
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "")
|
||||||
|
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
|
||||||
|
|
||||||
|
with patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
|
||||||
|
session_info = browser_tool._get_session_info("task-browser-use")
|
||||||
|
|
||||||
|
assert session_info["cdp_url"] == WS_URL
|
||||||
|
provider.create_session.assert_called_once_with("task-browser-use")
|
||||||
|
mock_get.assert_called_once_with(
|
||||||
|
"https://cdp.browser-use.example/session/json/version",
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -113,16 +113,15 @@ def _install_fake_tools_package():
|
||||||
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
|
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
|
||||||
|
|
||||||
|
|
||||||
def test_browserbase_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
|
def test_browser_use_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
|
||||||
_install_fake_tools_package()
|
_install_fake_tools_package()
|
||||||
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
|
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.pop("BROWSERBASE_API_KEY", None)
|
env.pop("BROWSER_USE_API_KEY", None)
|
||||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
|
||||||
env.update({
|
env.update({
|
||||||
"HERMES_HOME": str(tmp_path),
|
"HERMES_HOME": str(tmp_path),
|
||||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
})
|
})
|
||||||
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
|
@ -135,7 +134,7 @@ def test_browserbase_explicit_local_mode_stays_local_even_when_managed_gateway_i
|
||||||
assert provider is None
|
assert provider is None
|
||||||
|
|
||||||
|
|
||||||
def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
|
def test_browserbase_does_not_use_gateway_only_configuration():
|
||||||
_install_fake_tools_package()
|
_install_fake_tools_package()
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.pop("BROWSERBASE_API_KEY", None)
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
|
@ -145,104 +144,124 @@ def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
})
|
})
|
||||||
|
|
||||||
class _Response:
|
|
||||||
status_code = 200
|
|
||||||
ok = True
|
|
||||||
text = ""
|
|
||||||
headers = {"x-external-call-id": "call-browserbase-1"}
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {
|
|
||||||
"id": "bb_local_session_1",
|
|
||||||
"connectUrl": "wss://connect.browserbase.example/session",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
|
||||||
browserbase_module = _load_tool_module(
|
|
||||||
"tools.browser_providers.browserbase",
|
|
||||||
"browser_providers/browserbase.py",
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch.object(browserbase_module.requests, "post", return_value=_Response()) as post:
|
|
||||||
provider = browserbase_module.BrowserbaseProvider()
|
|
||||||
session = provider.create_session("task-browserbase-managed")
|
|
||||||
|
|
||||||
sent_headers = post.call_args.kwargs["headers"]
|
|
||||||
assert sent_headers["X-BB-API-Key"] == "nous-token"
|
|
||||||
assert sent_headers["X-Idempotency-Key"].startswith("browserbase-session-create:")
|
|
||||||
assert session["external_call_id"] == "call-browserbase-1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_browserbase_managed_gateway_reuses_pending_idempotency_key_after_timeout():
|
|
||||||
_install_fake_tools_package()
|
|
||||||
env = os.environ.copy()
|
|
||||||
env.pop("BROWSERBASE_API_KEY", None)
|
|
||||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
|
||||||
env.update({
|
|
||||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
|
||||||
})
|
|
||||||
|
|
||||||
class _Response:
|
|
||||||
status_code = 200
|
|
||||||
ok = True
|
|
||||||
text = ""
|
|
||||||
headers = {"x-external-call-id": "call-browserbase-2"}
|
|
||||||
|
|
||||||
def json(self):
|
|
||||||
return {
|
|
||||||
"id": "bb_local_session_2",
|
|
||||||
"connectUrl": "wss://connect.browserbase.example/session2",
|
|
||||||
}
|
|
||||||
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
browserbase_module = _load_tool_module(
|
browserbase_module = _load_tool_module(
|
||||||
"tools.browser_providers.browserbase",
|
"tools.browser_providers.browserbase",
|
||||||
"browser_providers/browserbase.py",
|
"browser_providers/browserbase.py",
|
||||||
)
|
)
|
||||||
provider = browserbase_module.BrowserbaseProvider()
|
provider = browserbase_module.BrowserbaseProvider()
|
||||||
timeout = browserbase_module.requests.Timeout("timed out")
|
|
||||||
|
assert provider.is_configured() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_use_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSER_USE_API_KEY", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browser-use-1"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bu_local_session_1",
|
||||||
|
"connectUrl": "wss://connect.browser-use.example/session",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browser_use_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browser_use",
|
||||||
|
"browser_providers/browser_use.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(browser_use_module.requests, "post", return_value=_Response()) as post:
|
||||||
|
provider = browser_use_module.BrowserUseProvider()
|
||||||
|
session = provider.create_session("task-browser-use-managed")
|
||||||
|
|
||||||
|
sent_headers = post.call_args.kwargs["headers"]
|
||||||
|
assert sent_headers["X-Browser-Use-API-Key"] == "nous-token"
|
||||||
|
assert sent_headers["X-Idempotency-Key"].startswith("browser-use-session-create:")
|
||||||
|
sent_payload = post.call_args.kwargs["json"]
|
||||||
|
assert sent_payload["timeout"] == 5
|
||||||
|
assert sent_payload["proxyCountryCode"] == "us"
|
||||||
|
assert session["external_call_id"] == "call-browser-use-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_browser_use_managed_gateway_reuses_pending_idempotency_key_after_timeout():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSER_USE_API_KEY", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browser-use-2"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bu_local_session_2",
|
||||||
|
"connectUrl": "wss://connect.browser-use.example/session2",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browser_use_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browser_use",
|
||||||
|
"browser_providers/browser_use.py",
|
||||||
|
)
|
||||||
|
provider = browser_use_module.BrowserUseProvider()
|
||||||
|
timeout = browser_use_module.requests.Timeout("timed out")
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
browserbase_module.requests,
|
browser_use_module.requests,
|
||||||
"post",
|
"post",
|
||||||
side_effect=[timeout, _Response()],
|
side_effect=[timeout, _Response()],
|
||||||
) as post:
|
) as post:
|
||||||
try:
|
try:
|
||||||
provider.create_session("task-browserbase-timeout")
|
provider.create_session("task-browser-use-timeout")
|
||||||
except browserbase_module.requests.Timeout:
|
except browser_use_module.requests.Timeout:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise AssertionError("Expected Browserbase create_session to propagate timeout")
|
raise AssertionError("Expected Browser Use create_session to propagate timeout")
|
||||||
|
|
||||||
provider.create_session("task-browserbase-timeout")
|
provider.create_session("task-browser-use-timeout")
|
||||||
|
|
||||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||||
|
|
||||||
|
|
||||||
def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts():
|
def test_browser_use_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts():
|
||||||
_install_fake_tools_package()
|
_install_fake_tools_package()
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.pop("BROWSERBASE_API_KEY", None)
|
env.pop("BROWSER_USE_API_KEY", None)
|
||||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
|
||||||
env.update({
|
env.update({
|
||||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
})
|
})
|
||||||
|
|
||||||
class _ConflictResponse:
|
class _ConflictResponse:
|
||||||
status_code = 409
|
status_code = 409
|
||||||
ok = False
|
ok = False
|
||||||
text = '{"error":{"code":"CONFLICT","message":"Managed Browserbase session creation is already in progress for this idempotency key"}}'
|
text = '{"error":{"code":"CONFLICT","message":"Managed Browser Use session creation is already in progress for this idempotency key"}}'
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
return {
|
return {
|
||||||
"error": {
|
"error": {
|
||||||
"code": "CONFLICT",
|
"code": "CONFLICT",
|
||||||
"message": "Managed Browserbase session creation is already in progress for this idempotency key",
|
"message": "Managed Browser Use session creation is already in progress for this idempotency key",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,72 +269,71 @@ def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_pr
|
||||||
status_code = 200
|
status_code = 200
|
||||||
ok = True
|
ok = True
|
||||||
text = ""
|
text = ""
|
||||||
headers = {"x-external-call-id": "call-browserbase-4"}
|
headers = {"x-external-call-id": "call-browser-use-4"}
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
return {
|
return {
|
||||||
"id": "bb_local_session_4",
|
"id": "bu_local_session_4",
|
||||||
"connectUrl": "wss://connect.browserbase.example/session4",
|
"connectUrl": "wss://connect.browser-use.example/session4",
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
browserbase_module = _load_tool_module(
|
browser_use_module = _load_tool_module(
|
||||||
"tools.browser_providers.browserbase",
|
"tools.browser_providers.browser_use",
|
||||||
"browser_providers/browserbase.py",
|
"browser_providers/browser_use.py",
|
||||||
)
|
)
|
||||||
provider = browserbase_module.BrowserbaseProvider()
|
provider = browser_use_module.BrowserUseProvider()
|
||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
browserbase_module.requests,
|
browser_use_module.requests,
|
||||||
"post",
|
"post",
|
||||||
side_effect=[_ConflictResponse(), _SuccessResponse()],
|
side_effect=[_ConflictResponse(), _SuccessResponse()],
|
||||||
) as post:
|
) as post:
|
||||||
try:
|
try:
|
||||||
provider.create_session("task-browserbase-conflict")
|
provider.create_session("task-browser-use-conflict")
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
raise AssertionError("Expected Browserbase create_session to propagate the in-progress conflict")
|
raise AssertionError("Expected Browser Use create_session to propagate the in-progress conflict")
|
||||||
|
|
||||||
provider.create_session("task-browserbase-conflict")
|
provider.create_session("task-browser-use-conflict")
|
||||||
|
|
||||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||||
|
|
||||||
|
|
||||||
def test_browserbase_managed_gateway_uses_new_idempotency_key_for_a_new_session_after_success():
|
def test_browser_use_managed_gateway_uses_new_idempotency_key_for_a_new_session_after_success():
|
||||||
_install_fake_tools_package()
|
_install_fake_tools_package()
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.pop("BROWSERBASE_API_KEY", None)
|
env.pop("BROWSER_USE_API_KEY", None)
|
||||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
|
||||||
env.update({
|
env.update({
|
||||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
"BROWSER_USE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
})
|
})
|
||||||
|
|
||||||
class _Response:
|
class _Response:
|
||||||
status_code = 200
|
status_code = 200
|
||||||
ok = True
|
ok = True
|
||||||
text = ""
|
text = ""
|
||||||
headers = {"x-external-call-id": "call-browserbase-3"}
|
headers = {"x-external-call-id": "call-browser-use-3"}
|
||||||
|
|
||||||
def json(self):
|
def json(self):
|
||||||
return {
|
return {
|
||||||
"id": "bb_local_session_3",
|
"id": "bu_local_session_3",
|
||||||
"connectUrl": "wss://connect.browserbase.example/session3",
|
"connectUrl": "wss://connect.browser-use.example/session3",
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch.dict(os.environ, env, clear=True):
|
with patch.dict(os.environ, env, clear=True):
|
||||||
browserbase_module = _load_tool_module(
|
browser_use_module = _load_tool_module(
|
||||||
"tools.browser_providers.browserbase",
|
"tools.browser_providers.browser_use",
|
||||||
"browser_providers/browserbase.py",
|
"browser_providers/browser_use.py",
|
||||||
)
|
)
|
||||||
provider = browserbase_module.BrowserbaseProvider()
|
provider = browser_use_module.BrowserUseProvider()
|
||||||
|
|
||||||
with patch.object(browserbase_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
|
with patch.object(browser_use_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
|
||||||
provider.create_session("task-browserbase-new")
|
provider.create_session("task-browser-use-new")
|
||||||
provider.create_session("task-browserbase-new")
|
provider.create_session("task-browser-use-new")
|
||||||
|
|
||||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
|
|
|
||||||
|
|
@ -40,17 +40,17 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
||||||
"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/",
|
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
|
||||||
},
|
},
|
||||||
clear=False,
|
clear=False,
|
||||||
):
|
):
|
||||||
result = resolve_managed_tool_gateway(
|
result = resolve_managed_tool_gateway(
|
||||||
"browserbase",
|
"browser-use",
|
||||||
token_reader=lambda: "nous-token",
|
token_reader=lambda: "nous-token",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.gateway_origin == "http://browserbase-gateway.localhost:3009"
|
assert result.gateway_origin == "http://browser-use-gateway.localhost:3009"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,62 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_pending_create_keys: Dict[str, str] = {}
|
||||||
|
_pending_create_keys_lock = threading.Lock()
|
||||||
|
|
||||||
_BASE_URL = "https://api.browser-use.com/api/v2"
|
_BASE_URL = "https://api.browser-use.com/api/v3"
|
||||||
|
_DEFAULT_MANAGED_TIMEOUT_MINUTES = 5
|
||||||
|
_DEFAULT_MANAGED_PROXY_COUNTRY_CODE = "us"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_pending_create_key(task_id: str) -> str:
|
||||||
|
with _pending_create_keys_lock:
|
||||||
|
existing = _pending_create_keys.get(task_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
created = f"browser-use-session-create:{uuid.uuid4().hex}"
|
||||||
|
_pending_create_keys[task_id] = created
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_pending_create_key(task_id: str) -> None:
|
||||||
|
with _pending_create_keys_lock:
|
||||||
|
_pending_create_keys.pop(task_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if response.status_code != 409:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
error = payload.get("error")
|
||||||
|
if not isinstance(error, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = str(error.get("message") or "").lower()
|
||||||
|
return "already in progress" in message
|
||||||
|
|
||||||
|
|
||||||
class BrowserUseProvider(CloudBrowserProvider):
|
class BrowserUseProvider(CloudBrowserProvider):
|
||||||
|
|
@ -21,55 +67,120 @@ class BrowserUseProvider(CloudBrowserProvider):
|
||||||
return "Browser Use"
|
return "Browser Use"
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(os.environ.get("BROWSER_USE_API_KEY"))
|
return self._get_config_or_none() is not None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Config resolution (direct API key OR managed Nous gateway)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||||
|
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
||||||
|
if api_key:
|
||||||
|
return {
|
||||||
|
"api_key": api_key,
|
||||||
|
"base_url": _BASE_URL,
|
||||||
|
"managed_mode": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
managed = resolve_managed_tool_gateway("browser-use")
|
||||||
|
if managed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_key": managed.nous_user_token,
|
||||||
|
"base_url": managed.gateway_origin.rstrip("/"),
|
||||||
|
"managed_mode": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_config(self) -> Dict[str, Any]:
|
||||||
|
config = self._get_config_or_none()
|
||||||
|
if config is None:
|
||||||
|
message = (
|
||||||
|
"Browser Use requires a direct BROWSER_USE_API_KEY credential."
|
||||||
|
)
|
||||||
|
if managed_nous_tools_enabled():
|
||||||
|
message = (
|
||||||
|
"Browser Use requires either a direct BROWSER_USE_API_KEY "
|
||||||
|
"credential or a managed Browser Use gateway configuration."
|
||||||
|
)
|
||||||
|
raise ValueError(message)
|
||||||
|
return config
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Session lifecycle
|
# Session lifecycle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _headers(self) -> Dict[str, str]:
|
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
|
||||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
headers = {
|
||||||
if not api_key:
|
|
||||||
raise ValueError(
|
|
||||||
"BROWSER_USE_API_KEY environment variable is required. "
|
|
||||||
"Get your key at https://browser-use.com"
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-Browser-Use-API-Key": api_key,
|
"X-Browser-Use-API-Key": config["api_key"],
|
||||||
}
|
}
|
||||||
|
return headers
|
||||||
|
|
||||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||||
|
config = self._get_config()
|
||||||
|
managed_mode = bool(config.get("managed_mode"))
|
||||||
|
|
||||||
|
headers = self._headers(config)
|
||||||
|
if managed_mode:
|
||||||
|
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
||||||
|
|
||||||
|
# Keep gateway-backed sessions short so billing authorization does not
|
||||||
|
# default to a long Browser-Use timeout when Hermes only needs a task-
|
||||||
|
# scoped ephemeral browser.
|
||||||
|
payload = (
|
||||||
|
{
|
||||||
|
"timeout": _DEFAULT_MANAGED_TIMEOUT_MINUTES,
|
||||||
|
"proxyCountryCode": _DEFAULT_MANAGED_PROXY_COUNTRY_CODE,
|
||||||
|
}
|
||||||
|
if managed_mode
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{_BASE_URL}/browsers",
|
f"{config['base_url']}/browsers",
|
||||||
headers=self._headers(),
|
headers=headers,
|
||||||
json={},
|
json=payload,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
|
if managed_mode and not _should_preserve_pending_create_key(response):
|
||||||
|
_clear_pending_create_key(task_id)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to create Browser Use session: "
|
f"Failed to create Browser Use session: "
|
||||||
f"{response.status_code} {response.text}"
|
f"{response.status_code} {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
session_data = response.json()
|
session_data = response.json()
|
||||||
|
if managed_mode:
|
||||||
|
_clear_pending_create_key(task_id)
|
||||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||||
|
external_call_id = response.headers.get("x-external-call-id") if managed_mode else None
|
||||||
|
|
||||||
logger.info("Created Browser Use session %s", session_name)
|
logger.info("Created Browser Use session %s", session_name)
|
||||||
|
|
||||||
|
cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"session_name": session_name,
|
"session_name": session_name,
|
||||||
"bb_session_id": session_data["id"],
|
"bb_session_id": session_data["id"],
|
||||||
"cdp_url": session_data["cdpUrl"],
|
"cdp_url": cdp_url,
|
||||||
"features": {"browser_use": True},
|
"features": {"browser_use": True},
|
||||||
|
"external_call_id": external_call_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def close_session(self, session_id: str) -> bool:
|
def close_session(self, session_id: str) -> bool:
|
||||||
|
try:
|
||||||
|
config = self._get_config()
|
||||||
|
except ValueError:
|
||||||
|
logger.warning("Cannot close Browser Use session %s — missing credentials", session_id)
|
||||||
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.patch(
|
response = requests.patch(
|
||||||
f"{_BASE_URL}/browsers/{session_id}",
|
f"{config['base_url']}/browsers/{session_id}",
|
||||||
headers=self._headers(),
|
headers=self._headers(config),
|
||||||
json={"action": "stop"},
|
json={"action": "stop"},
|
||||||
timeout=10,
|
timeout=10,
|
||||||
)
|
)
|
||||||
|
|
@ -89,17 +200,14 @@ class BrowserUseProvider(CloudBrowserProvider):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def emergency_cleanup(self, session_id: str) -> None:
|
def emergency_cleanup(self, session_id: str) -> None:
|
||||||
api_key = os.environ.get("BROWSER_USE_API_KEY")
|
config = self._get_config_or_none()
|
||||||
if not api_key:
|
if config is None:
|
||||||
logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
|
logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
requests.patch(
|
requests.patch(
|
||||||
f"{_BASE_URL}/browsers/{session_id}",
|
f"{config['base_url']}/browsers/{session_id}",
|
||||||
headers={
|
headers=self._headers(config),
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Browser-Use-API-Key": api_key,
|
|
||||||
},
|
|
||||||
json={"action": "stop"},
|
json={"action": "stop"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,63 +1,24 @@
|
||||||
"""Browserbase cloud browser provider."""
|
"""Browserbase cloud browser provider (direct credentials only)."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import threading
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
|
||||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_pending_create_keys: Dict[str, str] = {}
|
|
||||||
_pending_create_keys_lock = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_or_create_pending_create_key(task_id: str) -> str:
|
|
||||||
with _pending_create_keys_lock:
|
|
||||||
existing = _pending_create_keys.get(task_id)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
created = f"browserbase-session-create:{uuid.uuid4().hex}"
|
|
||||||
_pending_create_keys[task_id] = created
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def _clear_pending_create_key(task_id: str) -> None:
|
|
||||||
with _pending_create_keys_lock:
|
|
||||||
_pending_create_keys.pop(task_id, None)
|
|
||||||
|
|
||||||
|
|
||||||
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
|
||||||
if response.status_code >= 500:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if response.status_code != 409:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = response.json()
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
error = payload.get("error")
|
|
||||||
if not isinstance(error, dict):
|
|
||||||
return False
|
|
||||||
|
|
||||||
message = str(error.get("message") or "").lower()
|
|
||||||
return "already in progress" in message
|
|
||||||
|
|
||||||
|
|
||||||
class BrowserbaseProvider(CloudBrowserProvider):
|
class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"""Browserbase (https://browserbase.com) cloud browser backend."""
|
"""Browserbase (https://browserbase.com) cloud browser backend.
|
||||||
|
|
||||||
|
This provider requires direct BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID
|
||||||
|
credentials. Managed Nous gateway support has been removed — the Nous
|
||||||
|
subscription now routes through Browser Use instead.
|
||||||
|
"""
|
||||||
|
|
||||||
def provider_name(self) -> str:
|
def provider_name(self) -> str:
|
||||||
return "Browserbase"
|
return "Browserbase"
|
||||||
|
|
@ -77,37 +38,20 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
|
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
|
||||||
"managed_mode": False,
|
|
||||||
}
|
}
|
||||||
|
return None
|
||||||
managed = resolve_managed_tool_gateway("browserbase")
|
|
||||||
if managed is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"api_key": managed.nous_user_token,
|
|
||||||
"project_id": "managed",
|
|
||||||
"base_url": managed.gateway_origin.rstrip("/"),
|
|
||||||
"managed_mode": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_config(self) -> Dict[str, Any]:
|
def _get_config(self) -> Dict[str, Any]:
|
||||||
config = self._get_config_or_none()
|
config = self._get_config_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
message = (
|
raise ValueError(
|
||||||
"Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials."
|
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
|
||||||
|
"environment variables."
|
||||||
)
|
)
|
||||||
if managed_nous_tools_enabled():
|
|
||||||
message = (
|
|
||||||
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID "
|
|
||||||
"credentials or a managed Browserbase gateway configuration."
|
|
||||||
)
|
|
||||||
raise ValueError(message)
|
|
||||||
return config
|
return config
|
||||||
|
|
||||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||||
config = self._get_config()
|
config = self._get_config()
|
||||||
managed_mode = bool(config.get("managed_mode"))
|
|
||||||
|
|
||||||
# Optional env-var knobs
|
# Optional env-var knobs
|
||||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||||
|
|
@ -147,8 +91,6 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-BB-API-Key": config["api_key"],
|
"X-BB-API-Key": config["api_key"],
|
||||||
}
|
}
|
||||||
if managed_mode:
|
|
||||||
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"{config['base_url']}/v1/sessions",
|
f"{config['base_url']}/v1/sessions",
|
||||||
|
|
@ -161,7 +103,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
keepalive_fallback = False
|
keepalive_fallback = False
|
||||||
|
|
||||||
# Handle 402 — paid features unavailable
|
# Handle 402 — paid features unavailable
|
||||||
if response.status_code == 402 and not managed_mode:
|
if response.status_code == 402:
|
||||||
if enable_keep_alive:
|
if enable_keep_alive:
|
||||||
keepalive_fallback = True
|
keepalive_fallback = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -191,18 +133,13 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
if managed_mode and not _should_preserve_pending_create_key(response):
|
|
||||||
_clear_pending_create_key(task_id)
|
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to create Browserbase session: "
|
f"Failed to create Browserbase session: "
|
||||||
f"{response.status_code} {response.text}"
|
f"{response.status_code} {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
session_data = response.json()
|
session_data = response.json()
|
||||||
if managed_mode:
|
|
||||||
_clear_pending_create_key(task_id)
|
|
||||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||||
external_call_id = response.headers.get("x-external-call-id") if managed_mode else None
|
|
||||||
|
|
||||||
if enable_proxies and not proxies_fallback:
|
if enable_proxies and not proxies_fallback:
|
||||||
features_enabled["proxies"] = True
|
features_enabled["proxies"] = True
|
||||||
|
|
@ -221,7 +158,6 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"bb_session_id": session_data["id"],
|
"bb_session_id": session_data["id"],
|
||||||
"cdp_url": session_data["connectUrl"],
|
"cdp_url": session_data["connectUrl"],
|
||||||
"features": features_enabled,
|
"features": features_enabled,
|
||||||
"external_call_id": external_call_id,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def close_session(self, session_id: str) -> bool:
|
def close_session(self, session_id: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
Browser Tool Module
|
Browser Tool Module
|
||||||
|
|
||||||
This module provides browser automation tools using agent-browser CLI. It
|
This module provides browser automation tools using agent-browser CLI. It
|
||||||
supports two backends — **Browserbase** (cloud) and **local Chromium** — with
|
supports multiple backends — **Browser Use** (cloud, default for Nous
|
||||||
identical agent-facing behaviour. The backend is auto-detected: if
|
subscribers), **Browserbase** (cloud, direct credentials), and **local
|
||||||
``BROWSERBASE_API_KEY`` is set the cloud service is used; otherwise a local
|
Chromium** — with identical agent-facing behaviour. The backend is
|
||||||
headless Chromium instance is launched automatically.
|
auto-detected from config and available credentials.
|
||||||
|
|
||||||
The tool uses agent-browser's accessibility tree (ariaSnapshot) for text-based
|
The tool uses agent-browser's accessibility tree (ariaSnapshot) for text-based
|
||||||
page representation, making it ideal for LLM agents without vision capabilities.
|
page representation, making it ideal for LLM agents without vision capabilities.
|
||||||
|
|
@ -17,8 +17,7 @@ Features:
|
||||||
``agent-browser install`` (downloads Chromium) or
|
``agent-browser install`` (downloads Chromium) or
|
||||||
``agent-browser install --with-deps`` (also installs system libraries for
|
``agent-browser install --with-deps`` (also installs system libraries for
|
||||||
Debian/Ubuntu/Docker).
|
Debian/Ubuntu/Docker).
|
||||||
- **Cloud mode**: Browserbase cloud execution with stealth features, proxies,
|
- **Cloud mode**: Browserbase or Browser Use cloud execution when configured.
|
||||||
and CAPTCHA solving. Activated when BROWSERBASE_API_KEY is set.
|
|
||||||
- Session isolation per task ID
|
- Session isolation per task ID
|
||||||
- Text-based page snapshots using accessibility tree
|
- Text-based page snapshots using accessibility tree
|
||||||
- Element interaction via ref selectors (@e1, @e2, etc.)
|
- Element interaction via ref selectors (@e1, @e2, etc.)
|
||||||
|
|
@ -26,8 +25,9 @@ Features:
|
||||||
- Automatic cleanup of browser sessions
|
- Automatic cleanup of browser sessions
|
||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
- BROWSERBASE_API_KEY: API key for Browserbase (enables cloud mode)
|
- BROWSERBASE_API_KEY: API key for direct Browserbase cloud mode
|
||||||
- BROWSERBASE_PROJECT_ID: Project ID for Browserbase (required for cloud mode)
|
- BROWSERBASE_PROJECT_ID: Project ID for direct Browserbase cloud mode
|
||||||
|
- BROWSER_USE_API_KEY: API key for direct Browser Use cloud mode
|
||||||
- BROWSERBASE_PROXIES: Enable/disable residential proxies (default: "true")
|
- BROWSERBASE_PROXIES: Enable/disable residential proxies (default: "true")
|
||||||
- BROWSERBASE_ADVANCED_STEALTH: Enable advanced stealth mode with custom Chromium,
|
- BROWSERBASE_ADVANCED_STEALTH: Enable advanced stealth mode with custom Chromium,
|
||||||
requires Scale Plan (default: "false")
|
requires Scale Plan (default: "false")
|
||||||
|
|
@ -280,23 +280,19 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||||
logger.debug("Could not read cloud_provider from config: %s", e)
|
logger.debug("Could not read cloud_provider from config: %s", e)
|
||||||
|
|
||||||
if _cached_cloud_provider is None:
|
if _cached_cloud_provider is None:
|
||||||
fallback_provider = BrowserbaseProvider()
|
# Prefer Browser Use (managed Nous gateway or direct API key),
|
||||||
|
# fall back to Browserbase (direct credentials only).
|
||||||
|
fallback_provider = BrowserUseProvider()
|
||||||
if fallback_provider.is_configured():
|
if fallback_provider.is_configured():
|
||||||
_cached_cloud_provider = fallback_provider
|
_cached_cloud_provider = fallback_provider
|
||||||
|
else:
|
||||||
|
fallback_provider = BrowserbaseProvider()
|
||||||
|
if fallback_provider.is_configured():
|
||||||
|
_cached_cloud_provider = fallback_provider
|
||||||
|
|
||||||
return _cached_cloud_provider
|
return _cached_cloud_provider
|
||||||
|
|
||||||
|
|
||||||
def _get_browserbase_config_or_none() -> Optional[Dict[str, Any]]:
|
|
||||||
"""Return Browserbase direct or managed config, or None when unavailable."""
|
|
||||||
return BrowserbaseProvider()._get_config_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_browserbase_config() -> Dict[str, Any]:
|
|
||||||
"""Return Browserbase config or raise when neither direct nor managed mode is available."""
|
|
||||||
return BrowserbaseProvider()._get_config()
|
|
||||||
|
|
||||||
|
|
||||||
def _is_local_mode() -> bool:
|
def _is_local_mode() -> bool:
|
||||||
"""Return True when the browser tool will use a local browser backend."""
|
"""Return True when the browser tool will use a local browser backend."""
|
||||||
if _get_cdp_override():
|
if _get_cdp_override():
|
||||||
|
|
@ -615,7 +611,15 @@ BROWSER_TOOL_SCHEMAS = [
|
||||||
"required": ["key"]
|
"required": ["key"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "browser_close",
|
||||||
|
"description": "Close the browser session and release resources. Call this when done with browser tasks to free up cloud browser session quota.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "browser_get_images",
|
"name": "browser_get_images",
|
||||||
"description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.",
|
"description": "Get a list of all images on the current page with their URLs and alt text. Useful for finding images to analyze with the vision tool. Requires browser_navigate to be called first.",
|
||||||
|
|
@ -736,6 +740,11 @@ def _get_session_info(task_id: Optional[str] = None) -> Dict[str, str]:
|
||||||
session_info = _create_local_session(task_id)
|
session_info = _create_local_session(task_id)
|
||||||
else:
|
else:
|
||||||
session_info = provider.create_session(task_id)
|
session_info = provider.create_session(task_id)
|
||||||
|
if session_info.get("cdp_url"):
|
||||||
|
# Some cloud providers (including Browser-Use v3) return an HTTP
|
||||||
|
# CDP discovery URL instead of a raw websocket endpoint.
|
||||||
|
session_info = dict(session_info)
|
||||||
|
session_info["cdp_url"] = _resolve_cdp_override(str(session_info["cdp_url"]))
|
||||||
|
|
||||||
with _cleanup_lock:
|
with _cleanup_lock:
|
||||||
# Double-check: another thread may have created a session while we
|
# Double-check: another thread may have created a session while we
|
||||||
|
|
@ -1947,7 +1956,7 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
|
||||||
camofox_close(task_id)
|
camofox_close(task_id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Camofox cleanup for task %s: %s", task_id, e)
|
logger.debug("Camofox cleanup for task %s: %s", task_id, e)
|
||||||
|
|
||||||
logger.debug("cleanup_browser called for task_id: %s", task_id)
|
logger.debug("cleanup_browser called for task_id: %s", task_id)
|
||||||
logger.debug("Active sessions: %s", list(_active_sessions.keys()))
|
logger.debug("Active sessions: %s", list(_active_sessions.keys()))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue