From 95dc9aaa75630b4875f1e0dc71698558949f2059 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Thu, 26 Mar 2026 15:27:27 -0700 Subject: [PATCH 1/5] feat: add managed tool gateway and Nous subscription support - add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support --- .env.example | 11 + agent/prompt_builder.py | 63 +++ environments/patches.py | 15 +- hermes_cli/auth.py | 83 +++ hermes_cli/config.py | 41 +- hermes_cli/main.py | 87 +++- hermes_cli/nous_subscription.py | 437 ++++++++++++++++ hermes_cli/setup.py | 256 ++++++--- hermes_cli/status.py | 25 + hermes_cli/tools_config.py | 176 ++++++- pyproject.toml | 2 +- requirements.txt | 1 + run_agent.py | 5 + tests/agent/test_prompt_builder.py | 59 ++- tests/hermes_cli/test_setup.py | 172 ++++++ tests/hermes_cli/test_setup_noninteractive.py | 47 +- .../hermes_cli/test_status_model_provider.py | 41 ++ tests/hermes_cli/test_tools_config.py | 79 +++ tests/test_cli_provider_resolution.py | 135 ++++- tests/test_run_agent.py | 5 + .../test_managed_browserbase_and_modal.py | 418 +++++++++++++++ tests/tools/test_managed_media_gateways.py | 288 ++++++++++ tests/tools/test_managed_modal_environment.py | 213 ++++++++ tests/tools/test_managed_tool_gateway.py | 70 +++ tests/tools/test_modal_snapshot_isolation.py | 188 +++++++ tests/tools/test_terminal_requirements.py | 45 +- .../tools/test_terminal_tool_requirements.py | 27 + tests/tools/test_transcription_tools.py | 4 + tests/tools/test_web_tools_config.py | 249 ++++++++- tools/browser_providers/browserbase.py | 113 +++- tools/browser_tool.py | 40 +- tools/code_execution_tool.py | 3 +- tools/environments/managed_modal.py | 282 ++++++++++ tools/environments/modal.py | 149 ++++-- tools/image_generation_tool.py | 159 +++++- tools/managed_tool_gateway.py | 160 ++++++ tools/terminal_tool.py | 107 +++- tools/tool_backend_helpers.py | 41 ++ tools/transcription_tools.py | 123 +++-- tools/tts_tool.py | 62 ++- tools/web_tools.py | 490 ++++++++++++------ .../docs/reference/environment-variables.md | 5 + website/docs/user-guide/configuration.md | 7 +- website/docs/user-guide/features/tools.md | 7 + 44 files changed, 4567 insertions(+), 423 deletions(-) create mode 100644 hermes_cli/nous_subscription.py create mode 100644 tests/tools/test_managed_browserbase_and_modal.py create mode 100644 tests/tools/test_managed_media_gateways.py create mode 100644 tests/tools/test_managed_modal_environment.py create mode 100644 tests/tools/test_managed_tool_gateway.py create mode 100644 tests/tools/test_modal_snapshot_isolation.py create mode 100644 tools/environments/managed_modal.py create mode 100644 tools/managed_tool_gateway.py create mode 100644 tools/tool_backend_helpers.py diff --git a/.env.example b/.env.example index d273a6966..5567ca7ef 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,17 @@ OPENCODE_GO_API_KEY= # Get at: https://parallel.ai PARALLEL_API_KEY= +# Tool-gateway config (Nous Subscribers only; preferred when available) +# Uses your Nous Subscriber OAuth access token from the Hermes auth store by default. +# Defaults to the Nous production gateway. Override for local dev. +# +# Derive vendor gateway URLs from a shared domain suffix: +# TOOL_GATEWAY_DOMAIN=nousresearch.com +# TOOL_GATEWAY_SCHEME=https +# +# Override the subscriber token (defaults to ~/.hermes/auth.json): +# TOOL_GATEWAY_USER_TOKEN= + # Firecrawl API Key - Web search, extract, and crawl # Get at: https://firecrawl.dev/ FIRECRAWL_API_KEY= diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 6ed6e90a7..7a8d6d707 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -422,6 +422,69 @@ def build_skills_system_prompt( ) +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 + except Exception as exc: + logger.debug("Failed to import Nous subscription helper: %s", exc) + 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/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/hermes_cli/auth.py b/hermes_cli/auth.py index 493e5a1d8..9eb867352 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1295,6 +1295,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 resolve_nous_runtime_credentials( *, min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 826e3a8bc..af13046b0 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -142,6 +142,7 @@ DEFAULT_CONFIG = { "terminal": { "backend": "local", + "modal_mode": "auto", "cwd": ".", # Use current directory "timeout": 180, # Environment variables to pass through to sandboxed execution @@ -407,7 +408,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 10, + "_config_version": 11, } # ============================================================================= @@ -422,6 +423,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. @@ -617,6 +619,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", @@ -1808,7 +1842,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', - 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_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', @@ -1864,6 +1900,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 88fbf9cd9..a920c1c1b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -872,7 +872,7 @@ def cmd_model(args): 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": @@ -981,7 +981,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, @@ -989,7 +989,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") @@ -998,11 +1002,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 @@ -1049,11 +1061,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.") @@ -3174,6 +3211,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..f5f8e8615 --- /dev/null +++ b/hermes_cli/nous_subscription.py @@ -0,0 +1,437 @@ +"""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, + normalize_browser_cloud_provider, + normalize_modal_mode, + 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", + "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 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 = {} + + 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_backend = str(config.get("web", {}).get("backend") or "").strip().lower() if isinstance(config.get("web"), dict) else "" + tts_provider = str(config.get("tts", {}).get("provider") or "edge").strip().lower() if isinstance(config.get("tts"), dict) else "edge" + browser_provider = normalize_browser_cloud_provider( + config.get("browser", {}).get("cloud_provider") + if isinstance(config.get("browser"), dict) + else None + ) + terminal_backend = ( + str(config.get("terminal", {}).get("backend") or "local").strip().lower() + if isinstance(config.get("terminal"), dict) + else "local" + ) + modal_mode = normalize_modal_mode( + config.get("terminal", {}).get("modal_mode") + if isinstance(config.get("terminal"), dict) + else None + ) + + 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_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 = nous_auth_present and is_managed_tool_gateway_ready("firecrawl") + managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue") + managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio") + managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase") + managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal") + + 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 == "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_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_current_provider = browser_provider or "local" + browser_local_available = _has_agent_browser() + browser_managed = ( + browser_tool_enabled + and browser_current_provider == "browserbase" + and managed_browser_available + and not direct_browserbase + ) + browser_available = bool( + browser_local_available + or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase)) + or (browser_current_provider == "browser-use" and direct_browser_use) + ) + browser_active = bool( + browser_tool_enabled + and ( + (browser_current_provider == "local" and browser_local_available) + or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase)) + or (browser_current_provider == "browser-use" and direct_browser_use) + ) + ) + + if terminal_backend != "modal": + modal_managed = False + modal_available = True + modal_active = bool(modal_tool_enabled) + modal_direct_override = False + elif modal_mode == "managed": + modal_managed = bool(modal_tool_enabled and managed_modal_available) + modal_available = bool(managed_modal_available) + modal_active = bool(modal_tool_enabled and managed_modal_available) + modal_direct_override = False + elif modal_mode == "direct": + modal_managed = False + modal_available = bool(direct_modal) + modal_active = bool(modal_tool_enabled and direct_modal) + modal_direct_override = bool(direct_modal) + else: + modal_managed = bool( + modal_tool_enabled + and managed_modal_available + and not direct_modal + ) + modal_available = bool(managed_modal_available or direct_modal) + modal_active = bool(modal_tool_enabled and (direct_modal or managed_modal_available)) + modal_direct_override = bool(direct_modal) + + 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=isinstance(config.get("browser"), dict) and "cloud_provider" in config.get("browser", {}), + ), + "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]: + 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`.""" + 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]: + 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/setup.py b/hermes_cli/setup.py index 54ecbf165..59c8d92c1 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, +) + logger = logging.getLogger(__name__) PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -52,6 +58,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None: config["model"] = model_cfg +def _print_nous_subscription_guidance() -> None: + print() + print_header("Nous Subscription Tools") + for line in get_nous_subscription_explainer_lines(): + print_info(line) + + # Default model lists per provider — used as fallback when the live # /models endpoint can't be reached. _DEFAULT_PROVIDER_MODELS = { @@ -560,6 +573,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: @@ -581,8 +595,13 @@ def _print_setup_summary(config: dict, hermes_home): tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY")) # Web tools (Parallel, Firecrawl, or Tavily) - if 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, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY")) @@ -595,7 +614,9 @@ def _print_setup_summary(config: dict, hermes_home): Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" ).exists() ) - if get_env_value("BROWSERBASE_API_KEY"): + if subscription_features.browser.managed_by_nous: + tool_status.append(("Browser Automation (Nous Browserbase)", True, None)) + elif subscription_features.browser.current_provider == "Browserbase" and subscription_features.browser.available: tool_status.append(("Browser Automation (Browserbase)", True, None)) elif _ab_found: tool_status.append(("Browser Automation (local)", True, None)) @@ -605,16 +626,22 @@ def _print_setup_summary(config: dict, hermes_home): ) # 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: @@ -629,6 +656,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 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)) @@ -905,6 +942,7 @@ def setup_model_provider(config: dict): ) selected_base_url = None # deferred until after model selection nous_models = [] # populated if Nous login succeeds + nous_subscription_selected = False if provider_idx == 0: # OpenRouter selected_provider = "openrouter" @@ -1000,6 +1038,9 @@ def setup_model_provider(config: dict): except Exception as e: logger.debug("Could not fetch Nous models after login: %s", e) + nous_subscription_selected = True + _print_nous_subscription_guidance() + except SystemExit: print_warning("Nous Portal login was cancelled or failed.") print_info("You can try again later with: hermes model") @@ -1773,10 +1814,20 @@ def setup_model_provider(config: dict): if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: _update_config_for_provider(selected_provider, selected_base_url) + 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) # ============================================================================= @@ -1844,6 +1895,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", @@ -1858,20 +1910,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 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 @@ -1909,8 +1977,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) @@ -2065,63 +2133,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 swe-rex[modal] is installed - try: - __import__("swe_rex") - except ImportError: - print_info("Installing swe-rex[modal]...") - import subprocess - - uv_bin = shutil.which("uv") - if uv_bin: - result = subprocess.run( - [ - uv_bin, - "pip", - "install", - "--python", - sys.executable, - "swe-rex[modal]", - ], - capture_output=True, - text=True, - ) + managed_modal_available = bool( + 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", "swe-rex[modal]"], - capture_output=True, - text=True, - ) - if result.returncode == 0: - print_success("swe-rex[modal] installed") - else: - print_warning( - "Install failed — run manually: pip install 'swe-rex[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 swe-rex[modal] is installed + try: + __import__("swe_rex") + except ImportError: + print_info("Installing swe-rex[modal]...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [ + uv_bin, + "pip", + "install", + "--python", + sys.executable, + "swe-rex[modal]", + ], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "swe-rex[modal]"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success("swe-rex[modal] installed") + else: + print_warning( + "Install failed — run manually: pip install 'swe-rex[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) @@ -2235,6 +2339,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}") @@ -3089,6 +3195,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. @@ -3237,8 +3354,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 01f46b766..649d41231 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -15,6 +15,7 @@ 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 @@ -186,6 +187,30 @@ def show_status(args): if codex_status.get("error") and not codex_logged_in: print(f" Error: {codex_status.get('error')}") + # ========================================================================= + # Nous Subscription Features + # ========================================================================= + 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 a8f349e9c..be73dfcfa 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -18,6 +18,10 @@ 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, +) PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -146,6 +150,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", @@ -176,6 +189,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", @@ -214,6 +236,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", @@ -227,11 +257,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 }, { @@ -475,8 +515,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 @@ -486,10 +529,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 env_vars and all(get_env_value(e["key"]) for e in env_vars): return True @@ -629,11 +678,43 @@ 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("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"): @@ -698,6 +779,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: @@ -724,6 +826,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"): @@ -732,11 +841,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"): @@ -744,7 +854,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 @@ -847,7 +966,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: @@ -877,7 +996,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] @@ -912,6 +1031,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"] @@ -919,12 +1045,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"): @@ -932,7 +1058,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: @@ -1041,13 +1176,22 @@ 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, + ) + 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: @@ -1140,7 +1284,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) @@ -1180,7 +1324,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 8ba6d1f0c..bd5fa6481 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,7 +39,7 @@ dependencies = [ [project.optional-dependencies] modal = ["swe-rex[modal]>=1.4.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 3ad5b3ec4..1a6d57876 100644 --- a/run_agent.py +++ b/run_agent.py @@ -74,6 +74,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, @@ -2388,6 +2389,10 @@ 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) + # Honcho CLI awareness: tell Hermes about its own management commands # so it can refer the user to them rather than reinventing answers. if self._honcho and self._honcho_session_key: diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 37fddcc9c..b4d038fc0 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -5,6 +5,8 @@ import importlib import logging import sys +import pytest + from agent.prompt_builder import ( _scan_context_content, _truncate_content, @@ -15,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, @@ -22,6 +25,7 @@ from agent.prompt_builder import ( SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, ) +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures # ========================================================================= @@ -395,6 +399,53 @@ class TestBuildSkillsSystemPrompt: assert "backend-skill" in result +class TestBuildNousSubscriptionPrompt: + def test_includes_active_subscription_features(self, monkeypatch): + 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.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 + + # ========================================================================= # Context files prompt builder # ========================================================================= @@ -562,8 +613,12 @@ class TestBuildContextFilesPrompt: assert "Lowercase claude rules" in result 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_setup.py b/tests/hermes_cli/test_setup.py index a4c85ba2b..66af7faf0 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -1,4 +1,6 @@ import json +import sys +import types from hermes_cli.auth import _update_config_for_provider, get_active_provider from hermes_cli.config import load_config, save_config @@ -136,6 +138,8 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon def fake_prompt_choice(question, choices, default=0): if question == "Select your inference provider:": return 2 # OpenAI Codex + if question == "Configure vision:": + return len(choices) - 1 if question == "Select default model:": return 0 tts_idx = _maybe_keep_current_tts(question, choices) @@ -176,3 +180,171 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon assert reloaded["model"]["provider"] == "openai-codex" assert reloaded["model"]["default"] == "gpt-5.2-codex" assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex" + + +def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + + def fake_prompt_choice(question, choices, default=0): + if question == "Select your inference provider:": + return 1 + if question == "Configure vision:": + return len(choices) - 1 + if question == "Select default model:": + return len(choices) - 1 + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + + def _fake_login_nous(*args, **kwargs): + auth_path = tmp_path / "auth.json" + auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {"nous": {"access_token": "nous-token"}}})) + _update_config_for_provider("nous", "https://inference.example.com/v1") + + monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous) + 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: ["gemini-3-flash"], + ) + + setup_model_provider(config) + + out = capsys.readouterr().out + assert config["tts"]["provider"] == "openai" + assert "Nous subscription enables managed web tools" in out + assert "OpenAI TTS via your Nous subscription" in out + + +def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + config["tts"] = {"provider": "elevenlabs"} + + def fake_prompt_choice(question, choices, default=0): + if question == "Select your inference provider:": + return 1 + if question == "Configure vision:": + return len(choices) - 1 + if question == "Select default model:": + return len(choices) - 1 + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr( + "hermes_cli.auth._login_nous", + lambda *args, **kwargs: (tmp_path / "auth.json").write_text( + json.dumps({"active_provider": "nous", "providers": {"nous": {"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: ["gemini-3-flash"], + ) + + setup_model_provider(config) + + assert config["tts"]["provider"] == "elevenlabs" + + +def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): + 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_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_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..2056aac4f 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,42 @@ 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): + 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 diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 676305dbd..ae3455cb8 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, ) @@ -45,6 +49,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 @@ -204,3 +212,74 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present() # Deselected configurable toolset removed assert "terminal" not in saved + + +def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): + 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_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): + 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 == [] diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 667cd33a6..65bcdf5c7 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,81 @@ 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): + 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): + 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.""" @@ -468,4 +550,55 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): assert "Saving the working base URL instead" in output assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1" assert saved_env["OPENAI_API_KEY"] == "local-key" - 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_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 d961244f3..cfed4afbc 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -584,6 +584,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 + class TestInvalidateSystemPrompt: def test_clears_cache(self, agent): 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..3d97a4373 --- /dev/null +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -0,0 +1,418 @@ +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) + + +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_keeps_direct_modal_when_direct_credentials_exist(): + _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-direct", + ) + + 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..48cd5f41f --- /dev/null +++ b/tests/tools/test_managed_media_gateways.py @@ -0,0 +1,288 @@ +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) + + +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..b52801809 --- /dev/null +++ b/tests/tools/test_managed_modal_environment.py @@ -0,0 +1,213 @@ +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 + + +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(): + _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, + ) + ) + + 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") + + 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(managed_modal.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") + + 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(managed_modal.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") + + 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(managed_modal.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() diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py new file mode 100644 index 000000000..591708345 --- /dev/null +++ b/tests/tools/test_managed_tool_gateway.py @@ -0,0 +1,70 @@ +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, {"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, {"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, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: None, + ) + + 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..0b4f7fc56 --- /dev/null +++ b/tests/tools/test_modal_snapshot_isolation.py @@ -0,0 +1,188 @@ +import json +import sys +import types +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path + + +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) + + +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", "swerex", "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" + 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) + + from_id_calls: list[str] = [] + registry_calls: list[tuple[str, list[str] | None]] = [] + deployment_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} + + class _FakeRuntime: + async def execute(self, _command): + return types.SimpleNamespace(stdout="ok", exit_code=0) + + class _FakeModalDeployment: + def __init__(self, **kwargs): + deployment_calls.append(dict(kwargs)) + self.image = kwargs["image"] + self.runtime = _FakeRuntime() + + async def _snapshot_aio(): + return types.SimpleNamespace(object_id=snapshot_id) + + self._sandbox = types.SimpleNamespace( + snapshot_filesystem=types.SimpleNamespace(aio=_snapshot_aio), + ) + + async def start(self): + image = self.image if isinstance(self.image, dict) else {} + image_id = image.get("image_id") + if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids: + raise RuntimeError(f"cannot restore {image_id}") + + async def stop(self): + return None + + class _FakeRexCommand: + def __init__(self, **kwargs): + self.kwargs = kwargs + + sys.modules["modal"] = types.SimpleNamespace(Image=_FakeImage) + + swerex = types.ModuleType("swerex") + swerex.__path__ = [] # type: ignore[attr-defined] + sys.modules["swerex"] = swerex + swerex_deployment = types.ModuleType("swerex.deployment") + swerex_deployment.__path__ = [] # type: ignore[attr-defined] + sys.modules["swerex.deployment"] = swerex_deployment + sys.modules["swerex.deployment.modal"] = types.SimpleNamespace(ModalDeployment=_FakeModalDeployment) + swerex_runtime = types.ModuleType("swerex.runtime") + swerex_runtime.__path__ = [] # type: ignore[attr-defined] + sys.modules["swerex.runtime"] = swerex_runtime + sys.modules["swerex.runtime.abstract"] = types.SimpleNamespace(Command=_FakeRexCommand) + + return { + "snapshot_store": hermes_home / "modal_snapshots.json", + "deployment_calls": deployment_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["deployment_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["deployment_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 b3bc0b194..c93d68e17 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -8,9 +8,11 @@ def _clear_terminal_env(monkeypatch): """Remove terminal env vars that could affect requirements checks.""" keys = [ "TERMINAL_ENV", + "TERMINAL_MODAL_MODE", "TERMINAL_SSH_HOST", "TERMINAL_SSH_USER", "MODAL_TOKEN_ID", + "MODAL_TOKEN_SECRET", "HOME", "USERPROFILE", ] @@ -63,7 +65,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 swerex 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 +73,45 @@ 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 or managed tool gateway 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("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_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 ) diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index 5a347cc6e..216284932 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -26,3 +26,30 @@ 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("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 b5c9f9775..d43f89cf1 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -231,6 +231,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") @@ -272,6 +273,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") @@ -327,6 +329,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") @@ -341,6 +344,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..1354c2431 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -5,12 +5,14 @@ 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 pytest -from unittest.mock import patch, MagicMock +from unittest.mock import patch, MagicMock, AsyncMock class TestFirecrawlClientConfig: @@ -20,14 +22,30 @@ 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 ( + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ): os.environ.pop(key, None) 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 ( + "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 +85,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"))] + + fake_client = MagicMock(base_url="https://api.openrouter.ai/v1") + fake_client.chat.completions.create = AsyncMock(return_value=response) + + with patch( + "tools.web_tools.get_async_text_auxiliary_client", + side_effect=[(None, None), (fake_client, "test-model")], + ): + 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" + fake_client.chat.completions.create.assert_awaited_once() # ── Singleton caching ──────────────────────────────────────────── @@ -117,9 +278,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,7 +292,16 @@ class TestBackendSelection: setups. """ - _ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY") + _ENV_KEYS = ( + "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): for key in self._ENV_KEYS: @@ -276,10 +447,47 @@ 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 = ( + "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): for key in self._ENV_KEYS: @@ -329,3 +537,22 @@ 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 diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 1aad8e6e0..342b430b1 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -2,14 +2,57 @@ 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 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 +62,46 @@ 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: + 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: raise ValueError( - "BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment " - "variables are required. Get your credentials at " - "https://browserbase.com" + "Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials " + "or a managed Browserbase gateway configuration." ) - return {"api_key": api_key, "project_id": project_id} + 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 +141,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 +155,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 +164,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 +178,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 +215,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 +227,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 +254,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 e75025482..3018d5231 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 logger = logging.getLogger(__name__) @@ -235,7 +236,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: @@ -249,14 +252,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 _socket_safe_tmpdir() -> str: """Return a short temp directory path suitable for Unix domain sockets. @@ -1845,7 +1879,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 19270c6fe..dbf617444 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -757,7 +757,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/managed_modal.py b/tools/environments/managed_modal.py new file mode 100644 index 000000000..241c69094 --- /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 time +import uuid +from typing import Any, Dict, Optional + +from tools.environments.base import BaseEnvironment +from tools.interrupt import is_interrupted +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 + + +class ManagedModalEnvironment(BaseEnvironment): + """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) + + 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) + + 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 execute(self, command: str, cwd: str = "", *, + timeout: int | None = None, + stdin_data: str | None = None) -> dict: + exec_command, sudo_stdin = self._prepare_command(command) + + # When a sudo password is present, inject it via a shell-level pipe + # (same approach as the direct ModalEnvironment) since the gateway + # cannot pipe subprocess stdin directly. + if sudo_stdin is not None: + import shlex + exec_command = ( + f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}" + ) + + exec_cwd = cwd or self.cwd + effective_timeout = timeout or self.timeout + exec_id = str(uuid.uuid4()) + payload: Dict[str, Any] = { + "execId": exec_id, + "command": exec_command, + "cwd": exec_cwd, + "timeoutMs": int(effective_timeout * 1000), + } + if stdin_data is not None: + payload["stdinData"] = stdin_data + + try: + response = self._request( + "POST", + f"/v1/sandboxes/{self._sandbox_id}/execs", + json=payload, + timeout=10, + ) + except Exception as exc: + return { + "output": f"Managed Modal exec failed: {exc}", + "returncode": 1, + } + + if response.status_code >= 400: + return { + "output": self._format_error("Managed Modal exec failed", response), + "returncode": 1, + } + + body = response.json() + status = body.get("status") + if status in {"completed", "failed", "cancelled", "timeout"}: + return { + "output": body.get("output", ""), + "returncode": body.get("returncode", 1), + } + + if body.get("execId") != exec_id: + return { + "output": "Managed Modal exec start did not return the expected exec id", + "returncode": 1, + } + + poll_interval = 0.25 + deadline = time.monotonic() + effective_timeout + 10 + + while time.monotonic() < deadline: + if is_interrupted(): + self._cancel_exec(exec_id) + return { + "output": "[Command interrupted - Modal sandbox exec cancelled]", + "returncode": 130, + } + + try: + status_response = self._request( + "GET", + f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}", + timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS), + ) + except Exception as exc: + return { + "output": f"Managed Modal exec poll failed: {exc}", + "returncode": 1, + } + + if status_response.status_code == 404: + return { + "output": "Managed Modal exec not found", + "returncode": 1, + } + + if status_response.status_code >= 400: + return { + "output": self._format_error("Managed Modal exec poll failed", status_response), + "returncode": 1, + } + + status_body = status_response.json() + status = status_body.get("status") + if status in {"completed", "failed", "cancelled", "timeout"}: + return { + "output": status_body.get("output", ""), + "returncode": status_body.get("returncode", 1), + } + + time.sleep(poll_interval) + + self._cancel_exec(exec_id) + return { + "output": f"Managed Modal exec timed out after {effective_timeout}s", + "returncode": 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 _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 f8210ba78..d499dc4a3 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -20,6 +20,7 @@ from tools.interrupt import is_interrupted logger = logging.getLogger(__name__) _SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json" +_DIRECT_SNAPSHOT_NAMESPACE = "direct" def _load_snapshots() -> Dict[str, str]: @@ -38,12 +39,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 swe-rex 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 for direct Modal restore and whether the key is legacy.""" + 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 swe-rex calls.""" def __init__(self): self._loop: Optional[asyncio.AbstractEventLoop] = None @@ -101,42 +162,20 @@ class ModalEnvironment(BaseEnvironment): 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 + 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]) - 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", - ], - ) - - # Start the async worker thread and create the deployment on it - # so all gRPC channels are bound to the worker's event loop. self._worker.start() from swerex.deployment.modal import ModalDeployment - async def _create_and_start(): + async def _create_and_start(image_spec: Any): deployment = ModalDeployment( - image=effective_image, + image=image_spec, startup_timeout=180.0, runtime_timeout=3600.0, deployment_timeout=3600.0, @@ -146,7 +185,30 @@ class ModalEnvironment(BaseEnvironment): await deployment.start() return deployment - self._deployment = self._worker.run_coroutine(_create_and_start()) + try: + target_image_spec = restored_snapshot_id or image + try: + effective_image = _resolve_modal_image(target_image_spec) + self._deployment = self._worker.run_coroutine(_create_and_start(effective_image)) + 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._deployment = self._worker.run_coroutine(_create_and_start(base_image)) + 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 def execute(self, command: str, cwd: str = "", *, timeout: int | None = None, @@ -160,7 +222,7 @@ class ModalEnvironment(BaseEnvironment): exec_command, sudo_stdin = self._prepare_command(command) # Modal sandboxes execute commands via the Modal SDK and cannot pipe - # subprocess stdin directly the way a local Popen can. When a sudo + # subprocess stdin directly the way a local Popen can. When a sudo # password is present, use a shell-level pipe from printf so that the # password feeds sudo -S without appearing as an echo argument embedded # in the shell string. @@ -175,7 +237,6 @@ class ModalEnvironment(BaseEnvironment): effective_cwd = cwd or self.cwd effective_timeout = timeout or self.timeout - # Run in a background thread so we can poll for interrupts result_holder = {"value": None, "error": None} def _run(): @@ -191,6 +252,7 @@ class ModalEnvironment(BaseEnvironment): merge_output_streams=True, ) ) + output = self._worker.run_coroutine(_do_execute()) result_holder["value"] = { "output": output.stdout, @@ -227,7 +289,7 @@ class ModalEnvironment(BaseEnvironment): if self._persistent: try: - sandbox = getattr(self._deployment, '_sandbox', None) + sandbox = getattr(self._deployment, "_sandbox", None) if sandbox: async def _snapshot(): img = await sandbox.snapshot_filesystem.aio() @@ -239,11 +301,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/image_generation_tool.py b/tools/image_generation_tool.py index 5dadf4998..84edb93fe 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -32,9 +32,13 @@ 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 logger = logging.getLogger(__name__) @@ -77,6 +81,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 +321,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 +415,10 @@ 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()): + raise ValueError( + "FAL_KEY environment variable not set and managed FAL gateway is unavailable" + ) # Validate other parameters validated_params = _validate_parameters( @@ -312,9 +449,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 +516,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 +539,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 +695,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..96dd27b30 --- /dev/null +++ b/tools/managed_tool_gateway.py @@ -0,0 +1,160 @@ +"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs.""" + +from __future__ import annotations + +import json +import os +from datetime import datetime, timezone +from dataclasses import dataclass +from typing import Callable, Optional + +from hermes_cli.config import get_hermes_home + +_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: + pass + + 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.""" + 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/terminal_tool.py b/tools/terminal_tool.py index aa917ab1a..13b724bf5 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 @@ -50,12 +54,18 @@ logger = logging.getLogger(__name__) from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported +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 has_direct_modal_credentials, normalize_modal_mode # Disk usage warning threshold (in GB) @@ -361,10 +371,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. @@ -380,6 +392,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 @@ -493,6 +506,7 @@ def _get_env_config() -> Dict[str, Any]: return { "env_type": env_type, + "modal_mode": normalize_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}"), @@ -525,6 +539,27 @@ 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.""" + normalized_mode = normalize_modal_mode(modal_mode) + has_direct = has_direct_modal_credentials() + managed_ready = is_managed_tool_gateway_ready("modal") + + if normalized_mode == "managed": + selected_backend = "managed" if managed_ready else None + elif normalized_mode == "direct": + selected_backend = "direct" if has_direct else None + else: + selected_backend = "direct" if has_direct else "managed" if managed_ready else None + + return { + "mode": normalized_mode, + "has_direct": has_direct, + "managed_ready": managed_ready, + "selected_backend": selected_backend, + } + + def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ssh_config: dict = None, container_config: dict = None, local_config: dict = None, @@ -590,7 +625,29 @@ 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["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." + ) + raise ValueError( + "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found." + ) + return _ModalEnvironment( image=image, cwd=cwd, timeout=timeout, modal_sandbox_kwargs=sandbox_kwargs, @@ -956,6 +1013,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), } @@ -1173,10 +1231,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) @@ -1216,18 +1278,35 @@ 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["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." + ) + elif modal_state["mode"] == "direct": + 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 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." + ) + return False + if importlib.util.find_spec("swerex") is None: - logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[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("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[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..bcf93e849 --- /dev/null +++ b/tools/tool_backend_helpers.py @@ -0,0 +1,41 @@ +"""Shared helpers for tool backend selection.""" + +from __future__ import annotations + +import os +from pathlib import Path + + +_DEFAULT_BROWSER_PROVIDER = "local" +_DEFAULT_MODAL_MODE = "auto" +_VALID_MODAL_MODES = {"auto", "direct", "managed"} + + +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 normalize_modal_mode(value: object | None) -> str: + """Return a normalized modal execution mode.""" + mode = str(value or _DEFAULT_MODAL_MODE).strip().lower() + if mode in _VALID_MODAL_MODES: + return mode + return _DEFAULT_MODAL_MODE + + +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_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 0c0a1fc9f..ae05358b8 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -31,6 +31,10 @@ import subprocess import tempfile from pathlib import Path from typing import Optional, Dict, Any +from urllib.parse import urljoin + +from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import resolve_openai_audio_api_key from hermes_constants import get_hermes_home @@ -41,8 +45,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 @@ -116,9 +129,9 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool: return bool(enabled) -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 +223,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 +241,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 +417,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 +454,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 +473,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 +576,38 @@ 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: + raise ValueError( + "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" + ) + + 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 eed3961df..c71cdb1e8 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -32,11 +32,15 @@ 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 resolve_openai_audio_api_key # --------------------------------------------------------------------------- # Lazy imports -- providers are imported only when actually used to avoid @@ -74,6 +78,7 @@ 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" DEFAULT_OUTPUT_DIR = str(get_hermes_home() / "audio_cache") MAX_TEXT_LENGTH = 4000 @@ -233,14 +238,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"): @@ -250,15 +253,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() # =========================================================================== @@ -539,7 +548,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 @@ -548,6 +557,28 @@ 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: + raise ValueError( + "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" + ) + + 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 # =========================================================================== @@ -802,7 +833,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 d4afc06ae..1ebf36d77 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -4,15 +4,18 @@ 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) +- 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 +47,13 @@ import asyncio from typing import List, Dict, Any, Optional import httpx from firecrawl import Firecrawl -from agent.auxiliary_client import async_call_llm +from agent.auxiliary_client import 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.url_safety import is_safe_url from tools.website_policy import check_website_access @@ -78,10 +86,13 @@ def _get_backend() -> str: return configured # Fallback for manual / legacy config — use whichever key is present. - has_firecrawl = _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") + has_firecrawl = ( + _has_env("FIRECRAWL_API_KEY") + or _has_env("FIRECRAWL_API_URL") + or _is_tool_gateway_ready() + ) has_parallel = _has_env("PARALLEL_API_KEY") has_tavily = _has_env("TAVILY_API_KEY") - if has_tavily and not has_firecrawl and not has_parallel: return "tavily" if has_parallel and not has_firecrawl: @@ -90,35 +101,100 @@ def _get_backend() -> str: # Default to firecrawl (backward compat, or when both are set) return "firecrawl" + +def _is_backend_available(backend: str) -> bool: + """Return True when the selected backend is currently usable.""" + 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.""" + raise ValueError( + "Web tools are not configured. " + "Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, " + "or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide " + "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN." + ) + 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 +319,112 @@ 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.""" + base_url = str(getattr(client, "base_url", "") or "").lower() + return "nousresearch.com" in base_url + + +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 +433,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 +516,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,22 +582,22 @@ Create a markdown summary that captures all key information in a well-organized, for attempt in range(max_retries): try: - call_kwargs = { - "task": "web_extract", - "messages": [ + 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 + from agent.auxiliary_client import auxiliary_max_tokens_param + response = await aux_client.chat.completions.create( + model=effective_model, + messages=[ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt} ], - "temperature": 0.1, - "max_tokens": max_tokens, - } - if model: - call_kwargs["model"] = model - response = await async_call_llm(**call_kwargs) + temperature=0.1, + **auxiliary_max_tokens_param(max_tokens), + **({} if not extra_body else {"extra_body": extra_body}), + ) return response.choices[0].message.content.strip() - except RuntimeError: - logger.warning("No auxiliary model available for web content processing") - return None except Exception as api_error: last_error = api_error if attempt < max_retries - 1: @@ -436,7 +614,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]: @@ -523,18 +701,25 @@ Synthesize these into ONE cohesive, comprehensive summary that: Create a single, unified markdown summary.""" try: - call_kwargs = { - "task": "web_extract", - "messages": [ + 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 + + from agent.auxiliary_client import auxiliary_max_tokens_param + response = await aux_client.chat.completions.create( + 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} ], - "temperature": 0.1, - "max_tokens": 20000, - } - if model: - call_kwargs["model"] = model - response = await async_call_llm(**call_kwargs) + temperature=0.1, + **auxiliary_max_tokens_param(20000), + **({} if not extra_body else {"extra_body": extra_body}), + ) final_summary = response.choices[0].message.content.strip() # Enforce hard cap @@ -750,35 +935,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) @@ -807,11 +964,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) @@ -819,7 +976,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: """ @@ -832,7 +989,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) Returns: @@ -929,39 +1086,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): @@ -1019,9 +1148,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") @@ -1039,7 +1170,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: @@ -1055,7 +1186,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: @@ -1087,6 +1218,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') @@ -1141,7 +1275,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: """ @@ -1155,7 +1289,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: @@ -1185,6 +1319,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 @@ -1229,7 +1365,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") @@ -1240,12 +1376,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"} @@ -1258,6 +1394,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) @@ -1267,10 +1407,12 @@ 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, " + "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " + "or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, " + "or TOOL_GATEWAY_DOMAIN, " "or use web_search + web_extract instead.", "success": False, }, ensure_ascii=False) @@ -1431,7 +1573,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") @@ -1449,7 +1591,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: @@ -1465,7 +1607,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: @@ -1497,6 +1639,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') @@ -1540,38 +1685,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 (Parallel, Firecrawl, or Tavily).""" - return bool( - 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 ("parallel", "firecrawl", "tavily"): + return _is_backend_available(configured) + return any(_is_backend_available(backend) for backend in ("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]: @@ -1588,7 +1729,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() @@ -1598,17 +1743,28 @@ 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 PARALLEL_API_KEY, TAVILY_API_KEY, or FIRECRAWL_API_KEY") + print( + "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " + "or, if you are a Nous Subscriber, login to Nous and use " + "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN" + ) 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) @@ -1616,7 +1772,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 @@ -1711,7 +1867,16 @@ 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=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"], + requires_env=[ + "PARALLEL_API_KEY", + "TAVILY_API_KEY", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + ], emoji="🔍", ) registry.register( @@ -1721,7 +1886,16 @@ 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=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"], + requires_env=[ + "PARALLEL_API_KEY", + "TAVILY_API_KEY", + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + ], is_async=True, emoji="📄", ) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 39fb0b83a..d7d689580 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -78,6 +78,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | +| `TOOL_GATEWAY_DOMAIN` | Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, for example `nousresearch.com` -> `firecrawl-gateway.nousresearch.com` | +| `TOOL_GATEWAY_SCHEME` | Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts, `https` by default and `http` for local gateway testing | +| `TOOL_GATEWAY_USER_TOKEN` | Explicit Nous Subscriber access token for tool-gateway calls (optional; otherwise Hermes reads `~/.hermes/auth.json`) | | `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | @@ -114,6 +117,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 7e5dc5373..d8226062f 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -695,6 +695,8 @@ terminal: persistent_shell: true # Enabled by default for SSH 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. + ### Common Terminal Backend Issues If terminal commands fail immediately or the terminal tool is reported as disabled, check the following: @@ -723,8 +725,9 @@ If terminal commands fail immediately or the terminal tool is reported as disabl - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - **Modal backend** - - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. + - Hermes can use either direct Modal credentials (`MODAL_TOKEN_ID` plus `MODAL_TOKEN_SECRET`, or `~/.modal.toml`) or a configured managed tool gateway with a Nous user token. + - Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox. + - If neither direct credentials nor a managed gateway is present, Hermes will report that the Modal backend is not available. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 981d2caf2..bbea0a262 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -109,6 +109,13 @@ modal setup hermes config set terminal.backend modal ``` +Hermes can use Modal in two modes: + +- **Direct Modal**: Hermes talks to your Modal account directly. +- **Managed Modal**: Hermes talks to a gateway that owns the vendor credentials. + +In both cases, Modal is best treated as a task sandbox, not a deployment target. Persistent mode preserves filesystem state so later turns can resume your work, but Hermes may still clean up or recreate the live sandbox. Long-running servers and background processes are not guaranteed to survive idle cleanup, session teardown, or Hermes exit. + ### Container Resources Configure CPU, memory, disk, and persistence for all container backends: From 1cbb1b99cc89a6dfd5a93a2a9362839afdbde56d Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Mon, 30 Mar 2026 13:28:10 +0900 Subject: [PATCH 2/5] Gate tool-gateway behind an env var, so it's not in users' faces until we're ready. Even if users enable it, it'll be blocked server-side for now, until we unlock for non-admin users on tool-gateway. --- .env.example | 11 --- agent/prompt_builder.py | 4 + agent/smart_model_routing.py | 10 +-- gateway/config.py | 10 +-- hermes_cli/config.py | 12 ++- hermes_cli/nous_subscription.py | 21 +++-- hermes_cli/plugins.py | 4 +- hermes_cli/setup.py | 13 +++- hermes_cli/status.py | 40 +++++----- hermes_cli/tools_config.py | 10 ++- run_agent.py | 6 +- tests/agent/test_prompt_builder.py | 9 +++ tests/hermes_cli/test_setup.py | 3 + .../hermes_cli/test_status_model_provider.py | 22 ++++++ tests/hermes_cli/test_tools_config.py | 16 ++++ tests/test_cli_provider_resolution.py | 2 + tests/test_utils_truthy_values.py | 29 +++++++ .../test_managed_browserbase_and_modal.py | 5 ++ tests/tools/test_managed_media_gateways.py | 5 ++ tests/tools/test_managed_tool_gateway.py | 37 ++++++++- tests/tools/test_terminal_requirements.py | 22 +++++- .../tools/test_terminal_tool_requirements.py | 1 + tests/tools/test_web_tools_config.py | 29 ++++++- tools/browser_providers/browserbase.py | 12 ++- tools/image_generation_tool.py | 8 +- tools/managed_tool_gateway.py | 4 + tools/terminal_tool.py | 76 +++++++++++++++---- tools/tool_backend_helpers.py | 18 ++++- tools/transcription_tools.py | 16 ++-- tools/tts_tool.py | 9 ++- tools/web_tools.py | 76 +++++++++++-------- utils.py | 19 +++++ .../docs/reference/environment-variables.md | 3 - website/docs/user-guide/configuration.md | 4 +- website/docs/user-guide/features/tools.md | 7 -- 35 files changed, 426 insertions(+), 147 deletions(-) create mode 100644 tests/test_utils_truthy_values.py diff --git a/.env.example b/.env.example index 5567ca7ef..d273a6966 100644 --- a/.env.example +++ b/.env.example @@ -69,17 +69,6 @@ OPENCODE_GO_API_KEY= # Get at: https://parallel.ai PARALLEL_API_KEY= -# Tool-gateway config (Nous Subscribers only; preferred when available) -# Uses your Nous Subscriber OAuth access token from the Hermes auth store by default. -# Defaults to the Nous production gateway. Override for local dev. -# -# Derive vendor gateway URLs from a shared domain suffix: -# TOOL_GATEWAY_DOMAIN=nousresearch.com -# TOOL_GATEWAY_SCHEME=https -# -# Override the subscriber token (defaults to ~/.hermes/auth.json): -# TOOL_GATEWAY_USER_TOKEN= - # Firecrawl API Key - Web search, extract, and crawl # Get at: https://firecrawl.dev/ FIRECRAWL_API_KEY= diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 7a8d6d707..878c8658c 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -426,10 +426,14 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) - """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", diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index d57cd1b83..dd445a03f 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/gateway/config.py b/gateway/config.py index 935a50d74..1f84c7689 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,19 +17,14 @@ 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__) 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, str): - return value.strip().lower() in ("true", "1", "yes", "on") - return bool(value) + return is_truthy_value(value, default=default) def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: @@ -818,4 +813,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except ValueError: pass - diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b5ed25d6d..211e264e4 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 @@ -39,7 +41,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 @@ -959,6 +960,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]]: """ diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f5f8e8615..063732235 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -11,6 +11,7 @@ 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_openai_audio_api_key, @@ -156,6 +157,7 @@ def get_nous_subscription_features( 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 @@ -193,11 +195,11 @@ def get_nous_subscription_features( direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) direct_modal = has_direct_modal_credentials() - managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl") - managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue") - managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio") - managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase") - managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal") + 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") web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl web_active = bool( @@ -355,6 +357,9 @@ def get_nous_subscription_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.", @@ -364,6 +369,9 @@ def get_nous_subscription_explainer_lines() -> list[str]: 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() @@ -386,6 +394,9 @@ def apply_nous_managed_defaults( *, 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() diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5e27535a0..c5195ffa7 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) # --------------------------------------------------------------------------- diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 59c8d92c1..1abf37610 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -23,6 +23,7 @@ from hermes_cli.nous_subscription import ( get_nous_subscription_explainer_lines, get_nous_subscription_features, ) +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -59,9 +60,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None: def _print_nous_subscription_guidance() -> None: + lines = get_nous_subscription_explainer_lines() + if not lines: + return + print() print_header("Nous Subscription Tools") - for line in get_nous_subscription_explainer_lines(): + for line in lines: print_info(line) @@ -663,7 +668,7 @@ def _print_setup_summary(config: dict, hermes_home): tool_status.append(("Modal Execution (direct Modal)", True, None)) else: tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'")) - elif subscription_features.nous_auth_present: + 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) @@ -1912,7 +1917,7 @@ def _setup_tts_provider(config: dict): choices = [] providers = [] - if subscription_features.nous_auth_present: + 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( @@ -2137,6 +2142,8 @@ def setup_terminal_backend(config: dict): from tools.tool_backend_helpers import normalize_modal_mode managed_modal_available = bool( + managed_nous_tools_enabled() + and get_nous_subscription_features(config).nous_auth_present and is_managed_tool_gateway_ready("modal") ) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 649d41231..4b68c084b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -18,6 +18,7 @@ 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: @@ -190,26 +191,27 @@ def show_status(args): # ========================================================================= # Nous Subscription Features # ========================================================================= - 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)" + 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: - state = "not configured" - print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") + 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 2226d5173..4046f40ac 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -22,6 +22,7 @@ from hermes_cli.nous_subscription import ( apply_nous_managed_defaults, get_nous_subscription_features, ) +from tools.tool_backend_helpers import managed_nous_tools_enabled PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -737,6 +738,8 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: 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) @@ -1234,9 +1237,10 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): config, enabled_toolsets=new_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)) + 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), diff --git a/run_agent.py b/run_agent.py index 186e20711..cd3884c52 100644 --- a/run_agent.py +++ b/run_agent.py @@ -96,7 +96,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", @@ -2005,7 +2005,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 @@ -6052,7 +6052,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 f1859b036..deeac8990 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -401,6 +401,7 @@ class TestBuildSkillsSystemPrompt: 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( @@ -424,6 +425,7 @@ class TestBuildNousSubscriptionPrompt: 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( @@ -445,6 +447,13 @@ class TestBuildNousSubscriptionPrompt: 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 diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 66af7faf0..1a4839de4 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -183,6 +183,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) @@ -270,6 +271,7 @@ def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch): 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() @@ -311,6 +313,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon 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) diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index 2056aac4f..1e6531d37 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -64,6 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc 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) @@ -100,3 +101,24 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path 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 ebcef8327..dccbce9d3 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -248,6 +248,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present() 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( @@ -260,6 +261,20 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch) 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( @@ -275,6 +290,7 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch): 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": []}, diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 65bcdf5c7..cef89cf16 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -277,6 +277,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch): 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"}, @@ -315,6 +316,7 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_ 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"}, 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 index 3d97a4373..085f19cfd 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -45,6 +45,11 @@ def _restore_tool_and_agent_modules(): 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")) diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index 48cd5f41f..9a2d8391c 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -44,6 +44,11 @@ def _restore_tool_and_agent_modules(): 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] diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 591708345..39b9125e1 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -16,7 +16,14 @@ 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, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + 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", @@ -29,7 +36,14 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain() def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): - with patch.dict(os.environ, {"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/"}, clear=False): + 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", @@ -40,7 +54,14 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): - with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + 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, @@ -49,6 +70,16 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): 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)) diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index c93d68e17..c55fc8310 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -7,6 +7,7 @@ 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", @@ -73,13 +74,14 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, assert ok is False assert any( - "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" 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)) @@ -115,3 +117,21 @@ def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, ca "TERMINAL_MODAL_MODE=direct" 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 216284932..d0ce42735 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -28,6 +28,7 @@ class TestTerminalRequirements: 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) diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 1354c2431..93ab6846f 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -11,6 +11,8 @@ Coverage: import importlib import json import os +import sys +import types import pytest from unittest.mock import patch, MagicMock, AsyncMock @@ -24,6 +26,7 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -32,6 +35,7 @@ class TestFirecrawlClientConfig: "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.""" @@ -39,6 +43,7 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -293,6 +298,7 @@ class TestBackendSelection: """ _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", @@ -304,8 +310,10 @@ class TestBackendSelection: ) 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: @@ -417,11 +425,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.""" @@ -479,6 +501,7 @@ class TestCheckWebApiKey: """Test suite for check_web_api_key() unified availability check.""" _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", @@ -490,8 +513,10 @@ class TestCheckWebApiKey: ) 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: diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 342b430b1..5c580c3f3 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -10,6 +10,7 @@ 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] = {} @@ -93,10 +94,15 @@ class BrowserbaseProvider(CloudBrowserProvider): def _get_config(self) -> Dict[str, Any]: config = self._get_config_or_none() if config is None: - raise ValueError( - "Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials " - "or a managed Browserbase gateway configuration." + message = ( + "Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials." ) + 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]: diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 84edb93fe..77e090529 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -39,6 +39,7 @@ 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__) @@ -416,9 +417,10 @@ def image_generate_tool( # Check API key availability if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()): - raise ValueError( - "FAL_KEY environment variable not set and managed FAL gateway is unavailable" - ) + 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( diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 96dd27b30..4d9da52bf 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import Callable, Optional from hermes_cli.config 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" @@ -131,6 +132,9 @@ def resolve_managed_tool_gateway( 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 diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 13b724bf5..d9d2fa4f7 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -65,7 +65,12 @@ def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None: # 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 has_direct_modal_credentials, normalize_modal_mode +from tools.tool_backend_helpers import ( + coerce_modal_mode, + has_direct_modal_credentials, + managed_nous_tools_enabled, + normalize_modal_mode, +) # Disk usage warning threshold (in GB) @@ -506,7 +511,7 @@ def _get_env_config() -> Dict[str, Any]: return { "env_type": env_type, - "modal_mode": normalize_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), + "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}"), @@ -541,9 +546,13 @@ 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.""" + requested_mode = coerce_modal_mode(modal_mode) normalized_mode = normalize_modal_mode(modal_mode) has_direct = has_direct_modal_credentials() managed_ready = is_managed_tool_gateway_ready("modal") + managed_mode_blocked = ( + requested_mode == "managed" and not managed_nous_tools_enabled() + ) if normalized_mode == "managed": selected_backend = "managed" if managed_ready else None @@ -553,9 +562,11 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: selected_backend = "direct" if has_direct else "managed" if managed_ready 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, } @@ -636,6 +647,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) 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." @@ -644,9 +662,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, raise ValueError( "Modal backend is configured for direct mode, but no direct Modal credentials/config were found." ) - raise ValueError( - "Modal backend selected but no direct Modal credentials/config or managed tool gateway was 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, @@ -1283,25 +1304,48 @@ def check_terminal_requirements() -> bool: 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": - 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." - ) + 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: - 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." - ) - return False + 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("swerex") is None: logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'") diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index bcf93e849..4b8d9d157 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -5,26 +5,40 @@ from __future__ import annotations import os from pathlib import Path +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 normalize_modal_mode(value: object | None) -> str: - """Return a normalized modal execution mode.""" +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.""" + mode = coerce_modal_mode(value) + if mode == "managed" and not managed_nous_tools_enabled(): + return "direct" + return mode + + def has_direct_modal_credentials() -> bool: """Return True when direct Modal credentials/config are available.""" return bool( diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index ae05358b8..4a1f7ed51 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -33,8 +33,9 @@ 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 resolve_openai_audio_api_key +from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key from hermes_constants import get_hermes_home @@ -122,11 +123,7 @@ 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 _has_openai_audio_backend() -> bool: @@ -586,9 +583,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]: managed_gateway = resolve_managed_tool_gateway("openai-audio") if managed_gateway is None: - raise ValueError( - "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" - ) + 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" diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c71cdb1e8..9210c3318 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -40,7 +40,7 @@ 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 resolve_openai_audio_api_key +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 @@ -565,9 +565,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]: managed_gateway = resolve_managed_tool_gateway("openai-audio") if managed_gateway is None: - raise ValueError( - "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" - ) + 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" diff --git a/tools/web_tools.py b/tools/web_tools.py index 1ebf36d77..7e9e84483 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -54,6 +54,7 @@ from tools.managed_tool_gateway import ( 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 @@ -152,12 +153,46 @@ def _has_direct_firecrawl_config() -> bool: def _raise_web_backend_configuration_error() -> None: """Raise a clear error for unsupported web backend configuration.""" - raise ValueError( + message = ( "Web tools are not configured. " - "Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, " - "or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide " - "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN." + "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 = [ + "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(): @@ -1410,10 +1445,8 @@ async def web_crawl_tool( # 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, FIRECRAWL_API_URL, " - "or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, " - "or TOOL_GATEWAY_DOMAIN, " - "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) @@ -1754,9 +1787,8 @@ if __name__ == "__main__": else: print("❌ No web search backend configured") print( - "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " - "or, if you are a Nous Subscriber, login to Nous and use " - "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN" + "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL" + f"{_firecrawl_backend_help_suffix()}" ) if not nous_available: @@ -1867,16 +1899,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=[ - "PARALLEL_API_KEY", - "TAVILY_API_KEY", - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - "FIRECRAWL_API_KEY", - "FIRECRAWL_API_URL", - ], + requires_env=_web_requires_env(), emoji="🔍", ) registry.register( @@ -1886,16 +1909,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=[ - "PARALLEL_API_KEY", - "TAVILY_API_KEY", - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - "FIRECRAWL_API_KEY", - "FIRECRAWL_API_URL", - ], + 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 d7d689580..d228c3927 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -78,9 +78,6 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | -| `TOOL_GATEWAY_DOMAIN` | Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, for example `nousresearch.com` -> `firecrawl-gateway.nousresearch.com` | -| `TOOL_GATEWAY_SCHEME` | Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts, `https` by default and `http` for local gateway testing | -| `TOOL_GATEWAY_USER_TOKEN` | Explicit Nous Subscriber access token for tool-gateway calls (optional; otherwise Hermes reads `~/.hermes/auth.json`) | | `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 1d3085798..4aa5afb0b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -725,9 +725,9 @@ If terminal commands fail immediately or the terminal tool is reported as disabl - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - **Modal backend** - - Hermes can use either direct Modal credentials (`MODAL_TOKEN_ID` plus `MODAL_TOKEN_SECRET`, or `~/.modal.toml`) or a configured managed tool gateway with a Nous user token. + - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox. - - If neither direct credentials nor a managed gateway is present, Hermes will report that the Modal backend is not available. + - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index bbea0a262..981d2caf2 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -109,13 +109,6 @@ modal setup hermes config set terminal.backend modal ``` -Hermes can use Modal in two modes: - -- **Direct Modal**: Hermes talks to your Modal account directly. -- **Managed Modal**: Hermes talks to a gateway that owns the vendor credentials. - -In both cases, Modal is best treated as a task sandbox, not a deployment target. Persistent mode preserves filesystem state so later turns can resume your work, but Hermes may still clean up or recreate the live sandbox. Long-running servers and background processes are not guaranteed to survive idle cleanup, session teardown, or Hermes exit. - ### Container Resources Configure CPU, memory, disk, and persistence for all container backends: From 1b7473e702b23baad2a95df3b948f3518036a9f2 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 31 Mar 2026 09:29:59 +0900 Subject: [PATCH 3/5] Fixes and refactors enabled by recent updates to main. --- tests/tools/test_managed_modal_environment.py | 104 +++++++++- tests/tools/test_modal_snapshot_isolation.py | 4 + tools/environments/managed_modal.py | 172 ++++++++--------- tools/environments/modal.py | 98 ++++------ tools/environments/modal_common.py | 178 ++++++++++++++++++ 5 files changed, 406 insertions(+), 150 deletions(-) create mode 100644 tools/environments/modal_common.py diff --git a/tests/tools/test_managed_modal_environment.py b/tests/tools/test_managed_modal_environment.py index b52801809..10c1ab56f 100644 --- a/tests/tools/test_managed_modal_environment.py +++ b/tests/tools/test_managed_modal_environment.py @@ -6,6 +6,8 @@ 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" @@ -25,7 +27,7 @@ def _reset_modules(prefixes: tuple[str, ...]): sys.modules.pop(name, None) -def _install_fake_tools_package(): +def _install_fake_tools_package(*, credential_mounts=None): _reset_modules(("tools", "agent", "hermes_cli")) hermes_cli = types.ModuleType("hermes_cli") @@ -68,6 +70,9 @@ def _install_fake_tools_package(): managed_mode=True, ) ) + sys.modules["tools.credential_files"] = types.SimpleNamespace( + get_credential_file_mounts=lambda: list(credential_mounts or []), + ) return interrupt_event @@ -87,6 +92,7 @@ class _FakeResponse: 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} @@ -112,7 +118,7 @@ def test_managed_modal_execute_polls_until_completed(monkeypatch): raise AssertionError(f"Unexpected request: {method} {url}") monkeypatch.setattr(managed_modal.requests, "request", fake_request) - monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None) + monkeypatch.setattr(modal_common.time, "sleep", lambda _: None) env = managed_modal.ManagedModalEnvironment(image="python:3.11") result = env.execute("echo hello") @@ -149,6 +155,7 @@ def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch): 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 = [] @@ -170,7 +177,7 @@ def test_managed_modal_execute_cancels_on_interrupt(monkeypatch): interrupt_event.set() monkeypatch.setattr(managed_modal.requests, "request", fake_request) - monkeypatch.setattr(managed_modal.time, "sleep", fake_sleep) + monkeypatch.setattr(modal_common.time, "sleep", fake_sleep) env = managed_modal.ManagedModalEnvironment(image="python:3.11") result = env.execute("sleep 30") @@ -190,6 +197,7 @@ def test_managed_modal_execute_cancels_on_interrupt(monkeypatch): 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"): @@ -203,7 +211,7 @@ def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeyp raise AssertionError(f"Unexpected request: {method} {url}") monkeypatch.setattr(managed_modal.requests, "request", fake_request) - monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None) + monkeypatch.setattr(modal_common.time, "sleep", lambda _: None) env = managed_modal.ManagedModalEnvironment(image="python:3.11") result = env.execute("echo hello") @@ -211,3 +219,91 @@ def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeyp 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_modal_snapshot_isolation.py b/tests/tools/test_modal_snapshot_isolation.py index 1f9d9ff95..a3d0eeacd 100644 --- a/tests/tools/test_modal_snapshot_isolation.py +++ b/tests/tools/test_modal_snapshot_isolation.py @@ -87,6 +87,10 @@ def _install_modal_test_modules( 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]] = [] diff --git a/tools/environments/managed_modal.py b/tools/environments/managed_modal.py index 241c69094..a8197bccf 100644 --- a/tools/environments/managed_modal.py +++ b/tools/environments/managed_modal.py @@ -6,12 +6,15 @@ import json import logging import os import requests -import time import uuid +from dataclasses import dataclass from typing import Any, Dict, Optional -from tools.environments.base import BaseEnvironment -from tools.interrupt import is_interrupted +from tools.environments.modal_common import ( + BaseModalExecutionEnvironment, + ModalExecStart, + PreparedModalExec, +) from tools.managed_tool_gateway import resolve_managed_tool_gateway logger = logging.getLogger(__name__) @@ -25,12 +28,20 @@ def _request_timeout_env(name: str, default: float) -> float: return default -class ManagedModalEnvironment(BaseEnvironment): +@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, @@ -43,6 +54,8 @@ class ManagedModalEnvironment(BaseEnvironment): ): 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") @@ -56,31 +69,16 @@ class ManagedModalEnvironment(BaseEnvironment): self._create_idempotency_key = str(uuid.uuid4()) self._sandbox_id = self._create_sandbox() - def execute(self, command: str, cwd: str = "", *, - timeout: int | None = None, - stdin_data: str | None = None) -> dict: - exec_command, sudo_stdin = self._prepare_command(command) - - # When a sudo password is present, inject it via a shell-level pipe - # (same approach as the direct ModalEnvironment) since the gateway - # cannot pipe subprocess stdin directly. - if sudo_stdin is not None: - import shlex - exec_command = ( - f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}" - ) - - exec_cwd = cwd or self.cwd - effective_timeout = timeout or self.timeout + def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart: exec_id = str(uuid.uuid4()) payload: Dict[str, Any] = { "execId": exec_id, - "command": exec_command, - "cwd": exec_cwd, - "timeoutMs": int(effective_timeout * 1000), + "command": prepared.command, + "cwd": prepared.cwd, + "timeoutMs": int(prepared.timeout * 1000), } - if stdin_data is not None: - payload["stdinData"] = stdin_data + if prepared.stdin_data is not None: + payload["stdinData"] = prepared.stdin_data try: response = self._request( @@ -90,81 +88,68 @@ class ManagedModalEnvironment(BaseEnvironment): timeout=10, ) except Exception as exc: - return { - "output": f"Managed Modal exec failed: {exc}", - "returncode": 1, - } + return ModalExecStart( + immediate_result=self._error_result(f"Managed Modal exec failed: {exc}") + ) if response.status_code >= 400: - return { - "output": self._format_error("Managed Modal exec failed", response), - "returncode": 1, - } + 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 { - "output": body.get("output", ""), - "returncode": body.get("returncode", 1), - } + return ModalExecStart( + immediate_result=self._result( + body.get("output", ""), + body.get("returncode", 1), + ) + ) if body.get("execId") != exec_id: - return { - "output": "Managed Modal exec start did not return the expected exec id", - "returncode": 1, - } - - poll_interval = 0.25 - deadline = time.monotonic() + effective_timeout + 10 - - while time.monotonic() < deadline: - if is_interrupted(): - self._cancel_exec(exec_id) - return { - "output": "[Command interrupted - Modal sandbox exec cancelled]", - "returncode": 130, - } - - try: - status_response = self._request( - "GET", - f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}", - timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS), + return ModalExecStart( + immediate_result=self._error_result( + "Managed Modal exec start did not return the expected exec id" ) - except Exception as exc: - return { - "output": f"Managed Modal exec poll failed: {exc}", - "returncode": 1, - } + ) - if status_response.status_code == 404: - return { - "output": "Managed Modal exec not found", - "returncode": 1, - } + return ModalExecStart(handle=_ManagedModalExecHandle(exec_id=exec_id)) - if status_response.status_code >= 400: - return { - "output": self._format_error("Managed Modal exec poll failed", status_response), - "returncode": 1, - } + 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}") - status_body = status_response.json() - status = status_body.get("status") - if status in {"completed", "failed", "cancelled", "timeout"}: - return { - "output": status_body.get("output", ""), - "returncode": status_body.get("returncode", 1), - } + if status_response.status_code == 404: + return self._error_result("Managed Modal exec not found") - time.sleep(poll_interval) + if status_response.status_code >= 400: + return self._error_result( + self._format_error("Managed Modal exec poll failed", status_response) + ) - self._cancel_exec(exec_id) - return { - "output": f"Managed Modal exec timed out after {effective_timeout}s", - "returncode": 124, - } + 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): @@ -226,6 +211,21 @@ class ManagedModalEnvironment(BaseEnvironment): 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, diff --git a/tools/environments/modal.py b/tools/environments/modal.py index 8954a6f34..805f9ac28 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -9,13 +9,16 @@ 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_constants import get_hermes_home -from tools.environments.base import BaseEnvironment -from tools.interrupt import is_interrupted +from tools.environments.modal_common import ( + BaseModalExecutionEnvironment, + ModalExecStart, + PreparedModalExec, +) logger = logging.getLogger(__name__) @@ -135,9 +138,20 @@ class _AsyncWorker: self._thread.join(timeout=10) -class ModalEnvironment(BaseEnvironment): +@dataclass +class _DirectModalExecHandle: + thread: threading.Thread + result_holder: Dict[str, Any] + + +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, image: str, @@ -312,36 +326,11 @@ 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: + 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 - full_command = f"cd {shlex.quote(effective_cwd)} && {exec_command}" - + 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(): @@ -351,7 +340,7 @@ class ModalEnvironment(BaseEnvironment): "bash", "-c", full_command, - timeout=effective_timeout, + timeout=prepared.timeout, ) stdout = await process.stdout.read.aio() stderr = await process.stderr.read.aio() @@ -363,42 +352,31 @@ class ModalEnvironment(BaseEnvironment): 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( + result_holder["value"] = self._worker.run_coroutine( _do_execute(), - timeout=effective_timeout + 30, + 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.""" 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.""" From 647f99d4dd8c98da1b3ff01ba64cb71f5423fab6 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 2 Apr 2026 00:50:40 +0000 Subject: [PATCH 4/5] fix: resolve post-merge issues in auxiliary_client and model flow - Add missing `from agent.credential_pool import load_pool` import to auxiliary_client.py (introduced by the credential pool feature in main) - Thread `args` through `select_provider_and_model(args=None)` so TLS options from `cmd_model` reach `_model_flow_nous` - Mock `_require_tty` in test_cmd_model_forwards_nous_login_tls_options so it can run in non-interactive test environments Co-Authored-By: Claude Sonnet 4.6 --- agent/auxiliary_client.py | 1 + hermes_cli/main.py | 4 ++-- tests/test_cli_provider_resolution.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 70f81d134..3b05e8d12 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -47,6 +47,7 @@ from typing import Any, Dict, List, Optional, Tuple from openai import OpenAI +from agent.credential_pool import load_pool from hermes_cli.config import get_hermes_home from hermes_constants import OPENROUTER_BASE_URL diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 39cb2e9a2..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 diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 45161b2cf..4d876cf6e 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -560,6 +560,7 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys): 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"}}, From a0f5fc25702105624f60bdb8b8d1edd420e06626 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 2 Apr 2026 12:40:03 +1100 Subject: [PATCH 5/5] fix(tools): add debug logging for token refresh and tighten domain check - Add logger + debug log to read_nous_access_token() catch-all so token refresh failures are observable instead of silently swallowed - Tighten _is_nous_auxiliary_client() domain check to use proper URL hostname parsing instead of substring match, preventing false-positives on domains like not-nousresearch.com or nousresearch.com.evil.com --- tools/managed_tool_gateway.py | 7 +++++-- tools/web_tools.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index d3bec0678..cd27537fd 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -3,11 +3,14 @@ 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 @@ -93,8 +96,8 @@ def read_nous_access_token() -> Optional[str]: ) if isinstance(refreshed_token, str) and refreshed_token.strip(): return refreshed_token.strip() - except Exception: - pass + except Exception as exc: + logger.debug("Nous access token refresh failed: %s", exc) return cached_token diff --git a/tools/web_tools.py b/tools/web_tools.py index 42cecf9d1..ba6bdb077 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -445,8 +445,11 @@ DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000 def _is_nous_auxiliary_client(client: Any) -> bool: """Return True when the resolved auxiliary backend is Nous Portal.""" - base_url = str(getattr(client, "base_url", "") or "").lower() - return "nousresearch.com" in base_url + 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]]: