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:
@@ -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"]
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user