diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b8a044965..54339c088 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -652,6 +652,73 @@ def build_skills_system_prompt( return result +def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str: + """Build a compact Nous subscription capability block for the system prompt.""" + try: + from hermes_cli.nous_subscription import get_nous_subscription_features + from tools.tool_backend_helpers import managed_nous_tools_enabled + except Exception as exc: + logger.debug("Failed to import Nous subscription helper: %s", exc) + return "" + + if not managed_nous_tools_enabled(): + return "" + + valid_names = set(valid_tool_names or set()) + relevant_tool_names = { + "web_search", + "web_extract", + "browser_navigate", + "browser_snapshot", + "browser_click", + "browser_type", + "browser_scroll", + "browser_console", + "browser_close", + "browser_press", + "browser_get_images", + "browser_vision", + "image_generate", + "text_to_speech", + "terminal", + "process", + "execute_code", + } + + if valid_names and not (valid_names & relevant_tool_names): + return "" + + features = get_nous_subscription_features() + + def _status_line(feature) -> str: + if feature.managed_by_nous: + return f"- {feature.label}: active via Nous subscription" + if feature.active: + current = feature.current_provider or "configured provider" + return f"- {feature.label}: currently using {current}" + if feature.included_by_default and features.nous_auth_present: + return f"- {feature.label}: included with Nous subscription, not currently selected" + if feature.key == "modal" and features.nous_auth_present: + return f"- {feature.label}: optional via Nous subscription" + return f"- {feature.label}: not currently available" + + lines = [ + "# Nous Subscription", + "Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browserbase) by default. Modal execution is optional.", + "Current capability status:", + ] + lines.extend(_status_line(feature) for feature in features.items()) + lines.extend( + [ + "When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase 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.", + "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.", + ] + ) + return "\n".join(lines) + + # ========================================================================= # Context files (SOUL.md, AGENTS.md, .cursorrules) # ========================================================================= diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index ada865af0..8a62e98fc 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -6,6 +6,8 @@ import os import re from typing import Any, Dict, Optional +from utils import is_truthy_value + _COMPLEX_KEYWORDS = { "debug", "debugging", @@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE) def _coerce_bool(value: Any, default: bool = False) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) + return is_truthy_value(value, default=default) def _coerce_int(value: Any, default: int) -> int: diff --git a/environments/patches.py b/environments/patches.py index aed78da6e..a5afe751e 100644 --- a/environments/patches.py +++ b/environments/patches.py @@ -11,11 +11,11 @@ Solution: _AsyncWorker thread internally, making it safe for both CLI and Atropos use. No monkey-patching is required. - This module is kept for backward compatibility — apply_patches() is now a no-op. + This module is kept for backward compatibility. apply_patches() is a no-op. Usage: Call apply_patches() once at import time (done automatically by hermes_base_env.py). - This is idempotent — calling it multiple times is safe. + This is idempotent and safe to call multiple times. """ import logging @@ -26,17 +26,10 @@ _patches_applied = False def apply_patches(): - """Apply all monkey patches needed for Atropos compatibility. - - Now a no-op — Modal async safety is built directly into ModalEnvironment. - Safe to call multiple times. - """ + """Apply all monkey patches needed for Atropos compatibility.""" global _patches_applied if _patches_applied: return - # Modal async-safety is now built into tools/environments/modal.py - # via the _AsyncWorker class. No monkey-patching needed. - logger.debug("apply_patches() called — no patches needed (async safety is built-in)") - + logger.debug("apply_patches() called; no patches needed (async safety is built-in)") _patches_applied = True diff --git a/gateway/config.py b/gateway/config.py index c660bb48e..c7eb4adf1 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,6 +17,7 @@ from typing import Dict, List, Optional, Any from enum import Enum from hermes_cli.config import get_hermes_home +from utils import is_truthy_value logger = logging.getLogger(__name__) @@ -25,10 +26,6 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: """Coerce bool-ish config values, preserving a caller-provided default.""" if value is None: return default - if isinstance(value, bool): - return value - if isinstance(value, int): - return value != 0 if isinstance(value, str): lowered = value.strip().lower() if lowered in ("true", "1", "yes", "on"): @@ -36,7 +33,7 @@ def _coerce_bool(value: Any, default: bool = True) -> bool: if lowered in ("false", "0", "no", "off"): return False return default - return default + return is_truthy_value(value, default=default) def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: @@ -908,5 +905,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: config.default_reset_policy.at_hour = int(reset_hour) except ValueError: pass - - diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 250f842c7..6e9d4eb30 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1377,6 +1377,89 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool: return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds) +def resolve_nous_access_token( + *, + timeout_seconds: float = 15.0, + insecure: Optional[bool] = None, + ca_bundle: Optional[str] = None, + refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS, +) -> str: + """Resolve a refresh-aware Nous Portal access token for managed tool gateways.""" + with _auth_store_lock(): + auth_store = _load_auth_store() + state = _load_provider_state(auth_store, "nous") + + if not state: + raise AuthError( + "Hermes is not logged into Nous Portal.", + provider="nous", + relogin_required=True, + ) + + portal_base_url = ( + _optional_base_url(state.get("portal_base_url")) + or os.getenv("HERMES_PORTAL_BASE_URL") + or os.getenv("NOUS_PORTAL_BASE_URL") + or DEFAULT_NOUS_PORTAL_URL + ).rstrip("/") + client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID) + verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state) + + access_token = state.get("access_token") + refresh_token = state.get("refresh_token") + if not isinstance(access_token, str) or not access_token: + raise AuthError( + "No access token found for Nous Portal login.", + provider="nous", + relogin_required=True, + ) + + if not _is_expiring(state.get("expires_at"), refresh_skew_seconds): + return access_token + + if not isinstance(refresh_token, str) or not refresh_token: + raise AuthError( + "Session expired and no refresh token is available.", + provider="nous", + relogin_required=True, + ) + + timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0) + with httpx.Client( + timeout=timeout, + headers={"Accept": "application/json"}, + verify=verify, + ) as client: + refreshed = _refresh_access_token( + client=client, + portal_base_url=portal_base_url, + client_id=client_id, + refresh_token=refresh_token, + ) + + now = datetime.now(timezone.utc) + access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in")) + state["access_token"] = refreshed["access_token"] + state["refresh_token"] = refreshed.get("refresh_token") or refresh_token + state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer" + state["scope"] = refreshed.get("scope") or state.get("scope") + state["obtained_at"] = now.isoformat() + state["expires_in"] = access_ttl + state["expires_at"] = datetime.fromtimestamp( + now.timestamp() + access_ttl, + tz=timezone.utc, + ).isoformat() + state["portal_base_url"] = portal_base_url + state["client_id"] = client_id + state["tls"] = { + "insecure": verify is False, + "ca_bundle": verify if isinstance(verify, str) else None, + } + _save_provider_state(auth_store, "nous", state) + _save_auth_store(auth_store) + return state["access_token"] + + def refresh_nous_oauth_pure( access_token: str, refresh_token: str, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a7968a6c2..17a122606 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -22,6 +22,8 @@ import tempfile from pathlib import Path from typing import Dict, Any, Optional, List, Tuple +from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled + _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Env var names written to .env that aren't in OPTIONAL_ENV_VARS @@ -41,7 +43,6 @@ _EXTRA_ENV_KEYS = frozenset({ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", }) - import yaml from hermes_cli.colors import Colors, color @@ -212,6 +213,7 @@ DEFAULT_CONFIG = { "terminal": { "backend": "local", + "modal_mode": "auto", "cwd": ".", # Use current directory "timeout": 180, # Environment variables to pass through to sandboxed execution @@ -532,6 +534,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = { 5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"], 10: ["TAVILY_API_KEY"], + 11: ["TERMINAL_MODAL_MODE"], } # Required environment variables with metadata for migration prompts. @@ -750,6 +753,38 @@ OPTIONAL_ENV_VARS = { "category": "tool", "advanced": True, }, + "FIRECRAWL_GATEWAY_URL": { + "description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)", + "prompt": "Firecrawl gateway URL (leave empty to derive from domain)", + "url": None, + "password": False, + "category": "tool", + "advanced": True, + }, + "TOOL_GATEWAY_DOMAIN": { + "description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com", + "prompt": "Tool-gateway domain suffix", + "url": None, + "password": False, + "category": "tool", + "advanced": True, + }, + "TOOL_GATEWAY_SCHEME": { + "description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)", + "prompt": "Tool-gateway URL scheme", + "url": None, + "password": False, + "category": "tool", + "advanced": True, + }, + "TOOL_GATEWAY_USER_TOKEN": { + "description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)", + "prompt": "Tool-gateway user token", + "url": None, + "password": True, + "category": "tool", + "advanced": True, + }, "TAVILY_API_KEY": { "description": "Tavily API key for AI-native web search, extract, and crawl", "prompt": "Tavily API key", @@ -1079,6 +1114,15 @@ OPTIONAL_ENV_VARS = { }, } +if not _managed_nous_tools_enabled(): + for _hidden_var in ( + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ): + OPTIONAL_ENV_VARS.pop(_hidden_var, None) + def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: """ @@ -1994,7 +2038,9 @@ def set_config_value(key: str, value: str): # Check if it's an API key (goes to .env) api_keys = [ 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', - 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY', + 'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', + 'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME', + 'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', @@ -2050,6 +2096,7 @@ def set_config_value(key: str, value: str): # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc. _config_to_env_sync = { "terminal.backend": "TERMINAL_ENV", + "terminal.modal_mode": "TERMINAL_MODAL_MODE", "terminal.docker_image": "TERMINAL_DOCKER_IMAGE", "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 3344dae04..fe724878a 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -858,10 +858,10 @@ def cmd_setup(args): def cmd_model(args): """Select default model — starts with provider selection, then model picker.""" _require_tty("model") - select_provider_and_model() + select_provider_and_model(args=args) -def select_provider_and_model(): +def select_provider_and_model(args=None): """Core provider selection + model picking logic. Shared by ``cmd_model`` (``hermes model``) and the setup wizard @@ -1006,7 +1006,7 @@ def select_provider_and_model(): if selected_provider == "openrouter": _model_flow_openrouter(config, current_model) elif selected_provider == "nous": - _model_flow_nous(config, current_model) + _model_flow_nous(config, current_model, args=args) elif selected_provider == "openai-codex": _model_flow_openai_codex(config, current_model) elif selected_provider == "copilot-acp": @@ -1112,7 +1112,7 @@ def _model_flow_openrouter(config, current_model=""): print("No change.") -def _model_flow_nous(config, current_model=""): +def _model_flow_nous(config, current_model="", args=None): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( get_provider_auth_state, _prompt_model_selection, _save_model_choice, @@ -1120,7 +1120,11 @@ def _model_flow_nous(config, current_model=""): fetch_nous_models, AuthError, format_auth_error, _login_nous, PROVIDER_REGISTRY, ) - from hermes_cli.config import get_env_value, save_env_value + from hermes_cli.config import get_env_value, save_config, save_env_value + from hermes_cli.nous_subscription import ( + apply_nous_provider_defaults, + get_nous_subscription_explainer_lines, + ) import argparse state = get_provider_auth_state("nous") @@ -1129,11 +1133,19 @@ def _model_flow_nous(config, current_model=""): print() try: mock_args = argparse.Namespace( - portal_url=None, inference_url=None, client_id=None, - scope=None, no_browser=False, timeout=15.0, - ca_bundle=None, insecure=False, + portal_url=getattr(args, "portal_url", None), + inference_url=getattr(args, "inference_url", None), + client_id=getattr(args, "client_id", None), + scope=getattr(args, "scope", None), + no_browser=bool(getattr(args, "no_browser", False)), + timeout=getattr(args, "timeout", None) or 15.0, + ca_bundle=getattr(args, "ca_bundle", None), + insecure=bool(getattr(args, "insecure", False)), ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) + print() + for line in get_nous_subscription_explainer_lines(): + print(line) except SystemExit: print("Login cancelled or failed.") return @@ -1182,7 +1194,36 @@ def _model_flow_nous(config, current_model=""): # Reactivate Nous as the provider and update config inference_url = creds.get("base_url", "") _update_config_for_provider("nous", inference_url) + current_model_cfg = config.get("model") + if isinstance(current_model_cfg, dict): + model_cfg = dict(current_model_cfg) + elif isinstance(current_model_cfg, str) and current_model_cfg.strip(): + model_cfg = {"default": current_model_cfg.strip()} + else: + model_cfg = {} + model_cfg["provider"] = "nous" + model_cfg["default"] = selected + if inference_url and inference_url.strip(): + model_cfg["base_url"] = inference_url.rstrip("/") + else: + model_cfg.pop("base_url", None) + config["model"] = model_cfg + # Clear any custom endpoint that might conflict + if get_env_value("OPENAI_BASE_URL"): + save_env_value("OPENAI_BASE_URL", "") + save_env_value("OPENAI_API_KEY", "") + changed_defaults = apply_nous_provider_defaults(config) + save_config(config) print(f"Default model set to: {selected} (via Nous Portal)") + if "tts" in changed_defaults: + print("TTS provider set to: OpenAI TTS via your Nous subscription") + else: + current_tts = str(config.get("tts", {}).get("provider") or "edge") + if current_tts.lower() not in {"", "edge"}: + print(f"Keeping your existing TTS provider: {current_tts}") + print() + for line in get_nous_subscription_explainer_lines(): + print(line) else: print("No change.") @@ -3843,6 +3884,44 @@ For more help on a command: help="Select default model and provider", description="Interactively select your inference provider and default model" ) + model_parser.add_argument( + "--portal-url", + help="Portal base URL for Nous login (default: production portal)" + ) + model_parser.add_argument( + "--inference-url", + help="Inference API base URL for Nous login (default: production inference API)" + ) + model_parser.add_argument( + "--client-id", + default=None, + help="OAuth client id to use for Nous login (default: hermes-cli)" + ) + model_parser.add_argument( + "--scope", + default=None, + help="OAuth scope to request for Nous login" + ) + model_parser.add_argument( + "--no-browser", + action="store_true", + help="Do not attempt to open the browser automatically during Nous login" + ) + model_parser.add_argument( + "--timeout", + type=float, + default=15.0, + help="HTTP request timeout in seconds for Nous login (default: 15)" + ) + model_parser.add_argument( + "--ca-bundle", + help="Path to CA bundle PEM file for Nous TLS verification" + ) + model_parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS verification for Nous login (testing only)" + ) model_parser.set_defaults(func=cmd_model) # ========================================================================= diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py new file mode 100644 index 000000000..02814f75d --- /dev/null +++ b/hermes_cli/nous_subscription.py @@ -0,0 +1,517 @@ +"""Helpers for Nous subscription managed-tool capabilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, Optional, Set + +from hermes_cli.auth import get_nous_auth_status +from hermes_cli.config import get_env_value, load_config +from tools.managed_tool_gateway import is_managed_tool_gateway_ready +from tools.tool_backend_helpers import ( + has_direct_modal_credentials, + managed_nous_tools_enabled, + normalize_browser_cloud_provider, + normalize_modal_mode, + resolve_modal_backend_state, + resolve_openai_audio_api_key, +) + + +_DEFAULT_PLATFORM_TOOLSETS = { + "cli": "hermes-cli", +} + + +@dataclass(frozen=True) +class NousFeatureState: + key: str + label: str + included_by_default: bool + available: bool + active: bool + managed_by_nous: bool + direct_override: bool + toolset_enabled: bool + current_provider: str = "" + explicit_configured: bool = False + + +@dataclass(frozen=True) +class NousSubscriptionFeatures: + subscribed: bool + nous_auth_present: bool + provider_is_nous: bool + features: Dict[str, NousFeatureState] + + @property + def web(self) -> NousFeatureState: + return self.features["web"] + + @property + def image_gen(self) -> NousFeatureState: + return self.features["image_gen"] + + @property + def tts(self) -> NousFeatureState: + return self.features["tts"] + + @property + def browser(self) -> NousFeatureState: + return self.features["browser"] + + @property + def modal(self) -> NousFeatureState: + return self.features["modal"] + + def items(self) -> Iterable[NousFeatureState]: + ordered = ("web", "image_gen", "tts", "browser", "modal") + for key in ordered: + yield self.features[key] + + +def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]: + model_cfg = config.get("model") + if isinstance(model_cfg, dict): + return dict(model_cfg) + if isinstance(model_cfg, str) and model_cfg.strip(): + return {"default": model_cfg.strip()} + return {} + + +def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: + from toolsets import resolve_toolset + + platform_toolsets = config.get("platform_toolsets") + if not isinstance(platform_toolsets, dict) or not platform_toolsets: + platform_toolsets = {"cli": [_DEFAULT_PLATFORM_TOOLSETS["cli"]]} + + target_tools = set(resolve_toolset(toolset_key)) + if not target_tools: + return False + + for platform, raw_toolsets in platform_toolsets.items(): + if isinstance(raw_toolsets, list): + toolset_names = list(raw_toolsets) + else: + default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform) + toolset_names = [default_toolset] if default_toolset else [] + if not toolset_names: + default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform) + if default_toolset: + toolset_names = [default_toolset] + + available_tools: Set[str] = set() + for toolset_name in toolset_names: + if not isinstance(toolset_name, str) or not toolset_name: + continue + try: + available_tools.update(resolve_toolset(toolset_name)) + except Exception: + continue + + if target_tools and target_tools.issubset(available_tools): + return True + + return False + + +def _has_agent_browser() -> bool: + import shutil + + agent_browser_bin = shutil.which("agent-browser") + local_bin = ( + Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" + ) + return bool(agent_browser_bin or local_bin.exists()) + + +def _browser_label(current_provider: str) -> str: + mapping = { + "browserbase": "Browserbase", + "browser-use": "Browser Use", + "camofox": "Camofox", + "local": "Local browser", + } + return mapping.get(current_provider or "local", current_provider or "Local browser") + + +def _tts_label(current_provider: str) -> str: + mapping = { + "openai": "OpenAI TTS", + "elevenlabs": "ElevenLabs", + "edge": "Edge TTS", + "neutts": "NeuTTS", + } + return mapping.get(current_provider or "edge", current_provider or "Edge TTS") + + +def _resolve_browser_feature_state( + *, + browser_tool_enabled: bool, + browser_provider: str, + browser_provider_explicit: bool, + browser_local_available: bool, + direct_camofox: bool, + direct_browserbase: bool, + direct_browser_use: bool, + managed_browser_available: bool, +) -> tuple[str, bool, bool, bool]: + """Resolve browser availability using the same precedence as runtime.""" + if direct_camofox: + return "camofox", True, bool(browser_tool_enabled), False + + if browser_provider_explicit: + current_provider = browser_provider or "local" + if current_provider == "browserbase": + provider_available = managed_browser_available or direct_browserbase + available = bool(browser_local_available and provider_available) + managed = bool( + browser_tool_enabled + and browser_local_available + and managed_browser_available + and not direct_browserbase + ) + active = bool(browser_tool_enabled and available) + 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 == "camofox": + return current_provider, False, False, False + + current_provider = "local" + available = bool(browser_local_available) + active = bool(browser_tool_enabled and available) + return current_provider, available, active, False + + if managed_browser_available or direct_browserbase: + available = bool(browser_local_available) + managed = bool( + browser_tool_enabled + and browser_local_available + and managed_browser_available + and not direct_browserbase + ) + active = bool(browser_tool_enabled and available) + return "browserbase", available, active, managed + + available = bool(browser_local_available) + active = bool(browser_tool_enabled and available) + return "local", available, active, False + + +def get_nous_subscription_features( + config: Optional[Dict[str, object]] = None, +) -> NousSubscriptionFeatures: + if config is None: + config = load_config() or {} + config = dict(config) + model_cfg = _model_config_dict(config) + provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous" + + try: + nous_status = get_nous_auth_status() + except Exception: + nous_status = {} + + managed_tools_flag = managed_nous_tools_enabled() + nous_auth_present = bool(nous_status.get("logged_in")) + subscribed = provider_is_nous or nous_auth_present + + web_tool_enabled = _toolset_enabled(config, "web") + image_tool_enabled = _toolset_enabled(config, "image_gen") + tts_tool_enabled = _toolset_enabled(config, "tts") + browser_tool_enabled = _toolset_enabled(config, "browser") + modal_tool_enabled = _toolset_enabled(config, "terminal") + + web_cfg = config.get("web") if isinstance(config.get("web"), dict) else {} + tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {} + browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {} + terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {} + + web_backend = str(web_cfg.get("backend") or "").strip().lower() + tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower() + browser_provider_explicit = "cloud_provider" in browser_cfg + browser_provider = normalize_browser_cloud_provider( + browser_cfg.get("cloud_provider") if browser_provider_explicit else None + ) + terminal_backend = ( + str(terminal_cfg.get("backend") or "local").strip().lower() + ) + modal_mode = normalize_modal_mode( + terminal_cfg.get("modal_mode") + ) + + direct_exa = bool(get_env_value("EXA_API_KEY")) + direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) + direct_parallel = bool(get_env_value("PARALLEL_API_KEY")) + direct_tavily = bool(get_env_value("TAVILY_API_KEY")) + direct_fal = bool(get_env_value("FAL_KEY")) + direct_openai_tts = bool(resolve_openai_audio_api_key()) + direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) + direct_camofox = bool(get_env_value("CAMOFOX_URL")) + direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID")) + direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) + direct_modal = has_direct_modal_credentials() + + 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_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_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal") + modal_state = resolve_modal_backend_state( + modal_mode, + has_direct=direct_modal, + managed_ready=managed_modal_available, + ) + + web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl + web_active = bool( + web_tool_enabled + and ( + web_managed + or (web_backend == "exa" and direct_exa) + or (web_backend == "firecrawl" and direct_firecrawl) + or (web_backend == "parallel" and direct_parallel) + or (web_backend == "tavily" and direct_tavily) + ) + ) + web_available = bool( + managed_web_available or direct_exa or direct_firecrawl or direct_parallel or direct_tavily + ) + + image_managed = image_tool_enabled and managed_image_available and not direct_fal + image_active = bool(image_tool_enabled and (image_managed or direct_fal)) + image_available = bool(managed_image_available or direct_fal) + + tts_current_provider = tts_provider or "edge" + tts_managed = ( + tts_tool_enabled + and tts_current_provider == "openai" + and managed_tts_available + and not direct_openai_tts + ) + tts_available = bool( + tts_current_provider in {"edge", "neutts"} + or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts)) + or (tts_current_provider == "elevenlabs" and direct_elevenlabs) + ) + tts_active = bool(tts_tool_enabled and tts_available) + + browser_local_available = _has_agent_browser() + ( + browser_current_provider, + browser_available, + browser_active, + browser_managed, + ) = _resolve_browser_feature_state( + browser_tool_enabled=browser_tool_enabled, + browser_provider=browser_provider, + browser_provider_explicit=browser_provider_explicit, + browser_local_available=browser_local_available, + direct_camofox=direct_camofox, + direct_browserbase=direct_browserbase, + direct_browser_use=direct_browser_use, + managed_browser_available=managed_browser_available, + ) + + if terminal_backend != "modal": + modal_managed = False + modal_available = True + modal_active = bool(modal_tool_enabled) + modal_direct_override = False + elif modal_state["selected_backend"] == "managed": + modal_managed = bool(modal_tool_enabled) + modal_available = True + modal_active = bool(modal_tool_enabled) + modal_direct_override = False + elif modal_state["selected_backend"] == "direct": + modal_managed = False + modal_available = True + modal_active = bool(modal_tool_enabled) + modal_direct_override = bool(modal_tool_enabled) + elif modal_mode == "managed": + modal_managed = False + modal_available = bool(managed_modal_available) + modal_active = False + modal_direct_override = False + elif modal_mode == "direct": + modal_managed = False + modal_available = bool(direct_modal) + modal_active = False + modal_direct_override = False + else: + modal_managed = False + modal_available = bool(managed_modal_available or direct_modal) + modal_active = False + modal_direct_override = False + + tts_explicit_configured = False + raw_tts_cfg = config.get("tts") + if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg: + tts_explicit_configured = tts_provider not in {"", "edge"} + + features = { + "web": NousFeatureState( + key="web", + label="Web tools", + included_by_default=True, + available=web_available, + active=web_active, + managed_by_nous=web_managed, + direct_override=web_active and not web_managed, + toolset_enabled=web_tool_enabled, + current_provider=web_backend or "", + explicit_configured=bool(web_backend), + ), + "image_gen": NousFeatureState( + key="image_gen", + label="Image generation", + included_by_default=True, + available=image_available, + active=image_active, + managed_by_nous=image_managed, + direct_override=image_active and not image_managed, + toolset_enabled=image_tool_enabled, + current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""), + explicit_configured=direct_fal, + ), + "tts": NousFeatureState( + key="tts", + label="OpenAI TTS", + included_by_default=True, + available=tts_available, + active=tts_active, + managed_by_nous=tts_managed, + direct_override=tts_active and not tts_managed, + toolset_enabled=tts_tool_enabled, + current_provider=_tts_label(tts_current_provider), + explicit_configured=tts_explicit_configured, + ), + "browser": NousFeatureState( + key="browser", + label="Browser automation", + included_by_default=True, + available=browser_available, + active=browser_active, + managed_by_nous=browser_managed, + direct_override=browser_active and not browser_managed, + toolset_enabled=browser_tool_enabled, + current_provider=_browser_label(browser_current_provider), + explicit_configured=browser_provider_explicit, + ), + "modal": NousFeatureState( + key="modal", + label="Modal execution", + included_by_default=False, + available=modal_available, + active=modal_active, + managed_by_nous=modal_managed, + direct_override=terminal_backend == "modal" and modal_direct_override, + toolset_enabled=modal_tool_enabled, + current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local", + explicit_configured=terminal_backend == "modal", + ), + } + + return NousSubscriptionFeatures( + subscribed=subscribed, + nous_auth_present=nous_auth_present, + provider_is_nous=provider_is_nous, + features=features, + ) + + +def get_nous_subscription_explainer_lines() -> list[str]: + if not managed_nous_tools_enabled(): + return [] + + return [ + "Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.", + "Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.", + "Change these later with: hermes setup tools, hermes setup terminal, or hermes status.", + ] + + +def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]: + """Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`.""" + if not managed_nous_tools_enabled(): + return set() + + features = get_nous_subscription_features(config) + if not features.provider_is_nous: + return set() + + tts_cfg = config.get("tts") + if not isinstance(tts_cfg, dict): + tts_cfg = {} + config["tts"] = tts_cfg + + current_tts = str(tts_cfg.get("provider") or "edge").strip().lower() + if current_tts not in {"", "edge"}: + return set() + + tts_cfg["provider"] = "openai" + return {"tts"} + + +def apply_nous_managed_defaults( + config: Dict[str, object], + *, + enabled_toolsets: Optional[Iterable[str]] = None, +) -> set[str]: + if not managed_nous_tools_enabled(): + return set() + + features = get_nous_subscription_features(config) + if not features.provider_is_nous: + return set() + + selected_toolsets = set(enabled_toolsets or ()) + changed: set[str] = set() + + web_cfg = config.get("web") + if not isinstance(web_cfg, dict): + web_cfg = {} + config["web"] = web_cfg + + tts_cfg = config.get("tts") + if not isinstance(tts_cfg, dict): + tts_cfg = {} + config["tts"] = tts_cfg + + browser_cfg = config.get("browser") + if not isinstance(browser_cfg, dict): + browser_cfg = {} + config["browser"] = browser_cfg + + if "web" in selected_toolsets and not features.web.explicit_configured and not ( + get_env_value("PARALLEL_API_KEY") + or get_env_value("TAVILY_API_KEY") + or get_env_value("FIRECRAWL_API_KEY") + or get_env_value("FIRECRAWL_API_URL") + ): + web_cfg["backend"] = "firecrawl" + changed.add("web") + + if "tts" in selected_toolsets and not features.tts.explicit_configured and not ( + resolve_openai_audio_api_key() + or get_env_value("ELEVENLABS_API_KEY") + ): + tts_cfg["provider"] = "openai" + changed.add("tts") + + if "browser" in selected_toolsets and not features.browser.explicit_configured and not ( + get_env_value("BROWSERBASE_API_KEY") + or get_env_value("BROWSER_USE_API_KEY") + ): + browser_cfg["cloud_provider"] = "browserbase" + changed.add("browser") + + if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"): + changed.add("image_gen") + + return changed diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 0146014f3..141923b0f 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -38,6 +38,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set +from utils import env_var_enabled + try: import yaml except ImportError: # pragma: no cover – yaml is optional at import time @@ -65,7 +67,7 @@ _NS_PARENT = "hermes_plugins" def _env_enabled(name: str) -> bool: """Return True when an env var is set to a truthy opt-in value.""" - return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + return env_var_enabled(name) def _get_disabled_plugins() -> set: diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index bd64c75f8..2e0f0ad32 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -18,6 +18,12 @@ import sys from pathlib import Path from typing import Optional, Dict, Any +from hermes_cli.nous_subscription import ( + apply_nous_provider_defaults, + get_nous_subscription_explainer_lines, + get_nous_subscription_features, +) +from tools.tool_backend_helpers import managed_nous_tools_enabled from hermes_constants import get_optional_skills_dir logger = logging.getLogger(__name__) @@ -594,6 +600,7 @@ def _print_setup_summary(config: dict, hermes_home): print_header("Tool Availability Summary") tool_status = [] + subscription_features = get_nous_subscription_features(config) # Vision — use the same runtime resolver as the actual vision tools try: @@ -615,42 +622,61 @@ def _print_setup_summary(config: dict, hermes_home): tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY")) # Web tools (Exa, Parallel, Firecrawl, or Tavily) - if get_env_value("EXA_API_KEY") or get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"): - tool_status.append(("Web Search & Extract", True, None)) + if subscription_features.web.managed_by_nous: + tool_status.append(("Web Search & Extract (Nous subscription)", True, None)) + elif subscription_features.web.available: + label = "Web Search & Extract" + if subscription_features.web.current_provider: + label = f"Web Search & Extract ({subscription_features.web.current_provider})" + tool_status.append((label, True, None)) else: - tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY")) + tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY")) - # Browser tools (local Chromium or Browserbase cloud) - import shutil - - _ab_found = ( - shutil.which("agent-browser") - or ( - Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" - ).exists() - ) - if get_env_value("CAMOFOX_URL"): - tool_status.append(("Browser Automation (Camofox)", True, None)) - elif get_env_value("BROWSERBASE_API_KEY"): - tool_status.append(("Browser Automation (Browserbase)", True, None)) - elif _ab_found: - tool_status.append(("Browser Automation (local)", True, None)) + # Browser tools (local Chromium, Camofox, Browserbase, or Browser Use) + browser_provider = subscription_features.browser.current_provider + if subscription_features.browser.managed_by_nous: + tool_status.append(("Browser Automation (Nous Browserbase)", True, None)) + elif subscription_features.browser.available: + label = "Browser Automation" + if browser_provider: + label = f"Browser Automation ({browser_provider})" + tool_status.append((label, True, None)) else: + missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browserbase" + if browser_provider == "Browserbase": + missing_browser_hint = ( + "npm install -g agent-browser and set " + "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" + ) + elif browser_provider == "Browser Use": + missing_browser_hint = ( + "npm install -g agent-browser and set BROWSER_USE_API_KEY" + ) + elif browser_provider == "Camofox": + missing_browser_hint = "CAMOFOX_URL" + elif browser_provider == "Local browser": + missing_browser_hint = "npm install -g agent-browser" tool_status.append( - ("Browser Automation", False, "npm install -g agent-browser or set CAMOFOX_URL") + ("Browser Automation", False, missing_browser_hint) ) # FAL (image generation) - if get_env_value("FAL_KEY"): + if subscription_features.image_gen.managed_by_nous: + tool_status.append(("Image Generation (Nous subscription)", True, None)) + elif subscription_features.image_gen.available: tool_status.append(("Image Generation", True, None)) else: tool_status.append(("Image Generation", False, "FAL_KEY")) # TTS — show configured provider tts_provider = config.get("tts", {}).get("provider", "edge") - if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"): + if subscription_features.tts.managed_by_nous: + tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None)) + elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"): tool_status.append(("Text-to-Speech (ElevenLabs)", True, None)) - elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"): + elif tts_provider == "openai" and ( + get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY") + ): tool_status.append(("Text-to-Speech (OpenAI)", True, None)) elif tts_provider == "neutts": try: @@ -665,6 +691,16 @@ def _print_setup_summary(config: dict, hermes_home): else: tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) + if subscription_features.modal.managed_by_nous: + tool_status.append(("Modal Execution (Nous subscription)", True, None)) + elif config.get("terminal", {}).get("backend") == "modal": + if subscription_features.modal.direct_override: + tool_status.append(("Modal Execution (direct Modal)", True, None)) + else: + tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'")) + elif managed_nous_tools_enabled() and subscription_features.nous_auth_present: + tool_status.append(("Modal Execution (optional via Nous subscription)", True, None)) + # Tinker + WandB (RL training) if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"): tool_status.append(("RL Training (Tinker)", True, None)) @@ -874,6 +910,7 @@ def setup_model_provider(config: dict): if isinstance(_m, dict): selected_provider = _m.get("provider") + nous_subscription_selected = selected_provider == "nous" # ── Same-provider fallback & rotation setup ── if _supports_same_provider_pool_setup(selected_provider): @@ -1039,10 +1076,20 @@ def setup_model_provider(config: dict): print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings") + if selected_provider == "nous" and nous_subscription_selected: + changed_defaults = apply_nous_provider_defaults(config) + current_tts = str(config.get("tts", {}).get("provider") or "edge") + if "tts" in changed_defaults: + print_success("TTS provider set to: OpenAI TTS via your Nous subscription") + else: + print_info(f"Keeping your existing TTS provider: {current_tts}") + save_config(config) - # Offer TTS provider selection at the end of model setup - _setup_tts_provider(config) + # Offer TTS provider selection at the end of model setup, except when + # Nous subscription defaults are already being applied. + if selected_provider != "nous": + _setup_tts_provider(config) # ============================================================================= @@ -1110,6 +1157,7 @@ def _setup_tts_provider(config: dict): """Interactive TTS provider selection with install flow for NeuTTS.""" tts_config = config.get("tts", {}) current_provider = tts_config.get("provider", "edge") + subscription_features = get_nous_subscription_features(config) provider_labels = { "edge": "Edge TTS", @@ -1124,20 +1172,36 @@ def _setup_tts_provider(config: dict): print_info(f"Current: {current_label}") print() - choices = [ - "Edge TTS (free, cloud-based, no setup needed)", - "ElevenLabs (premium quality, needs API key)", - "OpenAI TTS (good quality, needs API key)", - "NeuTTS (local on-device, free, ~300MB model download)", - f"Keep current ({current_label})", - ] - idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1) + choices = [] + providers = [] + if managed_nous_tools_enabled() and subscription_features.nous_auth_present: + choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)") + providers.append("nous-openai") + choices.extend( + [ + "Edge TTS (free, cloud-based, no setup needed)", + "ElevenLabs (premium quality, needs API key)", + "OpenAI TTS (good quality, needs API key)", + "NeuTTS (local on-device, free, ~300MB model download)", + ] + ) + providers.extend(["edge", "elevenlabs", "openai", "neutts"]) + choices.append(f"Keep current ({current_label})") + keep_current_idx = len(choices) - 1 + idx = prompt_choice("Select TTS provider:", choices, keep_current_idx) - if idx == 4: # Keep current + if idx == keep_current_idx: return - providers = ["edge", "elevenlabs", "openai", "neutts"] selected = providers[idx] + selected_via_nous = selected == "nous-openai" + if selected == "nous-openai": + selected = "openai" + print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.") + if get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY"): + print_warning( + "Direct OpenAI credentials are still configured and may take precedence until removed from ~/.hermes/.env." + ) if selected == "neutts": # Check if already installed @@ -1175,8 +1239,8 @@ def _setup_tts_provider(config: dict): print_warning("No API key provided. Falling back to Edge TTS.") selected = "edge" - elif selected == "openai": - existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") + elif selected == "openai" and not selected_via_nous: + existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY") if not existing: print() api_key = prompt("OpenAI API key for TTS", password=True) @@ -1331,63 +1395,99 @@ def setup_terminal_backend(config: dict): elif selected_backend == "modal": print_success("Terminal backend: Modal") print_info("Serverless cloud sandboxes. Each session gets its own container.") - print_info("Requires a Modal account: https://modal.com") + from tools.managed_tool_gateway import is_managed_tool_gateway_ready + from tools.tool_backend_helpers import normalize_modal_mode - # Check if modal SDK is installed - try: - __import__("modal") - except ImportError: - print_info("Installing modal SDK...") - import subprocess - - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [ - uv_bin, - "pip", - "install", - "--python", - sys.executable, - "modal", - ], - capture_output=True, - text=True, - ) + managed_modal_available = bool( + managed_nous_tools_enabled() + and + get_nous_subscription_features(config).nous_auth_present + and is_managed_tool_gateway_ready("modal") + ) + modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode")) + use_managed_modal = False + if managed_modal_available: + modal_choices = [ + "Use my Nous subscription", + "Use my own Modal account", + ] + if modal_mode == "managed": + default_modal_idx = 0 + elif modal_mode == "direct": + default_modal_idx = 1 else: - result = subprocess.run( - [sys.executable, "-m", "pip", "install", "modal"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - print_success("modal SDK installed") - else: - print_warning( - "Install failed — run manually: pip install modal" - ) + default_modal_idx = 1 if get_env_value("MODAL_TOKEN_ID") else 0 + modal_mode_idx = prompt_choice( + "Select how Modal execution should be billed:", + modal_choices, + default_modal_idx, + ) + use_managed_modal = modal_mode_idx == 0 - # Modal token - print() - print_info("Modal authentication:") - print_info(" Get your token at: https://modal.com/settings") - existing_token = get_env_value("MODAL_TOKEN_ID") - if existing_token: - print_info(" Modal token: already configured") - if prompt_yes_no(" Update Modal credentials?", False): + if use_managed_modal: + config["terminal"]["modal_mode"] = "managed" + print_info("Modal execution will use the managed Nous gateway and bill to your subscription.") + if get_env_value("MODAL_TOKEN_ID") or get_env_value("MODAL_TOKEN_SECRET"): + print_info( + "Direct Modal credentials are still configured, but this backend is pinned to managed mode." + ) + else: + config["terminal"]["modal_mode"] = "direct" + print_info("Requires a Modal account: https://modal.com") + + # Check if modal SDK is installed + try: + __import__("modal") + except ImportError: + print_info("Installing modal SDK...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [ + uv_bin, + "pip", + "install", + "--python", + sys.executable, + "modal", + ], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "modal"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success("modal SDK installed") + else: + print_warning("Install failed — run manually: pip install modal") + + # Modal token + print() + print_info("Modal authentication:") + print_info(" Get your token at: https://modal.com/settings") + existing_token = get_env_value("MODAL_TOKEN_ID") + if existing_token: + print_info(" Modal token: already configured") + if prompt_yes_no(" Update Modal credentials?", False): + token_id = prompt(" Modal Token ID", password=True) + token_secret = prompt(" Modal Token Secret", password=True) + if token_id: + save_env_value("MODAL_TOKEN_ID", token_id) + if token_secret: + save_env_value("MODAL_TOKEN_SECRET", token_secret) + else: token_id = prompt(" Modal Token ID", password=True) token_secret = prompt(" Modal Token Secret", password=True) if token_id: save_env_value("MODAL_TOKEN_ID", token_id) if token_secret: save_env_value("MODAL_TOKEN_SECRET", token_secret) - else: - token_id = prompt(" Modal Token ID", password=True) - token_secret = prompt(" Modal Token Secret", password=True) - if token_id: - save_env_value("MODAL_TOKEN_ID", token_id) - if token_secret: - save_env_value("MODAL_TOKEN_SECRET", token_secret) _prompt_container_resources(config) @@ -1501,6 +1601,8 @@ def setup_terminal_backend(config: dict): # Sync terminal backend to .env so terminal_tool picks it up directly. # config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV. save_env_value("TERMINAL_ENV", selected_backend) + if selected_backend == "modal": + save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto")) save_config(config) print() print_success(f"Terminal backend set to: {selected_backend}") @@ -2472,6 +2574,17 @@ SETUP_SECTIONS = [ ("agent", "Agent Settings", setup_agent_settings), ] +# The returning-user menu intentionally omits standalone TTS because model setup +# already includes TTS selection and tools setup covers the rest of the provider +# configuration. Keep this list in the same order as the visible menu entries. +RETURNING_USER_MENU_SECTION_KEYS = [ + "model", + "terminal", + "gateway", + "tools", + "agent", +] + def run_setup_wizard(args): """Run the interactive setup wizard. @@ -2622,8 +2735,7 @@ def run_setup_wizard(args): # Individual section — map by key, not by position. # SETUP_SECTIONS includes TTS but the returning-user menu skips it, # so positional indexing (choice - 3) would dispatch the wrong section. - _RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"] - section_key = _RETURNING_USER_SECTION_KEYS[choice - 3] + section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3] section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None) if section: _, label, func = section diff --git a/hermes_cli/status.py b/hermes_cli/status.py index aeb159a55..67b15bab7 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -15,8 +15,10 @@ from hermes_cli.auth import AuthError, resolve_provider from hermes_cli.colors import Colors, color from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config from hermes_cli.models import provider_label +from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.runtime_provider import resolve_requested_provider from hermes_constants import OPENROUTER_MODELS_URL +from tools.tool_backend_helpers import managed_nous_tools_enabled def check_mark(ok: bool) -> str: if ok: @@ -186,6 +188,31 @@ def show_status(args): if codex_status.get("error") and not codex_logged_in: print(f" Error: {codex_status.get('error')}") + # ========================================================================= + # Nous Subscription Features + # ========================================================================= + if managed_nous_tools_enabled(): + features = get_nous_subscription_features(config) + print() + print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD)) + if not features.nous_auth_present: + print(" Nous Portal ✗ not logged in") + else: + print(" Nous Portal ✓ managed tools available") + for feature in features.items(): + if feature.managed_by_nous: + state = "active via Nous subscription" + elif feature.active: + current = feature.current_provider or "configured provider" + state = f"active via {current}" + elif feature.included_by_default and features.nous_auth_present: + state = "included by subscription, not currently selected" + elif feature.key == "modal" and features.nous_auth_present: + state = "available via subscription (optional)" + else: + state = "not configured" + print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") + # ========================================================================= # API-Key Providers # ========================================================================= diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 2150420f1..4410dc81e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -20,6 +20,11 @@ from hermes_cli.config import ( load_config, save_config, get_env_value, save_env_value, ) from hermes_cli.colors import Colors, color +from hermes_cli.nous_subscription import ( + apply_nous_managed_defaults, + get_nous_subscription_features, +) +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -158,6 +163,15 @@ TOOL_CATEGORIES = { "name": "Text-to-Speech", "icon": "🔊", "providers": [ + { + "name": "Nous Subscription", + "tag": "Managed OpenAI TTS billed to your subscription", + "env_vars": [], + "tts_provider": "openai", + "requires_nous_auth": True, + "managed_nous_feature": "tts", + "override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"], + }, { "name": "Microsoft Edge TTS", "tag": "Free - no API key needed", @@ -188,6 +202,15 @@ TOOL_CATEGORIES = { "setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.", "icon": "🔍", "providers": [ + { + "name": "Nous Subscription", + "tag": "Managed Firecrawl billed to your subscription", + "web_backend": "firecrawl", + "env_vars": [], + "requires_nous_auth": True, + "managed_nous_feature": "web", + "override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"], + }, { "name": "Firecrawl Cloud", "tag": "Hosted service - search, extract, and crawl", @@ -234,6 +257,14 @@ TOOL_CATEGORIES = { "name": "Image Generation", "icon": "🎨", "providers": [ + { + "name": "Nous Subscription", + "tag": "Managed FAL image generation billed to your subscription", + "env_vars": [], + "requires_nous_auth": True, + "managed_nous_feature": "image_gen", + "override_env_vars": ["FAL_KEY"], + }, { "name": "FAL.ai", "tag": "FLUX 2 Pro with auto-upscaling", @@ -247,11 +278,21 @@ TOOL_CATEGORIES = { "name": "Browser Automation", "icon": "🌐", "providers": [ + { + "name": "Nous Subscription (Browserbase cloud)", + "tag": "Managed Browserbase billed to your subscription", + "env_vars": [], + "browser_provider": "browserbase", + "requires_nous_auth": True, + "managed_nous_feature": "browser", + "override_env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"], + "post_setup": "browserbase", + }, { "name": "Local Browser", "tag": "Free headless Chromium (no API key needed)", "env_vars": [], - "browser_provider": None, + "browser_provider": "local", "post_setup": "browserbase", # Same npm install for agent-browser }, { @@ -581,8 +622,11 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[ save_config(config) -def _toolset_has_keys(ts_key: str) -> bool: +def _toolset_has_keys(ts_key: str, config: dict = None) -> bool: """Check if a toolset's required API keys are configured.""" + if config is None: + config = load_config() + if ts_key == "vision": try: from agent.auxiliary_client import resolve_vision_provider_client @@ -592,10 +636,16 @@ def _toolset_has_keys(ts_key: str) -> bool: except Exception: return False + if ts_key in {"web", "image_gen", "tts", "browser"}: + features = get_nous_subscription_features(config) + feature = features.features.get(ts_key) + if feature and (feature.available or feature.managed_by_nous): + return True + # Check TOOL_CATEGORIES first (provider-aware) cat = TOOL_CATEGORIES.get(ts_key) if cat: - for provider in cat.get("providers", []): + for provider in _visible_providers(cat, config): env_vars = provider.get("env_vars", []) if not env_vars: return True # No-key provider (e.g. Local Browser, Edge TTS) @@ -805,11 +855,45 @@ def _configure_toolset(ts_key: str, config: dict): _configure_simple_requirements(ts_key) +def _visible_providers(cat: dict, config: dict) -> list[dict]: + """Return provider entries visible for the current auth/config state.""" + features = get_nous_subscription_features(config) + visible = [] + for provider in cat.get("providers", []): + if provider.get("managed_nous_feature") and not managed_nous_tools_enabled(): + continue + if provider.get("requires_nous_auth") and not features.nous_auth_present: + continue + visible.append(provider) + return visible + + +def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool: + """Return True when enabling this toolset should open provider setup.""" + cat = TOOL_CATEGORIES.get(ts_key) + if not cat: + return not _toolset_has_keys(ts_key, config) + + if ts_key == "tts": + tts_cfg = config.get("tts", {}) + return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg + if ts_key == "web": + web_cfg = config.get("web", {}) + return not isinstance(web_cfg, dict) or "backend" not in web_cfg + if ts_key == "browser": + browser_cfg = config.get("browser", {}) + return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg + if ts_key == "image_gen": + return not get_env_value("FAL_KEY") + + return not _toolset_has_keys(ts_key, config) + + def _configure_tool_category(ts_key: str, cat: dict, config: dict): """Configure a tool category with provider selection.""" icon = cat.get("icon", "") name = cat["name"] - providers = cat["providers"] + providers = _visible_providers(cat, config) # Check Python version requirement if cat.get("requires_python"): @@ -874,6 +958,27 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict): def _is_provider_active(provider: dict, config: dict) -> bool: """Check if a provider entry matches the currently active config.""" + managed_feature = provider.get("managed_nous_feature") + if managed_feature: + features = get_nous_subscription_features(config) + feature = features.features.get(managed_feature) + if feature is None: + return False + if managed_feature == "image_gen": + return feature.managed_by_nous + if provider.get("tts_provider"): + return ( + feature.managed_by_nous + and config.get("tts", {}).get("provider") == provider["tts_provider"] + ) + if "browser_provider" in provider: + current = config.get("browser", {}).get("cloud_provider") + return feature.managed_by_nous and provider["browser_provider"] == current + if provider.get("web_backend"): + current = config.get("web", {}).get("backend") + return feature.managed_by_nous and current == provider["web_backend"] + return feature.managed_by_nous + if provider.get("tts_provider"): return config.get("tts", {}).get("provider") == provider["tts_provider"] if "browser_provider" in provider: @@ -900,6 +1005,13 @@ def _detect_active_provider_index(providers: list, config: dict) -> int: def _configure_provider(provider: dict, config: dict): """Configure a single provider - prompt for API keys and set config.""" env_vars = provider.get("env_vars", []) + managed_feature = provider.get("managed_nous_feature") + + if provider.get("requires_nous_auth"): + features = get_nous_subscription_features(config) + if not features.nous_auth_present: + _print_warning(" Nous Subscription is only available after logging into Nous Portal.") + return # Set TTS provider in config if applicable if provider.get("tts_provider"): @@ -908,11 +1020,12 @@ def _configure_provider(provider: dict, config: dict): # Set browser cloud provider in config if applicable if "browser_provider" in provider: bp = provider["browser_provider"] - if bp: + if bp == "local": + config.setdefault("browser", {})["cloud_provider"] = "local" + _print_success(" Browser set to local mode") + elif bp: config.setdefault("browser", {})["cloud_provider"] = bp _print_success(f" Browser cloud provider set to: {bp}") - else: - config.get("browser", {}).pop("cloud_provider", None) # Set web search backend in config if applicable if provider.get("web_backend"): @@ -920,7 +1033,16 @@ def _configure_provider(provider: dict, config: dict): _print_success(f" Web backend set to: {provider['web_backend']}") if not env_vars: + if provider.get("post_setup"): + _run_post_setup(provider["post_setup"]) _print_success(f" {provider['name']} - no configuration needed!") + if managed_feature: + _print_info(" Requests for this tool will be billed to your Nous subscription.") + override_envs = provider.get("override_env_vars", []) + if any(get_env_value(env_var) for env_var in override_envs): + _print_warning( + " Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env." + ) return # Prompt for each required env var @@ -1028,7 +1150,7 @@ def _reconfigure_tool(config: dict): cat = TOOL_CATEGORIES.get(ts_key) reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key) if cat or reqs: - if _toolset_has_keys(ts_key): + if _toolset_has_keys(ts_key, config): configurable.append((ts_key, ts_label)) if not configurable: @@ -1058,7 +1180,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict): """Reconfigure a tool category - provider selection + API key update.""" icon = cat.get("icon", "") name = cat["name"] - providers = cat["providers"] + providers = _visible_providers(cat, config) if len(providers) == 1: provider = providers[0] @@ -1093,6 +1215,13 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict): def _reconfigure_provider(provider: dict, config: dict): """Reconfigure a provider - update API keys.""" env_vars = provider.get("env_vars", []) + managed_feature = provider.get("managed_nous_feature") + + if provider.get("requires_nous_auth"): + features = get_nous_subscription_features(config) + if not features.nous_auth_present: + _print_warning(" Nous Subscription is only available after logging into Nous Portal.") + return if provider.get("tts_provider"): config.setdefault("tts", {})["provider"] = provider["tts_provider"] @@ -1100,12 +1229,12 @@ def _reconfigure_provider(provider: dict, config: dict): if "browser_provider" in provider: bp = provider["browser_provider"] - if bp: + if bp == "local": + config.setdefault("browser", {})["cloud_provider"] = "local" + _print_success(" Browser set to local mode") + elif bp: config.setdefault("browser", {})["cloud_provider"] = bp _print_success(f" Browser cloud provider set to: {bp}") - else: - config.get("browser", {}).pop("cloud_provider", None) - _print_success(" Browser set to local mode") # Set web search backend in config if applicable if provider.get("web_backend"): @@ -1113,7 +1242,16 @@ def _reconfigure_provider(provider: dict, config: dict): _print_success(f" Web backend set to: {provider['web_backend']}") if not env_vars: + if provider.get("post_setup"): + _run_post_setup(provider["post_setup"]) _print_success(f" {provider['name']} - no configuration needed!") + if managed_feature: + _print_info(" Requests for this tool will be billed to your Nous subscription.") + override_envs = provider.get("override_env_vars", []) + if any(get_env_value(env_var) for env_var in override_envs): + _print_warning( + " Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env." + ) return for var in env_vars: @@ -1222,13 +1360,23 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) print(color(f" - {label}", Colors.RED)) + auto_configured = apply_nous_managed_defaults( + config, + enabled_toolsets=new_enabled, + ) + if managed_nous_tools_enabled(): + for ts_key in sorted(auto_configured): + label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) + # Walk through ALL selected tools that have provider options or # need API keys. This ensures browser (Local vs Browserbase), # TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when # a free provider exists. to_configure = [ ts_key for ts_key in sorted(new_enabled) - if TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key) + if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)) + and ts_key not in auto_configured ] if to_configure: @@ -1321,7 +1469,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): # Configure API keys for newly enabled tools for ts_key in sorted(added): if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): - if not _toolset_has_keys(ts_key): + if _toolset_needs_configuration_prompt(ts_key, config): _configure_toolset(ts_key, config) _save_platform_tools(config, pk, new_enabled) save_config(config) @@ -1361,7 +1509,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): # Configure newly enabled toolsets that need API keys for ts_key in sorted(added): if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): - if not _toolset_has_keys(ts_key): + if _toolset_needs_configuration_prompt(ts_key, config): _configure_toolset(ts_key, config) _save_platform_tools(config, pkey, new_enabled) diff --git a/pyproject.toml b/pyproject.toml index 3cf339845..2e7d5929d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ [project.optional-dependencies] modal = ["modal>=1.0.0,<2"] daytona = ["daytona>=0.148.0,<1"] -dev = ["pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"] +dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"] messaging = ["python-telegram-bot>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] cron = ["croniter>=6.0.0,<7"] slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] diff --git a/requirements.txt b/requirements.txt index 6e65cc822..3709b1a63 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ requests jinja2 pydantic>=2.0 PyJWT[crypto] +debugpy # Web tools firecrawl-py diff --git a/run_agent.py b/run_agent.py index 58c2d3f20..13159b7b7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -79,6 +79,7 @@ from hermes_constants import OPENROUTER_BASE_URL from agent.prompt_builder import ( DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS, MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE, + build_nous_subscription_prompt, ) from agent.model_metadata import ( fetch_model_metadata, @@ -100,7 +101,7 @@ from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, ) -from utils import atomic_json_write +from utils import atomic_json_write, env_var_enabled HONCHO_TOOL_NAMES = { "honcho_context", @@ -2151,7 +2152,7 @@ class AIAgent: self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}") - if os.getenv("HERMES_DUMP_REQUEST_STDOUT", "").strip().lower() in {"1", "true", "yes", "on"}: + if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"): print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)) return dump_file @@ -2594,6 +2595,9 @@ class AIAgent: if tool_guidance: prompt_parts.append(" ".join(tool_guidance)) + nous_subscription_prompt = build_nous_subscription_prompt(self.valid_tool_names) + if nous_subscription_prompt: + prompt_parts.append(nous_subscription_prompt) # Tool-use enforcement: tells the model to actually call tools instead # of describing intended actions. Controlled by config.yaml # agent.tool_use_enforcement: @@ -6834,7 +6838,7 @@ class AIAgent: if self.api_mode == "codex_responses": api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False) - if os.getenv("HERMES_DUMP_REQUESTS", "").strip().lower() in {"1", "true", "yes", "on"}: + if env_var_enabled("HERMES_DUMP_REQUESTS"): self._dump_api_request_debug(api_kwargs, reason="preflight") # Always prefer the streaming path — even without stream diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index eba85d033..791f7ea0e 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -17,6 +17,7 @@ from agent.prompt_builder import ( _find_git_root, _strip_yaml_frontmatter, build_skills_system_prompt, + build_nous_subscription_prompt, build_context_files_prompt, CONTEXT_FILE_MAX_CHARS, DEFAULT_AGENT_IDENTITY, @@ -26,6 +27,7 @@ from agent.prompt_builder import ( SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, ) +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures # ========================================================================= @@ -407,6 +409,62 @@ class TestBuildSkillsSystemPrompt: assert "backend-skill" in result +class TestBuildNousSubscriptionPrompt: + def test_includes_active_subscription_features(self, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_subscription_features", + lambda config=None: NousSubscriptionFeatures( + subscribed=True, + nous_auth_present=True, + provider_is_nous=True, + features={ + "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"), + "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"), + "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"), + }, + ), + ) + + prompt = build_nous_subscription_prompt({"web_search", "browser_navigate"}) + + assert "Browserbase" 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 + + def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_subscription_features", + lambda config=None: NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={ + "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), + "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), + "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""), + "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""), + }, + ), + ) + + prompt = build_nous_subscription_prompt({"image_generate"}) + + assert "suggest Nous subscription as one option" in prompt + assert "Do not mention subscription unless" in prompt + + def test_feature_flag_off_returns_empty_prompt(self, monkeypatch): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + + prompt = build_nous_subscription_prompt({"web_search"}) + + assert prompt == "" + + # ========================================================================= # Context files prompt builder # ========================================================================= @@ -578,8 +636,12 @@ class TestBuildContextFilesPrompt: reason="APFS default volume is case-insensitive; CLAUDE.md and claude.md alias the same path", ) def test_claude_md_uppercase_takes_priority(self, tmp_path): - (tmp_path / "CLAUDE.md").write_text("From uppercase.") - (tmp_path / "claude.md").write_text("From lowercase.") + uppercase = tmp_path / "CLAUDE.md" + lowercase = tmp_path / "claude.md" + uppercase.write_text("From uppercase.") + lowercase.write_text("From lowercase.") + if uppercase.samefile(lowercase): + pytest.skip("filesystem is case-insensitive") result = build_context_files_prompt(cwd=str(tmp_path)) assert "From uppercase" in result assert "From lowercase" not in result diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py new file mode 100644 index 000000000..69428ab08 --- /dev/null +++ b/tests/hermes_cli/test_nous_subscription.py @@ -0,0 +1,96 @@ +"""Tests for Nous subscription feature detection.""" + +from hermes_cli import nous_subscription as ns + + +def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch): + env = {"EXA_API_KEY": "exa-test"} + + monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, "")) + monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {}) + monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: False) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False) + + features = ns.get_nous_subscription_features({"web": {"backend": "exa"}}) + + assert features.web.available is True + assert features.web.active is True + assert features.web.managed_by_nous is False + assert features.web.direct_override is True + assert features.web.current_provider == "exa" + + +def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + 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 == "terminal") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: False) + monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "") + monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: True) + monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: vendor == "modal") + + features = ns.get_nous_subscription_features( + {"terminal": {"backend": "modal", "modal_mode": "auto"}} + ) + + assert features.modal.available is True + assert features.modal.active is True + assert features.modal.managed_by_nous is True + assert features.modal.direct_override is False + + +def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase(monkeypatch): + env = {"CAMOFOX_URL": "http://localhost:9377"} + + 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: False) + 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 == "browserbase", + ) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "browserbase"}} + ) + + 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 == "Camofox" + + +def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(monkeypatch): + 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: {}) + monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False) + monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser") + monkeypatch.setattr(ns, "_has_agent_browser", lambda: False) + 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) + + features = ns.get_nous_subscription_features( + {"browser": {"cloud_provider": "browserbase"}} + ) + + assert features.browser.available is False + assert features.browser.active is False + assert features.browser.managed_by_nous is False + assert features.browser.current_provider == "Browserbase" diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index f4f13696c..47535d919 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -1,6 +1,8 @@ """Tests for setup_model_provider — verifies the delegation to select_provider_and_model() and config dict sync.""" import json +import sys +import types from hermes_cli.auth import get_active_provider from hermes_cli.config import load_config, save_config @@ -220,3 +222,86 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon reloaded = load_config() assert isinstance(reloaded["model"], dict) assert reloaded["model"]["provider"] == "openai-codex" + + +def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + config = load_config() + + def fake_prompt_choice(question, choices, default=0): + if question == "Select terminal backend:": + return 2 + if question == "Select how Modal execution should be billed:": + return 0 + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + def fake_prompt(message, *args, **kwargs): + assert "Modal Token" not in message + raise AssertionError(f"Unexpected prompt call: {message}") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt) + monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None) + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: type("Features", (), {"nous_auth_present": True})(), + ) + monkeypatch.setitem( + sys.modules, + "tools.managed_tool_gateway", + types.SimpleNamespace( + is_managed_tool_gateway_ready=lambda vendor: vendor == "modal", + resolve_managed_tool_gateway=lambda vendor: None, + ), + ) + + from hermes_cli.setup import setup_terminal_backend + + setup_terminal_backend(config) + + out = capsys.readouterr().out + assert config["terminal"]["backend"] == "modal" + assert config["terminal"]["modal_mode"] == "managed" + assert "bill to your subscription" in out + + +def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + config = load_config() + + def fake_prompt_choice(question, choices, default=0): + if question == "Select terminal backend:": + return 2 + if question == "Select how Modal execution should be billed:": + return 1 + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + prompt_values = iter(["token-id", "token-secret", ""]) + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values)) + monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None) + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: type("Features", (), {"nous_auth_present": True})(), + ) + monkeypatch.setitem( + sys.modules, + "tools.managed_tool_gateway", + types.SimpleNamespace( + is_managed_tool_gateway_ready=lambda vendor: vendor == "modal", + resolve_managed_tool_gateway=lambda vendor: None, + ), + ) + monkeypatch.setitem(sys.modules, "swe_rex", object()) + + from hermes_cli.setup import setup_terminal_backend + + setup_terminal_backend(config) + + assert config["terminal"]["backend"] == "modal" + assert config["terminal"]["modal_mode"] == "direct" diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index eb59360a0..6131595f4 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -8,7 +8,8 @@ that the setup wizard correctly syncs config from disk after the call. from __future__ import annotations from hermes_cli.config import load_config, save_config, save_env_value -from hermes_cli.setup import setup_model_provider +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures +from hermes_cli.setup import _print_setup_summary, setup_model_provider def _maybe_keep_current_tts(question, choices): @@ -405,3 +406,72 @@ def test_setup_switch_preserves_non_model_config(tmp_path, monkeypatch): reloaded = load_config() assert reloaded["terminal"]["timeout"] == 999 assert reloaded["model"]["provider"] == "openrouter" + + +def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key") + monkeypatch.setattr("shutil.which", lambda _name: None) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"]) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Vision (image analysis)" in output + assert "missing run 'hermes setup' to configure" not in output + + +def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={ + "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), + "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), + "browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"), + "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), + }, + ), + ) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Browser Automation (Camofox)" in output + + +def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key") + monkeypatch.setattr( + "hermes_cli.setup.get_nous_subscription_features", + lambda config: NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={ + "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), + "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), + "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"), + "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), + }, + ), + ) + monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: []) + + _print_setup_summary(load_config(), tmp_path) + output = capsys.readouterr().out + + assert "Browser Automation (Browserbase)" not in output + assert "Browser Automation" in output + assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output diff --git a/tests/hermes_cli/test_setup_noninteractive.py b/tests/hermes_cli/test_setup_noninteractive.py index 4e76c013d..ba1514723 100644 --- a/tests/hermes_cli/test_setup_noninteractive.py +++ b/tests/hermes_cli/test_setup_noninteractive.py @@ -1,7 +1,7 @@ """Tests for non-interactive setup and first-run headless behavior.""" from argparse import Namespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -92,3 +92,48 @@ class TestNonInteractiveSetup: mock_setup.assert_not_called() out = capsys.readouterr().out assert "hermes config set model.provider custom" in out + + def test_returning_user_terminal_menu_choice_dispatches_terminal_section(self, tmp_path): + """Returning-user menu should map Terminal Backend to the terminal setup, not TTS.""" + from hermes_cli import setup as setup_mod + + args = _make_setup_args() + config = {} + model_section = MagicMock() + tts_section = MagicMock() + terminal_section = MagicMock() + gateway_section = MagicMock() + tools_section = MagicMock() + agent_section = MagicMock() + + with ( + patch.object(setup_mod, "ensure_hermes_home"), + patch.object(setup_mod, "load_config", return_value=config), + patch.object(setup_mod, "get_hermes_home", return_value=tmp_path), + patch.object(setup_mod, "is_interactive_stdin", return_value=True), + patch.object( + setup_mod, + "get_env_value", + side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "", + ), + patch("hermes_cli.auth.get_active_provider", return_value=None), + patch.object(setup_mod, "prompt_choice", return_value=4), + patch.object( + setup_mod, + "SETUP_SECTIONS", + [ + ("model", "Model & Provider", model_section), + ("tts", "Text-to-Speech", tts_section), + ("terminal", "Terminal Backend", terminal_section), + ("gateway", "Messaging Platforms (Gateway)", gateway_section), + ("tools", "Tools", tools_section), + ("agent", "Agent Settings", agent_section), + ], + ), + patch.object(setup_mod, "save_config"), + patch.object(setup_mod, "_print_setup_summary"), + ): + setup_mod.run_setup_wizard(args) + + terminal_section.assert_called_once_with(config) + tts_section.assert_not_called() diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index 3a9ce17a0..1e6531d37 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -2,6 +2,8 @@ from types import SimpleNamespace +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures + def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""): import hermes_cli.auth as auth_mod @@ -59,3 +61,64 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc out = capsys.readouterr().out assert "Model: qwen3:latest" in out assert "Provider: Custom endpoint" in out + + +def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + from hermes_cli import status as status_mod + + _patch_common_status_deps(monkeypatch, status_mod, tmp_path) + monkeypatch.setattr( + status_mod, + "load_config", + lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}}, + raising=False, + ) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False) + monkeypatch.setattr( + status_mod, + "get_nous_subscription_features", + lambda config: NousSubscriptionFeatures( + subscribed=True, + nous_auth_present=True, + provider_is_nous=True, + features={ + "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"), + "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"), + "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"), + }, + ), + raising=False, + ) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + out = capsys.readouterr().out + assert "Nous Subscription Features" in out + assert "Browser automation" in out + assert "active via Nous subscription" in out + + +def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + from hermes_cli import status as status_mod + + _patch_common_status_deps(monkeypatch, status_mod, tmp_path) + monkeypatch.setattr( + status_mod, + "load_config", + lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}}, + raising=False, + ) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + out = capsys.readouterr().out + assert "Nous Subscription Features" not in out diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 4a25e35ee..946ba77fd 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -3,10 +3,14 @@ from unittest.mock import patch from hermes_cli.tools_config import ( + _configure_provider, _get_platform_tools, _platform_toolset_summary, _save_platform_tools, _toolset_has_keys, + TOOL_CATEGORIES, + _visible_providers, + tools_command, ) @@ -78,6 +82,10 @@ def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False) monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False) + monkeypatch.setattr( + "agent.auxiliary_client.resolve_vision_provider_client", + lambda: ("openai-codex", object(), "gpt-4.1"), + ) assert _toolset_has_keys("vision") is True @@ -239,6 +247,92 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present() assert "terminal" not in saved +def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + config = {"model": {"provider": "nous"}} + + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + + providers = _visible_providers(TOOL_CATEGORIES["browser"], config) + + assert providers[0]["name"].startswith("Nous Subscription") + + +def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + config = {"model": {"provider": "nous"}} + + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + + providers = _visible_providers(TOOL_CATEGORIES["browser"], config) + + assert all(not provider["name"].startswith("Nous Subscription") for provider in providers) + + +def test_local_browser_provider_is_saved_explicitly(monkeypatch): + config = {} + local_provider = next( + provider + for provider in TOOL_CATEGORIES["browser"]["providers"] + if provider.get("browser_provider") == "local" + ) + monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None) + + _configure_provider(local_provider, config) + + assert config["browser"]["cloud_provider"] == "local" + + +def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + config = { + "model": {"provider": "nous"}, + "platform_toolsets": {"cli": []}, + } + for env_var in ( + "VOICE_TOOLS_OPENAI_KEY", + "OPENAI_API_KEY", + "ELEVENLABS_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "TAVILY_API_KEY", + "PARALLEL_API_KEY", + "BROWSERBASE_API_KEY", + "BROWSERBASE_PROJECT_ID", + "BROWSER_USE_API_KEY", + "FAL_KEY", + ): + monkeypatch.delenv(env_var, raising=False) + + monkeypatch.setattr( + "hermes_cli.tools_config._prompt_toolset_checklist", + lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"}, + ) + monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None) + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + + configured = [] + monkeypatch.setattr( + "hermes_cli.tools_config._configure_toolset", + lambda ts_key, config: configured.append(ts_key), + ) + + tools_command(first_install=True, config=config) + + assert config["web"]["backend"] == "firecrawl" + assert config["tts"]["provider"] == "openai" + assert config["browser"]["cloud_provider"] == "browserbase" + assert configured == [] + # ── Platform / toolset consistency ──────────────────────────────────────────── diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 3c9b31f5f..4d876cf6e 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -78,6 +78,13 @@ def _install_prompt_toolkit_stubs(): def _import_cli(): + for name in list(sys.modules): + if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."): + sys.modules.pop(name, None) + + if "firecrawl" not in sys.modules: + sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object) + try: importlib.import_module("prompt_toolkit") except ModuleNotFoundError: @@ -269,6 +276,83 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch): assert shell.model == "gpt-5.2-codex" +def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + config = { + "model": {"provider": "nous", "default": "claude-opus-4-6"}, + "tts": {"provider": "elevenlabs"}, + "browser": {"cloud_provider": "browser-use"}, + } + + monkeypatch.setattr( + "hermes_cli.auth.get_provider_auth_state", + lambda provider: {"access_token": "nous-token"}, + ) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://inference.example.com/v1", + "api_key": "nous-key", + }, + ) + monkeypatch.setattr( + "hermes_cli.auth.fetch_nous_models", + lambda *args, **kwargs: ["claude-opus-4-6"], + ) + monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6") + monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_subscription_explainer_lines", + lambda: ["Nous subscription enables managed web tools."], + ) + + hermes_main._model_flow_nous(config, current_model="claude-opus-4-6") + + out = capsys.readouterr().out + assert "Nous subscription enables managed web tools." in out + assert config["tts"]["provider"] == "elevenlabs" + assert config["browser"]["cloud_provider"] == "browser-use" + + +def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + config = { + "model": {"provider": "nous", "default": "claude-opus-4-6"}, + "tts": {"provider": "edge"}, + } + + monkeypatch.setattr( + "hermes_cli.auth.get_provider_auth_state", + lambda provider: {"access_token": "nous-token"}, + ) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://inference.example.com/v1", + "api_key": "nous-key", + }, + ) + monkeypatch.setattr( + "hermes_cli.auth.fetch_nous_models", + lambda *args, **kwargs: ["claude-opus-4-6"], + ) + monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6") + monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None) + monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None) + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_subscription_explainer_lines", + lambda: ["Nous subscription enables managed web tools."], + ) + + hermes_main._model_flow_nous(config, current_model="claude-opus-4-6") + + out = capsys.readouterr().out + assert "Nous subscription enables managed web tools." in out + assert "OpenAI TTS via your Nous subscription" in out + assert config["tts"]["provider"] == "openai" + + def test_codex_provider_uses_config_model(monkeypatch): """Model comes from config.yaml, not LLM_MODEL env var. Config.yaml is the single source of truth to avoid multi-agent conflicts.""" @@ -472,4 +556,56 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): assert "Detected model: llm" in output # OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative assert "OPENAI_BASE_URL" not in saved_env - assert saved_env["MODEL"] == "llm" \ No newline at end of file + assert saved_env["MODEL"] == "llm" + + +def test_cmd_model_forwards_nous_login_tls_options(monkeypatch): + monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None) + monkeypatch.setattr( + "hermes_cli.config.load_config", + lambda: {"model": {"default": "gpt-5", "provider": "nous"}}, + ) + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None) + monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "") + monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None) + monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous") + monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None) + monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: 0) + + captured = {} + + def _fake_login(login_args, provider_config): + captured["portal_url"] = login_args.portal_url + captured["inference_url"] = login_args.inference_url + captured["client_id"] = login_args.client_id + captured["scope"] = login_args.scope + captured["no_browser"] = login_args.no_browser + captured["timeout"] = login_args.timeout + captured["ca_bundle"] = login_args.ca_bundle + captured["insecure"] = login_args.insecure + + monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login) + + hermes_main.cmd_model( + SimpleNamespace( + portal_url="https://portal.nousresearch.com", + inference_url="https://inference.nousresearch.com/v1", + client_id="hermes-local", + scope="openid profile", + no_browser=True, + timeout=7.5, + ca_bundle="/tmp/local-ca.pem", + insecure=True, + ) + ) + + assert captured == { + "portal_url": "https://portal.nousresearch.com", + "inference_url": "https://inference.nousresearch.com/v1", + "client_id": "hermes-local", + "scope": "openid profile", + "no_browser": True, + "timeout": 7.5, + "ca_bundle": "/tmp/local-ca.pem", + "insecure": True, + } diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 82490a52b..617ae0928 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -605,6 +605,11 @@ class TestBuildSystemPrompt: # Should contain current date info like "Conversation started:" assert "Conversation started:" in prompt + def test_includes_nous_subscription_prompt(self, agent, monkeypatch): + monkeypatch.setattr(run_agent, "build_nous_subscription_prompt", lambda tool_names: "NOUS SUBSCRIPTION BLOCK") + prompt = agent._build_system_prompt() + assert "NOUS SUBSCRIPTION BLOCK" in prompt + def test_skills_prompt_derives_available_toolsets_from_loaded_tools(self): tools = _make_tool_defs("web_search", "skills_list", "skill_view", "skill_manage") toolset_map = { diff --git a/tests/test_utils_truthy_values.py b/tests/test_utils_truthy_values.py new file mode 100644 index 000000000..f6d2856f4 --- /dev/null +++ b/tests/test_utils_truthy_values.py @@ -0,0 +1,29 @@ +"""Tests for shared truthy-value helpers.""" + +from utils import env_var_enabled, is_truthy_value + + +def test_is_truthy_value_accepts_common_truthy_strings(): + assert is_truthy_value("true") is True + assert is_truthy_value(" YES ") is True + assert is_truthy_value("on") is True + assert is_truthy_value("1") is True + + +def test_is_truthy_value_respects_default_for_none(): + assert is_truthy_value(None, default=True) is True + assert is_truthy_value(None, default=False) is False + + +def test_is_truthy_value_rejects_falsey_strings(): + assert is_truthy_value("false") is False + assert is_truthy_value("0") is False + assert is_truthy_value("off") is False + + +def test_env_var_enabled_uses_shared_truthy_rules(monkeypatch): + monkeypatch.setenv("HERMES_TEST_BOOL", "YeS") + assert env_var_enabled("HERMES_TEST_BOOL") is True + + monkeypatch.setenv("HERMES_TEST_BOOL", "no") + assert env_var_enabled("HERMES_TEST_BOOL") is False diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py new file mode 100644 index 000000000..3c8bb1214 --- /dev/null +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -0,0 +1,459 @@ +import os +import sys +import tempfile +import threading +import types +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from unittest.mock import patch + +import pytest + + +TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools" + + +def _load_tool_module(module_name: str, filename: str): + spec = spec_from_file_location(module_name, TOOLS_DIR / filename) + assert spec and spec.loader + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _reset_modules(prefixes: tuple[str, ...]): + for name in list(sys.modules): + if name.startswith(prefixes): + sys.modules.pop(name, None) + + +@pytest.fixture(autouse=True) +def _restore_tool_and_agent_modules(): + original_modules = { + name: module + for name, module in sys.modules.items() + if name == "tools" + or name.startswith("tools.") + or name == "agent" + or name.startswith("agent.") + } + try: + yield + finally: + _reset_modules(("tools", "agent")) + sys.modules.update(original_modules) + + +@pytest.fixture(autouse=True) +def _enable_managed_nous_tools(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + + +def _install_fake_tools_package(): + _reset_modules(("tools", "agent")) + + tools_package = types.ModuleType("tools") + tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] + sys.modules["tools"] = tools_package + + env_package = types.ModuleType("tools.environments") + env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined] + sys.modules["tools.environments"] = env_package + + agent_package = types.ModuleType("agent") + agent_package.__path__ = [] # type: ignore[attr-defined] + sys.modules["agent"] = agent_package + sys.modules["agent.auxiliary_client"] = types.SimpleNamespace( + call_llm=lambda *args, **kwargs: "", + ) + + sys.modules["tools.managed_tool_gateway"] = _load_tool_module( + "tools.managed_tool_gateway", + "managed_tool_gateway.py", + ) + + interrupt_event = threading.Event() + sys.modules["tools.interrupt"] = types.SimpleNamespace( + set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(), + is_interrupted=lambda: interrupt_event.is_set(), + _interrupt_event=interrupt_event, + ) + sys.modules["tools.approval"] = types.SimpleNamespace( + detect_dangerous_command=lambda *args, **kwargs: None, + check_dangerous_command=lambda *args, **kwargs: {"approved": True}, + check_all_command_guards=lambda *args, **kwargs: {"approved": True}, + load_permanent_allowlist=lambda *args, **kwargs: [], + DANGEROUS_PATTERNS=[], + ) + + class _Registry: + def register(self, **kwargs): + return None + + sys.modules["tools.registry"] = types.SimpleNamespace(registry=_Registry()) + + class _DummyEnvironment: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + def cleanup(self): + return None + + sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyEnvironment) + sys.modules["tools.environments.local"] = types.SimpleNamespace(LocalEnvironment=_DummyEnvironment) + sys.modules["tools.environments.singularity"] = types.SimpleNamespace( + _get_scratch_dir=lambda: Path(tempfile.gettempdir()), + SingularityEnvironment=_DummyEnvironment, + ) + sys.modules["tools.environments.ssh"] = types.SimpleNamespace(SSHEnvironment=_DummyEnvironment) + sys.modules["tools.environments.docker"] = types.SimpleNamespace(DockerEnvironment=_DummyEnvironment) + sys.modules["tools.environments.modal"] = types.SimpleNamespace(ModalEnvironment=_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): + _install_fake_tools_package() + (tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8") + env = os.environ.copy() + env.pop("BROWSERBASE_API_KEY", None) + env.pop("BROWSERBASE_PROJECT_ID", None) + env.update({ + "HERMES_HOME": str(tmp_path), + "TOOL_GATEWAY_USER_TOKEN": "nous-token", + "BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009", + }) + + with patch.dict(os.environ, env, clear=True): + browser_tool = _load_tool_module("tools.browser_tool", "browser_tool.py") + + local_mode = browser_tool._is_local_mode() + provider = browser_tool._get_cloud_provider() + + assert local_mode is True + assert provider is None + + +def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_call_id(): + _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-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): + browserbase_module = _load_tool_module( + "tools.browser_providers.browserbase", + "browser_providers/browserbase.py", + ) + provider = browserbase_module.BrowserbaseProvider() + timeout = browserbase_module.requests.Timeout("timed out") + + with patch.object( + browserbase_module.requests, + "post", + side_effect=[timeout, _Response()], + ) as post: + try: + provider.create_session("task-browserbase-timeout") + except browserbase_module.requests.Timeout: + pass + else: + raise AssertionError("Expected Browserbase create_session to propagate timeout") + + provider.create_session("task-browserbase-timeout") + + first_headers = post.call_args_list[0].kwargs["headers"] + second_headers = post.call_args_list[1].kwargs["headers"] + assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"] + + +def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts(): + _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 _ConflictResponse: + status_code = 409 + ok = False + text = '{"error":{"code":"CONFLICT","message":"Managed Browserbase session creation is already in progress for this idempotency key"}}' + headers = {} + + def json(self): + return { + "error": { + "code": "CONFLICT", + "message": "Managed Browserbase session creation is already in progress for this idempotency key", + } + } + + class _SuccessResponse: + status_code = 200 + ok = True + text = "" + headers = {"x-external-call-id": "call-browserbase-4"} + + def json(self): + return { + "id": "bb_local_session_4", + "connectUrl": "wss://connect.browserbase.example/session4", + } + + with patch.dict(os.environ, env, clear=True): + browserbase_module = _load_tool_module( + "tools.browser_providers.browserbase", + "browser_providers/browserbase.py", + ) + provider = browserbase_module.BrowserbaseProvider() + + with patch.object( + browserbase_module.requests, + "post", + side_effect=[_ConflictResponse(), _SuccessResponse()], + ) as post: + try: + provider.create_session("task-browserbase-conflict") + except RuntimeError: + pass + else: + raise AssertionError("Expected Browserbase create_session to propagate the in-progress conflict") + + provider.create_session("task-browserbase-conflict") + + first_headers = post.call_args_list[0].kwargs["headers"] + second_headers = post.call_args_list[1].kwargs["headers"] + 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(): + _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-3"} + + def json(self): + return { + "id": "bb_local_session_3", + "connectUrl": "wss://connect.browserbase.example/session3", + } + + with patch.dict(os.environ, env, clear=True): + browserbase_module = _load_tool_module( + "tools.browser_providers.browserbase", + "browser_providers/browserbase.py", + ) + provider = browserbase_module.BrowserbaseProvider() + + with patch.object(browserbase_module.requests, "post", side_effect=[_Response(), _Response()]) as post: + provider.create_session("task-browserbase-new") + provider.create_session("task-browserbase-new") + + first_headers = post.call_args_list[0].kwargs["headers"] + second_headers = post.call_args_list[1].kwargs["headers"] + assert first_headers["X-Idempotency-Key"] != second_headers["X-Idempotency-Key"] + + +def test_terminal_tool_prefers_managed_modal_when_gateway_ready_and_no_direct_creds(): + _install_fake_tools_package() + env = os.environ.copy() + env.pop("MODAL_TOKEN_ID", None) + env.pop("MODAL_TOKEN_SECRET", None) + + with patch.dict(os.environ, env, clear=True): + terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py") + + with ( + patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True), + patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor, + patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor, + patch.object(Path, "exists", return_value=False), + ): + result = terminal_tool._create_environment( + env_type="modal", + image="python:3.11", + cwd="/root", + timeout=60, + container_config={ + "container_cpu": 1, + "container_memory": 2048, + "container_disk": 1024, + "container_persistent": True, + "modal_mode": "auto", + }, + task_id="task-modal-managed", + ) + + assert result == "managed-modal-env" + assert managed_ctor.called + assert not direct_ctor.called + + +def test_terminal_tool_auto_mode_prefers_managed_modal_when_available(): + _install_fake_tools_package() + env = os.environ.copy() + env.update({ + "MODAL_TOKEN_ID": "tok-id", + "MODAL_TOKEN_SECRET": "tok-secret", + }) + + with patch.dict(os.environ, env, clear=True): + terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py") + + with ( + patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True), + patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor, + patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor, + ): + result = terminal_tool._create_environment( + env_type="modal", + image="python:3.11", + cwd="/root", + timeout=60, + container_config={ + "container_cpu": 1, + "container_memory": 2048, + "container_disk": 1024, + "container_persistent": True, + "modal_mode": "auto", + }, + task_id="task-modal-auto", + ) + + assert result == "managed-modal-env" + assert managed_ctor.called + assert not direct_ctor.called + + +def test_terminal_tool_auto_mode_falls_back_to_direct_modal_when_managed_unavailable(): + _install_fake_tools_package() + env = os.environ.copy() + env.update({ + "MODAL_TOKEN_ID": "tok-id", + "MODAL_TOKEN_SECRET": "tok-secret", + }) + + with patch.dict(os.environ, env, clear=True): + terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py") + + with ( + patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=False), + patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor, + patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor, + ): + result = terminal_tool._create_environment( + env_type="modal", + image="python:3.11", + cwd="/root", + timeout=60, + container_config={ + "container_cpu": 1, + "container_memory": 2048, + "container_disk": 1024, + "container_persistent": True, + "modal_mode": "auto", + }, + task_id="task-modal-direct-fallback", + ) + + assert result == "direct-modal-env" + assert direct_ctor.called + assert not managed_ctor.called + + +def test_terminal_tool_respects_direct_modal_mode_without_falling_back_to_managed(): + _install_fake_tools_package() + env = os.environ.copy() + env.pop("MODAL_TOKEN_ID", None) + env.pop("MODAL_TOKEN_SECRET", None) + + with patch.dict(os.environ, env, clear=True): + terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py") + + with ( + patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True), + patch.object(Path, "exists", return_value=False), + ): + with pytest.raises(ValueError, match="direct Modal credentials"): + terminal_tool._create_environment( + env_type="modal", + image="python:3.11", + cwd="/root", + timeout=60, + container_config={ + "container_cpu": 1, + "container_memory": 2048, + "container_disk": 1024, + "container_persistent": True, + "modal_mode": "direct", + }, + task_id="task-modal-direct-only", + ) diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py new file mode 100644 index 000000000..9a2d8391c --- /dev/null +++ b/tests/tools/test_managed_media_gateways.py @@ -0,0 +1,293 @@ +import sys +import types +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + + +TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools" + + +def _load_tool_module(module_name: str, filename: str): + spec = spec_from_file_location(module_name, TOOLS_DIR / filename) + assert spec and spec.loader + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +@pytest.fixture(autouse=True) +def _restore_tool_and_agent_modules(): + original_modules = { + name: module + for name, module in sys.modules.items() + if name == "tools" + or name.startswith("tools.") + or name == "agent" + or name.startswith("agent.") + or name in {"fal_client", "openai"} + } + try: + yield + finally: + for name in list(sys.modules): + if ( + name == "tools" + or name.startswith("tools.") + or name == "agent" + or name.startswith("agent.") + or name in {"fal_client", "openai"} + ): + sys.modules.pop(name, None) + sys.modules.update(original_modules) + + +@pytest.fixture(autouse=True) +def _enable_managed_nous_tools(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + + +def _install_fake_tools_package(): + tools_package = types.ModuleType("tools") + tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] + sys.modules["tools"] = tools_package + sys.modules["tools.debug_helpers"] = types.SimpleNamespace( + DebugSession=lambda *args, **kwargs: types.SimpleNamespace( + active=False, + session_id="debug-session", + log_call=lambda *a, **k: None, + save=lambda: None, + get_session_info=lambda: {}, + ) + ) + sys.modules["tools.managed_tool_gateway"] = _load_tool_module( + "tools.managed_tool_gateway", + "managed_tool_gateway.py", + ) + + +def _install_fake_fal_client(captured): + def submit(model, arguments=None, headers=None): + raise AssertionError("managed FAL gateway mode should use fal_client.SyncClient") + + class FakeResponse: + def json(self): + return { + "request_id": "req-123", + "response_url": "http://127.0.0.1:3009/requests/req-123", + "status_url": "http://127.0.0.1:3009/requests/req-123/status", + "cancel_url": "http://127.0.0.1:3009/requests/req-123/cancel", + } + + def _maybe_retry_request(client, method, url, json=None, timeout=None, headers=None): + captured["submit_via"] = "managed_client" + captured["http_client"] = client + captured["method"] = method + captured["submit_url"] = url + captured["arguments"] = json + captured["timeout"] = timeout + captured["headers"] = headers + return FakeResponse() + + class SyncRequestHandle: + def __init__(self, request_id, response_url, status_url, cancel_url, client): + captured["request_id"] = request_id + captured["response_url"] = response_url + captured["status_url"] = status_url + captured["cancel_url"] = cancel_url + captured["handle_client"] = client + + class SyncClient: + def __init__(self, key=None, default_timeout=120.0): + captured["sync_client_inits"] = captured.get("sync_client_inits", 0) + 1 + captured["client_key"] = key + captured["client_timeout"] = default_timeout + self.default_timeout = default_timeout + self._client = object() + + fal_client_module = types.SimpleNamespace( + submit=submit, + SyncClient=SyncClient, + client=types.SimpleNamespace( + _maybe_retry_request=_maybe_retry_request, + _raise_for_status=lambda response: None, + SyncRequestHandle=SyncRequestHandle, + ), + ) + sys.modules["fal_client"] = fal_client_module + return fal_client_module + + +def _install_fake_openai_module(captured, transcription_response=None): + class FakeSpeechResponse: + def stream_to_file(self, output_path): + captured["stream_to_file"] = output_path + + class FakeOpenAI: + def __init__(self, api_key, base_url, **kwargs): + captured["api_key"] = api_key + captured["base_url"] = base_url + captured["client_kwargs"] = kwargs + captured["close_calls"] = captured.get("close_calls", 0) + + def create_speech(**kwargs): + captured["speech_kwargs"] = kwargs + return FakeSpeechResponse() + + def create_transcription(**kwargs): + captured["transcription_kwargs"] = kwargs + return transcription_response + + self.audio = types.SimpleNamespace( + speech=types.SimpleNamespace( + create=create_speech + ), + transcriptions=types.SimpleNamespace( + create=create_transcription + ), + ) + + def close(self): + captured["close_calls"] += 1 + + fake_module = types.SimpleNamespace( + OpenAI=FakeOpenAI, + APIError=Exception, + APIConnectionError=Exception, + APITimeoutError=Exception, + ) + sys.modules["openai"] = fake_module + + +def test_managed_fal_submit_uses_gateway_origin_and_nous_token(monkeypatch): + captured = {} + _install_fake_tools_package() + _install_fake_fal_client(captured) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token") + + image_generation_tool = _load_tool_module( + "tools.image_generation_tool", + "image_generation_tool.py", + ) + monkeypatch.setattr(image_generation_tool.uuid, "uuid4", lambda: "fal-submit-123") + + image_generation_tool._submit_fal_request( + "fal-ai/flux-2-pro", + {"prompt": "test prompt", "num_images": 1}, + ) + + assert captured["submit_via"] == "managed_client" + assert captured["client_key"] == "nous-token" + assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/flux-2-pro" + assert captured["method"] == "POST" + assert captured["arguments"] == {"prompt": "test prompt", "num_images": 1} + assert captured["headers"] == {"x-idempotency-key": "fal-submit-123"} + assert captured["sync_client_inits"] == 1 + + +def test_managed_fal_submit_reuses_cached_sync_client(monkeypatch): + captured = {} + _install_fake_tools_package() + _install_fake_fal_client(captured) + monkeypatch.delenv("FAL_KEY", raising=False) + monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token") + + image_generation_tool = _load_tool_module( + "tools.image_generation_tool", + "image_generation_tool.py", + ) + + image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "first"}) + first_client = captured["http_client"] + image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "second"}) + + assert captured["sync_client_inits"] == 1 + assert captured["http_client"] is first_client + + +def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatch, tmp_path): + captured = {} + _install_fake_tools_package() + _install_fake_openai_module(captured) + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token") + + tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py") + monkeypatch.setattr(tts_tool.uuid, "uuid4", lambda: "tts-call-123") + output_path = tmp_path / "speech.mp3" + tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}}) + + assert captured["api_key"] == "nous-token" + assert captured["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1" + assert captured["speech_kwargs"]["model"] == "gpt-4o-mini-tts" + assert captured["speech_kwargs"]["extra_headers"] == {"x-idempotency-key": "tts-call-123"} + assert captured["stream_to_file"] == str(output_path) + assert captured["close_calls"] == 1 + + +def test_openai_tts_accepts_openai_api_key_as_direct_fallback(monkeypatch, tmp_path): + captured = {} + _install_fake_tools_package() + _install_fake_openai_module(captured) + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "openai-direct-key") + monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token") + + tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py") + output_path = tmp_path / "speech.mp3" + tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}}) + + assert captured["api_key"] == "openai-direct-key" + assert captured["base_url"] == "https://api.openai.com/v1" + assert captured["close_calls"] == 1 + + +def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_path): + whisper_capture = {} + _install_fake_tools_package() + _install_fake_openai_module(whisper_capture, transcription_response="hello from whisper") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + (tmp_path / "config.yaml").write_text("stt:\n provider: openai\n") + monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False) + monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com") + monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token") + + transcription_tools = _load_tool_module( + "tools.transcription_tools", + "transcription_tools.py", + ) + transcription_tools._load_stt_config = lambda: {"provider": "openai"} + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(b"RIFF0000WAVEfmt ") + + whisper_result = transcription_tools.transcribe_audio(str(audio_path), model="whisper-1") + assert whisper_result["success"] is True + assert whisper_capture["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1" + assert whisper_capture["transcription_kwargs"]["response_format"] == "text" + assert whisper_capture["close_calls"] == 1 + + json_capture = {} + _install_fake_openai_module( + json_capture, + transcription_response=types.SimpleNamespace(text="hello from gpt-4o"), + ) + transcription_tools = _load_tool_module( + "tools.transcription_tools", + "transcription_tools.py", + ) + + json_result = transcription_tools.transcribe_audio( + str(audio_path), + model="gpt-4o-mini-transcribe", + ) + assert json_result["success"] is True + assert json_result["transcript"] == "hello from gpt-4o" + assert json_capture["transcription_kwargs"]["response_format"] == "json" + assert json_capture["close_calls"] == 1 diff --git a/tests/tools/test_managed_modal_environment.py b/tests/tools/test_managed_modal_environment.py new file mode 100644 index 000000000..10c1ab56f --- /dev/null +++ b/tests/tools/test_managed_modal_environment.py @@ -0,0 +1,309 @@ +import json +import sys +import tempfile +import threading +import types +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + + +TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools" + + +def _load_tool_module(module_name: str, filename: str): + spec = spec_from_file_location(module_name, TOOLS_DIR / filename) + assert spec and spec.loader + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _reset_modules(prefixes: tuple[str, ...]): + for name in list(sys.modules): + if name.startswith(prefixes): + sys.modules.pop(name, None) + + +def _install_fake_tools_package(*, credential_mounts=None): + _reset_modules(("tools", "agent", "hermes_cli")) + + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] # type: ignore[attr-defined] + sys.modules["hermes_cli"] = hermes_cli + sys.modules["hermes_cli.config"] = types.SimpleNamespace( + get_hermes_home=lambda: Path(tempfile.gettempdir()) / "hermes-home", + ) + + tools_package = types.ModuleType("tools") + tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] + sys.modules["tools"] = tools_package + + env_package = types.ModuleType("tools.environments") + env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined] + sys.modules["tools.environments"] = env_package + + interrupt_event = threading.Event() + sys.modules["tools.interrupt"] = types.SimpleNamespace( + set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(), + is_interrupted=lambda: interrupt_event.is_set(), + _interrupt_event=interrupt_event, + ) + + class _DummyBaseEnvironment: + def __init__(self, cwd: str, timeout: int, env=None): + self.cwd = cwd + self.timeout = timeout + self.env = env or {} + + def _prepare_command(self, command: str): + return command, None + + sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment) + sys.modules["tools.managed_tool_gateway"] = types.SimpleNamespace( + resolve_managed_tool_gateway=lambda vendor: types.SimpleNamespace( + vendor=vendor, + gateway_origin="https://modal-gateway.example.com", + nous_user_token="user-token", + managed_mode=True, + ) + ) + sys.modules["tools.credential_files"] = types.SimpleNamespace( + get_credential_file_mounts=lambda: list(credential_mounts or []), + ) + + return interrupt_event + + +class _FakeResponse: + def __init__(self, status_code: int, payload=None, text: str = ""): + self.status_code = status_code + self._payload = payload + self.text = text + + def json(self): + if isinstance(self._payload, Exception): + raise self._payload + return self._payload + + +def test_managed_modal_execute_polls_until_completed(monkeypatch): + _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + modal_common = sys.modules["tools.environments.modal_common"] + + calls = [] + poll_count = {"value": 0} + + def fake_request(method, url, headers=None, json=None, timeout=None): + calls.append((method, url, json, timeout)) + if method == "POST" and url.endswith("/v1/sandboxes"): + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/execs"): + return _FakeResponse(202, {"execId": json["execId"], "status": "running"}) + if method == "GET" and "/execs/" in url: + poll_count["value"] += 1 + if poll_count["value"] == 1: + return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"}) + return _FakeResponse(200, { + "execId": url.rsplit("/", 1)[-1], + "status": "completed", + "output": "hello", + "returncode": 0, + }) + if method == "POST" and url.endswith("/terminate"): + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + monkeypatch.setattr(modal_common.time, "sleep", lambda _: None) + + env = managed_modal.ManagedModalEnvironment(image="python:3.11") + result = env.execute("echo hello") + env.cleanup() + + assert result == {"output": "hello", "returncode": 0} + assert any(call[0] == "POST" and call[1].endswith("/execs") for call in calls) + + +def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch): + _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + + create_headers = [] + + def fake_request(method, url, headers=None, json=None, timeout=None): + if method == "POST" and url.endswith("/v1/sandboxes"): + create_headers.append(headers or {}) + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/terminate"): + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + + env = managed_modal.ManagedModalEnvironment(image="python:3.11") + env.cleanup() + + assert len(create_headers) == 1 + assert isinstance(create_headers[0].get("x-idempotency-key"), str) + assert create_headers[0]["x-idempotency-key"] + + +def test_managed_modal_execute_cancels_on_interrupt(monkeypatch): + interrupt_event = _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + modal_common = sys.modules["tools.environments.modal_common"] + + calls = [] + + def fake_request(method, url, headers=None, json=None, timeout=None): + calls.append((method, url, json, timeout)) + if method == "POST" and url.endswith("/v1/sandboxes"): + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/execs"): + return _FakeResponse(202, {"execId": json["execId"], "status": "running"}) + if method == "GET" and "/execs/" in url: + return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"}) + if method == "POST" and url.endswith("/cancel"): + return _FakeResponse(202, {"status": "cancelling"}) + if method == "POST" and url.endswith("/terminate"): + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + def fake_sleep(_seconds): + interrupt_event.set() + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + monkeypatch.setattr(modal_common.time, "sleep", fake_sleep) + + env = managed_modal.ManagedModalEnvironment(image="python:3.11") + result = env.execute("sleep 30") + env.cleanup() + + assert result == { + "output": "[Command interrupted - Modal sandbox exec cancelled]", + "returncode": 130, + } + assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls) + poll_calls = [call for call in calls if call[0] == "GET" and "/execs/" in call[1]] + cancel_calls = [call for call in calls if call[0] == "POST" and call[1].endswith("/cancel")] + assert poll_calls[0][3] == (1.0, 5.0) + assert cancel_calls[0][3] == (1.0, 5.0) + + +def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeypatch): + _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + modal_common = sys.modules["tools.environments.modal_common"] + + def fake_request(method, url, headers=None, json=None, timeout=None): + if method == "POST" and url.endswith("/v1/sandboxes"): + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/execs"): + return _FakeResponse(202, {"execId": json["execId"], "status": "running"}) + if method == "GET" and "/execs/" in url: + return _FakeResponse(404, {"error": "not found"}, text="not found") + if method == "POST" and url.endswith("/terminate"): + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + monkeypatch.setattr(modal_common.time, "sleep", lambda _: None) + + env = managed_modal.ManagedModalEnvironment(image="python:3.11") + result = env.execute("echo hello") + env.cleanup() + + assert result["returncode"] == 1 + assert "not found" in result["output"].lower() + + +def test_managed_modal_create_and_cleanup_preserve_gateway_persistence_fields(monkeypatch): + _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + + create_payloads = [] + terminate_payloads = [] + + def fake_request(method, url, headers=None, json=None, timeout=None): + if method == "POST" and url.endswith("/v1/sandboxes"): + create_payloads.append(json) + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/terminate"): + terminate_payloads.append(json) + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + + env = managed_modal.ManagedModalEnvironment( + image="python:3.11", + task_id="task-managed-persist", + persistent_filesystem=False, + ) + env.cleanup() + + assert create_payloads == [{ + "image": "python:3.11", + "cwd": "/root", + "cpu": 1.0, + "memoryMiB": 5120.0, + "timeoutMs": 3_600_000, + "idleTimeoutMs": 300_000, + "persistentFilesystem": False, + "logicalKey": "task-managed-persist", + }] + assert terminate_payloads == [{"snapshotBeforeTerminate": False}] + + +def test_managed_modal_rejects_host_credential_passthrough(): + _install_fake_tools_package( + credential_mounts=[{ + "host_path": "/tmp/token.json", + "container_path": "/root/.hermes/token.json", + }] + ) + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + + with pytest.raises(ValueError, match="credential-file passthrough"): + managed_modal.ManagedModalEnvironment(image="python:3.11") + + +def test_managed_modal_execute_times_out_and_cancels(monkeypatch): + _install_fake_tools_package() + managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py") + modal_common = sys.modules["tools.environments.modal_common"] + + calls = [] + monotonic_values = iter([0.0, 12.5]) + + def fake_request(method, url, headers=None, json=None, timeout=None): + calls.append((method, url, json, timeout)) + if method == "POST" and url.endswith("/v1/sandboxes"): + return _FakeResponse(200, {"id": "sandbox-1"}) + if method == "POST" and url.endswith("/execs"): + return _FakeResponse(202, {"execId": json["execId"], "status": "running"}) + if method == "GET" and "/execs/" in url: + return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"}) + if method == "POST" and url.endswith("/cancel"): + return _FakeResponse(202, {"status": "cancelling"}) + if method == "POST" and url.endswith("/terminate"): + return _FakeResponse(200, {"status": "terminated"}) + raise AssertionError(f"Unexpected request: {method} {url}") + + monkeypatch.setattr(managed_modal.requests, "request", fake_request) + monkeypatch.setattr(modal_common.time, "monotonic", lambda: next(monotonic_values)) + monkeypatch.setattr(modal_common.time, "sleep", lambda _: None) + + env = managed_modal.ManagedModalEnvironment(image="python:3.11") + result = env.execute("sleep 30", timeout=2) + env.cleanup() + + assert result == { + "output": "Managed Modal exec timed out after 2s", + "returncode": 124, + } + assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls) diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py new file mode 100644 index 000000000..39b9125e1 --- /dev/null +++ b/tests/tools/test_managed_tool_gateway.py @@ -0,0 +1,101 @@ +import os +import json +from datetime import datetime, timedelta, timezone +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +import sys +from unittest.mock import patch + +MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py" +MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH) +assert MODULE_SPEC and MODULE_SPEC.loader +managed_tool_gateway = module_from_spec(MODULE_SPEC) +sys.modules[MODULE_SPEC.name] = managed_tool_gateway +MODULE_SPEC.loader.exec_module(managed_tool_gateway) +resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway + + +def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain(): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: "nous-token", + ) + + assert result is not None + assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com" + assert result.nous_user_token == "nous-token" + assert result.managed_mode is True + + +def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/", + }, + clear=False, + ): + result = resolve_managed_tool_gateway( + "browserbase", + token_reader=lambda: "nous-token", + ) + + assert result is not None + assert result.gateway_origin == "http://browserbase-gateway.localhost:3009" + + +def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: None, + ) + + assert result is None + + +def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag(): + with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: "nous-token", + ) + + assert result is None + + +def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch): + monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat() + (tmp_path / "auth.json").write_text(json.dumps({ + "providers": { + "nous": { + "access_token": "stale-token", + "refresh_token": "refresh-token", + "expires_at": expires_at, + } + } + })) + monkeypatch.setattr( + "hermes_cli.auth.resolve_nous_access_token", + lambda refresh_skew_seconds=120: "fresh-token", + ) + + assert managed_tool_gateway.read_nous_access_token() == "fresh-token" diff --git a/tests/tools/test_modal_snapshot_isolation.py b/tests/tools/test_modal_snapshot_isolation.py new file mode 100644 index 000000000..a3d0eeacd --- /dev/null +++ b/tests/tools/test_modal_snapshot_isolation.py @@ -0,0 +1,222 @@ +import json +import os +import sys +import types +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +TOOLS_DIR = REPO_ROOT / "tools" + + +def _load_module(module_name: str, path: Path): + spec = spec_from_file_location(module_name, path) + assert spec and spec.loader + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def _reset_modules(prefixes: tuple[str, ...]): + for name in list(sys.modules): + if name.startswith(prefixes): + sys.modules.pop(name, None) + + +@pytest.fixture(autouse=True) +def _restore_tool_modules(): + original_hermes_home = os.environ.get("HERMES_HOME") + original_modules = { + name: module + for name, module in sys.modules.items() + if name == "tools" + or name.startswith("tools.") + or name == "hermes_cli" + or name.startswith("hermes_cli.") + or name == "modal" + or name.startswith("modal.") + } + try: + yield + finally: + if original_hermes_home is None: + os.environ.pop("HERMES_HOME", None) + else: + os.environ["HERMES_HOME"] = original_hermes_home + _reset_modules(("tools", "hermes_cli", "modal")) + sys.modules.update(original_modules) + + +def _install_modal_test_modules( + tmp_path: Path, + *, + fail_on_snapshot_ids: set[str] | None = None, + snapshot_id: str = "im-fresh", +): + _reset_modules(("tools", "hermes_cli", "modal")) + + hermes_cli = types.ModuleType("hermes_cli") + hermes_cli.__path__ = [] # type: ignore[attr-defined] + sys.modules["hermes_cli"] = hermes_cli + hermes_home = tmp_path / "hermes-home" + os.environ["HERMES_HOME"] = str(hermes_home) + sys.modules["hermes_cli.config"] = types.SimpleNamespace( + get_hermes_home=lambda: hermes_home, + ) + + tools_package = types.ModuleType("tools") + tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] + sys.modules["tools"] = tools_package + + env_package = types.ModuleType("tools.environments") + env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined] + sys.modules["tools.environments"] = env_package + + class _DummyBaseEnvironment: + def __init__(self, cwd: str, timeout: int, env=None): + self.cwd = cwd + self.timeout = timeout + self.env = env or {} + + def _prepare_command(self, command: str): + return command, None + + sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment) + sys.modules["tools.interrupt"] = types.SimpleNamespace(is_interrupted=lambda: False) + sys.modules["tools.credential_files"] = types.SimpleNamespace( + get_credential_file_mounts=lambda: [], + iter_skills_files=lambda: [], + ) + + from_id_calls: list[str] = [] + registry_calls: list[tuple[str, list[str] | None]] = [] + create_calls: list[dict] = [] + + class _FakeImage: + @staticmethod + def from_id(image_id: str): + from_id_calls.append(image_id) + return {"kind": "snapshot", "image_id": image_id} + + @staticmethod + def from_registry(image: str, setup_dockerfile_commands=None): + registry_calls.append((image, setup_dockerfile_commands)) + return {"kind": "registry", "image": image} + + async def _lookup_aio(_name: str, create_if_missing: bool = False): + return types.SimpleNamespace(name="hermes-agent", create_if_missing=create_if_missing) + + class _FakeSandboxInstance: + def __init__(self, image): + self.image = image + + async def _snapshot_aio(): + return types.SimpleNamespace(object_id=snapshot_id) + + async def _terminate_aio(): + return None + + self.snapshot_filesystem = types.SimpleNamespace(aio=_snapshot_aio) + self.terminate = types.SimpleNamespace(aio=_terminate_aio) + + async def _create_aio(*_args, image=None, app=None, timeout=None, **kwargs): + create_calls.append({ + "image": image, + "app": app, + "timeout": timeout, + **kwargs, + }) + image_id = image.get("image_id") if isinstance(image, dict) else None + if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids: + raise RuntimeError(f"cannot restore {image_id}") + return _FakeSandboxInstance(image) + + class _FakeMount: + @staticmethod + def from_local_file(host_path: str, remote_path: str): + return {"host_path": host_path, "remote_path": remote_path} + + class _FakeApp: + lookup = types.SimpleNamespace(aio=_lookup_aio) + + class _FakeSandbox: + create = types.SimpleNamespace(aio=_create_aio) + + sys.modules["modal"] = types.SimpleNamespace( + Image=_FakeImage, + App=_FakeApp, + Sandbox=_FakeSandbox, + Mount=_FakeMount, + ) + + return { + "snapshot_store": hermes_home / "modal_snapshots.json", + "create_calls": create_calls, + "from_id_calls": from_id_calls, + "registry_calls": registry_calls, + } + + +def test_modal_environment_migrates_legacy_snapshot_key_and_uses_snapshot_id(tmp_path): + state = _install_modal_test_modules(tmp_path) + snapshot_store = state["snapshot_store"] + snapshot_store.parent.mkdir(parents=True, exist_ok=True) + snapshot_store.write_text(json.dumps({"task-legacy": "im-legacy123"})) + + modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py") + env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-legacy") + + try: + assert state["from_id_calls"] == ["im-legacy123"] + assert state["create_calls"][0]["image"] == {"kind": "snapshot", "image_id": "im-legacy123"} + assert json.loads(snapshot_store.read_text()) == {"direct:task-legacy": "im-legacy123"} + finally: + env.cleanup() + + +def test_modal_environment_prunes_stale_direct_snapshot_and_retries_base_image(tmp_path): + state = _install_modal_test_modules(tmp_path, fail_on_snapshot_ids={"im-stale123"}) + snapshot_store = state["snapshot_store"] + snapshot_store.parent.mkdir(parents=True, exist_ok=True) + snapshot_store.write_text(json.dumps({"direct:task-stale": "im-stale123"})) + + modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py") + env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-stale") + + try: + assert [call["image"] for call in state["create_calls"]] == [ + {"kind": "snapshot", "image_id": "im-stale123"}, + {"kind": "registry", "image": "python:3.11"}, + ] + assert json.loads(snapshot_store.read_text()) == {} + finally: + env.cleanup() + + +def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path): + state = _install_modal_test_modules(tmp_path, snapshot_id="im-cleanup456") + snapshot_store = state["snapshot_store"] + + modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py") + env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-cleanup") + env.cleanup() + + assert json.loads(snapshot_store.read_text()) == {"direct:task-cleanup": "im-cleanup456"} + + +def test_resolve_modal_image_uses_snapshot_ids_and_registry_images(tmp_path): + state = _install_modal_test_modules(tmp_path) + modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py") + + snapshot_image = modal_module._resolve_modal_image("im-snapshot123") + registry_image = modal_module._resolve_modal_image("python:3.11") + + assert snapshot_image == {"kind": "snapshot", "image_id": "im-snapshot123"} + assert registry_image == {"kind": "registry", "image": "python:3.11"} + assert state["from_id_calls"] == ["im-snapshot123"] + assert state["registry_calls"][0][0] == "python:3.11" + assert "ensurepip" in state["registry_calls"][0][1][0] diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index cefb81cd2..2cbe3f711 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -7,10 +7,13 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool") def _clear_terminal_env(monkeypatch): """Remove terminal env vars that could affect requirements checks.""" keys = [ + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "TERMINAL_ENV", + "TERMINAL_MODAL_MODE", "TERMINAL_SSH_HOST", "TERMINAL_SSH_USER", "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", "HOME", "USERPROFILE", ] @@ -63,7 +66,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) - # Pretend modal is installed + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False) monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object()) with caplog.at_level(logging.ERROR): @@ -71,6 +74,102 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, assert ok is False assert any( - "Modal backend selected but no MODAL_TOKEN_ID environment variable" in record.getMessage() + "Modal backend selected but no direct Modal credentials/config was found" in record.getMessage() + for record in caplog.records + ) + + +def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed") + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True) + monkeypatch.setattr( + terminal_tool_module, + "ensure_minisweagent_on_path", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + monkeypatch.setattr( + terminal_tool_module.importlib.util, + "find_spec", + lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + + assert terminal_tool_module.check_terminal_requirements() is True + + +def test_modal_backend_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True) + monkeypatch.setattr( + terminal_tool_module.importlib.util, + "find_spec", + lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + + assert terminal_tool_module.check_terminal_requirements() is True + + +def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, caplog, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("TERMINAL_MODAL_MODE", "direct") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True) + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "TERMINAL_MODAL_MODE=direct" in record.getMessage() + for record in caplog.records + ) + + +def test_modal_backend_managed_mode_does_not_fall_back_to_direct(monkeypatch, caplog, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed") + monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id") + monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False) + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage() + for record in caplog.records + ) + + +def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkeypatch, caplog, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False) + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage() for record in caplog.records ) diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index 5a347cc6e..d0ce42735 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -26,3 +26,31 @@ class TestTerminalRequirements: names = {tool["function"]["name"] for tool in tools} assert "terminal" in names assert {"read_file", "write_file", "patch", "search_files"}.issubset(names) + + def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) + monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) + monkeypatch.setattr( + terminal_tool_module, + "_get_env_config", + lambda: {"env_type": "modal", "modal_mode": "managed"}, + ) + monkeypatch.setattr( + terminal_tool_module, + "is_managed_tool_gateway_ready", + lambda _vendor: True, + ) + monkeypatch.setattr( + terminal_tool_module, + "ensure_minisweagent_on_path", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")), + ) + + tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True) + names = {tool["function"]["name"] for tool in tools} + + assert "terminal" in names + assert "execute_code" in names diff --git a/tests/tools/test_transcription_tools.py b/tests/tools/test_transcription_tools.py index 1cdf33ecf..0cd4c8e3c 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -236,6 +236,7 @@ class TestTranscribeGroq: assert result["success"] is True assert result["transcript"] == "hello world" assert result["provider"] == "groq" + mock_client.close.assert_called_once() def test_whitespace_stripped(self, monkeypatch, sample_wav): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") @@ -277,6 +278,7 @@ class TestTranscribeGroq: assert result["success"] is False assert "API error" in result["error"] + mock_client.close.assert_called_once() def test_permission_error(self, monkeypatch, sample_wav): monkeypatch.setenv("GROQ_API_KEY", "gsk-test") @@ -332,6 +334,7 @@ class TestTranscribeOpenAIExtended: result = _transcribe_openai(sample_wav, "whisper-1") assert result["transcript"] == "hello" + mock_client.close.assert_called_once() def test_permission_error(self, monkeypatch, sample_wav): monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") @@ -346,6 +349,7 @@ class TestTranscribeOpenAIExtended: assert result["success"] is False assert "Permission denied" in result["error"] + mock_client.close.assert_called_once() class TestTranscribeLocalCommand: diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index d291a005b..9e33d7445 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -5,12 +5,16 @@ Coverage: constructor failure recovery, return value verification, edge cases. _get_backend() — backend selection logic with env var combinations. _get_parallel_client() — Parallel client configuration, singleton caching. - check_web_api_key() — unified availability check. + check_web_api_key() — unified availability check across all web backends. """ +import importlib +import json import os +import sys +import types import pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock class TestFirecrawlClientConfig: @@ -20,14 +24,33 @@ class TestFirecrawlClientConfig: """Reset client and env vars before each test.""" import tools.web_tools tools.web_tools._firecrawl_client = None - for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"): + tools.web_tools._firecrawl_client_config = None + for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ): os.environ.pop(key, None) + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" def teardown_method(self): """Reset client after each test.""" import tools.web_tools tools.web_tools._firecrawl_client = None - for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"): + tools.web_tools._firecrawl_client_config = None + for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ): os.environ.pop(key, None) # ── Configuration matrix ───────────────────────────────────────── @@ -67,9 +90,152 @@ class TestFirecrawlClientConfig: def test_no_config_raises_with_helpful_message(self): """Neither key nor URL → ValueError with guidance.""" with patch("tools.web_tools.Firecrawl"): - from tools.web_tools import _get_firecrawl_client - with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"): + with patch("tools.web_tools._read_nous_access_token", return_value=None): + from tools.web_tools import _get_firecrawl_client + with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"): + _get_firecrawl_client() + + def test_tool_gateway_domain_builds_firecrawl_gateway_origin(self): + """Shared gateway domain should derive the Firecrawl vendor hostname.""" + with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch("tools.web_tools.Firecrawl") as mock_fc: + from tools.web_tools import _get_firecrawl_client + result = _get_firecrawl_client() + mock_fc.assert_called_once_with( + api_key="nous-token", + api_url="https://firecrawl-gateway.nousresearch.com", + ) + assert result is mock_fc.return_value + + def test_tool_gateway_scheme_can_switch_derived_gateway_origin_to_http(self): + """Shared gateway scheme should allow local plain-http vendor hosts.""" + with patch.dict(os.environ, { + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + "TOOL_GATEWAY_SCHEME": "http", + }): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch("tools.web_tools.Firecrawl") as mock_fc: + from tools.web_tools import _get_firecrawl_client + result = _get_firecrawl_client() + mock_fc.assert_called_once_with( + api_key="nous-token", + api_url="http://firecrawl-gateway.nousresearch.com", + ) + assert result is mock_fc.return_value + + def test_invalid_tool_gateway_scheme_raises(self): + """Unexpected shared gateway schemes should fail fast.""" + with patch.dict(os.environ, { + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + "TOOL_GATEWAY_SCHEME": "ftp", + }): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + from tools.web_tools import _get_firecrawl_client + with pytest.raises(ValueError, match="TOOL_GATEWAY_SCHEME"): + _get_firecrawl_client() + + def test_explicit_firecrawl_gateway_url_takes_precedence(self): + """An explicit Firecrawl gateway origin should override the shared domain.""" + with patch.dict(os.environ, { + "FIRECRAWL_GATEWAY_URL": "https://firecrawl-gateway.localhost:3009/", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch("tools.web_tools.Firecrawl") as mock_fc: + from tools.web_tools import _get_firecrawl_client + _get_firecrawl_client() + mock_fc.assert_called_once_with( + api_key="nous-token", + api_url="https://firecrawl-gateway.localhost:3009", + ) + + def test_default_gateway_domain_targets_nous_production_origin(self): + """Default gateway origin should point at the Firecrawl vendor hostname.""" + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch("tools.web_tools.Firecrawl") as mock_fc: + from tools.web_tools import _get_firecrawl_client _get_firecrawl_client() + mock_fc.assert_called_once_with( + api_key="nous-token", + api_url="https://firecrawl-gateway.nousresearch.com", + ) + + def test_direct_mode_is_preferred_over_tool_gateway(self): + """Explicit Firecrawl config should win over the gateway fallback.""" + with patch.dict(os.environ, { + "FIRECRAWL_API_KEY": "fc-test", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch("tools.web_tools.Firecrawl") as mock_fc: + from tools.web_tools import _get_firecrawl_client + _get_firecrawl_client() + mock_fc.assert_called_once_with(api_key="fc-test") + + def test_nous_auth_token_respects_hermes_home_override(self, tmp_path): + """Auth lookup should read from HERMES_HOME/auth.json, not ~/.hermes/auth.json.""" + real_home = tmp_path / "real-home" + (real_home / ".hermes").mkdir(parents=True) + + hermes_home = tmp_path / "hermes-home" + hermes_home.mkdir() + (hermes_home / "auth.json").write_text(json.dumps({ + "providers": { + "nous": { + "access_token": "nous-token", + } + } + })) + + with patch.dict(os.environ, { + "HOME": str(real_home), + "HERMES_HOME": str(hermes_home), + }, clear=False): + import tools.web_tools + importlib.reload(tools.web_tools) + assert tools.web_tools._read_nous_access_token() == "nous-token" + + def test_check_auxiliary_model_re_resolves_backend_each_call(self): + """Availability checks should not be pinned to module import state.""" + import tools.web_tools + + # Simulate the pre-fix import-time cache slot for regression coverage. + tools.web_tools.__dict__["_aux_async_client"] = None + + with patch( + "tools.web_tools.get_async_text_auxiliary_client", + side_effect=[(None, None), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model")], + ): + assert tools.web_tools.check_auxiliary_model() is False + assert tools.web_tools.check_auxiliary_model() is True + + @pytest.mark.asyncio + async def test_summarizer_re_resolves_backend_after_initial_unavailable_state(self): + """Summarization should pick up a backend that becomes available later in-process.""" + import tools.web_tools + + tools.web_tools.__dict__["_aux_async_client"] = None + + response = MagicMock() + response.choices = [MagicMock(message=MagicMock(content="summary text"))] + + with patch( + "tools.web_tools._resolve_web_extract_auxiliary", + side_effect=[(None, None, {}), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model", {})], + ), patch( + "tools.web_tools.async_call_llm", + new=AsyncMock(return_value=response), + ) as mock_async_call: + assert tools.web_tools.check_auxiliary_model() is False + result = await tools.web_tools._call_summarizer_llm( + "Some content worth summarizing", + "Source: https://example.com\n\n", + None, + ) + + assert result == "summary text" + mock_async_call.assert_awaited_once() # ── Singleton caching ──────────────────────────────────────────── @@ -117,9 +283,10 @@ class TestFirecrawlClientConfig: """FIRECRAWL_API_KEY='' with no URL → should raise.""" with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}): with patch("tools.web_tools.Firecrawl"): - from tools.web_tools import _get_firecrawl_client - with pytest.raises(ValueError): - _get_firecrawl_client() + with patch("tools.web_tools._read_nous_access_token", return_value=None): + from tools.web_tools import _get_firecrawl_client + with pytest.raises(ValueError): + _get_firecrawl_client() class TestBackendSelection: @@ -130,11 +297,24 @@ class TestBackendSelection: setups. """ - _ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY") + _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + "TAVILY_API_KEY", + ) def setup_method(self): + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - os.environ.pop(key, None) + if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": + os.environ.pop(key, None) def teardown_method(self): for key in self._ENV_KEYS: @@ -148,6 +328,13 @@ class TestBackendSelection: with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}): assert _get_backend() == "parallel" + def test_config_exa(self): + """web.backend=exa in config → 'exa' regardless of other keys.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \ + patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}): + assert _get_backend() == "exa" + def test_config_firecrawl(self): """web.backend=firecrawl in config → 'firecrawl' even if Parallel key set.""" from tools.web_tools import _get_backend @@ -189,6 +376,20 @@ class TestBackendSelection: patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}): assert _get_backend() == "parallel" + def test_fallback_exa_only_key(self): + """Only EXA_API_KEY set → 'exa'.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}): + assert _get_backend() == "exa" + + def test_fallback_parallel_takes_priority_over_exa(self): + """Exa should only win the fallback path when it is the only configured backend.""" + from tools.web_tools import _get_backend + with patch("tools.web_tools._load_web_config", return_value={}), \ + patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "PARALLEL_API_KEY": "par-test"}): + assert _get_backend() == "parallel" + def test_fallback_tavily_only_key(self): """Only TAVILY_API_KEY set → 'tavily'.""" from tools.web_tools import _get_backend @@ -246,11 +447,25 @@ class TestParallelClientConfig: import tools.web_tools tools.web_tools._parallel_client = None os.environ.pop("PARALLEL_API_KEY", None) + fake_parallel = types.ModuleType("parallel") + + class Parallel: + def __init__(self, api_key): + self.api_key = api_key + + class AsyncParallel: + def __init__(self, api_key): + self.api_key = api_key + + fake_parallel.Parallel = Parallel + fake_parallel.AsyncParallel = AsyncParallel + sys.modules["parallel"] = fake_parallel def teardown_method(self): import tools.web_tools tools.web_tools._parallel_client = None os.environ.pop("PARALLEL_API_KEY", None) + sys.modules.pop("parallel", None) def test_creates_client_with_key(self): """PARALLEL_API_KEY set → creates Parallel client.""" @@ -276,14 +491,55 @@ class TestParallelClientConfig: assert client1 is client2 +class TestWebSearchErrorHandling: + """Test suite for web_search_tool() error responses.""" + + def test_search_error_response_does_not_expose_diagnostics(self): + import tools.web_tools + + firecrawl_client = MagicMock() + firecrawl_client.search.side_effect = RuntimeError("boom") + + with patch("tools.web_tools._get_backend", return_value="firecrawl"), \ + patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \ + patch("tools.interrupt.is_interrupted", return_value=False), \ + patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \ + patch.object(tools.web_tools._debug, "save"): + result = json.loads(tools.web_tools.web_search_tool("test query", limit=3)) + + assert result == {"error": "Error searching web: boom"} + + debug_payload = mock_log_call.call_args.args[1] + assert debug_payload["error"] == "Error searching web: boom" + assert "traceback" not in debug_payload["error"] + assert "exception_type" not in debug_payload["error"] + assert "config" not in result + assert "exception_type" not in result + assert "exception_chain" not in result + assert "traceback" not in result + + class TestCheckWebApiKey: """Test suite for check_web_api_key() unified availability check.""" - _ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY") + _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", + "EXA_API_KEY", + "PARALLEL_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + "TAVILY_API_KEY", + ) def setup_method(self): + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - os.environ.pop(key, None) + if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": + os.environ.pop(key, None) def teardown_method(self): for key in self._ENV_KEYS: @@ -294,6 +550,11 @@ class TestCheckWebApiKey: from tools.web_tools import check_web_api_key assert check_web_api_key() is True + def test_exa_key_only(self): + with patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is True + def test_firecrawl_key_only(self): with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}): from tools.web_tools import check_web_api_key @@ -329,3 +590,28 @@ class TestCheckWebApiKey: }): from tools.web_tools import check_web_api_key assert check_web_api_key() is True + + def test_tool_gateway_returns_true(self): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is True + + def test_configured_backend_must_match_available_provider(self): + with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is False + + def test_configured_firecrawl_backend_accepts_managed_gateway(self): + with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}): + with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"): + with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False): + from tools.web_tools import check_web_api_key + assert check_web_api_key() is True + + +def test_web_requires_env_includes_exa_key(): + from tools.web_tools import _web_requires_env + + assert "EXA_API_KEY" in _web_requires_env() diff --git a/tools/__init__.py b/tools/__init__.py index 9b2542296..3214b979e 100644 --- a/tools/__init__.py +++ b/tools/__init__.py @@ -1,262 +1,25 @@ #!/usr/bin/env python3 -""" -Tools Package +"""Tools package namespace. -This package contains all the specific tool implementations for the Hermes Agent. -Each module provides specialized functionality for different capabilities: +Keep package import side effects minimal. Importing ``tools`` should not +eagerly import the full tool stack, because several subsystems load tools while +``hermes_cli.config`` is still initializing. -- web_tools: Web search, content extraction, and crawling -- terminal_tool: Command execution (local/docker/modal/daytona/ssh/singularity backends) -- vision_tools: Image analysis and understanding -- mixture_of_agents_tool: Multi-model collaborative reasoning -- image_generation_tool: Text-to-image generation with upscaling +Callers should import concrete submodules directly, for example: -The tools are imported into model_tools.py which provides a unified interface -for the AI agent to access all capabilities. + import tools.web_tools + from tools import browser_tool + +Python will resolve those submodules via the package path without needing them +to be re-exported here. """ -# Export all tools for easy importing -from .web_tools import ( - web_search_tool, - web_extract_tool, - web_crawl_tool, - check_firecrawl_api_key -) -# Primary terminal tool (local/docker/singularity/modal/daytona/ssh) -from .terminal_tool import ( - terminal_tool, - check_terminal_requirements, - cleanup_vm, - cleanup_all_environments, - get_active_environments_info, - register_task_env_overrides, - clear_task_env_overrides, - TERMINAL_TOOL_DESCRIPTION -) - -from .vision_tools import ( - vision_analyze_tool, - check_vision_requirements -) - -from .mixture_of_agents_tool import ( - mixture_of_agents_tool, - check_moa_requirements -) - -from .image_generation_tool import ( - image_generate_tool, - check_image_generation_requirements -) - -from .skills_tool import ( - skills_list, - skill_view, - check_skills_requirements, - SKILLS_TOOL_DESCRIPTION -) - -from .skill_manager_tool import ( - skill_manage, - check_skill_manage_requirements, - SKILL_MANAGE_SCHEMA -) - -# Browser automation tools (agent-browser + Browserbase) -from .browser_tool import ( - browser_navigate, - browser_snapshot, - browser_click, - browser_type, - browser_scroll, - browser_back, - browser_press, - browser_close, - browser_get_images, - browser_vision, - cleanup_browser, - cleanup_all_browsers, - get_active_browser_sessions, - check_browser_requirements, - BROWSER_TOOL_SCHEMAS -) - -# Cronjob management tools (CLI-only, hermes-cli toolset) -from .cronjob_tools import ( - cronjob, - schedule_cronjob, - list_cronjobs, - remove_cronjob, - check_cronjob_requirements, - get_cronjob_tool_definitions, - CRONJOB_SCHEMA, -) - -# RL Training tools (Tinker-Atropos) -from .rl_training_tool import ( - rl_list_environments, - rl_select_environment, - rl_get_current_config, - rl_edit_config, - rl_start_training, - rl_check_status, - rl_stop_training, - rl_get_results, - rl_list_runs, - rl_test_inference, - check_rl_api_keys, - get_missing_keys, -) - -# File manipulation tools (read, write, patch, search) -from .file_tools import ( - read_file_tool, - write_file_tool, - patch_tool, - search_tool, - get_file_tools, - clear_file_ops_cache, -) - -# Text-to-speech tools (Edge TTS / ElevenLabs / OpenAI) -from .tts_tool import ( - text_to_speech_tool, - check_tts_requirements, -) - -# Planning & task management tool -from .todo_tool import ( - todo_tool, - check_todo_requirements, - TODO_SCHEMA, - TodoStore, -) - -# Clarifying questions tool (interactive Q&A with the user) -from .clarify_tool import ( - clarify_tool, - check_clarify_requirements, - CLARIFY_SCHEMA, -) - -# Code execution sandbox (programmatic tool calling) -from .code_execution_tool import ( - execute_code, - check_sandbox_requirements, - EXECUTE_CODE_SCHEMA, -) - -# Subagent delegation (spawn child agents with isolated context) -from .delegate_tool import ( - delegate_task, - check_delegate_requirements, - DELEGATE_TASK_SCHEMA, -) - -# File tools have no external requirements - they use the terminal backend def check_file_requirements(): - """File tools only require terminal backend to be available.""" + """File tools only require terminal backend availability.""" from .terminal_tool import check_terminal_requirements + return check_terminal_requirements() -__all__ = [ - # Web tools - 'web_search_tool', - 'web_extract_tool', - 'web_crawl_tool', - 'check_firecrawl_api_key', - # Terminal tools - 'terminal_tool', - 'check_terminal_requirements', - 'cleanup_vm', - 'cleanup_all_environments', - 'get_active_environments_info', - 'register_task_env_overrides', - 'clear_task_env_overrides', - 'TERMINAL_TOOL_DESCRIPTION', - # Vision tools - 'vision_analyze_tool', - 'check_vision_requirements', - # MoA tools - 'mixture_of_agents_tool', - 'check_moa_requirements', - # Image generation tools - 'image_generate_tool', - 'check_image_generation_requirements', - # Skills tools - 'skills_list', - 'skill_view', - 'check_skills_requirements', - 'SKILLS_TOOL_DESCRIPTION', - # Skill management - 'skill_manage', - 'check_skill_manage_requirements', - 'SKILL_MANAGE_SCHEMA', - # Browser automation tools - 'browser_navigate', - 'browser_snapshot', - 'browser_click', - 'browser_type', - 'browser_scroll', - 'browser_back', - 'browser_press', - 'browser_close', - 'browser_get_images', - 'browser_vision', - 'cleanup_browser', - 'cleanup_all_browsers', - 'get_active_browser_sessions', - 'check_browser_requirements', - 'BROWSER_TOOL_SCHEMAS', - # Cronjob management tools (CLI-only) - 'cronjob', - 'schedule_cronjob', - 'list_cronjobs', - 'remove_cronjob', - 'check_cronjob_requirements', - 'get_cronjob_tool_definitions', - 'CRONJOB_SCHEMA', - # RL Training tools - 'rl_list_environments', - 'rl_select_environment', - 'rl_get_current_config', - 'rl_edit_config', - 'rl_start_training', - 'rl_check_status', - 'rl_stop_training', - 'rl_get_results', - 'rl_list_runs', - 'rl_test_inference', - 'check_rl_api_keys', - 'get_missing_keys', - # File manipulation tools - 'read_file_tool', - 'write_file_tool', - 'patch_tool', - 'search_tool', - 'get_file_tools', - 'clear_file_ops_cache', - 'check_file_requirements', - # Text-to-speech tools - 'text_to_speech_tool', - 'check_tts_requirements', - # Planning & task management tool - 'todo_tool', - 'check_todo_requirements', - 'TODO_SCHEMA', - 'TodoStore', - # Clarifying questions tool - 'clarify_tool', - 'check_clarify_requirements', - 'CLARIFY_SCHEMA', - # Code execution sandbox - 'execute_code', - 'check_sandbox_requirements', - 'EXECUTE_CODE_SCHEMA', - # Subagent delegation - 'delegate_task', - 'check_delegate_requirements', - 'DELEGATE_TASK_SCHEMA', -] +__all__ = ["check_file_requirements"] diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 1aad8e6e0..5c580c3f3 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -2,14 +2,58 @@ import logging import os +import threading import uuid -from typing import Dict +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 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): @@ -19,28 +63,51 @@ class BrowserbaseProvider(CloudBrowserProvider): return "Browserbase" def is_configured(self) -> bool: - return bool( - os.environ.get("BROWSERBASE_API_KEY") - and os.environ.get("BROWSERBASE_PROJECT_ID") - ) + return self._get_config_or_none() is not None # ------------------------------------------------------------------ # Session lifecycle # ------------------------------------------------------------------ - def _get_config(self) -> Dict[str, str]: + 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 not api_key or not project_id: - raise ValueError( - "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment " - "variables are required. Get your credentials at " - "https://browserbase.com" + 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("/"), + "managed_mode": False, + } + + 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]: + config = self._get_config_or_none() + if config is None: + message = ( + "Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials." ) - return {"api_key": api_key, "project_id": project_id} + 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 def create_session(self, task_id: str) -> Dict[str, object]: config = self._get_config() + managed_mode = bool(config.get("managed_mode")) # Optional env-var knobs enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false" @@ -80,8 +147,11 @@ class BrowserbaseProvider(CloudBrowserProvider): "Content-Type": "application/json", "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( - "https://api.browserbase.com/v1/sessions", + f"{config['base_url']}/v1/sessions", headers=headers, json=session_config, timeout=30, @@ -91,7 +161,7 @@ class BrowserbaseProvider(CloudBrowserProvider): keepalive_fallback = False # Handle 402 — paid features unavailable - if response.status_code == 402: + if response.status_code == 402 and not managed_mode: if enable_keep_alive: keepalive_fallback = True logger.warning( @@ -100,7 +170,7 @@ class BrowserbaseProvider(CloudBrowserProvider): ) session_config.pop("keepAlive", None) response = requests.post( - "https://api.browserbase.com/v1/sessions", + f"{config['base_url']}/v1/sessions", headers=headers, json=session_config, timeout=30, @@ -114,20 +184,25 @@ class BrowserbaseProvider(CloudBrowserProvider): ) session_config.pop("proxies", None) response = requests.post( - "https://api.browserbase.com/v1/sessions", + f"{config['base_url']}/v1/sessions", headers=headers, json=session_config, timeout=30, ) 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 Browserbase 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 if enable_proxies and not proxies_fallback: features_enabled["proxies"] = True @@ -146,6 +221,7 @@ class BrowserbaseProvider(CloudBrowserProvider): "bb_session_id": session_data["id"], "cdp_url": session_data["connectUrl"], "features": features_enabled, + "external_call_id": external_call_id, } def close_session(self, session_id: str) -> bool: @@ -157,7 +233,7 @@ class BrowserbaseProvider(CloudBrowserProvider): try: response = requests.post( - f"https://api.browserbase.com/v1/sessions/{session_id}", + f"{config['base_url']}/v1/sessions/{session_id}", headers={ "X-BB-API-Key": config["api_key"], "Content-Type": "application/json", @@ -184,20 +260,19 @@ class BrowserbaseProvider(CloudBrowserProvider): return False def emergency_cleanup(self, session_id: str) -> None: - api_key = os.environ.get("BROWSERBASE_API_KEY") - project_id = os.environ.get("BROWSERBASE_PROJECT_ID") - if not api_key or not project_id: + 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"https://api.browserbase.com/v1/sessions/{session_id}", + f"{config['base_url']}/v1/sessions/{session_id}", headers={ - "X-BB-API-Key": api_key, + "X-BB-API-Key": config["api_key"], "Content-Type": "application/json", }, json={ - "projectId": project_id, + "projectId": config["project_id"], "status": "REQUEST_RELEASE", }, timeout=5, diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 04e869b0f..56870c041 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -78,6 +78,7 @@ except Exception: from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browser_use import BrowserUseProvider +from tools.tool_backend_helpers import normalize_browser_cloud_provider # Camofox local anti-detection browser backend (optional). # When CAMOFOX_URL is set, all browser operations route through the @@ -245,7 +246,9 @@ 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. If unset → local mode (None). + 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. """ global _cached_cloud_provider, _cloud_provider_resolved if _cloud_provider_resolved: @@ -259,14 +262,45 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: import yaml with open(config_path) as f: cfg = yaml.safe_load(f) or {} - provider_key = cfg.get("browser", {}).get("cloud_provider") + browser_cfg = cfg.get("browser", {}) + provider_key = None + if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg: + provider_key = normalize_browser_cloud_provider( + browser_cfg.get("cloud_provider") + ) + if provider_key == "local": + _cached_cloud_provider = None + return None if provider_key and provider_key in _PROVIDER_REGISTRY: _cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]() except Exception as e: logger.debug("Could not read cloud_provider from config: %s", e) + + if _cached_cloud_provider is None: + fallback_provider = BrowserbaseProvider() + if fallback_provider.is_configured(): + _cached_cloud_provider = fallback_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: + """Return True when the browser tool will use a local browser backend.""" + if _get_cdp_override(): + return False + return _get_cloud_provider() is None + + def _is_local_backend() -> bool: """Return True when the browser runs locally (no cloud provider). @@ -1970,7 +2004,7 @@ if __name__ == "__main__": print(" Install: npm install -g agent-browser && agent-browser install --with-deps") if _cp is not None and not _cp.is_configured(): print(f" - {_cp.provider_name()} credentials not configured") - print(" Tip: remove cloud_provider from config to use free local mode instead") + print(" Tip: set browser.cloud_provider to 'local' to use free local mode instead") print("\n📋 Available Browser Tools:") for schema in BROWSER_TOOL_SCHEMAS: diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index ce78c9061..2dfdc989a 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -765,7 +765,8 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None) -> dict: f"Available via `from hermes_tools import ...`:\n\n" f"{tool_lines}\n\n" "Limits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. " - "terminal() is foreground-only (no background or pty).\n\n" + "terminal() is foreground-only (no background or pty). " + "If the session uses a cloud sandbox backend, treat it as resumable task state rather than a durable always-on machine.\n\n" "Print your final result to stdout. Use Python stdlib (json, re, math, csv, " "datetime, collections, etc.) for processing between tool calls.\n\n" "Also available (no import needed — built into hermes_tools):\n" diff --git a/tools/environments/base.py b/tools/environments/base.py index 896937adf..2b02c3c47 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -5,7 +5,7 @@ import os import subprocess from pathlib import Path -from hermes_cli.config import get_hermes_home +from hermes_constants import get_hermes_home def get_sandbox_dir() -> Path: diff --git a/tools/environments/managed_modal.py b/tools/environments/managed_modal.py new file mode 100644 index 000000000..a8197bccf --- /dev/null +++ b/tools/environments/managed_modal.py @@ -0,0 +1,282 @@ +"""Managed Modal environment backed by tool-gateway.""" + +from __future__ import annotations + +import json +import logging +import os +import requests +import uuid +from dataclasses import dataclass +from typing import Any, Dict, Optional + +from tools.environments.modal_common import ( + BaseModalExecutionEnvironment, + ModalExecStart, + PreparedModalExec, +) +from tools.managed_tool_gateway import resolve_managed_tool_gateway + +logger = logging.getLogger(__name__) + + +def _request_timeout_env(name: str, default: float) -> float: + try: + value = float(os.getenv(name, str(default))) + return value if value > 0 else default + except (TypeError, ValueError): + return default + + +@dataclass(frozen=True) +class _ManagedModalExecHandle: + exec_id: str + + +class ManagedModalEnvironment(BaseModalExecutionEnvironment): + """Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup.""" + + _CONNECT_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CONNECT_TIMEOUT_SECONDS", 1.0) + _POLL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_POLL_READ_TIMEOUT_SECONDS", 5.0) + _CANCEL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CANCEL_READ_TIMEOUT_SECONDS", 5.0) + _client_timeout_grace_seconds = 10.0 + _interrupt_output = "[Command interrupted - Modal sandbox exec cancelled]" + _unexpected_error_prefix = "Managed Modal exec failed" + + def __init__( + self, + image: str, + cwd: str = "/root", + timeout: int = 60, + modal_sandbox_kwargs: Optional[Dict[str, Any]] = None, + persistent_filesystem: bool = True, + task_id: str = "default", + ): + super().__init__(cwd=cwd, timeout=timeout) + + self._guard_unsupported_credential_passthrough() + + gateway = resolve_managed_tool_gateway("modal") + if gateway is None: + raise ValueError("Managed Modal requires a configured tool gateway and Nous user token") + + self._gateway_origin = gateway.gateway_origin.rstrip("/") + self._nous_user_token = gateway.nous_user_token + self._task_id = task_id + self._persistent = persistent_filesystem + self._image = image + self._sandbox_kwargs = dict(modal_sandbox_kwargs or {}) + self._create_idempotency_key = str(uuid.uuid4()) + self._sandbox_id = self._create_sandbox() + + def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart: + exec_id = str(uuid.uuid4()) + payload: Dict[str, Any] = { + "execId": exec_id, + "command": prepared.command, + "cwd": prepared.cwd, + "timeoutMs": int(prepared.timeout * 1000), + } + if prepared.stdin_data is not None: + payload["stdinData"] = prepared.stdin_data + + try: + response = self._request( + "POST", + f"/v1/sandboxes/{self._sandbox_id}/execs", + json=payload, + timeout=10, + ) + except Exception as exc: + return ModalExecStart( + immediate_result=self._error_result(f"Managed Modal exec failed: {exc}") + ) + + if response.status_code >= 400: + return ModalExecStart( + immediate_result=self._error_result( + self._format_error("Managed Modal exec failed", response) + ) + ) + + body = response.json() + status = body.get("status") + if status in {"completed", "failed", "cancelled", "timeout"}: + return ModalExecStart( + immediate_result=self._result( + body.get("output", ""), + body.get("returncode", 1), + ) + ) + + if body.get("execId") != exec_id: + return ModalExecStart( + immediate_result=self._error_result( + "Managed Modal exec start did not return the expected exec id" + ) + ) + + return ModalExecStart(handle=_ManagedModalExecHandle(exec_id=exec_id)) + + def _poll_modal_exec(self, handle: _ManagedModalExecHandle) -> dict | None: + try: + status_response = self._request( + "GET", + f"/v1/sandboxes/{self._sandbox_id}/execs/{handle.exec_id}", + timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS), + ) + except Exception as exc: + return self._error_result(f"Managed Modal exec poll failed: {exc}") + + if status_response.status_code == 404: + return self._error_result("Managed Modal exec not found") + + if status_response.status_code >= 400: + return self._error_result( + self._format_error("Managed Modal exec poll failed", status_response) + ) + + status_body = status_response.json() + status = status_body.get("status") + if status in {"completed", "failed", "cancelled", "timeout"}: + return self._result( + status_body.get("output", ""), + status_body.get("returncode", 1), + ) + return None + + def _cancel_modal_exec(self, handle: _ManagedModalExecHandle) -> None: + self._cancel_exec(handle.exec_id) + + def _timeout_result_for_modal(self, timeout: int) -> dict: + return self._result(f"Managed Modal exec timed out after {timeout}s", 124) + + def cleanup(self): + if not getattr(self, "_sandbox_id", None): + return + + try: + self._request( + "POST", + f"/v1/sandboxes/{self._sandbox_id}/terminate", + json={ + "snapshotBeforeTerminate": self._persistent, + }, + timeout=60, + ) + except Exception as exc: + logger.warning("Managed Modal cleanup failed: %s", exc) + finally: + self._sandbox_id = None + + def _create_sandbox(self) -> str: + cpu = self._coerce_number(self._sandbox_kwargs.get("cpu"), 1) + memory = self._coerce_number( + self._sandbox_kwargs.get("memoryMiB", self._sandbox_kwargs.get("memory")), + 5120, + ) + disk = self._coerce_number( + self._sandbox_kwargs.get("ephemeral_disk", self._sandbox_kwargs.get("diskMiB")), + None, + ) + + create_payload = { + "image": self._image, + "cwd": self.cwd, + "cpu": cpu, + "memoryMiB": memory, + "timeoutMs": 3_600_000, + "idleTimeoutMs": max(300_000, int(self.timeout * 1000)), + "persistentFilesystem": self._persistent, + "logicalKey": self._task_id, + } + if disk is not None: + create_payload["diskMiB"] = disk + + response = self._request( + "POST", + "/v1/sandboxes", + json=create_payload, + timeout=60, + extra_headers={ + "x-idempotency-key": self._create_idempotency_key, + }, + ) + if response.status_code >= 400: + raise RuntimeError(self._format_error("Managed Modal create failed", response)) + + body = response.json() + sandbox_id = body.get("id") + if not isinstance(sandbox_id, str) or not sandbox_id: + raise RuntimeError("Managed Modal create did not return a sandbox id") + return sandbox_id + + def _guard_unsupported_credential_passthrough(self) -> None: + """Managed Modal does not sync or mount host credential files.""" + try: + from tools.credential_files import get_credential_file_mounts + except Exception: + return + + mounts = get_credential_file_mounts() + if mounts: + raise ValueError( + "Managed Modal does not support host credential-file passthrough. " + "Use TERMINAL_MODAL_MODE=direct when skills or config require " + "credential files inside the sandbox." + ) + + def _request(self, method: str, path: str, *, + json: Dict[str, Any] | None = None, + timeout: int = 30, + extra_headers: Dict[str, str] | None = None) -> requests.Response: + headers = { + "Authorization": f"Bearer {self._nous_user_token}", + "Content-Type": "application/json", + } + if extra_headers: + headers.update(extra_headers) + + return requests.request( + method, + f"{self._gateway_origin}{path}", + headers=headers, + json=json, + timeout=timeout, + ) + + def _cancel_exec(self, exec_id: str) -> None: + try: + self._request( + "POST", + f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}/cancel", + timeout=(self._CONNECT_TIMEOUT_SECONDS, self._CANCEL_READ_TIMEOUT_SECONDS), + ) + except Exception as exc: + logger.warning("Managed Modal exec cancel failed: %s", exc) + + @staticmethod + def _coerce_number(value: Any, default: float) -> float: + try: + if value is None: + return default + return float(value) + except (TypeError, ValueError): + return default + + @staticmethod + def _format_error(prefix: str, response: requests.Response) -> str: + try: + payload = response.json() + if isinstance(payload, dict): + message = payload.get("error") or payload.get("message") or payload.get("code") + if isinstance(message, str) and message: + return f"{prefix}: {message}" + return f"{prefix}: {json.dumps(payload, ensure_ascii=False)}" + except Exception: + pass + + text = response.text.strip() + if text: + return f"{prefix}: {text}" + return f"{prefix}: HTTP {response.status_code}" diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 89e8f4776..805f9ac28 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -1,14 +1,7 @@ -"""Modal cloud execution environment using the Modal SDK directly. +"""Modal cloud execution environment using the native Modal SDK directly. -Replaces the previous swe-rex ModalDeployment wrapper with native Modal -Sandbox.create() + Sandbox.exec() calls. This eliminates the need for -swe-rex's HTTP runtime server and unencrypted tunnel, fixing: - - AsyncUsageWarning from synchronous App.lookup in async context - - DeprecationError from unencrypted_ports / .url on unencrypted tunnels - -Supports persistent filesystem snapshots: when enabled, the sandbox's -filesystem is snapshotted on cleanup and restored on next creation, so -installed packages, project files, and config changes survive across sessions. +Uses ``Sandbox.create()`` + ``Sandbox.exec()`` instead of the older runtime +wrapper, while preserving Hermes' persistent snapshot behavior across sessions. """ import asyncio @@ -16,17 +9,21 @@ import json import logging import shlex import threading -import uuid +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Optional -from hermes_cli.config import get_hermes_home -from tools.environments.base import BaseEnvironment -from tools.interrupt import is_interrupted +from hermes_constants import get_hermes_home +from tools.environments.modal_common import ( + BaseModalExecutionEnvironment, + ModalExecStart, + PreparedModalExec, +) logger = logging.getLogger(__name__) _SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json" +_DIRECT_SNAPSHOT_NAMESPACE = "direct" def _load_snapshots() -> Dict[str, str]: @@ -45,12 +42,72 @@ def _save_snapshots(data: Dict[str, str]) -> None: _SNAPSHOT_STORE.write_text(json.dumps(data, indent=2)) -class _AsyncWorker: - """Background thread with its own event loop for async-safe Modal calls. +def _direct_snapshot_key(task_id: str) -> str: + return f"{_DIRECT_SNAPSHOT_NAMESPACE}:{task_id}" - Allows sync code to submit async coroutines and block for results, - even when called from inside another running event loop (e.g. Atropos). - """ + +def _get_snapshot_restore_candidate(task_id: str) -> tuple[str | None, bool]: + """Return a snapshot id and whether it came from the legacy key format.""" + snapshots = _load_snapshots() + + namespaced_key = _direct_snapshot_key(task_id) + snapshot_id = snapshots.get(namespaced_key) + if isinstance(snapshot_id, str) and snapshot_id: + return snapshot_id, False + + legacy_snapshot_id = snapshots.get(task_id) + if isinstance(legacy_snapshot_id, str) and legacy_snapshot_id: + return legacy_snapshot_id, True + + return None, False + + +def _store_direct_snapshot(task_id: str, snapshot_id: str) -> None: + """Persist the direct Modal snapshot id under the direct namespace.""" + snapshots = _load_snapshots() + snapshots[_direct_snapshot_key(task_id)] = snapshot_id + snapshots.pop(task_id, None) + _save_snapshots(snapshots) + + +def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> None: + """Remove direct Modal snapshot entries for a task, including legacy keys.""" + snapshots = _load_snapshots() + updated = False + + for key in (_direct_snapshot_key(task_id), task_id): + value = snapshots.get(key) + if value is None: + continue + if snapshot_id is None or value == snapshot_id: + snapshots.pop(key, None) + updated = True + + if updated: + _save_snapshots(snapshots) + + +def _resolve_modal_image(image_spec: Any) -> Any: + """Convert registry references or snapshot ids into Modal image objects.""" + import modal as _modal + + if not isinstance(image_spec, str): + return image_spec + + if image_spec.startswith("im-"): + return _modal.Image.from_id(image_spec) + + return _modal.Image.from_registry( + image_spec, + setup_dockerfile_commands=[ + "RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; " + "python -m ensurepip --upgrade --default-pip 2>/dev/null || true", + ], + ) + + +class _AsyncWorker: + """Background thread with its own event loop for async-safe Modal calls.""" def __init__(self): self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -81,14 +138,19 @@ class _AsyncWorker: self._thread.join(timeout=10) -class ModalEnvironment(BaseEnvironment): - """Modal cloud execution via native Modal SDK. +@dataclass +class _DirectModalExecHandle: + thread: threading.Thread + result_holder: Dict[str, Any] - Uses Modal's Sandbox.create() for container lifecycle and Sandbox.exec() - for command execution — no intermediate HTTP server or tunnel required. - Adds sudo -S support, configurable resources (CPU, memory, disk), - and optional filesystem persistence via Modal's snapshot API. - """ + +class ModalEnvironment(BaseModalExecutionEnvironment): + """Modal cloud execution via native Modal sandboxes.""" + + _stdin_mode = "heredoc" + _poll_interval_seconds = 0.2 + _interrupt_output = "[Command interrupted - Modal sandbox terminated]" + _unexpected_error_prefix = "Modal execution error" def __init__( self, @@ -107,39 +169,21 @@ class ModalEnvironment(BaseEnvironment): self._sandbox = None self._app = None self._worker = _AsyncWorker() + self._synced_files: Dict[str, tuple] = {} sandbox_kwargs = dict(modal_sandbox_kwargs or {}) - # If persistent, try to restore from a previous snapshot - restored_image = None + restored_snapshot_id = None + restored_from_legacy_key = False if self._persistent: - snapshot_id = _load_snapshots().get(self._task_id) - if snapshot_id: - try: - import modal - restored_image = modal.Image.from_id(snapshot_id) - logger.info("Modal: restoring from snapshot %s", snapshot_id[:20]) - except Exception as e: - logger.warning("Modal: failed to restore snapshot, using base image: %s", e) - restored_image = None - - effective_image = restored_image if restored_image else image - - # Pre-build a modal.Image with pip fix for Modal's legacy image builder. - # Some task images have broken pip; fix via ensurepip before Modal uses it. - import modal as _modal - if isinstance(effective_image, str): - effective_image = _modal.Image.from_registry( - effective_image, - setup_dockerfile_commands=[ - "RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; " - "python -m ensurepip --upgrade --default-pip 2>/dev/null || true", - ], + restored_snapshot_id, restored_from_legacy_key = _get_snapshot_restore_candidate( + self._task_id ) + if restored_snapshot_id: + logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20]) + + import modal as _modal - # Mount credential files (OAuth tokens, etc.) declared by skills. - # These are read-only copies so the sandbox can authenticate with - # external services but can't modify the host's credentials. cred_mounts = [] try: from tools.credential_files import get_credential_file_mounts, iter_skills_files @@ -171,34 +215,63 @@ class ModalEnvironment(BaseEnvironment): except Exception as e: logger.debug("Modal: could not load credential file mounts: %s", e) - # Start the async worker thread and create sandbox on it - # so all gRPC channels are bound to the worker's event loop. self._worker.start() - async def _create_sandbox(): - app = await _modal.App.lookup.aio( - "hermes-agent", create_if_missing=True - ) + async def _create_sandbox(image_spec: Any): + app = await _modal.App.lookup.aio("hermes-agent", create_if_missing=True) create_kwargs = dict(sandbox_kwargs) if cred_mounts: existing_mounts = list(create_kwargs.pop("mounts", [])) existing_mounts.extend(cred_mounts) create_kwargs["mounts"] = existing_mounts sandbox = await _modal.Sandbox.create.aio( - "sleep", "infinity", - image=effective_image, + "sleep", + "infinity", + image=image_spec, app=app, timeout=int(create_kwargs.pop("timeout", 3600)), **create_kwargs, ) return app, sandbox - self._app, self._sandbox = self._worker.run_coroutine( - _create_sandbox(), timeout=300 - ) - # Track synced files to avoid redundant pushes. - # Key: container_path, Value: (mtime, size) of last synced version. - self._synced_files: Dict[str, tuple] = {} + try: + target_image_spec = restored_snapshot_id or image + try: + # _resolve_modal_image keeps the Modal bootstrap fix together: + # it applies setup_dockerfile_commands with ensurepip before + # Modal builds registry images, while snapshot ids restore via + # modal.Image.from_id() without rebuilding. + effective_image = _resolve_modal_image(target_image_spec) + self._app, self._sandbox = self._worker.run_coroutine( + _create_sandbox(effective_image), + timeout=300, + ) + except Exception as exc: + if not restored_snapshot_id: + raise + + logger.warning( + "Modal: failed to restore snapshot %s, retrying with base image: %s", + restored_snapshot_id[:20], + exc, + ) + _delete_direct_snapshot(self._task_id, restored_snapshot_id) + base_image = _resolve_modal_image(image) + self._app, self._sandbox = self._worker.run_coroutine( + _create_sandbox(base_image), + timeout=300, + ) + else: + if restored_snapshot_id and restored_from_legacy_key: + _store_direct_snapshot(self._task_id, restored_snapshot_id) + logger.info( + "Modal: migrated legacy snapshot entry for task %s", + self._task_id, + ) + except Exception: + self._worker.stop() + raise + logger.info("Modal: sandbox created (task=%s)", self._task_id) def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool: @@ -253,86 +326,57 @@ class ModalEnvironment(BaseEnvironment): except Exception as e: logger.debug("Modal: file sync failed: %s", e) - def execute(self, command: str, cwd: str = "", *, - timeout: int | None = None, - stdin_data: str | None = None) -> dict: - # Sync credential files before each command so mid-session - # OAuth setups are picked up without requiring a restart. + def _before_execute(self) -> None: self._sync_files() - if stdin_data is not None: - marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" - while marker in stdin_data: - marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" - command = f"{command} << '{marker}'\n{stdin_data}\n{marker}" - - exec_command, sudo_stdin = self._prepare_command(command) - - # Modal sandboxes execute commands via exec() and cannot pipe - # subprocess stdin directly. When a sudo password is present, - # use a shell-level pipe from printf. - if sudo_stdin is not None: - exec_command = ( - f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}" - ) - - effective_cwd = cwd or self.cwd - effective_timeout = timeout or self.timeout - - # Wrap command with cd + stderr merge - full_command = f"cd {shlex.quote(effective_cwd)} && {exec_command}" - - # Run in a background thread so we can poll for interrupts + def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart: + full_command = f"cd {shlex.quote(prepared.cwd)} && {prepared.command}" result_holder = {"value": None, "error": None} def _run(): try: async def _do_execute(): process = await self._sandbox.exec.aio( - "bash", "-c", full_command, - timeout=effective_timeout, + "bash", + "-c", + full_command, + timeout=prepared.timeout, ) - # Read stdout; redirect stderr to stdout in the shell - # command so we get merged output stdout = await process.stdout.read.aio() stderr = await process.stderr.read.aio() exit_code = await process.wait.aio() - # Merge stdout + stderr (stderr after stdout) + if isinstance(stdout, bytes): + stdout = stdout.decode("utf-8", errors="replace") + if isinstance(stderr, bytes): + stderr = stderr.decode("utf-8", errors="replace") output = stdout if stderr: output = f"{stdout}\n{stderr}" if stdout else stderr - return output, exit_code + return self._result(output, exit_code) - output, exit_code = self._worker.run_coroutine( - _do_execute(), timeout=effective_timeout + 30 + result_holder["value"] = self._worker.run_coroutine( + _do_execute(), + timeout=prepared.timeout + 30, ) - result_holder["value"] = { - "output": output, - "returncode": exit_code, - } except Exception as e: result_holder["error"] = e t = threading.Thread(target=_run, daemon=True) t.start() - while t.is_alive(): - t.join(timeout=0.2) - if is_interrupted(): - try: - self._worker.run_coroutine( - self._sandbox.terminate.aio(), - timeout=15, - ) - except Exception: - pass - return { - "output": "[Command interrupted - Modal sandbox terminated]", - "returncode": 130, - } + return ModalExecStart(handle=_DirectModalExecHandle(thread=t, result_holder=result_holder)) - if result_holder["error"]: - return {"output": f"Modal execution error: {result_holder['error']}", "returncode": 1} - return result_holder["value"] + def _poll_modal_exec(self, handle: _DirectModalExecHandle) -> dict | None: + if handle.thread.is_alive(): + return None + if handle.result_holder["error"]: + return self._error_result(f"Modal execution error: {handle.result_holder['error']}") + return handle.result_holder["value"] + + def _cancel_modal_exec(self, handle: _DirectModalExecHandle) -> None: + self._worker.run_coroutine( + self._sandbox.terminate.aio(), + timeout=15, + ) def cleanup(self): """Snapshot the filesystem (if persistent) then stop the sandbox.""" @@ -351,11 +395,12 @@ class ModalEnvironment(BaseEnvironment): snapshot_id = None if snapshot_id: - snapshots = _load_snapshots() - snapshots[self._task_id] = snapshot_id - _save_snapshots(snapshots) - logger.info("Modal: saved filesystem snapshot %s for task %s", - snapshot_id[:20], self._task_id) + _store_direct_snapshot(self._task_id, snapshot_id) + logger.info( + "Modal: saved filesystem snapshot %s for task %s", + snapshot_id[:20], + self._task_id, + ) except Exception as e: logger.warning("Modal: filesystem snapshot failed: %s", e) diff --git a/tools/environments/modal_common.py b/tools/environments/modal_common.py new file mode 100644 index 000000000..0affd0209 --- /dev/null +++ b/tools/environments/modal_common.py @@ -0,0 +1,178 @@ +"""Shared Hermes-side execution flow for Modal transports. + +This module deliberately stops at the Hermes boundary: +- command preparation +- cwd/timeout normalization +- stdin/sudo shell wrapping +- common result shape +- interrupt/cancel polling + +Direct Modal and managed Modal keep separate transport logic, persistence, and +trust-boundary decisions in their own modules. +""" + +from __future__ import annotations + +import shlex +import time +import uuid +from abc import abstractmethod +from dataclasses import dataclass +from typing import Any + +from tools.environments.base import BaseEnvironment +from tools.interrupt import is_interrupted + + +@dataclass(frozen=True) +class PreparedModalExec: + """Normalized command data passed to a transport-specific exec runner.""" + + command: str + cwd: str + timeout: int + stdin_data: str | None = None + + +@dataclass(frozen=True) +class ModalExecStart: + """Transport response after starting an exec.""" + + handle: Any | None = None + immediate_result: dict | None = None + + +def wrap_modal_stdin_heredoc(command: str, stdin_data: str) -> str: + """Append stdin as a shell heredoc for transports without stdin piping.""" + marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" + while marker in stdin_data: + marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" + return f"{command} << '{marker}'\n{stdin_data}\n{marker}" + + +def wrap_modal_sudo_pipe(command: str, sudo_stdin: str) -> str: + """Feed sudo via a shell pipe for transports without direct stdin piping.""" + return f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {command}" + + +class BaseModalExecutionEnvironment(BaseEnvironment): + """Common execute() flow for direct and managed Modal transports.""" + + _stdin_mode = "payload" + _poll_interval_seconds = 0.25 + _client_timeout_grace_seconds: float | None = None + _interrupt_output = "[Command interrupted]" + _unexpected_error_prefix = "Modal execution error" + + def execute( + self, + command: str, + cwd: str = "", + *, + timeout: int | None = None, + stdin_data: str | None = None, + ) -> dict: + self._before_execute() + prepared = self._prepare_modal_exec( + command, + cwd=cwd, + timeout=timeout, + stdin_data=stdin_data, + ) + + try: + start = self._start_modal_exec(prepared) + except Exception as exc: + return self._error_result(f"{self._unexpected_error_prefix}: {exc}") + + if start.immediate_result is not None: + return start.immediate_result + + if start.handle is None: + return self._error_result( + f"{self._unexpected_error_prefix}: transport did not return an exec handle" + ) + + deadline = None + if self._client_timeout_grace_seconds is not None: + deadline = time.monotonic() + prepared.timeout + self._client_timeout_grace_seconds + + while True: + if is_interrupted(): + try: + self._cancel_modal_exec(start.handle) + except Exception: + pass + return self._result(self._interrupt_output, 130) + + try: + result = self._poll_modal_exec(start.handle) + except Exception as exc: + return self._error_result(f"{self._unexpected_error_prefix}: {exc}") + + if result is not None: + return result + + if deadline is not None and time.monotonic() >= deadline: + try: + self._cancel_modal_exec(start.handle) + except Exception: + pass + return self._timeout_result_for_modal(prepared.timeout) + + time.sleep(self._poll_interval_seconds) + + def _before_execute(self) -> None: + """Hook for backends that need pre-exec sync or validation.""" + return None + + def _prepare_modal_exec( + self, + command: str, + *, + cwd: str = "", + timeout: int | None = None, + stdin_data: str | None = None, + ) -> PreparedModalExec: + effective_cwd = cwd or self.cwd + effective_timeout = timeout or self.timeout + + exec_command = command + exec_stdin = stdin_data if self._stdin_mode == "payload" else None + if stdin_data is not None and self._stdin_mode == "heredoc": + exec_command = wrap_modal_stdin_heredoc(exec_command, stdin_data) + + exec_command, sudo_stdin = self._prepare_command(exec_command) + if sudo_stdin is not None: + exec_command = wrap_modal_sudo_pipe(exec_command, sudo_stdin) + + return PreparedModalExec( + command=exec_command, + cwd=effective_cwd, + timeout=effective_timeout, + stdin_data=exec_stdin, + ) + + def _result(self, output: str, returncode: int) -> dict: + return { + "output": output, + "returncode": returncode, + } + + def _error_result(self, output: str) -> dict: + return self._result(output, 1) + + def _timeout_result_for_modal(self, timeout: int) -> dict: + return self._result(f"Command timed out after {timeout}s", 124) + + @abstractmethod + def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart: + """Begin a transport-specific exec.""" + + @abstractmethod + def _poll_modal_exec(self, handle: Any) -> dict | None: + """Return a final result dict when complete, else ``None``.""" + + @abstractmethod + def _cancel_modal_exec(self, handle: Any) -> None: + """Cancel or terminate the active transport exec.""" diff --git a/tools/environments/singularity.py b/tools/environments/singularity.py index 381ac2b2d..2ee525a36 100644 --- a/tools/environments/singularity.py +++ b/tools/environments/singularity.py @@ -16,7 +16,7 @@ import uuid from pathlib import Path from typing import Any, Dict, Optional -from hermes_cli.config import get_hermes_home +from hermes_constants import get_hermes_home from tools.environments.base import BaseEnvironment from tools.interrupt import is_interrupted diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 5dadf4998..77e090529 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -32,9 +32,14 @@ import json import logging import os import datetime +import threading +import uuid from typing import Dict, Any, Optional, Union +from urllib.parse import urlencode import fal_client from tools.debug_helpers import DebugSession +from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -77,6 +82,137 @@ VALID_OUTPUT_FORMATS = ["jpeg", "png"] VALID_ACCELERATION_MODES = ["none", "regular", "high"] _debug = DebugSession("image_tools", env_var="IMAGE_TOOLS_DEBUG") +_managed_fal_client = None +_managed_fal_client_config = None +_managed_fal_client_lock = threading.Lock() + + +def _resolve_managed_fal_gateway(): + """Return managed fal-queue gateway config when direct FAL credentials are absent.""" + if os.getenv("FAL_KEY"): + return None + return resolve_managed_tool_gateway("fal-queue") + + +def _normalize_fal_queue_url_format(queue_run_origin: str) -> str: + normalized_origin = str(queue_run_origin or "").strip().rstrip("/") + if not normalized_origin: + raise ValueError("Managed FAL queue origin is required") + return f"{normalized_origin}/" + + +class _ManagedFalSyncClient: + """Small per-instance wrapper around fal_client.SyncClient for managed queue hosts.""" + + def __init__(self, *, key: str, queue_run_origin: str): + sync_client_class = getattr(fal_client, "SyncClient", None) + if sync_client_class is None: + raise RuntimeError("fal_client.SyncClient is required for managed FAL gateway mode") + + client_module = getattr(fal_client, "client", None) + if client_module is None: + raise RuntimeError("fal_client.client is required for managed FAL gateway mode") + + self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin) + self._sync_client = sync_client_class(key=key) + self._http_client = getattr(self._sync_client, "_client", None) + self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None) + self._raise_for_status = getattr(client_module, "_raise_for_status", None) + self._request_handle_class = getattr(client_module, "SyncRequestHandle", None) + self._add_hint_header = getattr(client_module, "add_hint_header", None) + self._add_priority_header = getattr(client_module, "add_priority_header", None) + self._add_timeout_header = getattr(client_module, "add_timeout_header", None) + + if self._http_client is None: + raise RuntimeError("fal_client.SyncClient._client is required for managed FAL gateway mode") + if self._maybe_retry_request is None or self._raise_for_status is None: + raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode") + if self._request_handle_class is None: + raise RuntimeError("fal_client.client.SyncRequestHandle is required for managed FAL gateway mode") + + def submit( + self, + application: str, + arguments: Dict[str, Any], + *, + path: str = "", + hint: Optional[str] = None, + webhook_url: Optional[str] = None, + priority: Any = None, + headers: Optional[Dict[str, str]] = None, + start_timeout: Optional[Union[int, float]] = None, + ): + url = self._queue_url_format + application + if path: + url += "/" + path.lstrip("/") + if webhook_url is not None: + url += "?" + urlencode({"fal_webhook": webhook_url}) + + request_headers = dict(headers or {}) + if hint is not None and self._add_hint_header is not None: + self._add_hint_header(hint, request_headers) + if priority is not None: + if self._add_priority_header is None: + raise RuntimeError("fal_client.client.add_priority_header is required for priority requests") + self._add_priority_header(priority, request_headers) + if start_timeout is not None: + if self._add_timeout_header is None: + raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests") + self._add_timeout_header(start_timeout, request_headers) + + response = self._maybe_retry_request( + self._http_client, + "POST", + url, + json=arguments, + timeout=getattr(self._sync_client, "default_timeout", 120.0), + headers=request_headers, + ) + self._raise_for_status(response) + + data = response.json() + return self._request_handle_class( + request_id=data["request_id"], + response_url=data["response_url"], + status_url=data["status_url"], + cancel_url=data["cancel_url"], + client=self._http_client, + ) + + +def _get_managed_fal_client(managed_gateway): + """Reuse the managed FAL client so its internal httpx.Client is not leaked per call.""" + global _managed_fal_client, _managed_fal_client_config + + client_config = ( + managed_gateway.gateway_origin.rstrip("/"), + managed_gateway.nous_user_token, + ) + with _managed_fal_client_lock: + if _managed_fal_client is not None and _managed_fal_client_config == client_config: + return _managed_fal_client + + _managed_fal_client = _ManagedFalSyncClient( + key=managed_gateway.nous_user_token, + queue_run_origin=managed_gateway.gateway_origin, + ) + _managed_fal_client_config = client_config + return _managed_fal_client + + +def _submit_fal_request(model: str, arguments: Dict[str, Any]): + """Submit a FAL request using direct credentials or the managed queue gateway.""" + request_headers = {"x-idempotency-key": str(uuid.uuid4())} + managed_gateway = _resolve_managed_fal_gateway() + if managed_gateway is None: + return fal_client.submit(model, arguments=arguments, headers=request_headers) + + managed_client = _get_managed_fal_client(managed_gateway) + return managed_client.submit( + model, + arguments=arguments, + headers=request_headers, + ) def _validate_parameters( @@ -186,9 +322,9 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]: # The async API (submit_async) caches a global httpx.AsyncClient via # @cached_property, which breaks when asyncio.run() destroys the loop # between calls (gateway thread-pool pattern). - handler = fal_client.submit( + handler = _submit_fal_request( UPSCALER_MODEL, - arguments=upscaler_arguments + arguments=upscaler_arguments, ) # Get the upscaled result (sync — blocks until done) @@ -280,8 +416,11 @@ def image_generate_tool( raise ValueError("Prompt is required and must be a non-empty string") # Check API key availability - if not os.getenv("FAL_KEY"): - raise ValueError("FAL_KEY environment variable not set") + if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()): + message = "FAL_KEY environment variable not set" + if managed_nous_tools_enabled(): + message += " and managed FAL gateway is unavailable" + raise ValueError(message) # Validate other parameters validated_params = _validate_parameters( @@ -312,9 +451,9 @@ def image_generate_tool( logger.info(" Guidance: %s", validated_params['guidance_scale']) # Submit request to FAL.ai using sync API (avoids cached event loop issues) - handler = fal_client.submit( + handler = _submit_fal_request( DEFAULT_MODEL, - arguments=arguments + arguments=arguments, ) # Get the result (sync — blocks until done) @@ -379,10 +518,12 @@ def image_generate_tool( error_msg = f"Error generating image: {str(e)}" logger.error("%s", error_msg, exc_info=True) - # Prepare error response - minimal format + # Include error details so callers can diagnose failures response_data = { "success": False, - "image": None + "image": None, + "error": str(e), + "error_type": type(e).__name__, } debug_call_data["error"] = error_msg @@ -400,7 +541,7 @@ def check_fal_api_key() -> bool: Returns: bool: True if API key is set, False otherwise """ - return bool(os.getenv("FAL_KEY")) + return bool(os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()) def check_image_generation_requirements() -> bool: @@ -556,7 +697,7 @@ registry.register( schema=IMAGE_GENERATE_SCHEMA, handler=_handle_image_generate, check_fn=check_image_generation_requirements, - requires_env=["FAL_KEY"], + requires_env=[], is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway emoji="🎨", ) diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py new file mode 100644 index 000000000..cd27537fd --- /dev/null +++ b/tools/managed_tool_gateway.py @@ -0,0 +1,167 @@ +"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs.""" + +from __future__ import annotations + +import json +import logging +import os +from datetime import datetime, timezone +from dataclasses import dataclass +from typing import Callable, Optional + +logger = logging.getLogger(__name__) + +from hermes_constants import get_hermes_home +from tools.tool_backend_helpers import managed_nous_tools_enabled + +_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com" +_DEFAULT_TOOL_GATEWAY_SCHEME = "https" +_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120 + + +@dataclass(frozen=True) +class ManagedToolGatewayConfig: + vendor: str + gateway_origin: str + nous_user_token: str + managed_mode: bool + + +def auth_json_path(): + """Return the Hermes auth store path, respecting HERMES_HOME overrides.""" + return get_hermes_home() / "auth.json" + + +def _read_nous_provider_state() -> Optional[dict]: + try: + path = auth_json_path() + if not path.is_file(): + return None + data = json.loads(path.read_text()) + providers = data.get("providers", {}) + if not isinstance(providers, dict): + return None + nous_provider = providers.get("nous", {}) + if isinstance(nous_provider, dict): + return nous_provider + except Exception: + pass + return None + + +def _parse_timestamp(value: object) -> Optional[datetime]: + if not isinstance(value, str) or not value.strip(): + return None + normalized = value.strip() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(normalized) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool: + expires = _parse_timestamp(expires_at) + if expires is None: + return True + remaining = (expires - datetime.now(timezone.utc)).total_seconds() + return remaining <= max(0, int(skew_seconds)) + + +def read_nous_access_token() -> Optional[str]: + """Read a Nous Subscriber OAuth access token from auth store or env override.""" + explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN") + if isinstance(explicit, str) and explicit.strip(): + return explicit.strip() + + nous_provider = _read_nous_provider_state() or {} + access_token = nous_provider.get("access_token") + cached_token = access_token.strip() if isinstance(access_token, str) and access_token.strip() else None + + if cached_token and not _access_token_is_expiring( + nous_provider.get("expires_at"), + _NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ): + return cached_token + + try: + from hermes_cli.auth import resolve_nous_access_token + + refreshed_token = resolve_nous_access_token( + refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + ) + if isinstance(refreshed_token, str) and refreshed_token.strip(): + return refreshed_token.strip() + except Exception as exc: + logger.debug("Nous access token refresh failed: %s", exc) + + return cached_token + + +def get_tool_gateway_scheme() -> str: + """Return configured shared gateway URL scheme.""" + scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower() + if not scheme: + return _DEFAULT_TOOL_GATEWAY_SCHEME + + if scheme in {"http", "https"}: + return scheme + + raise ValueError("TOOL_GATEWAY_SCHEME must be 'http' or 'https'") + + +def build_vendor_gateway_url(vendor: str) -> str: + """Return the gateway origin for a specific vendor.""" + vendor_key = f"{vendor.upper().replace('-', '_')}_GATEWAY_URL" + explicit_vendor_url = os.getenv(vendor_key, "").strip().rstrip("/") + if explicit_vendor_url: + return explicit_vendor_url + + shared_scheme = get_tool_gateway_scheme() + shared_domain = os.getenv("TOOL_GATEWAY_DOMAIN", "").strip().strip("/") + if shared_domain: + return f"{shared_scheme}://{vendor}-gateway.{shared_domain}" + + return f"{shared_scheme}://{vendor}-gateway.{_DEFAULT_TOOL_GATEWAY_DOMAIN}" + + +def resolve_managed_tool_gateway( + vendor: str, + gateway_builder: Optional[Callable[[str], str]] = None, + token_reader: Optional[Callable[[], Optional[str]]] = None, +) -> Optional[ManagedToolGatewayConfig]: + """Resolve shared managed-tool gateway config for a vendor.""" + if not managed_nous_tools_enabled(): + return None + + resolved_gateway_builder = gateway_builder or build_vendor_gateway_url + resolved_token_reader = token_reader or read_nous_access_token + + gateway_origin = resolved_gateway_builder(vendor) + nous_user_token = resolved_token_reader() + if not gateway_origin or not nous_user_token: + return None + + return ManagedToolGatewayConfig( + vendor=vendor, + gateway_origin=gateway_origin, + nous_user_token=nous_user_token, + managed_mode=True, + ) + + +def is_managed_tool_gateway_ready( + vendor: str, + gateway_builder: Optional[Callable[[str], str]] = None, + token_reader: Optional[Callable[[], Optional[str]]] = None, +) -> bool: + """Return True when gateway URL and Nous access token are available.""" + return resolve_managed_tool_gateway( + vendor, + gateway_builder=gateway_builder, + token_reader=token_reader, + ) is not None diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 61e045f0d..6c9e2441a 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -78,7 +78,6 @@ from pathlib import Path from typing import Dict, Any, List, Optional, Set, Tuple import yaml -from hermes_cli.config import load_env, _ENV_VAR_NAME_RE from tools.registry import registry logger = logging.getLogger(__name__) @@ -101,11 +100,28 @@ _PLATFORM_MAP = { "linux": "linux", "windows": "win32", } +_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub")) _REMOTE_ENV_BACKENDS = frozenset({"docker", "singularity", "modal", "ssh", "daytona"}) _secret_capture_callback = None +def load_env() -> Dict[str, str]: + """Load profile-scoped environment variables from HERMES_HOME/.env.""" + env_path = get_hermes_home() / ".env" + env_vars: Dict[str, str] = {} + if not env_path.exists(): + return env_vars + + with env_path.open() as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + env_vars[key.strip()] = value.strip().strip("\"'") + return env_vars + + class SkillReadinessStatus(str, Enum): AVAILABLE = "available" SETUP_NEEDED = "setup_needed" diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index e97bc483c..f4ffeec79 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -3,12 +3,12 @@ Terminal Tool Module A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments. -Supports local execution, Docker containers, and Modal cloud sandboxes. +Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode. Environment Selection (via TERMINAL_ENV environment variable): - "local": Execute directly on the host machine (default, fastest) - "docker": Execute in Docker containers (isolated, requires Docker) -- "modal": Execute in Modal cloud sandboxes (scalable, requires Modal account) +- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway) Features: - Multiple execution backends (local, docker, modal) @@ -16,6 +16,10 @@ Features: - VM/container lifecycle management - Automatic cleanup after inactivity +Cloud sandbox note: +- Persistent filesystems preserve working state across sandbox recreation +- Persistent filesystems do NOT guarantee the same live sandbox or long-running processes survive cleanup, idle reaping, or Hermes exit + Usage: from terminal_tool import terminal_tool @@ -51,12 +55,23 @@ from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — r # display_hermes_home imported lazily at call site (stale-module safety during hermes update) +def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None: + """Backward-compatible no-op after minisweagent_path.py removal.""" + return + + # ============================================================================= # Custom Singularity Environment with more space # ============================================================================= # Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py from tools.environments.singularity import _get_scratch_dir +from tools.tool_backend_helpers import ( + coerce_modal_mode, + has_direct_modal_credentials, + managed_nous_tools_enabled, + resolve_modal_backend_state, +) # Disk usage warning threshold (in GB) @@ -363,10 +378,12 @@ from tools.environments.singularity import SingularityEnvironment as _Singularit from tools.environments.ssh import SSHEnvironment as _SSHEnvironment from tools.environments.docker import DockerEnvironment as _DockerEnvironment from tools.environments.modal import ModalEnvironment as _ModalEnvironment +from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment +from tools.managed_tool_gateway import is_managed_tool_gateway_ready # Tool description for LLM -TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem persists between calls. +TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls. Do NOT use cat/head/tail to read files — use read_file instead. Do NOT use grep/rg/find to search — use search_files instead. @@ -382,6 +399,7 @@ Working directory: Use 'workdir' for per-command cwd. PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL). Do NOT use vim/nano/interactive tools without pty=true — they hang without a pseudo-terminal. Pipe git output to cat if it might page. +Important: cloud sandboxes may be cleaned up, idled out, or recreated between turns. Persistent filesystem means files can resume later; it does NOT guarantee a continuously running machine or surviving background processes. Use terminal sandboxes for task work, not durable hosting. """ # Global state for environment lifecycle management @@ -495,6 +513,7 @@ def _get_env_config() -> Dict[str, Any]: return { "env_type": env_type, + "modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), @@ -527,6 +546,15 @@ def _get_env_config() -> Dict[str, Any]: } +def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: + """Resolve direct vs managed Modal backend selection.""" + return resolve_modal_backend_state( + modal_mode, + has_direct=has_direct_modal_credentials(), + managed_ready=is_managed_tool_gateway_ready("modal"), + ) + + def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ssh_config: dict = None, container_config: dict = None, local_config: dict = None, @@ -592,7 +620,39 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, sandbox_kwargs["ephemeral_disk"] = disk except Exception: pass - + + modal_state = _get_modal_backend_state(cc.get("modal_mode")) + + if modal_state["selected_backend"] == "managed": + return _ManagedModalEnvironment( + image=image, cwd=cwd, timeout=timeout, + modal_sandbox_kwargs=sandbox_kwargs, + persistent_filesystem=persistent, task_id=task_id, + ) + + if modal_state["selected_backend"] != "direct": + if modal_state["managed_mode_blocked"]: + raise ValueError( + "Modal backend is configured for managed mode, but " + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct " + "Modal credentials/config were found. Enable the feature flag or " + "choose TERMINAL_MODAL_MODE=direct/auto." + ) + if modal_state["mode"] == "managed": + raise ValueError( + "Modal backend is configured for managed mode, but the managed tool gateway is unavailable." + ) + if modal_state["mode"] == "direct": + raise ValueError( + "Modal backend is configured for direct mode, but no direct Modal credentials/config were found." + ) + message = "Modal backend selected but no direct Modal credentials/config was found." + if managed_nous_tools_enabled(): + message = ( + "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found." + ) + raise ValueError(message) + return _ModalEnvironment( image=image, cwd=cwd, timeout=timeout, modal_sandbox_kwargs=sandbox_kwargs, @@ -958,6 +1018,7 @@ def terminal_tool( "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), + "modal_mode": config.get("modal_mode", "auto"), "docker_volumes": config.get("docker_volumes", []), "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), } @@ -1175,10 +1236,14 @@ def terminal_tool( }, ensure_ascii=False) except Exception as e: + import traceback + tb_str = traceback.format_exc() + logger.error("terminal_tool exception:\n%s", tb_str) return json.dumps({ "output": "", "exit_code": -1, "error": f"Failed to execute command: {str(e)}", + "traceback": tb_str, "status": "error" }, ensure_ascii=False) @@ -1218,18 +1283,58 @@ def check_terminal_requirements() -> bool: return True elif env_type == "modal": + modal_state = _get_modal_backend_state(config.get("modal_mode")) + if modal_state["selected_backend"] == "managed": + return True + + if modal_state["selected_backend"] != "direct": + if modal_state["managed_mode_blocked"]: + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=managed, but " + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct " + "Modal credentials/config were found. Enable the feature flag " + "or choose TERMINAL_MODAL_MODE=direct/auto." + ) + return False + if modal_state["mode"] == "managed": + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed " + "tool gateway is unavailable. Configure the managed gateway or choose " + "TERMINAL_MODAL_MODE=direct/auto." + ) + return False + elif modal_state["mode"] == "direct": + if managed_nous_tools_enabled(): + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " + "Modal credentials/config were found. Configure Modal or choose " + "TERMINAL_MODAL_MODE=managed/auto." + ) + else: + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " + "Modal credentials/config were found. Configure Modal or choose " + "TERMINAL_MODAL_MODE=auto." + ) + return False + else: + if managed_nous_tools_enabled(): + logger.error( + "Modal backend selected but no direct Modal credentials/config or managed " + "tool gateway was found. Configure Modal, set up the managed gateway, " + "or choose a different TERMINAL_ENV." + ) + else: + logger.error( + "Modal backend selected but no direct Modal credentials/config was found. " + "Configure Modal or choose a different TERMINAL_ENV." + ) + return False + if importlib.util.find_spec("modal") is None: - logger.error("modal is required for modal terminal backend: pip install modal") - return False - has_token = os.getenv("MODAL_TOKEN_ID") is not None - has_config = Path.home().joinpath(".modal.toml").exists() - if not (has_token or has_config): - logger.error( - "Modal backend selected but no MODAL_TOKEN_ID environment variable " - "or ~/.modal.toml config file was found. Configure Modal or choose " - "a different TERMINAL_ENV." - ) + logger.error("modal is required for direct modal terminal backend: pip install modal") return False + return True elif env_type == "daytona": diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py new file mode 100644 index 000000000..b65e19174 --- /dev/null +++ b/tools/tool_backend_helpers.py @@ -0,0 +1,89 @@ +"""Shared helpers for tool backend selection.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any, Dict + +from utils import env_var_enabled + +_DEFAULT_BROWSER_PROVIDER = "local" +_DEFAULT_MODAL_MODE = "auto" +_VALID_MODAL_MODES = {"auto", "direct", "managed"} + + +def managed_nous_tools_enabled() -> bool: + """Return True when the hidden Nous-managed tools feature flag is enabled.""" + return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS") + + +def normalize_browser_cloud_provider(value: object | None) -> str: + """Return a normalized browser provider key.""" + provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower() + return provider or _DEFAULT_BROWSER_PROVIDER + + +def coerce_modal_mode(value: object | None) -> str: + """Return the requested modal mode when valid, else the default.""" + mode = str(value or _DEFAULT_MODAL_MODE).strip().lower() + if mode in _VALID_MODAL_MODES: + return mode + return _DEFAULT_MODAL_MODE + + +def normalize_modal_mode(value: object | None) -> str: + """Return a normalized modal execution mode.""" + return coerce_modal_mode(value) + + +def has_direct_modal_credentials() -> bool: + """Return True when direct Modal credentials/config are available.""" + return bool( + (os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET")) + or (Path.home() / ".modal.toml").exists() + ) + + +def resolve_modal_backend_state( + modal_mode: object | None, + *, + has_direct: bool, + managed_ready: bool, +) -> Dict[str, Any]: + """Resolve direct vs managed Modal backend selection. + + Semantics: + - ``direct`` means direct-only + - ``managed`` means managed-only + - ``auto`` prefers managed when available, then falls back to direct + """ + requested_mode = coerce_modal_mode(modal_mode) + normalized_mode = normalize_modal_mode(modal_mode) + managed_mode_blocked = ( + requested_mode == "managed" and not managed_nous_tools_enabled() + ) + + if normalized_mode == "managed": + selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else None + elif normalized_mode == "direct": + selected_backend = "direct" if has_direct else None + else: + selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else "direct" if has_direct else None + + return { + "requested_mode": requested_mode, + "mode": normalized_mode, + "has_direct": has_direct, + "managed_ready": managed_ready, + "managed_mode_blocked": managed_mode_blocked, + "selected_backend": selected_backend, + } + + +def resolve_openai_audio_api_key() -> str: + """Prefer the voice-tools key, but fall back to the normal OpenAI key.""" + return ( + os.getenv("VOICE_TOOLS_OPENAI_KEY", "") + or os.getenv("OPENAI_API_KEY", "") + ).strip() diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 70791b0ca..976a59d40 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -31,6 +31,11 @@ import subprocess import tempfile from pathlib import Path from typing import Optional, Dict, Any +from urllib.parse import urljoin + +from utils import is_truthy_value +from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key from hermes_constants import get_hermes_home @@ -41,8 +46,17 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- import importlib.util as _ilu -_HAS_FASTER_WHISPER = _ilu.find_spec("faster_whisper") is not None -_HAS_OPENAI = _ilu.find_spec("openai") is not None + + +def _safe_find_spec(module_name: str) -> bool: + try: + return _ilu.find_spec(module_name) is not None + except (ImportError, ValueError): + return module_name in globals() or module_name in os.sys.modules + + +_HAS_FASTER_WHISPER = _safe_find_spec("faster_whisper") +_HAS_OPENAI = _safe_find_spec("openai") # --------------------------------------------------------------------------- # Constants @@ -109,16 +123,12 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool: if stt_config is None: stt_config = _load_stt_config() enabled = stt_config.get("enabled", True) - if isinstance(enabled, str): - return enabled.strip().lower() in ("true", "1", "yes", "on") - if enabled is None: - return True - return bool(enabled) + return is_truthy_value(enabled, default=True) -def _resolve_openai_api_key() -> str: - """Prefer the voice-tools key, but fall back to the normal OpenAI key.""" - return os.getenv("VOICE_TOOLS_OPENAI_KEY", "") or os.getenv("OPENAI_API_KEY", "") +def _has_openai_audio_backend() -> bool: + """Return True when OpenAI audio can use direct credentials or the managed gateway.""" + return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio")) def _find_binary(binary_name: str) -> Optional[str]: @@ -210,7 +220,7 @@ def _get_provider(stt_config: dict) -> str: return "none" if provider == "openai": - if _HAS_OPENAI and _resolve_openai_api_key(): + if _HAS_OPENAI and _has_openai_audio_backend(): return "openai" logger.warning( "STT provider 'openai' configured but no API key available" @@ -228,7 +238,7 @@ def _get_provider(stt_config: dict) -> str: if _HAS_OPENAI and os.getenv("GROQ_API_KEY"): logger.info("No local STT available, using Groq Whisper API") return "groq" - if _HAS_OPENAI and _resolve_openai_api_key(): + if _HAS_OPENAI and _has_openai_audio_backend(): logger.info("No local STT available, using OpenAI Whisper API") return "openai" return "none" @@ -404,19 +414,23 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]: try: from openai import OpenAI, APIError, APIConnectionError, APITimeoutError client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0) + try: + with open(file_path, "rb") as audio_file: + transcription = client.audio.transcriptions.create( + model=model_name, + file=audio_file, + response_format="text", + ) - with open(file_path, "rb") as audio_file: - transcription = client.audio.transcriptions.create( - model=model_name, - file=audio_file, - response_format="text", - ) + transcript_text = str(transcription).strip() + logger.info("Transcribed %s via Groq API (%s, %d chars)", + Path(file_path).name, model_name, len(transcript_text)) - transcript_text = str(transcription).strip() - logger.info("Transcribed %s via Groq API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) - - return {"success": True, "transcript": transcript_text, "provider": "groq"} + return {"success": True, "transcript": transcript_text, "provider": "groq"} + finally: + close = getattr(client, "close", None) + if callable(close): + close() except PermissionError: return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} @@ -437,12 +451,13 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]: def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: """Transcribe using OpenAI Whisper API (paid).""" - api_key = _resolve_openai_api_key() - if not api_key: + try: + api_key, base_url = _resolve_openai_audio_client_config() + except ValueError as exc: return { "success": False, "transcript": "", - "error": "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set", + "error": str(exc), } if not _HAS_OPENAI: @@ -455,20 +470,24 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: try: from openai import OpenAI, APIError, APIConnectionError, APITimeoutError - client = OpenAI(api_key=api_key, base_url=OPENAI_BASE_URL, timeout=30, max_retries=0) + client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0) + try: + with open(file_path, "rb") as audio_file: + transcription = client.audio.transcriptions.create( + model=model_name, + file=audio_file, + response_format="text" if model_name == "whisper-1" else "json", + ) - with open(file_path, "rb") as audio_file: - transcription = client.audio.transcriptions.create( - model=model_name, - file=audio_file, - response_format="text", - ) + transcript_text = _extract_transcript_text(transcription) + logger.info("Transcribed %s via OpenAI API (%s, %d chars)", + Path(file_path).name, model_name, len(transcript_text)) - transcript_text = str(transcription).strip() - logger.info("Transcribed %s via OpenAI API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) - - return {"success": True, "transcript": transcript_text, "provider": "openai"} + return {"success": True, "transcript": transcript_text, "provider": "openai"} + finally: + close = getattr(client, "close", None) + if callable(close): + close() except PermissionError: return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} @@ -554,3 +573,39 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A "or OPENAI_API_KEY for the OpenAI Whisper API." ), } + + +def _resolve_openai_audio_client_config() -> tuple[str, str]: + """Return direct OpenAI audio config or a managed gateway fallback.""" + direct_api_key = resolve_openai_audio_api_key() + if direct_api_key: + return direct_api_key, OPENAI_BASE_URL + + managed_gateway = resolve_managed_tool_gateway("openai-audio") + if managed_gateway is None: + message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set" + if managed_nous_tools_enabled(): + message += ", and the managed OpenAI audio gateway is unavailable" + raise ValueError(message) + + return managed_gateway.nous_user_token, urljoin( + f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" + ) + + +def _extract_transcript_text(transcription: Any) -> str: + """Normalize text and JSON transcription responses to a plain string.""" + if isinstance(transcription, str): + return transcription.strip() + + if hasattr(transcription, "text"): + value = getattr(transcription, "text") + if isinstance(value, str): + return value.strip() + + if isinstance(transcription, dict): + value = transcription.get("text") + if isinstance(value, str): + return value.strip() + + return str(transcription).strip() diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 60f89787a..6487dbfa4 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -32,11 +32,14 @@ import shutil import subprocess import tempfile import threading +import uuid from pathlib import Path -from hermes_constants import get_hermes_home from typing import Callable, Dict, Any, Optional +from urllib.parse import urljoin logger = logging.getLogger(__name__) +from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key # --------------------------------------------------------------------------- # Lazy imports -- providers are imported only when actually used to avoid @@ -74,6 +77,8 @@ DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2" DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5" DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts" DEFAULT_OPENAI_VOICE = "alloy" +DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" + def _get_default_output_dir() -> str: from hermes_constants import get_hermes_dir return str(get_hermes_dir("cache/audio", "audio_cache")) @@ -237,14 +242,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] Returns: Path to the saved audio file. """ - api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "") - if not api_key: - raise ValueError("VOICE_TOOLS_OPENAI_KEY not set. Get one at https://platform.openai.com/api-keys") + api_key, base_url = _resolve_openai_audio_client_config() oai_config = tts_config.get("openai", {}) model = oai_config.get("model", DEFAULT_OPENAI_MODEL) voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) - base_url = oai_config.get("base_url", "https://api.openai.com/v1") + base_url = oai_config.get("base_url", base_url) # Determine response format from extension if output_path.endswith(".ogg"): @@ -254,15 +257,21 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any] OpenAIClient = _import_openai_client() client = OpenAIClient(api_key=api_key, base_url=base_url) - response = client.audio.speech.create( - model=model, - voice=voice, - input=text, - response_format=response_format, - ) + try: + response = client.audio.speech.create( + model=model, + voice=voice, + input=text, + response_format=response_format, + extra_headers={"x-idempotency-key": str(uuid.uuid4())}, + ) - response.stream_to_file(output_path) - return output_path + response.stream_to_file(output_path) + return output_path + finally: + close = getattr(client, "close", None) + if callable(close): + close() # =========================================================================== @@ -543,7 +552,7 @@ def check_tts_requirements() -> bool: pass try: _import_openai_client() - if os.getenv("VOICE_TOOLS_OPENAI_KEY"): + if _has_openai_audio_backend(): return True except ImportError: pass @@ -552,6 +561,29 @@ def check_tts_requirements() -> bool: return False +def _resolve_openai_audio_client_config() -> tuple[str, str]: + """Return direct OpenAI audio config or a managed gateway fallback.""" + direct_api_key = resolve_openai_audio_api_key() + if direct_api_key: + return direct_api_key, DEFAULT_OPENAI_BASE_URL + + managed_gateway = resolve_managed_tool_gateway("openai-audio") + if managed_gateway is None: + message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set" + if managed_nous_tools_enabled(): + message += ", and the managed OpenAI audio gateway is unavailable" + raise ValueError(message) + + return managed_gateway.nous_user_token, urljoin( + f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" + ) + + +def _has_openai_audio_backend() -> bool: + """Return True when OpenAI audio can use direct credentials or the managed gateway.""" + return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio")) + + # =========================================================================== # Streaming TTS: sentence-by-sentence pipeline for ElevenLabs # =========================================================================== @@ -806,7 +838,10 @@ if __name__ == "__main__": print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}") print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}") print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}") - print(f" API Key: {'set' if os.getenv('VOICE_TOOLS_OPENAI_KEY') else 'not set (VOICE_TOOLS_OPENAI_KEY)'}") + print( + " API Key: " + f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}" + ) print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}") print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}") diff --git a/tools/web_tools.py b/tools/web_tools.py index ded458d2f..ba6bdb077 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -4,15 +4,19 @@ Standalone Web Tools Module This module provides generic web tools that work with multiple backend providers. Backend is selected during ``hermes tools`` setup (web.backend in config.yaml). +When available, Hermes can route Firecrawl calls through a Nous-hosted tool-gateway +for Nous Subscribers only. Available tools: - web_search_tool: Search the web for information - web_extract_tool: Extract content from specific web pages -- web_crawl_tool: Crawl websites with specific instructions (Firecrawl only) +- web_crawl_tool: Crawl websites with specific instructions Backend compatibility: -- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl) +- Exa: https://exa.ai (search, extract) +- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl; direct or derived firecrawl-gateway. for Nous Subscribers) - Parallel: https://docs.parallel.ai (search, extract) +- Tavily: https://tavily.com (search, extract, crawl) LLM Processing: - Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction @@ -44,8 +48,18 @@ import asyncio from typing import List, Dict, Any, Optional import httpx from firecrawl import Firecrawl -from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning +from agent.auxiliary_client import ( + async_call_llm, + extract_content_or_reasoning, + get_async_text_auxiliary_client, +) from tools.debug_helpers import DebugSession +from tools.managed_tool_gateway import ( + build_vendor_gateway_url, + read_nous_access_token as _read_nous_access_token, + resolve_managed_tool_gateway, +) +from tools.tool_backend_helpers import managed_nous_tools_enabled from tools.url_safety import is_safe_url from tools.website_policy import check_website_access @@ -77,48 +91,152 @@ def _get_backend() -> str: if configured in ("parallel", "firecrawl", "tavily", "exa"): return configured - # Fallback for manual / legacy config — pick highest-priority backend - # that has a key configured. Order: firecrawl > parallel > tavily > exa. - for backend, keys in [ - ("firecrawl", ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL")), - ("parallel", ("PARALLEL_API_KEY",)), - ("tavily", ("TAVILY_API_KEY",)), - ("exa", ("EXA_API_KEY",)), - ]: - if any(_has_env(k) for k in keys): + # Fallback for manual / legacy config — pick the highest-priority + # available backend. Firecrawl also counts as available when the managed + # tool gateway is configured for Nous subscribers. + backend_candidates = ( + ("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()), + ("parallel", _has_env("PARALLEL_API_KEY")), + ("tavily", _has_env("TAVILY_API_KEY")), + ("exa", _has_env("EXA_API_KEY")), + ) + for backend, available in backend_candidates: + if available: return backend return "firecrawl" # default (backward compat) + +def _is_backend_available(backend: str) -> bool: + """Return True when the selected backend is currently usable.""" + if backend == "exa": + return _has_env("EXA_API_KEY") + if backend == "parallel": + return _has_env("PARALLEL_API_KEY") + if backend == "firecrawl": + return check_firecrawl_api_key() + if backend == "tavily": + return _has_env("TAVILY_API_KEY") + return False + # ─── Firecrawl Client ──────────────────────────────────────────────────────── _firecrawl_client = None +_firecrawl_client_config = None + + +def _get_direct_firecrawl_config() -> Optional[tuple[Dict[str, str], tuple[str, Optional[str], Optional[str]]]]: + """Return explicit direct Firecrawl kwargs + cache key, or None when unset.""" + api_key = os.getenv("FIRECRAWL_API_KEY", "").strip() + api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/") + + if not api_key and not api_url: + return None + + kwargs: Dict[str, str] = {} + if api_key: + kwargs["api_key"] = api_key + if api_url: + kwargs["api_url"] = api_url + + return kwargs, ("direct", api_url or None, api_key or None) + + +def _get_firecrawl_gateway_url() -> str: + """Return configured Firecrawl gateway URL.""" + return build_vendor_gateway_url("firecrawl") + + +def _is_tool_gateway_ready() -> bool: + """Return True when gateway URL and a Nous Subscriber token are available.""" + return resolve_managed_tool_gateway("firecrawl", token_reader=_read_nous_access_token) is not None + + +def _has_direct_firecrawl_config() -> bool: + """Return True when direct Firecrawl config is explicitly configured.""" + return _get_direct_firecrawl_config() is not None + + +def _raise_web_backend_configuration_error() -> None: + """Raise a clear error for unsupported web backend configuration.""" + message = ( + "Web tools are not configured. " + "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance." + ) + if managed_nous_tools_enabled(): + message += ( + " If you have the hidden Nous-managed tools flag enabled, you can also login to Nous " + "(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN." + ) + raise ValueError(message) + + +def _firecrawl_backend_help_suffix() -> str: + """Return optional managed-gateway guidance for Firecrawl help text.""" + if not managed_nous_tools_enabled(): + return "" + return ( + ", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use " + "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN" + ) + + +def _web_requires_env() -> list[str]: + """Return tool metadata env vars for the currently enabled web backends.""" + requires = [ + "EXA_API_KEY", + "PARALLEL_API_KEY", + "TAVILY_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + ] + if managed_nous_tools_enabled(): + requires.extend( + [ + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ] + ) + return requires + def _get_firecrawl_client(): - """Get or create the Firecrawl client (lazy initialization). + """Get or create Firecrawl client. - Uses the cloud API by default (requires FIRECRAWL_API_KEY). - Set FIRECRAWL_API_URL to point at a self-hosted instance instead — - in that case the API key is optional (set USE_DB_AUTHENTICATION=false - on your Firecrawl server to disable auth entirely). + Direct Firecrawl takes precedence when explicitly configured. Otherwise + Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers. """ - global _firecrawl_client - if _firecrawl_client is None: - api_key = os.getenv("FIRECRAWL_API_KEY") - api_url = os.getenv("FIRECRAWL_API_URL") - if not api_key and not api_url: - logger.error("Firecrawl client initialization failed: missing configuration.") - raise ValueError( - "Firecrawl client not configured. " - "Set FIRECRAWL_API_KEY (cloud) or FIRECRAWL_API_URL (self-hosted). " - "This tool requires Firecrawl to be available." - ) - kwargs = {} - if api_key: - kwargs["api_key"] = api_key - if api_url: - kwargs["api_url"] = api_url - _firecrawl_client = Firecrawl(**kwargs) + global _firecrawl_client, _firecrawl_client_config + + direct_config = _get_direct_firecrawl_config() + if direct_config is not None: + kwargs, client_config = direct_config + else: + managed_gateway = resolve_managed_tool_gateway( + "firecrawl", + token_reader=_read_nous_access_token, + ) + if managed_gateway is None: + logger.error("Firecrawl client initialization failed: missing direct config and tool-gateway auth.") + _raise_web_backend_configuration_error() + + kwargs = { + "api_key": managed_gateway.nous_user_token, + "api_url": managed_gateway.gateway_origin, + } + client_config = ( + "tool-gateway", + kwargs["api_url"], + managed_gateway.nous_user_token, + ) + + if _firecrawl_client is not None and _firecrawl_client_config == client_config: + return _firecrawl_client + + _firecrawl_client = Firecrawl(**kwargs) + _firecrawl_client_config = client_config return _firecrawl_client # ─── Parallel Client ───────────────────────────────────────────────────────── @@ -243,10 +361,115 @@ def _normalize_tavily_documents(response: dict, fallback_url: str = "") -> List[ return documents +def _to_plain_object(value: Any) -> Any: + """Convert SDK objects to plain python data structures when possible.""" + if value is None: + return None + + if isinstance(value, (dict, list, str, int, float, bool)): + return value + + if hasattr(value, "model_dump"): + try: + return value.model_dump() + except Exception: + pass + + if hasattr(value, "__dict__"): + try: + return {k: v for k, v in value.__dict__.items() if not k.startswith("_")} + except Exception: + pass + + return value + + +def _normalize_result_list(values: Any) -> List[Dict[str, Any]]: + """Normalize mixed SDK/list payloads into a list of dicts.""" + if not isinstance(values, list): + return [] + + normalized: List[Dict[str, Any]] = [] + for item in values: + plain = _to_plain_object(item) + if isinstance(plain, dict): + normalized.append(plain) + return normalized + + +def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]: + """Extract Firecrawl search results across SDK/direct/gateway response shapes.""" + response_plain = _to_plain_object(response) + + if isinstance(response_plain, dict): + data = response_plain.get("data") + if isinstance(data, list): + return _normalize_result_list(data) + + if isinstance(data, dict): + data_web = _normalize_result_list(data.get("web")) + if data_web: + return data_web + data_results = _normalize_result_list(data.get("results")) + if data_results: + return data_results + + top_web = _normalize_result_list(response_plain.get("web")) + if top_web: + return top_web + + top_results = _normalize_result_list(response_plain.get("results")) + if top_results: + return top_results + + if hasattr(response, "web"): + return _normalize_result_list(getattr(response, "web", [])) + + return [] + + +def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]: + """Normalize Firecrawl scrape payload shape across SDK and gateway variants.""" + result_plain = _to_plain_object(scrape_result) + if not isinstance(result_plain, dict): + return {} + + nested = result_plain.get("data") + if isinstance(nested, dict): + return nested + + return result_plain + + DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000 -# Allow per-task override via env var -DEFAULT_SUMMARIZER_MODEL = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None +def _is_nous_auxiliary_client(client: Any) -> bool: + """Return True when the resolved auxiliary backend is Nous Portal.""" + from urllib.parse import urlparse + + base_url = str(getattr(client, "base_url", "") or "") + host = (urlparse(base_url).hostname or "").lower() + return host == "nousresearch.com" or host.endswith(".nousresearch.com") + + +def _resolve_web_extract_auxiliary(model: Optional[str] = None) -> tuple[Optional[Any], Optional[str], Dict[str, Any]]: + """Resolve the current web-extract auxiliary client, model, and extra body.""" + client, default_model = get_async_text_auxiliary_client("web_extract") + configured_model = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() + effective_model = model or configured_model or default_model + + extra_body: Dict[str, Any] = {} + if client is not None and _is_nous_auxiliary_client(client): + from agent.auxiliary_client import get_auxiliary_extra_body + extra_body = get_auxiliary_extra_body() or {"tags": ["product=hermes-agent"]} + + return client, effective_model, extra_body + + +def _get_default_summarizer_model() -> Optional[str]: + """Return the current default model for web extraction summarization.""" + _, model, _ = _resolve_web_extract_auxiliary() + return model _debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG") @@ -255,7 +478,7 @@ async def process_content_with_llm( content: str, url: str = "", title: str = "", - model: str = DEFAULT_SUMMARIZER_MODEL, + model: Optional[str] = None, min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION ) -> Optional[str]: """ @@ -338,7 +561,7 @@ async def process_content_with_llm( async def _call_summarizer_llm( content: str, context_str: str, - model: str, + model: Optional[str], max_tokens: int = 20000, is_chunk: bool = False, chunk_info: str = "" @@ -404,17 +627,22 @@ Create a markdown summary that captures all key information in a well-organized, for attempt in range(max_retries): try: + aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model) + if aux_client is None or not effective_model: + logger.warning("No auxiliary model available for web content processing") + return None call_kwargs = { "task": "web_extract", + "model": effective_model, "messages": [ {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_prompt} + {"role": "user", "content": user_prompt}, ], "temperature": 0.1, "max_tokens": max_tokens, } - if model: - call_kwargs["model"] = model + if extra_body: + call_kwargs["extra_body"] = extra_body response = await async_call_llm(**call_kwargs) content = extract_content_or_reasoning(response) if content: @@ -445,7 +673,7 @@ Create a markdown summary that captures all key information in a well-organized, async def _process_large_content_chunked( content: str, context_str: str, - model: str, + model: Optional[str], chunk_size: int, max_output_size: int ) -> Optional[str]: @@ -532,17 +760,26 @@ Synthesize these into ONE cohesive, comprehensive summary that: Create a single, unified markdown summary.""" try: + aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model) + if aux_client is None or not effective_model: + logger.warning("No auxiliary model for synthesis, concatenating summaries") + fallback = "\n\n".join(summaries) + if len(fallback) > max_output_size: + fallback = fallback[:max_output_size] + "\n\n[... truncated ...]" + return fallback + call_kwargs = { "task": "web_extract", + "model": effective_model, "messages": [ {"role": "system", "content": "You synthesize multiple summaries into one cohesive, comprehensive summary. Be thorough but concise."}, - {"role": "user", "content": synthesis_prompt} + {"role": "user", "content": synthesis_prompt}, ], "temperature": 0.1, "max_tokens": 20000, } - if model: - call_kwargs["model"] = model + if extra_body: + call_kwargs["extra_body"] = extra_body response = await async_call_llm(**call_kwargs) final_summary = extract_content_or_reasoning(response) @@ -551,7 +788,6 @@ Create a single, unified markdown summary.""" logger.warning("Synthesis LLM returned empty content, retrying once") response = await async_call_llm(**call_kwargs) final_summary = extract_content_or_reasoning(response) - # Enforce hard cap if len(final_summary) > max_output_size: final_summary = final_summary[:max_output_size] + "\n\n[... summary truncated for context management ...]" @@ -859,35 +1095,7 @@ def web_search_tool(query: str, limit: int = 5) -> str: limit=limit ) - # The response is a SearchData object with web, news, and images attributes - # When not scraping, the results are directly in these attributes - web_results = [] - - # Check if response has web attribute (SearchData object) - if hasattr(response, 'web'): - # Response is a SearchData object with web attribute - if response.web: - # Convert each SearchResultWeb object to dict - for result in response.web: - if hasattr(result, 'model_dump'): - # Pydantic model - use model_dump - web_results.append(result.model_dump()) - elif hasattr(result, '__dict__'): - # Regular object - use __dict__ - web_results.append(result.__dict__) - elif isinstance(result, dict): - # Already a dict - web_results.append(result) - elif hasattr(response, 'model_dump'): - # Response has model_dump method - use it to get dict - response_dict = response.model_dump() - if 'web' in response_dict and response_dict['web']: - web_results = response_dict['web'] - elif isinstance(response, dict): - # Response is already a dictionary - if 'web' in response and response['web']: - web_results = response['web'] - + web_results = _extract_web_search_results(response) results_count = len(web_results) logger.info("Found %d search results", results_count) @@ -916,11 +1124,11 @@ def web_search_tool(query: str, limit: int = 5) -> str: except Exception as e: error_msg = f"Error searching web: {str(e)}" logger.debug("%s", error_msg) - + debug_call_data["error"] = error_msg _debug.log_call("web_search_tool", debug_call_data) _debug.save() - + return json.dumps({"error": error_msg}, ensure_ascii=False) @@ -928,7 +1136,7 @@ async def web_extract_tool( urls: List[str], format: str = None, use_llm_processing: bool = True, - model: str = DEFAULT_SUMMARIZER_MODEL, + model: Optional[str] = None, min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION ) -> str: """ @@ -941,7 +1149,7 @@ async def web_extract_tool( urls (List[str]): List of URLs to extract content from format (str): Desired output format ("markdown" or "html", optional) use_llm_processing (bool): Whether to process content with LLM for summarization (default: True) - model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview) + model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model) min_length (int): Minimum content length to trigger LLM processing (default: 5000) Security: URLs are checked for embedded secrets before fetching. @@ -1052,39 +1260,11 @@ async def web_extract_tool( formats=formats ) - # Process the result - properly handle object serialization - metadata = {} + scrape_payload = _extract_scrape_payload(scrape_result) + metadata = scrape_payload.get("metadata", {}) title = "" - content_markdown = None - content_html = None - - # Extract data from the scrape result - if hasattr(scrape_result, 'model_dump'): - # Pydantic model - use model_dump to get dict - result_dict = scrape_result.model_dump() - content_markdown = result_dict.get('markdown') - content_html = result_dict.get('html') - metadata = result_dict.get('metadata', {}) - elif hasattr(scrape_result, '__dict__'): - # Regular object with attributes - content_markdown = getattr(scrape_result, 'markdown', None) - content_html = getattr(scrape_result, 'html', None) - - # Handle metadata - convert to dict if it's an object - metadata_obj = getattr(scrape_result, 'metadata', {}) - if hasattr(metadata_obj, 'model_dump'): - metadata = metadata_obj.model_dump() - elif hasattr(metadata_obj, '__dict__'): - metadata = metadata_obj.__dict__ - elif isinstance(metadata_obj, dict): - metadata = metadata_obj - else: - metadata = {} - elif isinstance(scrape_result, dict): - # Already a dictionary - content_markdown = scrape_result.get('markdown') - content_html = scrape_result.get('html') - metadata = scrape_result.get('metadata', {}) + content_markdown = scrape_payload.get("markdown") + content_html = scrape_payload.get("html") # Ensure metadata is a dict (not an object) if not isinstance(metadata, dict): @@ -1142,9 +1322,11 @@ async def web_extract_tool( debug_call_data["pages_extracted"] = pages_extracted debug_call_data["original_response_size"] = len(json.dumps(response)) + effective_model = model or _get_default_summarizer_model() + auxiliary_available = check_auxiliary_model() # Process each result with LLM if enabled - if use_llm_processing: + if use_llm_processing and auxiliary_available: logger.info("Processing extracted content with LLM (parallel)...") debug_call_data["processing_applied"].append("llm_processing") @@ -1162,7 +1344,7 @@ async def web_extract_tool( # Process content with LLM processed = await process_content_with_llm( - raw_content, url, title, model, min_length + raw_content, url, title, effective_model, min_length ) if processed: @@ -1178,7 +1360,7 @@ async def web_extract_tool( "original_size": original_size, "processed_size": processed_size, "compression_ratio": compression_ratio, - "model_used": model + "model_used": effective_model } return result, metrics, "processed" else: @@ -1210,6 +1392,9 @@ async def web_extract_tool( else: logger.warning("%s (no content to process)", url) else: + if use_llm_processing and not auxiliary_available: + logger.warning("LLM processing requested but no auxiliary model available, returning raw content") + debug_call_data["processing_applied"].append("llm_processing_unavailable") # Print summary of extracted pages for debugging (original behavior) for result in response.get('results', []): url = result.get('url', 'Unknown URL') @@ -1264,7 +1449,7 @@ async def web_crawl_tool( instructions: str = None, depth: str = "basic", use_llm_processing: bool = True, - model: str = DEFAULT_SUMMARIZER_MODEL, + model: Optional[str] = None, min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION ) -> str: """ @@ -1278,7 +1463,7 @@ async def web_crawl_tool( instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional) depth (str): Depth of extraction ("basic" or "advanced", default: "basic") use_llm_processing (bool): Whether to process content with LLM for summarization (default: True) - model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview) + model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model) min_length (int): Minimum content length to trigger LLM processing (default: 5000) Returns: @@ -1308,6 +1493,8 @@ async def web_crawl_tool( } try: + effective_model = model or _get_default_summarizer_model() + auxiliary_available = check_auxiliary_model() backend = _get_backend() # Tavily supports crawl via its /crawl endpoint @@ -1352,7 +1539,7 @@ async def web_crawl_tool( debug_call_data["original_response_size"] = len(json.dumps(response)) # Process each result with LLM if enabled - if use_llm_processing: + if use_llm_processing and auxiliary_available: logger.info("Processing crawled content with LLM (parallel)...") debug_call_data["processing_applied"].append("llm_processing") @@ -1363,12 +1550,12 @@ async def web_crawl_tool( if not content: return result, None, "no_content" original_size = len(content) - processed = await process_content_with_llm(content, page_url, title, model, min_length) + processed = await process_content_with_llm(content, page_url, title, effective_model, min_length) if processed: result['raw_content'] = content result['content'] = processed metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed), - "compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": model} + "compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": effective_model} return result, metrics, "processed" metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size, "compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"} @@ -1381,6 +1568,10 @@ async def web_crawl_tool( debug_call_data["compression_metrics"].append(metrics) debug_call_data["pages_processed_with_llm"] += 1 + if use_llm_processing and not auxiliary_available: + logger.warning("LLM processing requested but no auxiliary model available, returning raw content") + debug_call_data["processing_applied"].append("llm_processing_unavailable") + trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"), **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])] result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False) @@ -1390,11 +1581,11 @@ async def web_crawl_tool( _debug.save() return cleaned_result - # web_crawl requires Firecrawl — Parallel has no crawl API - if not (os.getenv("FIRECRAWL_API_KEY") or os.getenv("FIRECRAWL_API_URL")): + # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API + if not check_firecrawl_api_key(): return json.dumps({ - "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, " - "or use web_search + web_extract instead.", + "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL" + f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.", "success": False, }, ensure_ascii=False) @@ -1554,7 +1745,7 @@ async def web_crawl_tool( debug_call_data["original_response_size"] = len(json.dumps(response)) # Process each result with LLM if enabled - if use_llm_processing: + if use_llm_processing and auxiliary_available: logger.info("Processing crawled content with LLM (parallel)...") debug_call_data["processing_applied"].append("llm_processing") @@ -1572,7 +1763,7 @@ async def web_crawl_tool( # Process content with LLM processed = await process_content_with_llm( - content, page_url, title, model, min_length + content, page_url, title, effective_model, min_length ) if processed: @@ -1588,7 +1779,7 @@ async def web_crawl_tool( "original_size": original_size, "processed_size": processed_size, "compression_ratio": compression_ratio, - "model_used": model + "model_used": effective_model } return result, metrics, "processed" else: @@ -1620,6 +1811,9 @@ async def web_crawl_tool( else: logger.warning("%s (no content to process)", page_url) else: + if use_llm_processing and not auxiliary_available: + logger.warning("LLM processing requested but no auxiliary model available, returning raw content") + debug_call_data["processing_applied"].append("llm_processing_unavailable") # Print summary of crawled pages for debugging (original behavior) for result in response.get('results', []): page_url = result.get('url', 'Unknown URL') @@ -1663,39 +1857,34 @@ async def web_crawl_tool( return json.dumps({"error": error_msg}, ensure_ascii=False) -# Convenience function to check if API key is available +# Convenience function to check Firecrawl credentials def check_firecrawl_api_key() -> bool: """ - Check if the Firecrawl API key is available in environment variables. + Check whether the Firecrawl backend is available. + + Availability is true when either: + 1) direct Firecrawl config (`FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL`), or + 2) Firecrawl gateway origin + Nous Subscriber access token + (fallback when direct Firecrawl is not configured). Returns: - bool: True if API key is set, False otherwise + bool: True if direct Firecrawl or the tool-gateway can be used. """ - return bool(os.getenv("FIRECRAWL_API_KEY")) + return _has_direct_firecrawl_config() or _is_tool_gateway_ready() def check_web_api_key() -> bool: - """Check if any web backend API key is available (Exa, Parallel, Firecrawl, or Tavily).""" - return bool( - os.getenv("EXA_API_KEY") - or os.getenv("PARALLEL_API_KEY") - or os.getenv("FIRECRAWL_API_KEY") - or os.getenv("FIRECRAWL_API_URL") - or os.getenv("TAVILY_API_KEY") - ) + """Check whether the configured web backend is available.""" + configured = _load_web_config().get("backend", "").lower().strip() + if configured in ("exa", "parallel", "firecrawl", "tavily"): + return _is_backend_available(configured) + return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily")) def check_auxiliary_model() -> bool: """Check if an auxiliary text model is available for LLM content processing.""" - try: - from agent.auxiliary_client import resolve_provider_client - for p in ("openrouter", "nous", "custom", "codex"): - client, _ = resolve_provider_client(p) - if client is not None: - return True - return False - except Exception: - return False + client, _, _ = _resolve_web_extract_auxiliary() + return client is not None def get_debug_session_info() -> Dict[str, Any]: @@ -1712,7 +1901,11 @@ if __name__ == "__main__": # Check if API keys are available web_available = check_web_api_key() + tool_gateway_available = _is_tool_gateway_ready() + firecrawl_key_available = bool(os.getenv("FIRECRAWL_API_KEY", "").strip()) + firecrawl_url_available = bool(os.getenv("FIRECRAWL_API_URL", "").strip()) nous_available = check_auxiliary_model() + default_summarizer_model = _get_default_summarizer_model() if web_available: backend = _get_backend() @@ -1724,17 +1917,27 @@ if __name__ == "__main__": elif backend == "tavily": print(" Using Tavily API (https://tavily.com)") else: - print(" Using Firecrawl API (https://firecrawl.dev)") + if firecrawl_url_available: + print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}") + elif firecrawl_key_available: + print(" Using direct Firecrawl cloud API") + elif tool_gateway_available: + print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}") + else: + print(" Firecrawl backend selected but not configured") else: print("❌ No web search backend configured") - print("Set EXA_API_KEY, PARALLEL_API_KEY, TAVILY_API_KEY, or FIRECRAWL_API_KEY") + print( + "Set EXA_API_KEY, PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL" + f"{_firecrawl_backend_help_suffix()}" + ) if not nous_available: print("❌ No auxiliary model available for LLM content processing") print("Set OPENROUTER_API_KEY, configure Nous Portal, or set OPENAI_BASE_URL + OPENAI_API_KEY") print("⚠️ Without an auxiliary model, LLM content processing will be disabled") else: - print(f"✅ Auxiliary model available: {DEFAULT_SUMMARIZER_MODEL}") + print(f"✅ Auxiliary model available: {default_summarizer_model}") if not web_available: exit(1) @@ -1742,7 +1945,7 @@ if __name__ == "__main__": print("🛠️ Web tools ready for use!") if nous_available: - print(f"🧠 LLM content processing available with {DEFAULT_SUMMARIZER_MODEL}") + print(f"🧠 LLM content processing available with {default_summarizer_model}") print(f" Default min length for processing: {DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION} chars") # Show debug mode status @@ -1837,7 +2040,7 @@ registry.register( schema=WEB_SEARCH_SCHEMA, handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5), check_fn=check_web_api_key, - requires_env=["EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"], + requires_env=_web_requires_env(), emoji="🔍", ) registry.register( @@ -1847,7 +2050,7 @@ registry.register( handler=lambda args, **kw: web_extract_tool( args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"), check_fn=check_web_api_key, - requires_env=["EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"], + requires_env=_web_requires_env(), is_async=True, emoji="📄", ) diff --git a/utils.py b/utils.py index 66d552909..9a2105d54 100644 --- a/utils.py +++ b/utils.py @@ -9,6 +9,25 @@ from typing import Any, Union import yaml +TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) + + +def is_truthy_value(value: Any, default: bool = False) -> bool: + """Coerce bool-ish values using the project's shared truthy string set.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in TRUTHY_STRINGS + return bool(value) + + +def env_var_enabled(name: str, default: str = "") -> bool: + """Return True when an environment variable is set to a truthy value.""" + return is_truthy_value(os.getenv(name, default), default=False) + + def atomic_json_write( path: Union[str, Path], data: Any, diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index c86092a0b..2b0a84211 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -118,6 +118,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `TERMINAL_CWD` | Working directory for all terminal sessions | | `SUDO_PASSWORD` | Enable sudo without interactive prompt | +For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETIME_SECONDS` controls when Hermes cleans up an idle terminal session, and later resumes may recreate the sandbox rather than keep the same live processes running. + ## SSH Backend | Variable | Description | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 071f8c77f..f58ee21ab 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -88,6 +88,8 @@ terminal: daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend ``` +For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. + ### Backend Overview | Backend | Where commands run | Isolation | Best for | @@ -188,7 +190,7 @@ terminal: **Required:** Either `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` environment variables, or a `~/.modal.toml` config file. -**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. +**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. This preserves filesystem state, not live processes, PID space, or background jobs. **Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command. @@ -243,7 +245,7 @@ If terminal commands fail immediately or the terminal tool is reported as disabl - **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration. - **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters. -When in doubt, set `terminal.backend` back to `local` and verify commands run there first. +When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. ### Docker Volume Mounts