Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	cli.py
#	hermes_cli/main.py
#	run_agent.py
#	tests/hermes_cli/test_cmd_update.py
#	tools/mcp_tool.py
#	web/src/lib/gatewayClient.ts
This commit is contained in:
Brooklyn Nicholson
2026-05-18 01:26:56 -05:00
260 changed files with 24547 additions and 13573 deletions
-10
View File
@@ -1,10 +0,0 @@
"""Cloud browser provider abstraction.
Import the ABC so callers can do::
from tools.browser_providers import CloudBrowserProvider
"""
from tools.browser_providers.base import CloudBrowserProvider
__all__ = ["CloudBrowserProvider"]
-59
View File
@@ -1,59 +0,0 @@
"""Abstract base class for cloud browser providers."""
from abc import ABC, abstractmethod
from typing import Dict
class CloudBrowserProvider(ABC):
"""Interface for cloud browser backends (Browserbase, Steel, etc.).
Implementations live in sibling modules and are registered in
``browser_tool._PROVIDER_REGISTRY``. The user selects a provider via
``hermes setup`` / ``hermes tools``; the choice is persisted as
``config["browser"]["cloud_provider"]``.
"""
@abstractmethod
def provider_name(self) -> str:
"""Short, human-readable name shown in logs and diagnostics."""
@abstractmethod
def is_configured(self) -> bool:
"""Return True when all required env vars / credentials are present.
Called at tool-registration time (``check_browser_requirements``) to
gate availability. Must be cheap — no network calls.
"""
@abstractmethod
def create_session(self, task_id: str) -> Dict[str, object]:
"""Create a cloud browser session and return session metadata.
Must return a dict with at least::
{
"session_name": str, # unique name for agent-browser --session
"bb_session_id": str, # provider session ID (for close/cleanup)
"cdp_url": str, # CDP websocket URL
"features": dict, # feature flags that were enabled
}
``bb_session_id`` is a legacy key name kept for backward compat with
the rest of browser_tool.py — it holds the provider's session ID
regardless of which provider is in use.
"""
@abstractmethod
def close_session(self, session_id: str) -> bool:
"""Release / terminate a cloud session by its provider session ID.
Returns True on success, False on failure. Should not raise.
"""
@abstractmethod
def emergency_cleanup(self, session_id: str) -> None:
"""Best-effort session teardown during process exit.
Called from atexit / signal handlers. Must tolerate missing
credentials, network errors, etc. — log and move on.
"""
-225
View File
@@ -1,225 +0,0 @@
"""Browser Use cloud browser provider."""
import logging
import os
import threading
import uuid
from typing import Any, Dict, Optional
import requests
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, prefers_gateway
logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {}
_pending_create_keys_lock = threading.Lock()
_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):
"""Browser Use (https://browser-use.com) cloud browser backend."""
def provider_name(self) -> str:
return "Browser Use"
def is_configured(self) -> bool:
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 and not prefers_gateway("browser"):
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
# ------------------------------------------------------------------
def _headers(self, config: Dict[str, Any]) -> Dict[str, str]:
headers = {
"Content-Type": "application/json",
"X-Browser-Use-API-Key": config["api_key"],
}
return headers
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 {}
)
try:
response = requests.post(
f"{config['base_url']}/browsers",
headers=headers,
json=payload,
timeout=30,
)
except requests.RequestException as exc:
# Managed mode: propagate raw so callers can retry with the
# preserved idempotency key. Direct mode: wrap network failures
# into a clean RuntimeError for end users.
if managed_mode:
raise
raise RuntimeError(
f"Browser Use API connection failed: {exc}"
) from exc
if not response.ok:
if managed_mode and not _should_preserve_pending_create_key(response):
_clear_pending_create_key(task_id)
raise RuntimeError(
f"Failed to create Browser Use session: "
f"{response.status_code} {response.text}"
)
session_data = response.json()
if managed_mode:
_clear_pending_create_key(task_id)
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)
cdp_url = session_data.get("cdpUrl") or session_data.get("connectUrl") or ""
return {
"session_name": session_name,
"bb_session_id": session_data["id"],
"cdp_url": cdp_url,
"features": {"browser_use": True},
"external_call_id": external_call_id,
}
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:
response = requests.patch(
f"{config['base_url']}/browsers/{session_id}",
headers=self._headers(config),
json={"action": "stop"},
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browser Use session %s", session_id)
return True
else:
logger.warning(
"Failed to close Browser Use session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Browser Use session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
config = self._get_config_or_none()
if config is None:
logger.warning("Cannot emergency-cleanup Browser Use session %s — missing credentials", session_id)
return
try:
requests.patch(
f"{config['base_url']}/browsers/{session_id}",
headers=self._headers(config),
json={"action": "stop"},
timeout=5,
)
except Exception as e:
logger.debug("Emergency cleanup failed for Browser Use session %s: %s", session_id, e)
-222
View File
@@ -1,222 +0,0 @@
"""Browserbase cloud browser provider (direct credentials only)."""
import logging
import os
import uuid
from typing import Any, Dict, Optional
import requests
from tools.browser_providers.base import CloudBrowserProvider
logger = logging.getLogger(__name__)
class BrowserbaseProvider(CloudBrowserProvider):
"""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:
return "Browserbase"
def is_configured(self) -> bool:
return self._get_config_or_none() is not None
# ------------------------------------------------------------------
# Session lifecycle
# ------------------------------------------------------------------
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
api_key = os.environ.get("BROWSERBASE_API_KEY")
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
if api_key and project_id:
return {
"api_key": api_key,
"project_id": project_id,
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
}
return None
def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none()
if config is None:
raise ValueError(
"Browserbase requires BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID "
"environment variables."
)
return config
def create_session(self, task_id: str) -> Dict[str, object]:
config = self._get_config()
# Optional env-var knobs
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
enable_advanced_stealth = os.environ.get("BROWSERBASE_ADVANCED_STEALTH", "false").lower() == "true"
enable_keep_alive = os.environ.get("BROWSERBASE_KEEP_ALIVE", "true").lower() != "false"
custom_timeout_ms = os.environ.get("BROWSERBASE_SESSION_TIMEOUT")
features_enabled = {
"basic_stealth": True,
"proxies": False,
"advanced_stealth": False,
"keep_alive": False,
"custom_timeout": False,
}
session_config: Dict[str, object] = {"projectId": config["project_id"]}
if enable_keep_alive:
session_config["keepAlive"] = True
if custom_timeout_ms:
try:
timeout_val = int(custom_timeout_ms)
if timeout_val > 0:
session_config["timeout"] = timeout_val
except ValueError:
logger.warning("Invalid BROWSERBASE_SESSION_TIMEOUT value: %s", custom_timeout_ms)
if enable_proxies:
session_config["proxies"] = True
if enable_advanced_stealth:
session_config["browserSettings"] = {"advancedStealth": True}
# --- Create session via API ---
headers = {
"Content-Type": "application/json",
"X-BB-API-Key": config["api_key"],
}
try:
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
proxies_fallback = False
keepalive_fallback = False
# Handle 402 — paid features unavailable
if response.status_code == 402:
if enable_keep_alive:
keepalive_fallback = True
logger.warning(
"keepAlive may require paid plan (402), retrying without it. "
"Sessions may timeout during long operations."
)
session_config.pop("keepAlive", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
if response.status_code == 402 and enable_proxies:
proxies_fallback = True
logger.warning(
"Proxies unavailable (402), retrying without proxies. "
"Bot detection may be less effective."
)
session_config.pop("proxies", None)
response = requests.post(
f"{config['base_url']}/v1/sessions",
headers=headers,
json=session_config,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Browserbase API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
f"Failed to create Browserbase session: "
f"{response.status_code} {response.text}"
)
session_data = response.json()
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
if enable_proxies and not proxies_fallback:
features_enabled["proxies"] = True
if enable_advanced_stealth:
features_enabled["advanced_stealth"] = True
if enable_keep_alive and not keepalive_fallback:
features_enabled["keep_alive"] = True
if custom_timeout_ms and "timeout" in session_config:
features_enabled["custom_timeout"] = True
feature_str = ", ".join(k for k, v in features_enabled.items() if v)
logger.info("Created Browserbase session %s with features: %s", session_name, feature_str)
return {
"session_name": session_name,
"bb_session_id": session_data["id"],
"cdp_url": session_data["connectUrl"],
"features": features_enabled,
}
def close_session(self, session_id: str) -> bool:
try:
config = self._get_config()
except ValueError:
logger.warning("Cannot close Browserbase session %s — missing credentials", session_id)
return False
try:
response = requests.post(
f"{config['base_url']}/v1/sessions/{session_id}",
headers={
"X-BB-API-Key": config["api_key"],
"Content-Type": "application/json",
},
json={
"projectId": config["project_id"],
"status": "REQUEST_RELEASE",
},
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Browserbase session %s", session_id)
return True
else:
logger.warning(
"Failed to close session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Browserbase session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
config = self._get_config_or_none()
if config is None:
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
return
try:
requests.post(
f"{config['base_url']}/v1/sessions/{session_id}",
headers={
"X-BB-API-Key": config["api_key"],
"Content-Type": "application/json",
},
json={
"projectId": config["project_id"],
"status": "REQUEST_RELEASE",
},
timeout=5,
)
except Exception as e:
logger.debug("Emergency cleanup failed for Browserbase session %s: %s", session_id, e)
-112
View File
@@ -1,112 +0,0 @@
"""Firecrawl cloud browser provider."""
import logging
import os
import uuid
from typing import Dict
import requests
from tools.browser_providers.base import CloudBrowserProvider
logger = logging.getLogger(__name__)
_BASE_URL = "https://api.firecrawl.dev"
class FirecrawlProvider(CloudBrowserProvider):
"""Firecrawl (https://firecrawl.dev) cloud browser backend."""
def provider_name(self) -> str:
return "Firecrawl"
def is_configured(self) -> bool:
return bool(os.environ.get("FIRECRAWL_API_KEY"))
# ------------------------------------------------------------------
# Session lifecycle
# ------------------------------------------------------------------
def _api_url(self) -> str:
return os.environ.get("FIRECRAWL_API_URL", _BASE_URL)
def _headers(self) -> Dict[str, str]:
api_key = os.environ.get("FIRECRAWL_API_KEY")
if not api_key:
raise ValueError(
"FIRECRAWL_API_KEY environment variable is required. "
"Get your key at https://firecrawl.dev"
)
return {
"Content-Type": "application/json",
"Authorization": f"Bearer {api_key}",
}
def create_session(self, task_id: str) -> Dict[str, object]:
ttl = int(os.environ.get("FIRECRAWL_BROWSER_TTL", "300"))
body: Dict[str, object] = {"ttl": ttl}
try:
response = requests.post(
f"{self._api_url()}/v2/browser",
headers=self._headers(),
json=body,
timeout=30,
)
except requests.RequestException as exc:
raise RuntimeError(
f"Firecrawl API connection failed: {exc}"
) from exc
if not response.ok:
raise RuntimeError(
f"Failed to create Firecrawl browser session: "
f"{response.status_code} {response.text}"
)
data = response.json()
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
logger.info("Created Firecrawl browser session %s", session_name)
return {
"session_name": session_name,
"bb_session_id": data["id"],
"cdp_url": data["cdpUrl"],
"features": {"firecrawl": True},
}
def close_session(self, session_id: str) -> bool:
try:
response = requests.delete(
f"{self._api_url()}/v2/browser/{session_id}",
headers=self._headers(),
timeout=10,
)
if response.status_code in {200, 201, 204}:
logger.debug("Successfully closed Firecrawl session %s", session_id)
return True
else:
logger.warning(
"Failed to close Firecrawl session %s: HTTP %s - %s",
session_id,
response.status_code,
response.text[:200],
)
return False
except Exception as e:
logger.error("Exception closing Firecrawl session %s: %s", session_id, e)
return False
def emergency_cleanup(self, session_id: str) -> None:
try:
requests.delete(
f"{self._api_url()}/v2/browser/{session_id}",
headers=self._headers(),
timeout=5,
)
except ValueError:
logger.warning("Cannot emergency-cleanup Firecrawl session %s — missing credentials", session_id)
except Exception as e:
logger.debug("Emergency cleanup failed for Firecrawl session %s: %s", session_id, e)
+124 -10
View File
@@ -83,10 +83,24 @@ 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_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,12 +405,29 @@ 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,
"browser-use": BrowserUseProvider,
"firecrawl": FirecrawlProvider,
}
# Frozen copy of the import-time _PROVIDER_REGISTRY, used by
# ``_is_legacy_provider_registry_overridden`` to detect test-time
# monkeypatching. NEVER mutate this dict.
_DEFAULT_PROVIDER_REGISTRY: Dict[str, type] = dict(_PROVIDER_REGISTRY)
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
_cloud_provider_resolved = False
@@ -411,13 +442,65 @@ _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 spotting any registered class that *isn't* the canonical
plugin-backed class for that name. Tests that
``monkeypatch.setattr(browser_tool, "_PROVIDER_REGISTRY", ...)`` install
custom factories (`exploding_factory`, `lambda: fake_provider`, etc.);
those entries fail the canonical-class identity check below.
Note: a future maintainer adding a 4th built-in provider only needs to
extend ``_DEFAULT_PROVIDER_REGISTRY`` below — they do NOT need to update
a hardcoded set of keys here. The detection just compares each registered
value against the corresponding canonical class.
"""
try:
for key, default_cls in _DEFAULT_PROVIDER_REGISTRY.items():
if _PROVIDER_REGISTRY.get(key) is not default_cls:
return True
# Extra keys not in the default registry → also an override.
return len(_PROVIDER_REGISTRY) != len(_DEFAULT_PROVIDER_REGISTRY)
except Exception:
return False
def _ensure_browser_plugins_loaded() -> None:
"""Idempotently trigger plugin discovery so the browser registry is populated.
Normally `model_tools` is imported early in any session and that
triggers `discover_plugins()` as a side effect. But `_get_cloud_provider`
can be called from contexts that haven't gone through `model_tools` —
standalone scripts, certain unit-test paths, the parity-sweep harness.
Make discovery idempotent and side-effect-only here so users always
see registered plugins regardless of import order. Cheap: subsequent
calls early-return inside `_ensure_plugins_discovered`.
"""
try:
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
except Exception as exc:
logger.debug("Browser plugin discovery failed (non-fatal): %s", exc)
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 +520,33 @@ 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:
# Ensure plugins are discovered so the registry is
# populated. Idempotent — cheap on subsequent calls.
_ensure_browser_plugins_loaded()
resolved = _registry_get_browser_provider(provider_key)
if resolved is None:
# Explicit config name unknown to the registry —
# might be a typo, an uninstalled plugin, or a
# registry-population failure. Warn the user
# (legacy code would have surfaced a typed
# credentials error via direct class instantiation;
# post-migration we surface this WARNING instead).
logger.warning(
"browser.cloud_provider=%r is not a registered "
"browser plugin; falling back to auto-detect "
"(install the corresponding plugin or fix the "
"config key spelling).",
provider_key,
)
except Exception:
logger.warning(
"Failed to instantiate explicit cloud_provider %r; will retry on next call",
@@ -453,8 +560,15 @@ 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: Browser Use first (managed Nous gateway or
# direct API key), then Browserbase (direct credentials). Uses
# the legacy class names imported at the top of this module so
# tests that ``monkeypatch.setattr(browser_tool, "BrowserUseProvider", ...)``
# keep driving this branch deterministically. Third-party browser
# plugins are intentionally NOT reachable from auto-detect — they
# participate only via explicit ``browser.cloud_provider: <name>``,
# mirroring the firecrawl gate documented on
# :data:`agent.browser_registry._LEGACY_PREFERENCE`.
try:
fallback_provider = BrowserUseProvider()
if fallback_provider.is_configured():
+2
View File
@@ -1238,6 +1238,7 @@ def execute_code(
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
# --- Poll loop: watch for exit, timeout, and interrupt ---
@@ -1568,6 +1569,7 @@ def _is_usable_python(python_path: str) -> bool:
"import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)"],
timeout=5,
capture_output=True,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
return result.returncode == 0
except (OSError, subprocess.TimeoutExpired, subprocess.SubprocessError):
+6 -1
View File
@@ -31,6 +31,11 @@ from concurrent.futures import (
from typing import Any, Dict, List, Optional
from toolsets import TOOLSETS
# Sentinel value used by the runtime provider system for providers that are
# not natively known (named custom providers, third-party aggregators, etc.).
# Must match hermes_cli.runtime_provider.RUNTIME_PROVIDER_TYPE_CUSTOM.
_RUNTIME_PROVIDER_CUSTOM = "custom"
from tools import file_state
from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb
from utils import base_url_hostname, is_truthy_value
@@ -2442,7 +2447,7 @@ def _resolve_delegation_credentials(cfg: dict, parent_agent) -> dict:
return {
"model": configured_model or runtime.get("model") or None,
"provider": runtime.get("provider"),
"provider": configured_provider if runtime.get("provider") == _RUNTIME_PROVIDER_CUSTOM else runtime.get("provider"),
"base_url": runtime.get("base_url"),
"api_key": api_key,
"api_mode": runtime.get("api_mode"),
+1
View File
@@ -513,6 +513,7 @@ class LocalEnvironment(BaseEnvironment):
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE if stdin_data is not None else subprocess.DEVNULL,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
cwd=_popen_cwd,
)
if not _IS_WINDOWS:
+1 -1
View File
@@ -450,7 +450,7 @@ def ensure(feature: str, *, prompt: bool = True) -> None:
).strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer and answer not in ("y", "yes"):
if answer and answer not in {"y", "yes"}:
raise FeatureUnavailable(
feature, missing, "user declined install at prompt"
)
+17
View File
@@ -401,6 +401,23 @@ async def _redirect_handler(authorization_url: str) -> None:
)
print(msg, file=sys.stderr)
# On a remote SSH session the OAuth provider redirects to
# http://127.0.0.1:<port>/callback, which reaches the callback server on
# the *remote* machine — not the user's local machine where the browser
# opened. Print a port-forward hint so the user knows to tunnel first.
if _oauth_port and (os.getenv("SSH_CLIENT") or os.getenv("SSH_TTY")):
print(
f" Remote session detected. The OAuth provider will redirect your browser to\n"
f" http://127.0.0.1:{_oauth_port}/callback\n"
f" which the callback listener on THIS machine is waiting on. If your browser\n"
f" is on a different machine, forward the port first in a separate terminal:\n"
f"\n"
f" ssh -N -L {_oauth_port}:127.0.0.1:{_oauth_port} <user>@<this-host>\n"
f"\n"
f" Then open the URL above. See: https://hermes-agent.nousresearch.com/docs/guides/oauth-over-ssh\n",
file=sys.stderr,
)
if _can_open_browser():
try:
opened = webbrowser.open(authorization_url)
+103 -15
View File
@@ -91,6 +91,7 @@ import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -492,6 +493,73 @@ def _cache_mcp_image_block(block) -> str:
return f"MEDIA:{image_path}"
# ---------------------------------------------------------------------------
# Remote MCP URL validation
# ---------------------------------------------------------------------------
class InvalidMcpUrlError(ValueError):
"""Raised when a remote MCP server's ``url`` cannot be parsed as http(s)://.
Validated once at startup so we fail fast with a clear message instead of
burning through the reconnect-backoff loop on every attempt. (Ported from
anomalyco/opencode#25019.)
"""
def _validate_remote_mcp_url(server_name: str, url: Any) -> str:
"""Return the URL as a string if it's a valid http(s) remote MCP URL.
Raises :class:`InvalidMcpUrlError` otherwise with a message naming the
offending server, so users can spot the bad entry in their config.
Accepts:
- ``http://host`` / ``https://host`` with optional port, path, query
- IPv4, IPv6 (bracketed), DNS hostnames
Rejects:
- Non-string values (``None``, dicts, ints)
- Missing scheme (``example.com/mcp``)
- Non-http(s) schemes (``file://``, ``ws://``, ``stdio:`` stdio servers
use the ``command`` key, not ``url``)
- Empty host (``http://``, ``https:///path``)
"""
if not isinstance(url, str):
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': expected a string, got "
f"{type(url).__name__}"
)
stripped = url.strip()
if not stripped:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': empty url"
)
try:
parsed = urlparse(stripped)
except Exception as exc: # urlparse is very permissive — belt and braces
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': {stripped!r} ({exc})"
) from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': scheme must be http or "
f"https, got {parsed.scheme!r} ({stripped!r})"
)
if not parsed.netloc:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': missing host ({stripped!r})"
)
# ``urlparse`` accepts ``http://:8080`` (empty host, explicit port).
# Reject that — we need a real host.
if not parsed.hostname:
raise InvalidMcpUrlError(
f"Invalid MCP URL for '{server_name}': missing hostname "
f"({stripped!r})"
)
return stripped
def _format_connect_error(exc: BaseException) -> str:
"""Render nested MCP connection errors into an actionable short message."""
@@ -1094,6 +1162,7 @@ class MCPServerTask:
}
for tool_name in stale_tool_names:
registry.deregister(tool_name)
_forget_mcp_tool_server(tool_name)
# 3. Re-register with fresh tool list
self._tools = new_mcp_tools
@@ -1614,6 +1683,7 @@ class MCPServerTask:
self._pending_refresh_tasks.clear()
for tool_name in list(getattr(self, "_registered_tool_names", [])):
registry.deregister(tool_name)
_forget_mcp_tool_server(tool_name)
self._registered_tool_names = []
self.session = None
@@ -1984,11 +2054,20 @@ def _handle_session_expired_and_retry(
# ``is_mcp_tool_parallel_safe()`` for the parallel-execution check in run_agent.
_parallel_safe_servers: set = set()
# Exact MCP tool-name provenance. MCP tool names are formatted as
# ``mcp_{sanitized_server}_{sanitized_tool}``, which is ambiguous when server
# names contain underscores (``mcp_a_b_tool`` could be server ``a`` + tool
# ``b_tool`` or server ``a_b`` + tool ``tool``). Keep the server component
# captured at registration time so parallel safety never relies on prefix
# guessing.
_mcp_tool_server_names: Dict[str, str] = {}
# Dedicated event loop running in a background daemon thread.
_mcp_loop: Optional[asyncio.AbstractEventLoop] = None
_mcp_thread: Optional[threading.Thread] = None
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers, and _stdio_pids.
# Protects _mcp_loop, _mcp_thread, _servers, _parallel_safe_servers,
# _mcp_tool_server_names, and _stdio_pids.
_lock = threading.Lock()
# PIDs of stdio MCP server subprocesses. Tracked so we can force-kill
@@ -2871,6 +2950,19 @@ _UTILITY_CAPABILITY_ATTRS = {
}
def _track_mcp_tool_server(tool_name: str, server_name: str) -> None:
"""Remember the exact MCP server that registered *tool_name*."""
safe_server_name = sanitize_mcp_name_component(server_name)
with _lock:
_mcp_tool_server_names[tool_name] = safe_server_name
def _forget_mcp_tool_server(tool_name: str) -> None:
"""Forget MCP server provenance for a deregistered tool."""
with _lock:
_mcp_tool_server_names.pop(tool_name, None)
def _select_utility_schemas(server_name: str, server: MCPServerTask, config: dict) -> List[dict]:
"""Select utility schemas based on config and server capabilities."""
tools_filter = config.get("tools") or {}
@@ -3005,6 +3097,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
is_async=False,
description=schema["description"],
)
_track_mcp_tool_server(tool_name_prefixed, name)
registered_names.append(tool_name_prefixed)
# Register MCP Resources & Prompts utility tools, filtered by config and
@@ -3041,6 +3134,7 @@ def _register_server_tools(name: str, server: MCPServerTask, config: dict) -> Li
is_async=False,
description=schema["description"],
)
_track_mcp_tool_server(util_name, name)
registered_names.append(util_name)
if registered_names:
@@ -3225,24 +3319,19 @@ def discover_mcp_tools() -> List[str]:
def is_mcp_tool_parallel_safe(tool_name: str) -> bool:
"""Check if an MCP tool belongs to a server that supports parallel tool calls.
MCP tool names follow the pattern ``mcp_{server}_{tool}``. This extracts
the server component and checks it against the set of servers whose config
includes ``supports_parallel_tool_calls: true``.
MCP tool names follow the pattern ``mcp_{server}_{tool}``, but that string
shape is ambiguous when server names contain underscores. Use the exact
server provenance captured at registration time rather than prefix
matching, then check whether that server's config includes
``supports_parallel_tool_calls: true``.
Returns False for non-MCP tools or tools from servers without the flag.
"""
if not tool_name.startswith("mcp_"):
return False
# Strip the "mcp_" prefix and extract the server name.
# Tool names are: mcp_{sanitized_server}_{sanitized_tool}
# We need to check all possible server prefixes because the server name
# itself may contain underscores after sanitization.
rest = tool_name[4:] # strip "mcp_"
with _lock:
for server_name in _parallel_safe_servers:
if rest.startswith(server_name + "_") and len(rest) > len(server_name) + 1:
return True
return False
server_name = _mcp_tool_server_names.get(tool_name)
return bool(server_name and server_name in _parallel_safe_servers)
def get_mcp_status() -> List[dict]:
@@ -3415,7 +3504,6 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
sessions can still be in flight.
"""
import signal as _signal
import time as _time
with _lock:
pids: Dict[int, str] = {}
@@ -3440,7 +3528,7 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
pass
# Phase 2: Wait for graceful exit
_time.sleep(2)
time.sleep(2)
# Phase 3: SIGKILL any survivors
_sigkill = getattr(_signal, "SIGKILL", _signal.SIGTERM)
+1
View File
@@ -557,6 +557,7 @@ class ProcessRegistry:
stderr=subprocess.STDOUT,
stdin=subprocess.PIPE,
preexec_fn=None if _IS_WINDOWS else os.setsid,
creationflags=subprocess.CREATE_NO_WINDOW if _IS_WINDOWS else 0,
)
session.process = proc
+13 -1
View File
@@ -355,11 +355,23 @@ def strip_pattern_and_format(tools: list[dict]) -> tuple[list[dict], int]:
_walk(item)
for tool in tools:
fn = tool.get("function") if isinstance(tool, dict) else None
if not isinstance(tool, dict):
continue
# OpenAI-format: {"function": {"parameters": {...}}}
fn = tool.get("function")
if isinstance(fn, dict):
params = fn.get("parameters")
if isinstance(params, dict):
_walk(params)
continue
# Responses-format: {"name": "...", "parameters": {...}}
# (used by codex_responses API mode — xAI, OpenAI Codex, etc.)
params = tool.get("parameters")
if isinstance(params, dict):
_walk(params)
continue
if stripped:
logger.info(
+10
View File
@@ -28,6 +28,8 @@ _FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::(
# conversations.open to obtain a D... ID. Without this gate, Slack IDs fall
# through to channel-name resolution, which only matches by name and fails.
_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$")
# Session-derived Slack thread targets use "<conversation_id>:<thread_ts>".
_SLACK_THREAD_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,}):([^\s:]+)\s*$")
_WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$")
_YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$")
# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets.
@@ -330,9 +332,17 @@ def _parse_target_ref(platform_name: str, target_ref: str):
if match:
return match.group(1), match.group(2), True
if platform_name == "slack":
match = _SLACK_THREAD_TARGET_RE.fullmatch(target_ref)
if match:
return match.group(1), match.group(2), True
match = _SLACK_TARGET_RE.fullmatch(target_ref)
if match:
return match.group(1), None, True
if platform_name == "matrix":
trimmed = target_ref.strip()
split_idx = trimmed.rfind(":$")
if split_idx > 0:
return trimmed[:split_idx], trimmed[split_idx + 1 :], True
if platform_name == "weixin":
match = _WEIXIN_TARGET_RE.fullmatch(target_ref)
if match:
+2 -2
View File
@@ -286,9 +286,9 @@ def _coerce_bool(value: Any) -> Optional[bool]:
return value
if isinstance(value, str):
v = value.strip().lower()
if v in ("true", "1", "yes", "on"):
if v in {"true", "1", "yes", "on"}:
return True
if v in ("false", "0", "no", "off"):
if v in {"false", "0", "no", "off"}:
return False
return None
+1 -1
View File
@@ -147,7 +147,7 @@ def _extract_response_text(payload: Dict[str, Any]) -> str:
continue
for content in item.get("content", []) or []:
ctype = content.get("type")
if ctype in ("output_text", "text"):
if ctype in {"output_text", "text"}:
text = str(content.get("text") or "").strip()
if text:
parts.append(text)
+5 -7
View File
@@ -5,12 +5,6 @@ from __future__ import annotations
import os
from typing import Dict
try:
from hermes_cli.config import get_env_value as _hermes_get_env_value
except Exception:
_hermes_get_env_value = None
def get_env_value(name: str, default=None):
"""Read ``name`` from ``~/.hermes/.env`` first, then ``os.environ``.
@@ -18,10 +12,14 @@ def get_env_value(name: str, default=None):
``tools.xai_http.get_env_value`` to inject dotenv-only secrets into the
xAI credential resolver.
"""
if _hermes_get_env_value is not None:
try:
from hermes_cli.config import get_env_value as _hermes_get_env_value
value = _hermes_get_env_value(name)
if value is not None:
return value
except Exception:
pass
return os.environ.get(name, default)