From 95dc9aaa75630b4875f1e0dc71698558949f2059 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Thu, 26 Mar 2026 15:27:27 -0700 Subject: [PATCH 001/212] 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 | 254 ++++++--- 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 | 131 +++-- 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, 4570 insertions(+), 426 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 d273a6966a0b..5567ca7efd90 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 6ed6e90a76c5..7a8d6d707141 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 aed78da6e7ed..a5afe751ece2 100644 --- a/environments/patches.py +++ b/environments/patches.py @@ -11,11 +11,11 @@ _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 @@ 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 493e5a1d8065..9eb867352901 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 826e3a8bc0af..af13046b0b82 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -142,6 +142,7 @@ def ensure_hermes_home(): "terminal": { "backend": "local", + "modal_mode": "auto", "cwd": ".", # Use current directory "timeout": 180, # Environment variables to pass through to sandboxed execution @@ -407,7 +408,7 @@ def ensure_hermes_home(): }, # Config schema version - bump this when adding new required fields - "_config_version": 10, + "_config_version": 11, } # ============================================================================= @@ -422,6 +423,7 @@ def ensure_hermes_home(): 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 @@ def ensure_hermes_home(): "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 88fbf9cd922b..a920c1c1b6df 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 @@ def main(): 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 000000000000..f5f8e8615997 --- /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 54ecbf165a31..59c8d92c17d8 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -18,6 +18,12 @@ 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") - - # Check if swe-rex[modal] is installed - try: - __import__("swe_rex") - except ImportError: - print_info("Installing swe-rex[modal]...") - import subprocess + from tools.managed_tool_gateway import is_managed_tool_gateway_ready + from tools.tool_backend_helpers import normalize_modal_mode - 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") + 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: - 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 + + 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") - # 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): + # 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 @@ def _offer_openclaw_migration(hermes_home: Path) -> bool: ("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 01f46b766ef4..649d41231378 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -15,6 +15,7 @@ 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 a8f349e9c1d9..be73dfcfa228 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -18,6 +18,10 @@ 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 @@ def _get_plugin_toolset_keys() -> set: "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 @@ def _get_plugin_toolset_keys() -> set: "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 @@ def _get_plugin_toolset_keys() -> set: "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 @@ def _get_plugin_toolset_keys() -> set: "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 8ba6d1f0c19a..bd5fa648111c 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 6e65cc8223e4..3709b1a63db9 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 3ad5b3ec449f..1a6d57876681 100644 --- a/run_agent.py +++ b/run_agent.py @@ -74,6 +74,7 @@ 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 @@ def _build_system_prompt(self, system_message: str = None) -> str: 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 37fddcc9cc54..b4d038fc0d7b 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -5,6 +5,8 @@ import logging import sys +import pytest + from agent.prompt_builder import ( _scan_context_content, _truncate_content, @@ -15,6 +17,7 @@ _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 @@ SESSION_SEARCH_GUIDANCE, PLATFORM_HINTS, ) +from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures # ========================================================================= @@ -395,6 +399,53 @@ def test_non_local_backend_keeps_skill_visible_without_probe( 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 @@ def test_loads_claude_md_lowercase(self, tmp_path): 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 a4c85ba2b124..66af7faf03aa 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 _fake_get_codex_model_ids(access_token=None): 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 4e76c013d26a..ba15147231d3 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 @@ def test_chat_first_run_headless_skips_setup_prompt(self, capsys): 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 3a9ce17a0ee1..2056aac4ff0a 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 676305dbd7b0..ae3455cb8fd7 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 667cd33a6c06..65bcdf5c79ea 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -78,6 +78,13 @@ class _ANSI(str): 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 _runtime_resolve(**kwargs): 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 d961244f36fd..cfed4afbc61f 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -584,6 +584,11 @@ def test_includes_datetime(self, agent): # 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 000000000000..3d97a43732bb --- /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 000000000000..48cd5f41f464 --- /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 000000000000..b52801809f89 --- /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 000000000000..591708345c00 --- /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 000000000000..0b4f7fc56d3b --- /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 b3bc0b19483c..c93d68e178fa 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 5a347cc6eb6c..21628493289c 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -26,3 +26,30 @@ def test_terminal_and_file_tools_resolve_for_local_backend(self, monkeypatch): 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 b5c9f9775136..d43f89cf187a 100644 --- a/tests/tools/test_transcription_tools.py +++ b/tests/tools/test_transcription_tools.py @@ -231,6 +231,7 @@ def test_successful_transcription(self, monkeypatch, sample_wav): 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 @@ def test_api_error_returns_failure(self, monkeypatch, sample_wav): 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 @@ def test_whitespace_stripped(self, monkeypatch, sample_wav): 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 @@ def test_permission_error(self, monkeypatch, sample_wav): 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 d291a005be9e..1354c2431b5b 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -5,12 +5,14 @@ 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 @@ def setup_method(self): """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 @@ def test_self_hosted_no_key(self): 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 @@ def test_empty_string_key_no_url_raises(self): """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 @@ def test_singleton_returns_same_instance(self): 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 @@ def test_all_three_keys_returns_true(self): }): 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 1aad8e6e07b7..342b430b1159 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 @@ def provider_name(self) -> str: 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 @@ def create_session(self, task_id: str) -> Dict[str, object]: "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 @@ def create_session(self, task_id: str) -> Dict[str, object]: 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 @@ def create_session(self, task_id: str) -> Dict[str, object]: ) 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 @@ def create_session(self, task_id: str) -> Dict[str, object]: ) 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 @@ def create_session(self, task_id: str) -> Dict[str, object]: "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 @@ def close_session(self, session_id: str) -> bool: 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 @@ def close_session(self, session_id: str) -> bool: 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 e75025482b3a..3018d52313fe 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -78,6 +78,7 @@ 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 @@ def check_browser_requirements() -> bool: 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 19270c6fe93c..dbf617444ced 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 000000000000..241c690943cf --- /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 f8210ba78e7b..d499dc4a3fbb 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -20,6 +20,7 @@ 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 @@ def __init__( sandbox_kwargs = dict(modal_sandbox_kwargs or {}) - # If persistent, try to restore from a previous snapshot - restored_image = None + restored_snapshot_id = None + restored_from_legacy_key = False if self._persistent: - snapshot_id = _load_snapshots().get(self._task_id) - if snapshot_id: - try: - import modal - restored_image = modal.Image.from_id(snapshot_id) - logger.info("Modal: restoring from snapshot %s", snapshot_id[:20]) - except Exception as e: - logger.warning("Modal: failed to restore snapshot, using base image: %s", e) - restored_image = None - - effective_image = restored_image if restored_image else image - - # Pre-build a modal.Image with pip fix for Modal's legacy image builder. - # Some task images have broken pip; fix via ensurepip before Modal uses it. - import modal as _modal - if isinstance(effective_image, str): - effective_image = _modal.Image.from_registry( - effective_image, - setup_dockerfile_commands=[ - "RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; " - "python -m ensurepip --upgrade --default-pip 2>/dev/null || true", - ], - ) + restored_snapshot_id, restored_from_legacy_key = _get_snapshot_restore_candidate(self._task_id) + if restored_snapshot_id: + logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20]) - # 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 @@ async def _create_and_start(): 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 @@ def execute(self, command: str, cwd: str = "", *, 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 @@ def execute(self, command: str, cwd: str = "", *, 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 @@ async def _do_execute(): merge_output_streams=True, ) ) + output = self._worker.run_coroutine(_do_execute()) result_holder["value"] = { "output": output.stdout, @@ -227,7 +289,7 @@ def cleanup(self): 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 @@ async def _snapshot(): 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 5dadf4998223..84edb93fe2ab 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -32,9 +32,13 @@ 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_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 @@ def _handle_image_generate(args, **kw): 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 000000000000..96dd27b30aef --- /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 aa917ab1ab7c..13b724bf5742 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 @@ - 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 @@ 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 @@ def replace_sudo(match): 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 @@ def replace_sudo(match): 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": - if importlib.util.find_spec("swerex") is None: - logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[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 - 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." - ) + + if importlib.util.find_spec("swerex") is None: + 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 000000000000..bcf93e849eba --- /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 0c0a1fc9f65b..ae05358b8395 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -31,6 +31,10 @@ 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 @@ # --------------------------------------------------------------------------- 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) - - 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)) - - return {"success": True, "transcript": transcript_text, "provider": "groq"} + try: + 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)) + + 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) - - 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 OpenAI API (%s, %d chars)", - Path(file_path).name, model_name, len(transcript_text)) - - return {"success": True, "transcript": transcript_text, "provider": "openai"} + 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", + ) + + 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)) + + 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 eed3961dfeec..c71cdb1e8947 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -32,11 +32,15 @@ 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 @@ def _import_sounddevice(): 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 @@ def _check(importer, label): 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 d4afc06ae819..1ebf36d77ea9 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -4,15 +4,18 @@ 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 @@ 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 @@ async def _call_summarizer_llm( 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 @@ async def _call_summarizer_llm( 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 @@ async def summarize_chunk(chunk_idx: int, chunk_content: str) -> tuple[int, Opti 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 process_single_result(result): # 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 process_single_result(result): "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 process_single_result(result): 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 _process_tavily_crawl(result): 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 _process_tavily_crawl(result): 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 _process_tavily_crawl(result): _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 _process_tavily_crawl(result): 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 process_single_crawl_result(result): # 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 process_single_crawl_result(result): "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 process_single_crawl_result(result): 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 process_single_crawl_result(result): 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 @@ def get_debug_session_info() -> Dict[str, Any]: # 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 39fb0b83aa87..d7d689580d1c 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 7e5dc53735a5..d8226062f4bd 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 981d2caf2e47..bbea0a26247b 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 002/212] 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 5567ca7efd90..d273a6966a0b 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 7a8d6d707141..878c8658cccc 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 d57cd1b83a83..dd445a03f74b 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -6,6 +6,8 @@ import re from typing import Any, Dict, Optional +from utils import is_truthy_value + _COMPLEX_KEYWORDS = { "debug", "debugging", @@ -47,13 +49,7 @@ 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 935a50d74ad9..1f84c7689b13 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,19 +17,14 @@ 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 b5ed25d6d6c5..211e264e4d55 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -22,6 +22,8 @@ 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 @@ "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 @@ def ensure_hermes_home(): }, } +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 f5f8e8615997..063732235fa6 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -11,6 +11,7 @@ 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 5e27535a0b73..c5195ffa7f38 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -38,6 +38,8 @@ 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 @@ 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 59c8d92c17d8..1abf376109c1 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -23,6 +23,7 @@ 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 649d41231378..4b68c084bbf1 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -18,6 +18,7 @@ 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 2226d5173676..4046f40acb2e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -22,6 +22,7 @@ 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 186e20711855..cd3884c5217e 100644 --- a/run_agent.py +++ b/run_agent.py @@ -96,7 +96,7 @@ 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 @@ def _dump_api_request_debug( 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 @@ def run_conversation( 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 f1859b03649b..deeac8990f2d 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -401,6 +401,7 @@ def test_non_local_backend_keeps_skill_visible_without_probe( 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 @@ def test_includes_active_subscription_features(self, monkeypatch): 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 @@ def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypa 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 66af7faf03aa..1a4839de4104 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -183,6 +183,7 @@ def _fake_get_codex_model_ids(access_token=None): 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 fake_prompt_choice(question, choices, default=0): 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 fake_prompt(message, *args, **kwargs): 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 2056aac4ff0a..1e6531d37294 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 ebcef832779e..dccbce9d3f4f 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 65bcdf5c79ea..cef89cf16d90 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -277,6 +277,7 @@ def _runtime_resolve(**kwargs): 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 000000000000..f6d2856f4a18 --- /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 3d97a43732bb..085f19cfd760 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 48cd5f41f464..9a2d8391c7f4 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 591708345c00..39b9125e1a0e 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -16,7 +16,14 @@ 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 c93d68e178fa..c55fc8310136 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -7,6 +7,7 @@ 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 21628493289c..d0ce427358d9 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -28,6 +28,7 @@ def test_terminal_and_file_tools_resolve_for_local_backend(self, monkeypatch): 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 1354c2431b5b..93ab6846ffff 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -11,6 +11,8 @@ import importlib import json import os +import sys +import types import pytest from unittest.mock import patch, MagicMock, AsyncMock @@ -24,6 +26,7 @@ def setup_method(self): 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 @@ def setup_method(self): "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 @@ def teardown_method(self): 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 @@ def setup_method(self): 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 342b430b1159..5c580c3f3a3b 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -10,6 +10,7 @@ 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 @@ def _get_config_or_none(self) -> Optional[Dict[str, Any]]: 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 84edb93fe2ab..77e0905298e2 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -39,6 +39,7 @@ 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 96dd27b30aef..4d9da52bf1e8 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -9,6 +9,7 @@ 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 13b724bf5742..d9d2fa4f717a 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 bcf93e849eba..4b8d9d1572d6 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -5,26 +5,40 @@ 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 ae05358b8395..4a1f7ed5101d 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -33,8 +33,9 @@ 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 c71cdb1e8947..9210c3318d7f 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -40,7 +40,7 @@ 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 1ebf36d77ea9..7e9e8448392e 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -54,6 +54,7 @@ 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 _process_tavily_crawl(result): # 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 @@ def get_debug_session_info() -> Dict[str, Any]: 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 66d552909856..9a2105d54f36 100644 --- a/utils.py +++ b/utils.py @@ -9,6 +9,25 @@ 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 d7d689580d1c..d228c3927c4b 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 1d3085798e1b..4aa5afb0bfbc 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 bbea0a26247b..981d2caf2e47 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 8210e7aba6a7ce37ed5c2a70c93f4c09e62487fb Mon Sep 17 00:00:00 2001 From: Bryan Cross Date: Mon, 30 Mar 2026 15:19:52 -0500 Subject: [PATCH 003/212] Optimize Dockerfile: combine RUN commands, clear caches, add .dockerignore - Combine apt-get update and install into single RUN with cache clearing - Remove APT lists after installation - Add --no-cache-dir to pip install - Add --prefer-offline --no-audit to npm install - Create .dockerignore to exclude unnecessary files from build context - Update docker-publish.yml workflow to tag images with release names - Ensure buildx caching is used (type=gha) --- .dockerignore | 72 ++++++++++++++++++++++++++-- .github/workflows/docker-publish.yml | 20 +++++++- Dockerfile | 19 +++++--- 3 files changed, 97 insertions(+), 14 deletions(-) diff --git a/.dockerignore b/.dockerignore index a690443f72b4..356ab9decf14 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,11 +3,73 @@ .gitignore .gitmodules -# Dependencies -node_modules +# GitHub +.github + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +.pytest_cache +.mypy_cache +.ruff_cache +*.egg-info +.eggs + +# Virtual environments +.venv +venv/ +ENV/ +env/ + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Environment files (secrets) +.env +.env.* +!.env.example + +# Logs and data +logs/ +data/ +tmp/ +temp_vision_images/ +testlogs +wandb/ + +# Test files +tests/ +*.test.py +*.spec.py + +# Documentation +*.md +!README.md # CI/CD -.github +*.yml +!package.json + +# Development files +examples/ +result +.direnv/ + +# Release scripts +.release_notes.md +mini-swe-agent/ + +# Nix +.direnv/ +result -# Environment files -.env \ No newline at end of file +# Skills hub +skills/.hub/ +ignored/ diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 11b98c3a989e..1f83913b2013 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -5,6 +5,8 @@ on: branches: [main] pull_request: branches: [main] + release: + types: [published] concurrency: group: docker-${{ github.ref }} @@ -41,13 +43,13 @@ jobs: nousresearch/hermes-agent:test --help - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Push image + - name: Push image (main branch) if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: @@ -59,3 +61,17 @@ jobs: nousresearch/hermes-agent:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Push image (release) + if: github.event_name == 'release' + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + nousresearch/hermes-agent:latest + nousresearch/hermes-agent:${{ github.event.release.tag_name }} + nousresearch/hermes-agent:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 61b725d397c3..0ffe0fc2f109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,25 @@ FROM debian:13.4 -RUN apt-get update -RUN apt-get install -y nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev +# Install system dependencies in one layer, clear APT cache +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev && \ + rm -rf /var/lib/apt/lists/* COPY . /opt/hermes WORKDIR /opt/hermes -RUN pip install -e ".[all]" --break-system-packages -RUN npm install -RUN npx playwright install --with-deps chromium +# Install Python and Node dependencies in one layer, no cache +RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \ + npm install --prefer-offline --no-audit && \ + npx playwright install --with-deps chromium + WORKDIR /opt/hermes/scripts/whatsapp-bridge -RUN npm install +RUN npm install --prefer-offline --no-audit WORKDIR /opt/hermes RUN chmod +x /opt/hermes/docker/entrypoint.sh ENV HERMES_HOME=/opt/data VOLUME [ "/opt/data" ] -ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ] \ No newline at end of file +ENTRYPOINT [ "/opt/hermes/docker/entrypoint.sh" ] From 48942c89b526274d560d6e9452f2bb675be391c2 Mon Sep 17 00:00:00 2001 From: Bryan Cross Date: Mon, 30 Mar 2026 15:27:11 -0500 Subject: [PATCH 004/212] Further npm optimizations --- Dockerfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0ffe0fc2f109..7efb14a6f8ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,10 @@ WORKDIR /opt/hermes # Install Python and Node dependencies in one layer, no cache RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \ npm install --prefer-offline --no-audit && \ - npx playwright install --with-deps chromium - -WORKDIR /opt/hermes/scripts/whatsapp-bridge -RUN npm install --prefer-offline --no-audit + npx playwright install --with-deps chromium && \ + cd /opt/hermes/scripts/whatsapp-bridge && \ + npm install --prefer-offline --no-audit && \ + npm cache clean --force WORKDIR /opt/hermes RUN chmod +x /opt/hermes/docker/entrypoint.sh From 5de312c9e39ad0ee88a2ff41f040b16d84d66c42 Mon Sep 17 00:00:00 2001 From: Bryan Cross Date: Mon, 30 Mar 2026 15:29:06 -0500 Subject: [PATCH 005/212] Simplify dockerignore --- .dockerignore | 70 ++++----------------------------------------------- 1 file changed, 5 insertions(+), 65 deletions(-) diff --git a/.dockerignore b/.dockerignore index 356ab9decf14..ecf199fc96f1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,73 +3,13 @@ .gitignore .gitmodules -# GitHub -.github - -# Python -__pycache__ -*.py[cod] -*$py.class -*.so -.Python -.pytest_cache -.mypy_cache -.ruff_cache -*.egg-info -.eggs +# Dependencies +node_modules -# Virtual environments -.venv -venv/ -ENV/ -env/ - -# IDE -.vscode -.idea -*.swp -*.swo -*~ +# CI/CD +.github -# Environment files (secrets) +# Environment files .env -.env.* -!.env.example - -# Logs and data -logs/ -data/ -tmp/ -temp_vision_images/ -testlogs -wandb/ - -# Test files -tests/ -*.test.py -*.spec.py -# Documentation *.md -!README.md - -# CI/CD -*.yml -!package.json - -# Development files -examples/ -result -.direnv/ - -# Release scripts -.release_notes.md -mini-swe-agent/ - -# Nix -.direnv/ -result - -# Skills hub -skills/.hub/ -ignored/ From 3a1e489dd6d0bf99f54ef513204065318fd8c985 Mon Sep 17 00:00:00 2001 From: Bryan Cross Date: Mon, 30 Mar 2026 15:57:22 -0500 Subject: [PATCH 006/212] Add build-essential to Dockerfile dependencies --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7efb14a6f8ca..3b2862a818b5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:13.4 # Install system dependencies in one layer, clear APT cache RUN apt-get update && \ apt-get install -y --no-install-recommends \ - nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev && \ + build-essential nodejs npm python3 python3-pip ripgrep ffmpeg gcc python3-dev libffi-dev && \ rm -rf /var/lib/apt/lists/* COPY . /opt/hermes From 0287597d02c74f26084f36ff610044b7a930dd85 Mon Sep 17 00:00:00 2001 From: Bryan Cross Date: Mon, 30 Mar 2026 17:38:07 -0500 Subject: [PATCH 007/212] Optimize Playwright install --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3b2862a818b5..a9624530c0dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ WORKDIR /opt/hermes # Install Python and Node dependencies in one layer, no cache RUN pip install --no-cache-dir -e ".[all]" --break-system-packages && \ npm install --prefer-offline --no-audit && \ - npx playwright install --with-deps chromium && \ + npx playwright install --with-deps chromium --only-shell && \ cd /opt/hermes/scripts/whatsapp-bridge && \ npm install --prefer-offline --no-audit && \ npm cache clean --force From 1b7473e702b23baad2a95df3b948f3518036a9f2 Mon Sep 17 00:00:00 2001 From: Robin Fernandes Date: Tue, 31 Mar 2026 09:29:59 +0900 Subject: [PATCH 008/212] 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 | 182 +++++++++--------- tools/environments/modal.py | 100 ++++------ tools/environments/modal_common.py | 178 +++++++++++++++++ 5 files changed, 412 insertions(+), 156 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 b52801809f89..10c1ab56f5ea 100644 --- a/tests/tools/test_managed_modal_environment.py +++ b/tests/tools/test_managed_modal_environment.py @@ -6,6 +6,8 @@ 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 _prepare_command(self, command: str): 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 @@ def json(self): 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 fake_request(method, url, headers=None, json=None, timeout=None): 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 fake_request(method, url, headers=None, json=None, timeout=None): 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 fake_sleep(_seconds): 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 fake_sleep(_seconds): 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 fake_request(method, url, headers=None, json=None, timeout=None): 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 fake_request(method, url, headers=None, json=None, timeout=None): 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 1f9d9ff95ba0..a3d0eeacd728 100644 --- a/tests/tools/test_modal_snapshot_isolation.py +++ b/tests/tools/test_modal_snapshot_isolation.py @@ -87,6 +87,10 @@ def _prepare_command(self, command: str): 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 241c690943cf..a8197bccf28d 100644 --- a/tools/environments/managed_modal.py +++ b/tools/environments/managed_modal.py @@ -6,12 +6,15 @@ 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 @@ def __init__( ): 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 @@ def __init__( 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 @@ def execute(self, command: str, cwd: str = "", *, 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, - } - - 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, - } + ) + + return ModalExecStart(handle=_ManagedModalExecHandle(exec_id=exec_id)) + + def _poll_modal_exec(self, handle: _ManagedModalExecHandle) -> dict | None: + try: + status_response = self._request( + "GET", + f"/v1/sandboxes/{self._sandbox_id}/execs/{handle.exec_id}", + timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS), + ) + except Exception as exc: + return self._error_result(f"Managed Modal exec poll failed: {exc}") + + if status_response.status_code == 404: + return self._error_result("Managed Modal exec not found") + + if status_response.status_code >= 400: + return self._error_result( + self._format_error("Managed Modal exec poll failed", status_response) + ) + + status_body = status_response.json() + status = status_body.get("status") + if status in {"completed", "failed", "cancelled", "timeout"}: + return self._result( + status_body.get("output", ""), + status_body.get("returncode", 1), + ) + return None + + def _cancel_modal_exec(self, handle: _ManagedModalExecHandle) -> None: + self._cancel_exec(handle.exec_id) + + def _timeout_result_for_modal(self, timeout: int) -> dict: + return self._result(f"Managed Modal exec timed out after {timeout}s", 124) def cleanup(self): if not getattr(self, "_sandbox_id", None): @@ -226,6 +211,21 @@ def _create_sandbox(self) -> str: 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 8954a6f34fc1..805f9ac2880d 100644 --- a/tools/environments/modal.py +++ b/tools/environments/modal.py @@ -9,13 +9,16 @@ 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 @@ def stop(self): 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 @@ def _sync_files(self) -> None: 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 @@ async def _do_execute(): "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 @@ async def _do_execute(): 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, - } - - if result_holder["error"]: - return { - "output": f"Modal execution error: {result_holder['error']}", - "returncode": 1, - } - return result_holder["value"] + return ModalExecStart(handle=_DirectModalExecHandle(thread=t, result_holder=result_holder)) + + 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 000000000000..0affd02095a5 --- /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 6dcc3330b3313dd27dd21a2f233e48fee0e8fee5 Mon Sep 17 00:00:00 2001 From: Dilee Date: Mon, 30 Mar 2026 17:54:55 +0300 Subject: [PATCH 009/212] fix(security): add missing GitHub OAuth token patterns and snapshot redact flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add gho_, ghu_, ghs_, ghr_ prefix patterns (OAuth, user-to-server, server-to-server, and refresh tokens) — all four types used by GitHub Apps and Copilot auth flows were absent from _PREFIX_PATTERNS - Snapshot HERMES_REDACT_SECRETS at module import time instead of re-reading os.getenv() on every call, preventing runtime env mutations (e.g. LLM-generated export commands) from disabling redaction --- agent/redact.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/agent/redact.py b/agent/redact.py index 895e3265fd55..2906d920eaf6 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -13,11 +13,19 @@ logger = logging.getLogger(__name__) +# Snapshot at import time so runtime env mutations (e.g. LLM-generated +# `export HERMES_REDACT_SECRETS=false`) cannot disable redaction mid-session. +_REDACT_ENABLED = os.getenv("HERMES_REDACT_SECRETS", "").lower() not in ("0", "false", "no", "off") + # Known API key prefixes -- match the prefix + contiguous token chars _PREFIX_PATTERNS = [ r"sk-[A-Za-z0-9_-]{10,}", # OpenAI / OpenRouter / Anthropic (sk-ant-*) r"ghp_[A-Za-z0-9]{10,}", # GitHub PAT (classic) r"github_pat_[A-Za-z0-9_]{10,}", # GitHub PAT (fine-grained) + r"gho_[A-Za-z0-9]{10,}", # GitHub OAuth access token + r"ghu_[A-Za-z0-9]{10,}", # GitHub user-to-server token + r"ghs_[A-Za-z0-9]{10,}", # GitHub server-to-server token + r"ghr_[A-Za-z0-9]{10,}", # GitHub refresh token r"xox[baprs]-[A-Za-z0-9-]{10,}", # Slack tokens r"AIza[A-Za-z0-9_-]{30,}", # Google API keys r"pplx-[A-Za-z0-9]{10,}", # Perplexity @@ -109,7 +117,7 @@ def redact_sensitive_text(text: str) -> str: text = str(text) if not text: return text - if os.getenv("HERMES_REDACT_SECRETS", "").lower() in ("0", "false", "no", "off"): + if not _REDACT_ENABLED: return text # Known prefixes (sk-, ghp_, etc.) From fad3f338d1a9e68f923f35566beaa45548796041 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 31 Mar 2026 10:30:15 -0700 Subject: [PATCH 010/212] fix: patch _REDACT_ENABLED in test fixture for module-level snapshot The _REDACT_ENABLED constant is snapshotted at import time, so monkeypatch.delenv() alone doesn't re-enable redaction during tests when HERMES_REDACT_SECRETS=false is set in the host environment. --- tests/agent/test_redact.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 27098ee6dcdf..6b7cfa586c5e 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -12,6 +12,8 @@ def _ensure_redaction_enabled(monkeypatch): """Ensure HERMES_REDACT_SECRETS is not disabled by prior test imports.""" monkeypatch.delenv("HERMES_REDACT_SECRETS", raising=False) + # Also patch the module-level snapshot so it reflects the cleared env var + monkeypatch.setattr("agent.redact._REDACT_ENABLED", True) class TestKnownPrefixes: From cca0996a28aa57a892bb5e9fe3657eb825345b48 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:40:13 -0700 Subject: [PATCH 011/212] fix(browser): skip SSRF check for local backends (Camofox, headless Chromium) (#4292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSRF protection added in #3041 blocks all private/internal addresses unconditionally in browser_navigate(). This prevents legitimate local use cases (localhost apps, LAN devices) when using Camofox or the built-in headless Chromium without a cloud provider. The check is only meaningful for cloud backends (Browserbase, BrowserUse) where the agent could reach internal resources on a remote machine. Local backends give the user full terminal and network access already — the SSRF check adds zero security value. Add _is_local_backend() helper that returns True when Camofox is active or no cloud provider is configured. Both the pre-navigation and post-redirect SSRF checks now skip when running locally. The browser.allow_private_urls config option remains available as an explicit opt-out for cloud mode. --- tests/tools/test_browser_ssrf_local.py | 118 ++++++++++++++++++++----- tools/browser_tool.py | 24 ++++- 2 files changed, 116 insertions(+), 26 deletions(-) diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py index 44d3b8ea19de..27b6e3933b69 100644 --- a/tests/tools/test_browser_ssrf_local.py +++ b/tests/tools/test_browser_ssrf_local.py @@ -1,8 +1,12 @@ -"""Tests that browser_navigate SSRF checks respect the allow_private_urls setting. +"""Tests that browser_navigate SSRF checks respect local-backend mode and +the allow_private_urls setting. -When ``browser.allow_private_urls`` is ``False`` (default), private/internal -addresses are blocked. When set to ``True``, they are allowed — useful for -local development, LAN access, and Hermes self-testing. +Local backends (Camofox, headless Chromium without a cloud provider) skip +SSRF checks entirely — the agent already has full local-network access via +the terminal tool. + +Cloud backends (Browserbase, BrowserUse) enforce SSRF by default. Users +can opt out for cloud mode via ``browser.allow_private_urls: true``. """ import json @@ -47,8 +51,11 @@ def _common_patches(self, monkeypatch): lambda *a, **kw: _make_browser_result(), ) - def test_blocks_private_url_by_default(self, monkeypatch, _common_patches): - """SSRF protection is on when allow_private_urls is not set (False).""" + # -- Cloud mode: SSRF active ----------------------------------------------- + + def test_cloud_blocks_private_url_by_default(self, monkeypatch, _common_patches): + """SSRF protection blocks private URLs in cloud mode.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False) @@ -57,27 +64,41 @@ def test_blocks_private_url_by_default(self, monkeypatch, _common_patches): assert result["success"] is False assert "private or internal address" in result["error"] - def test_blocks_private_url_when_setting_false(self, monkeypatch, _common_patches): - """SSRF protection is on when allow_private_urls is explicitly False.""" - monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + def test_cloud_allows_private_url_when_setting_true(self, monkeypatch, _common_patches): + """Private URLs pass in cloud mode when allow_private_urls is True.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True) monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False) result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL)) - assert result["success"] is False + assert result["success"] is True - def test_allows_private_url_when_setting_true(self, monkeypatch, _common_patches): - """Private URLs are allowed when allow_private_urls is True.""" - monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True) - # _is_safe_url would block this, but the setting overrides it + def test_cloud_allows_public_url(self, monkeypatch, _common_patches): + """Public URLs always pass in cloud mode.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) + + result = json.loads(browser_tool.browser_navigate("https://example.com")) + + assert result["success"] is True + + # -- Local mode: SSRF skipped ---------------------------------------------- + + def test_local_allows_private_url(self, monkeypatch, _common_patches): + """Local backends skip SSRF — private URLs are always allowed.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False) result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL)) assert result["success"] is True - def test_allows_public_url_regardless_of_setting(self, monkeypatch, _common_patches): - """Public URLs always pass regardless of the allow_private_urls setting.""" + def test_local_allows_public_url(self, monkeypatch, _common_patches): + """Local backends pass public URLs too (sanity check).""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True) monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) @@ -86,6 +107,34 @@ def test_allows_public_url_regardless_of_setting(self, monkeypatch, _common_patc assert result["success"] is True +# --------------------------------------------------------------------------- +# _is_local_backend() unit tests +# --------------------------------------------------------------------------- + + +class TestIsLocalBackend: + def test_camofox_is_local(self, monkeypatch): + """Camofox mode counts as a local backend.""" + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True) + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: "anything") + + assert browser_tool._is_local_backend() is True + + def test_no_cloud_provider_is_local(self, monkeypatch): + """No cloud provider configured → local backend.""" + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None) + + assert browser_tool._is_local_backend() is True + + def test_cloud_provider_is_not_local(self, monkeypatch): + """Cloud provider configured and not Camofox → NOT local.""" + monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) + monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: "bb") + + assert browser_tool._is_local_backend() is False + + # --------------------------------------------------------------------------- # Post-redirect SSRF check # --------------------------------------------------------------------------- @@ -112,8 +161,11 @@ def _common_patches(self, monkeypatch): }, ) - def test_blocks_redirect_to_private_by_default(self, monkeypatch, _common_patches): - """Redirects to private addresses are blocked when setting is False.""" + # -- Cloud mode: redirect SSRF active -------------------------------------- + + def test_cloud_blocks_redirect_to_private(self, monkeypatch, _common_patches): + """Redirects to private addresses are blocked in cloud mode.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) monkeypatch.setattr( browser_tool, "_is_safe_url", lambda url: "192.168" not in url, @@ -129,8 +181,9 @@ def test_blocks_redirect_to_private_by_default(self, monkeypatch, _common_patche assert result["success"] is False assert "redirect landed on a private/internal address" in result["error"] - def test_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches): - """Redirects to private addresses are allowed when setting is True.""" + def test_cloud_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches): + """Redirects to private addresses pass in cloud mode with allow_private_urls.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True) monkeypatch.setattr( browser_tool, "_is_safe_url", lambda url: "192.168" not in url, @@ -146,9 +199,30 @@ def test_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common assert result["success"] is True assert result["url"] == self.PRIVATE_FINAL_URL - def test_allows_redirect_to_public_regardless_of_setting(self, monkeypatch, _common_patches): - """Redirects to public addresses always pass.""" + # -- Local mode: redirect SSRF skipped ------------------------------------- + + def test_local_allows_redirect_to_private(self, monkeypatch, _common_patches): + """Redirects to private addresses pass in local mode.""" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True) + monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) + monkeypatch.setattr( + browser_tool, "_is_safe_url", lambda url: "192.168" not in url, + ) + monkeypatch.setattr( + browser_tool, + "_run_browser_command", + lambda *a, **kw: _make_browser_result(url=self.PRIVATE_FINAL_URL), + ) + + result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL)) + + assert result["success"] is True + assert result["url"] == self.PRIVATE_FINAL_URL + + def test_cloud_allows_redirect_to_public(self, monkeypatch, _common_patches): + """Redirects to public addresses always pass (cloud mode).""" final = "https://example.com/final" + monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False) monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False) monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True) monkeypatch.setattr( diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 1861152e31d7..441dc21f65ce 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -267,6 +267,19 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]: return _cached_cloud_provider +def _is_local_backend() -> bool: + """Return True when the browser runs locally (no cloud provider). + + SSRF protection is only meaningful for cloud backends (Browserbase, + BrowserUse) where the agent could reach internal resources on a remote + machine. For local backends — Camofox, or the built-in headless + Chromium without a cloud provider — the user already has full terminal + and network access on the same machine, so the check adds no security + value. + """ + return _is_camofox_mode() or _get_cloud_provider() is None + + def _allow_private_urls() -> bool: """Return whether the browser is allowed to navigate to private/internal addresses. @@ -1066,9 +1079,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: JSON string with navigation result (includes stealth features info on first nav) """ # SSRF protection — block private/internal addresses before navigating. - # Can be opted out via ``browser.allow_private_urls`` in config for local - # development or LAN access use cases. - if not _allow_private_urls() and not _is_safe_url(url): + # Skipped for local backends (Camofox, headless Chromium without a cloud + # provider) because the agent already has full local network access via + # the terminal tool. Can also be opted out for cloud mode via + # ``browser.allow_private_urls`` in config. + if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url): return json.dumps({ "success": False, "error": "Blocked: URL targets a private or internal address", @@ -1110,7 +1125,8 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str: # Post-redirect SSRF check — if the browser followed a redirect to a # private/internal address, block the result so the model can't read # internal content via subsequent browser_snapshot calls. - if not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url): + # Skipped for local backends (same rationale as the pre-nav check). + if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url): # Navigate away to a blank page to prevent snapshot leaks _run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10) return json.dumps({ From 84a541b619238427d038e92746102c87a6ac5c36 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:42:03 -0700 Subject: [PATCH 012/212] feat: support * wildcard in platform allowlists and improve WhatsApp docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: clarify WhatsApp allowlist behavior and document WHATSAPP_ALLOW_ALL_USERS - Add WHATSAPP_ALLOW_ALL_USERS and WHATSAPP_DEBUG to env vars reference - Warn that * is not a wildcard and silently blocks all messages - Show WHATSAPP_ALLOWED_USERS as optional, not required - Update troubleshooting with the * trap and debug mode tip - Fix Security section to mention the allow-all alternative Prompted by a user report in Discord where WHATSAPP_ALLOWED_USERS=* caused all incoming messages to be silently dropped at the bridge level. * feat: support * wildcard in platform allowlists Follow the precedent set by SIGNAL_GROUP_ALLOWED_USERS which already supports * as an allow-all wildcard. Bridge (allowlist.js): matchesAllowedUser() now checks for * in the allowedUsers set before iterating sender aliases. Gateway (run.py): _is_authorized() checks for * in allowed_ids after parsing the allowlist. This is generic — works for all platforms, not just WhatsApp. Updated docs to document * as a supported value instead of warning against it. Added WHATSAPP_ALLOW_ALL_USERS and WHATSAPP_DEBUG to the env vars reference. Tests: JS allowlist test + 2 Python gateway tests (WhatsApp + Telegram to verify cross-platform behavior). --- gateway/run.py | 5 +++ scripts/whatsapp-bridge/allowlist.js | 5 +++ scripts/whatsapp-bridge/allowlist.test.mjs | 12 ++++++ .../gateway/test_unauthorized_dm_behavior.py | 40 +++++++++++++++++++ .../docs/reference/environment-variables.md | 4 +- website/docs/user-guide/messaging/whatsapp.md | 20 ++++++++-- 6 files changed, 81 insertions(+), 5 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 2fe929447c9a..cc1a6666fdd7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1650,6 +1650,11 @@ def _is_user_authorized(self, source: SessionSource) -> bool: if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) + # "*" in any allowlist means allow everyone (consistent with + # SIGNAL_GROUP_ALLOWED_USERS precedent) + if "*" in allowed_ids: + return True + check_ids = {user_id} if "@" in user_id: check_ids.add(user_id.split("@")[0]) diff --git a/scripts/whatsapp-bridge/allowlist.js b/scripts/whatsapp-bridge/allowlist.js index 760e413f2b8e..4cbd82d0d238 100644 --- a/scripts/whatsapp-bridge/allowlist.js +++ b/scripts/whatsapp-bridge/allowlist.js @@ -68,6 +68,11 @@ export function matchesAllowedUser(senderId, allowedUsers, sessionDir) { return true; } + // "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS) + if (allowedUsers.has('*')) { + return true; + } + const aliases = expandWhatsAppIdentifiers(senderId, sessionDir); for (const alias of aliases) { if (allowedUsers.has(alias)) { diff --git a/scripts/whatsapp-bridge/allowlist.test.mjs b/scripts/whatsapp-bridge/allowlist.test.mjs index 7eea7399cd59..86e1f1d6bdff 100644 --- a/scripts/whatsapp-bridge/allowlist.test.mjs +++ b/scripts/whatsapp-bridge/allowlist.test.mjs @@ -45,3 +45,15 @@ test('matchesAllowedUser accepts mapped lid sender when allowlist only contains rmSync(sessionDir, { recursive: true, force: true }); } }); + +test('matchesAllowedUser treats * as allow-all wildcard', () => { + const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-')); + + try { + const allowedUsers = parseAllowedUsers('*'); + assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', allowedUsers, sessionDir), true); + assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } +}); diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index 25b51dc2f28f..5f898b5e6ed2 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -90,6 +90,46 @@ def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypat assert runner._is_user_authorized(source) is True +def test_star_wildcard_in_allowlist_authorizes_any_user(monkeypatch): + """WHATSAPP_ALLOWED_USERS=* should act as allow-all wildcard.""" + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "*") + + runner, _adapter = _make_runner( + Platform.WHATSAPP, + GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}), + ) + + source = SessionSource( + platform=Platform.WHATSAPP, + user_id="99998887776@s.whatsapp.net", + chat_id="99998887776@s.whatsapp.net", + user_name="stranger", + chat_type="dm", + ) + assert runner._is_user_authorized(source) is True + + +def test_star_wildcard_works_for_any_platform(monkeypatch): + """The * wildcard should work generically, not just for WhatsApp.""" + _clear_auth_env(monkeypatch) + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*") + + runner, _adapter = _make_runner( + Platform.TELEGRAM, + GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="t")}), + ) + + source = SessionSource( + platform=Platform.TELEGRAM, + user_id="123456789", + chat_id="123456789", + user_name="stranger", + chat_type="dm", + ) + assert runner._is_user_authorized(source) is True + + @pytest.mark.asyncio async def test_unauthorized_dm_pairs_by_default(monkeypatch): _clear_auth_env(monkeypatch) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index fd57ffb028b4..10b6367bec63 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -170,7 +170,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `SLACK_HOME_CHANNEL_NAME` | Display name for the Slack home channel | | `WHATSAPP_ENABLED` | Enable the WhatsApp bridge (`true`/`false`) | | `WHATSAPP_MODE` | `bot` (separate number) or `self-chat` (message yourself) | -| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`) | +| `WHATSAPP_ALLOWED_USERS` | Comma-separated phone numbers (with country code, no `+`), or `*` to allow all senders | +| `WHATSAPP_ALLOW_ALL_USERS` | Allow all WhatsApp senders without an allowlist (`true`/`false`) | +| `WHATSAPP_DEBUG` | Log raw message events in the bridge for troubleshooting (`true`/`false`) | | `SIGNAL_HTTP_URL` | signal-cli daemon HTTP endpoint (for example `http://127.0.0.1:8080`) | | `SIGNAL_ACCOUNT` | Bot phone number in E.164 format | | `SIGNAL_ALLOWED_USERS` | Comma-separated E.164 phone numbers or UUIDs | diff --git a/website/docs/user-guide/messaging/whatsapp.md b/website/docs/user-guide/messaging/whatsapp.md index 1c522681350f..6011992ece89 100644 --- a/website/docs/user-guide/messaging/whatsapp.md +++ b/website/docs/user-guide/messaging/whatsapp.md @@ -94,9 +94,20 @@ Add the following to your `~/.hermes/.env` file: # Required WHATSAPP_ENABLED=true WHATSAPP_MODE=bot # "bot" or "self-chat" + +# Access control — pick ONE of these options: WHATSAPP_ALLOWED_USERS=15551234567 # Comma-separated phone numbers (with country code, no +) +# WHATSAPP_ALLOWED_USERS=* # OR use * to allow everyone +# WHATSAPP_ALLOW_ALL_USERS=true # OR set this flag instead (same effect as *) ``` +:::tip Allow-all shorthand +Setting `WHATSAPP_ALLOWED_USERS=*` allows **all** senders (equivalent to `WHATSAPP_ALLOW_ALL_USERS=true`). +This is consistent with [Signal group allowlists](/docs/reference/environment-variables). +To use the pairing flow instead, remove both variables and rely on the +[DM pairing system](/docs/user-guide/security#dm-pairing-system). +::: + Optional behavior settings in `~/.hermes/config.yaml`: ```yaml @@ -174,7 +185,7 @@ whatsapp: | **Bridge crashes or reconnect loops** | Restart the gateway, update Hermes, and re-pair if the session was invalidated by a WhatsApp protocol change. | | **Bot stops working after WhatsApp update** | Update Hermes to get the latest bridge version, then re-pair. | | **macOS: "Node.js not installed" but node works in terminal** | launchd services don't inherit your shell PATH. Run `hermes gateway install` to re-snapshot your current PATH into the plist, then `hermes gateway start`. See the [Gateway Service docs](./index.md#macos-launchd) for details. | -| **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces). | +| **Messages not being received** | Verify `WHATSAPP_ALLOWED_USERS` includes the sender's number (with country code, no `+` or spaces), or set it to `*` to allow everyone. Set `WHATSAPP_DEBUG=true` in `.env` and restart the gateway to see raw message events in `bridge.log`. | | **Bot replies to strangers with a pairing code** | Set `whatsapp.unauthorized_dm_behavior: ignore` in `~/.hermes/config.yaml` if you want unauthorized DMs to be silently ignored instead. | --- @@ -182,9 +193,10 @@ whatsapp: ## Security :::warning -**Always set `WHATSAPP_ALLOWED_USERS`** with phone numbers (including country code, without the `+`) -of authorized users. Without this setting, the gateway will **deny all incoming messages** as a -safety measure. +**Configure access control** before going live. Set `WHATSAPP_ALLOWED_USERS` with specific +phone numbers (including country code, without the `+`), use `*` to allow everyone, or set +`WHATSAPP_ALLOW_ALL_USERS=true`. Without any of these, the gateway **denies all incoming +messages** as a safety measure. ::: By default, unauthorized DMs still receive a pairing code reply. If you want a private WhatsApp number to stay completely silent to strangers, set: From 49d7210fede960796d4d0d80f5a88bfb8d45e3de Mon Sep 17 00:00:00 2001 From: MacroAnarchy Date: Mon, 30 Mar 2026 16:10:32 +0200 Subject: [PATCH 013/212] fix(gateway): parse thread_id from delivery target format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delivery target parser uses split(':', 1) which only splits on the first colon. For the documented format platform:chat_id:thread_id (e.g. 'telegram:-1001234567890:17585'), thread_id gets munged into chat_id and is never extracted. Fix: split(':', 2) to correctly extract all three parts. Also fix to_string() to include thread_id for proper round-tripping. The downstream plumbing in _deliver_to_platform() already handles thread_id correctly (line 292-293) — it just never received a value. --- gateway/delivery.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/gateway/delivery.py b/gateway/delivery.py index 5adb3c2c1296..fff0aeadf7d5 100644 --- a/gateway/delivery.py +++ b/gateway/delivery.py @@ -70,12 +70,15 @@ def parse(cls, target: str, origin: Optional[SessionSource] = None) -> "Delivery if target == "local": return cls(platform=Platform.LOCAL) - # Check for platform:chat_id format + # Check for platform:chat_id or platform:chat_id:thread_id format if ":" in target: - platform_str, chat_id = target.split(":", 1) + parts = target.split(":", 2) + platform_str = parts[0] + chat_id = parts[1] if len(parts) > 1 else None + thread_id = parts[2] if len(parts) > 2 else None try: platform = Platform(platform_str) - return cls(platform=platform, chat_id=chat_id, is_explicit=True) + return cls(platform=platform, chat_id=chat_id, thread_id=thread_id, is_explicit=True) except ValueError: # Unknown platform, treat as local return cls(platform=Platform.LOCAL) @@ -94,6 +97,8 @@ def to_string(self) -> str: return "origin" if self.platform == Platform.LOCAL: return "local" + if self.chat_id and self.thread_id: + return f"{self.platform.value}:{self.chat_id}:{self.thread_id}" if self.chat_id: return f"{self.platform.value}:{self.chat_id}" return self.platform.value From c1606aed69f3685a6cc5d866f2d2c80fadcedbef Mon Sep 17 00:00:00 2001 From: Dakota Secula-Rosell Date: Tue, 31 Mar 2026 13:32:54 -0400 Subject: [PATCH 014/212] fix(cli): allow empty strings and falsy values in config set `hermes config set KEY ""` and `hermes config set KEY 0` were rejected because the guard used `not value` which is truthy for empty strings, zero, and False. Changed to `value is None` so only truly missing arguments are rejected. Closes #4277 Co-Authored-By: Claude Opus 4.6 (1M context) --- hermes_cli/config.py | 2 +- tests/hermes_cli/test_set_config_value.py | 42 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 51b8b9af7120..e62a4cdc1bcc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -2040,7 +2040,7 @@ def config_command(args): elif subcmd == "set": key = getattr(args, 'key', None) value = getattr(args, 'value', None) - if not key or not value: + if not key or value is None: print("Usage: hermes config set ") print() print("Examples:") diff --git a/tests/hermes_cli/test_set_config_value.py b/tests/hermes_cli/test_set_config_value.py index 4eae64d6e978..fbd71dbb53ba 100644 --- a/tests/hermes_cli/test_set_config_value.py +++ b/tests/hermes_cli/test_set_config_value.py @@ -1,12 +1,13 @@ """Tests for set_config_value — verifying secrets route to .env and config to config.yaml.""" +import argparse import os from pathlib import Path from unittest.mock import patch, call import pytest -from hermes_cli.config import set_config_value +from hermes_cli.config import set_config_value, config_command @pytest.fixture(autouse=True) @@ -125,3 +126,42 @@ def test_terminal_docker_cwd_mount_flag_goes_to_config_and_env(self, _isolated_h "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=true" in env_content or "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE=True" in env_content ) + + +# --------------------------------------------------------------------------- +# Empty / falsy values — regression tests for #4277 +# --------------------------------------------------------------------------- + +class TestFalsyValues: + """config set should accept empty strings and falsy values like '0'.""" + + def test_empty_string_routes_to_env(self, _isolated_hermes_home): + """Blanking an API key should write an empty value to .env.""" + set_config_value("OPENROUTER_API_KEY", "") + env_content = _read_env(_isolated_hermes_home) + assert "OPENROUTER_API_KEY=" in env_content + + def test_empty_string_routes_to_config(self, _isolated_hermes_home): + """Blanking a config key should write an empty string to config.yaml.""" + set_config_value("model", "") + config = _read_config(_isolated_hermes_home) + assert "model: ''" in config or "model: \"\"" in config + + def test_zero_routes_to_config(self, _isolated_hermes_home): + """Setting a config key to '0' should write 0 to config.yaml.""" + set_config_value("verbose", "0") + config = _read_config(_isolated_hermes_home) + assert "verbose: 0" in config + + def test_config_command_rejects_missing_value(self): + """config set with no value arg (None) should still exit.""" + args = argparse.Namespace(config_command="set", key="model", value=None) + with pytest.raises(SystemExit): + config_command(args) + + def test_config_command_accepts_empty_string(self, _isolated_hermes_home): + """config set KEY '' should not exit — it should set the value.""" + args = argparse.Namespace(config_command="set", key="model", value="") + config_command(args) + config = _read_config(_isolated_hermes_home) + assert "model" in config From 0240baa357522654026e4aa04c716d209f79b704 Mon Sep 17 00:00:00 2001 From: arasovic Date: Tue, 31 Mar 2026 19:42:44 +0300 Subject: [PATCH 015/212] fix: strip orphaned think/reasoning tags from user-facing responses Some models (e.g. Kimi K2.5 on Alibaba OpenAI-compatible endpoint) emit reasoning text followed by a closing without a matching opening tag. The existing paired-tag regexes in _strip_think_blocks() cannot match these orphaned tags, so leaks into user-facing responses on all platforms. Add a catch-all regex that strips any remaining opening or closing think/thinking/reasoning/REASONING_SCRATCHPAD tags after the existing paired-block removal pass. Closes #4285 --- run_agent.py | 1 + tests/test_run_agent.py | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/run_agent.py b/run_agent.py index 13278d94c04d..717c26b4a7d6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1389,6 +1389,7 @@ def _strip_think_blocks(self, content: str) -> str: content = re.sub(r'.*?', '', content, flags=re.DOTALL | re.IGNORECASE) content = re.sub(r'.*?', '', content, flags=re.DOTALL) content = re.sub(r'.*?', '', content, flags=re.DOTALL) + content = re.sub(r'\s*', '', content, flags=re.IGNORECASE) return content def _looks_like_codex_intermediate_ack( diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 7ea3a63fe23f..aa74164a7544 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -230,6 +230,27 @@ def test_multiline_block_removed(self, agent): assert "line1" not in result assert "visible" in result + def test_orphaned_closing_think_tag(self, agent): + result = agent._strip_think_blocks("some reasoningactual answer") + assert "" not in result + assert "actual answer" in result + + def test_orphaned_closing_thinking_tag(self, agent): + result = agent._strip_think_blocks("reasoninganswer") + assert "" not in result + assert "answer" in result + + def test_orphaned_opening_think_tag(self, agent): + result = agent._strip_think_blocks("orphaned reasoning without close") + assert "" not in result + + def test_mixed_orphaned_and_paired_tags(self, agent): + text = "straypaired reasoning visible" + result = agent._strip_think_blocks(text) + assert "" not in result + assert "" not in result + assert "visible" in result + class TestExtractReasoning: def test_reasoning_field(self, agent): From 57625329a218775b70b51237d8dbe5f632c864c2 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:42:48 -0700 Subject: [PATCH 016/212] docs+feat: comprehensive local LLM provider guides and context length warning (#4294) * docs: update llama.cpp section with --jinja flag and tool calling guide The llama.cpp docs were missing the --jinja flag which is required for tool calling to work. Without it, models output tool calls as raw JSON text instead of structured API responses, making Hermes unable to execute them. Changes: - Add --jinja and -fa flags to the server startup example - Replace deprecated env vars (OPENAI_BASE_URL, LLM_MODEL) with hermes model interactive setup - Add caution block explaining the --jinja requirement and symptoms - List models with native tool calling support - Add /props endpoint verification tip * docs+feat: comprehensive local LLM provider guides and context length warning Docs (providers.md): - Rewrote Ollama section with context length warning (defaults to 4k on <24GB VRAM), three methods to increase it, and verification steps - Rewrote vLLM section with --max-model-len, tool calling flags (--enable-auto-tool-choice, --tool-call-parser), and context guidance - Rewrote SGLang section with --context-length, --tool-call-parser, and warning about 128-token default max output - Added LM Studio section (port 1234, context length defaults to 2048, tool calling since 0.3.6) - Added llama.cpp context length flag (-c) and GPU offload (-ngl) - Added Troubleshooting Local Models section covering: - Tool calls appearing as text (with per-server fix table) - Silent context truncation and diagnosis commands - Low detected context at startup - Truncated responses - Replaced all deprecated env vars (OPENAI_BASE_URL, LLM_MODEL) with hermes model interactive setup and config.yaml examples - Added deprecation warning for legacy env vars in General Setup Code (cli.py): - Added context length warning in show_banner() when detected context is <= 8192 tokens, with server-specific fix hints: - Ollama (port 11434): suggests OLLAMA_CONTEXT_LENGTH env var - LM Studio (port 1234): suggests model settings adjustment - Other servers: suggests config.yaml override Tests: - 9 new tests covering warning thresholds, server-specific hints, and no-warning cases --- cli.py | 26 ++- tests/test_cli_context_warning.py | 147 +++++++++++++ website/docs/integrations/providers.md | 291 ++++++++++++++++++++----- 3 files changed, 413 insertions(+), 51 deletions(-) create mode 100644 tests/test_cli_context_warning.py diff --git a/cli.py b/cli.py index 978b36091a32..e5f88e752be7 100644 --- a/cli.py +++ b/cli.py @@ -2192,7 +2192,31 @@ def show_banner(self): # Show tool availability warnings if any tools are disabled self._show_tool_availability_warnings() - + + # Warn about very low context lengths (common with local servers) + if ctx_len and ctx_len <= 8192: + self.console.print() + self.console.print( + f"[yellow]⚠️ Context length is only {ctx_len:,} tokens — " + f"this is likely too low for agent use with tools.[/]" + ) + self.console.print( + "[dim] Hermes needs 16k–32k minimum. Tool schemas + system prompt alone use ~4k–8k.[/]" + ) + base_url = getattr(self, "base_url", "") or "" + if "11434" in base_url or "ollama" in base_url.lower(): + self.console.print( + "[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH=32768 ollama serve[/]" + ) + elif "1234" in base_url: + self.console.print( + "[dim] LM Studio fix: Set context length in model settings → reload model[/]" + ) + else: + self.console.print( + "[dim] Fix: Set model.context_length in config.yaml, or increase your server's context setting[/]" + ) + self.console.print() def _preload_resumed_session(self) -> bool: diff --git a/tests/test_cli_context_warning.py b/tests/test_cli_context_warning.py new file mode 100644 index 000000000000..fa0305a270c7 --- /dev/null +++ b/tests/test_cli_context_warning.py @@ -0,0 +1,147 @@ +"""Tests for the low context length warning in the CLI banner.""" + +import os +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def _isolate(tmp_path, monkeypatch): + """Isolate HERMES_HOME so tests don't touch real config.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + +@pytest.fixture +def cli_obj(_isolate): + """Create a minimal HermesCLI instance for banner testing.""" + with patch("cli.load_cli_config", return_value={ + "display": {"tool_progress": "new"}, + "terminal": {}, + }), patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + from cli import HermesCLI + obj = HermesCLI.__new__(HermesCLI) + obj.model = "test-model" + obj.enabled_toolsets = ["hermes-core"] + obj.compact = False + obj.console = MagicMock() + obj.session_id = None + obj.api_key = "test" + obj.base_url = "" + # Mock agent with context compressor + obj.agent = SimpleNamespace( + context_compressor=SimpleNamespace(context_length=None) + ) + return obj + + +class TestLowContextWarning: + """Tests that the CLI warns about low context lengths.""" + + def test_no_warning_for_normal_context(self, cli_obj): + """No warning when context is 32k+.""" + cli_obj.agent.context_compressor.context_length = 32768 + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + # Check that no yellow warning was printed + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 0 + + def test_warning_for_low_context(self, cli_obj): + """Warning shown when context is 4096 (Ollama default).""" + cli_obj.agent.context_compressor.context_length = 4096 + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 1 + assert "4,096" in warning_calls[0] + + def test_warning_for_2048_context(self, cli_obj): + """Warning shown for 2048 tokens (common LM Studio default).""" + cli_obj.agent.context_compressor.context_length = 2048 + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 1 + + def test_no_warning_at_boundary(self, cli_obj): + """No warning at exactly 8192 — 8192 is borderline but included in warning.""" + cli_obj.agent.context_compressor.context_length = 8192 + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 1 # 8192 is still warned about + + def test_no_warning_above_boundary(self, cli_obj): + """No warning at 16384.""" + cli_obj.agent.context_compressor.context_length = 16384 + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 0 + + def test_ollama_specific_hint(self, cli_obj): + """Ollama-specific fix shown when port 11434 detected.""" + cli_obj.agent.context_compressor.context_length = 4096 + cli_obj.base_url = "http://localhost:11434/v1" + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + ollama_hints = [c for c in calls if "OLLAMA_CONTEXT_LENGTH" in c] + assert len(ollama_hints) == 1 + + def test_lm_studio_specific_hint(self, cli_obj): + """LM Studio-specific fix shown when port 1234 detected.""" + cli_obj.agent.context_compressor.context_length = 2048 + cli_obj.base_url = "http://localhost:1234/v1" + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + lms_hints = [c for c in calls if "LM Studio" in c] + assert len(lms_hints) == 1 + + def test_generic_hint_for_other_servers(self, cli_obj): + """Generic fix shown for unknown servers.""" + cli_obj.agent.context_compressor.context_length = 4096 + cli_obj.base_url = "http://localhost:8080/v1" + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + generic_hints = [c for c in calls if "config.yaml" in c] + assert len(generic_hints) == 1 + + def test_no_warning_when_no_context_length(self, cli_obj): + """No warning when context length is not yet known.""" + cli_obj.agent.context_compressor.context_length = None + with patch("cli.get_tool_definitions", return_value=[]), \ + patch("cli.build_welcome_banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 0 diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index ab4c8f354fcb..7740e36db9a5 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -218,15 +218,11 @@ model: api_key: your-key-or-leave-empty-for-local ``` -**Environment variables (`.env` file):** -```bash -# Add to ~/.hermes/.env -OPENAI_BASE_URL=http://localhost:8000/v1 -OPENAI_API_KEY=your-key # Any non-empty string for local servers -LLM_MODEL=your-model-name -``` +:::warning Legacy env vars +`OPENAI_BASE_URL` and `LLM_MODEL` in `.env` are **deprecated**. The CLI ignores `LLM_MODEL` entirely (only the gateway reads it). Use `hermes model` or edit `config.yaml` directly — both persist correctly across restarts and Docker containers. +::: -All three approaches end up in the same runtime path. `hermes model` persists provider, model, and base URL to `config.yaml` so later sessions keep using that endpoint even if env vars are not set. +Both approaches persist to `config.yaml`, which is the source of truth for model, provider, and base URL. ### Switching Models with `/model` @@ -257,23 +253,73 @@ Everything below follows this same pattern — just change the URL, key, and mod ### Ollama — Local Models, Zero Config -[Ollama](https://ollama.com/) runs open-weight models locally with one command. Best for: quick local experimentation, privacy-sensitive work, offline use. +[Ollama](https://ollama.com/) runs open-weight models locally with one command. Best for: quick local experimentation, privacy-sensitive work, offline use. Supports tool calling via the OpenAI-compatible API. ```bash # Install and run a model -ollama pull llama3.1:70b +ollama pull qwen2.5-coder:32b ollama serve # Starts on port 11434 +``` -# Configure Hermes -OPENAI_BASE_URL=http://localhost:11434/v1 -OPENAI_API_KEY=ollama # Any non-empty string -LLM_MODEL=llama3.1:70b +Then configure Hermes: + +```bash +hermes model +# Select "Custom endpoint (self-hosted / VLLM / etc.)" +# Enter URL: http://localhost:11434/v1 +# Skip API key (Ollama doesn't need one) +# Enter model name (e.g. qwen2.5-coder:32b) ``` -Ollama's OpenAI-compatible endpoint supports chat completions, streaming, and tool calling (for supported models). No GPU required for smaller models — Ollama handles CPU inference automatically. +Or configure `config.yaml` directly: + +```yaml +model: + default: qwen2.5-coder:32b + provider: custom + base_url: http://localhost:11434/v1 + context_length: 32768 # See warning below +``` + +:::caution Ollama defaults to very low context lengths +Ollama does **not** use your model's full context window by default. Depending on your VRAM, the default is: + +| Available VRAM | Default context | +|----------------|----------------| +| Less than 24 GB | **4,096 tokens** | +| 24–48 GB | 32,768 tokens | +| 48+ GB | 256,000 tokens | + +For agent use with tools, **you need at least 16k–32k context**. At 4k, the system prompt + tool schemas alone can fill the window, leaving no room for conversation. + +**How to increase it** (pick one): + +```bash +# Option 1: Set server-wide via environment variable (recommended) +OLLAMA_CONTEXT_LENGTH=32768 ollama serve + +# Option 2: For systemd-managed Ollama +sudo systemctl edit ollama.service +# Add: Environment="OLLAMA_CONTEXT_LENGTH=32768" +# Then: sudo systemctl daemon-reload && sudo systemctl restart ollama + +# Option 3: Bake it into a custom model (persistent per-model) +echo -e "FROM qwen2.5-coder:32b\nPARAMETER num_ctx 32768" > Modelfile +ollama create qwen2.5-coder-32k -f Modelfile +``` + +**You cannot set context length through the OpenAI-compatible API** (`/v1/chat/completions`). It must be configured server-side or via a Modelfile. This is the #1 source of confusion when integrating Ollama with tools like Hermes. +::: + +**Verify your context is set correctly:** + +```bash +ollama ps +# Look at the CONTEXT column — it should show your configured value +``` :::tip -List available models with `ollama list`. Pull any model from the [Ollama library](https://ollama.com/library) with `ollama pull `. +List available models with `ollama list`. Pull any model from the [Ollama library](https://ollama.com/library) with `ollama pull `. Ollama handles GPU offloading automatically — no configuration needed for most setups. ::: --- @@ -283,19 +329,39 @@ List available models with `ollama list`. Pull any model from the [Ollama librar [vLLM](https://docs.vllm.ai/) is the standard for production LLM serving. Best for: maximum throughput on GPU hardware, serving large models, continuous batching. ```bash -# Start vLLM server pip install vllm vllm serve meta-llama/Llama-3.1-70B-Instruct \ --port 8000 \ - --tensor-parallel-size 2 # Multi-GPU + --max-model-len 65536 \ + --tensor-parallel-size 2 \ + --enable-auto-tool-choice \ + --tool-call-parser hermes +``` -# Configure Hermes -OPENAI_BASE_URL=http://localhost:8000/v1 -OPENAI_API_KEY=dummy -LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct +Then configure Hermes: + +```bash +hermes model +# Select "Custom endpoint (self-hosted / VLLM / etc.)" +# Enter URL: http://localhost:8000/v1 +# Skip API key (or enter one if you configured vLLM with --api-key) +# Enter model name: meta-llama/Llama-3.1-70B-Instruct ``` -vLLM supports tool calling, structured output, and multi-modal models. Use `--enable-auto-tool-choice` and `--tool-call-parser hermes` for Hermes-format tool calling with NousResearch models. +**Context length:** vLLM reads the model's `max_position_embeddings` by default. If that exceeds your GPU memory, it errors and asks you to set `--max-model-len` lower. You can also use `--max-model-len auto` to automatically find the maximum that fits. Set `--gpu-memory-utilization 0.95` (default 0.9) to squeeze more context into VRAM. + +**Tool calling requires explicit flags:** + +| Flag | Purpose | +|------|---------| +| `--enable-auto-tool-choice` | Required for `tool_choice: "auto"` (the default in Hermes) | +| `--tool-call-parser ` | Parser for the model's tool call format | + +Supported parsers: `hermes` (Qwen 2.5, Hermes 2/3), `llama3_json` (Llama 3.x), `mistral`, `deepseek_v3`, `deepseek_v31`, `xlam`, `pythonic`. Without these flags, tool calls won't work — the model will output tool calls as text. + +:::tip +vLLM supports human-readable sizes: `--max-model-len 64k` (lowercase k = 1000, uppercase K = 1024). +::: --- @@ -304,19 +370,32 @@ vLLM supports tool calling, structured output, and multi-modal models. Use `--en [SGLang](https://github.com/sgl-project/sglang) is an alternative to vLLM with RadixAttention for KV cache reuse. Best for: multi-turn conversations (prefix caching), constrained decoding, structured output. ```bash -# Start SGLang server pip install "sglang[all]" python -m sglang.launch_server \ --model meta-llama/Llama-3.1-70B-Instruct \ - --port 8000 \ - --tp 2 + --port 30000 \ + --context-length 65536 \ + --tp 2 \ + --tool-call-parser qwen +``` -# Configure Hermes -OPENAI_BASE_URL=http://localhost:8000/v1 -OPENAI_API_KEY=dummy -LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct +Then configure Hermes: + +```bash +hermes model +# Select "Custom endpoint (self-hosted / VLLM / etc.)" +# Enter URL: http://localhost:30000/v1 +# Enter model name: meta-llama/Llama-3.1-70B-Instruct ``` +**Context length:** SGLang reads from the model's config by default. Use `--context-length` to override. If you need to exceed the model's declared maximum, set `SGLANG_ALLOW_OVERWRITE_LONGER_CONTEXT_LEN=1`. + +**Tool calling:** Use `--tool-call-parser` with the appropriate parser for your model family: `qwen` (Qwen 2.5), `llama3`, `llama4`, `deepseekv3`, `mistral`, `glm`. Without this flag, tool calls come back as plain text. + +:::caution SGLang defaults to 128 max output tokens +If responses seem truncated, add `max_tokens` to your requests or set `--default-max-tokens` on the server. SGLang's default is only 128 tokens per response if not specified in the request. +::: + --- ### llama.cpp / llama-server — CPU & Metal Inference @@ -327,21 +406,136 @@ LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct # Build and start llama-server cmake -B build && cmake --build build --config Release ./build/bin/llama-server \ - -m models/llama-3.1-8b-instruct-Q4_K_M.gguf \ + --jinja -fa \ + -c 32768 \ + -ngl 99 \ + -m models/qwen2.5-coder-32b-instruct-Q4_K_M.gguf \ --port 8080 --host 0.0.0.0 +``` + +**Context length (`-c`):** Recent builds default to `0` which reads the model's training context from the GGUF metadata. For models with 128k+ training context, this can OOM trying to allocate the full KV cache. Set `-c` explicitly to what you need (32k–64k is a good range for agent use). If using parallel slots (`-np`), the total context is divided among slots — with `-c 32768 -np 4`, each slot only gets 8k. -# Configure Hermes -OPENAI_BASE_URL=http://localhost:8080/v1 -OPENAI_API_KEY=dummy -LLM_MODEL=llama-3.1-8b-instruct +Then configure Hermes to point at it: + +```bash +hermes model +# Select "Custom endpoint (self-hosted / VLLM / etc.)" +# Enter URL: http://localhost:8080/v1 +# Skip API key (local servers don't need one) +# Enter model name — or leave blank to auto-detect if only one model is loaded ``` +This saves the endpoint to `config.yaml` so it persists across sessions. + +:::caution `--jinja` is required for tool calling +Without `--jinja`, llama-server ignores the `tools` parameter entirely. The model will try to call tools by writing JSON in its response text, but Hermes won't recognize it as a tool call — you'll see raw JSON like `{"name": "web_search", ...}` printed as a message instead of an actual search. + +Native tool calling support (best performance): Llama 3.x, Qwen 2.5 (including Coder), Hermes 2/3, Mistral, DeepSeek, Functionary. All other models use a generic handler that works but may be less efficient. See the [llama.cpp function calling docs](https://github.com/ggml-org/llama.cpp/blob/master/docs/function-calling.md) for the full list. + +You can verify tool support is active by checking `http://localhost:8080/props` — the `chat_template` field should be present. +::: + :::tip Download GGUF models from [Hugging Face](https://huggingface.co/models?library=gguf). Q4_K_M quantization offers the best balance of quality vs. memory usage. ::: --- +### LM Studio — Desktop App with Local Models + +[LM Studio](https://lmstudio.ai/) is a desktop app for running local models with a GUI. Best for: users who prefer a visual interface, quick model testing, developers on macOS/Windows/Linux. + +Start the server from the LM Studio app (Developer tab → Start Server), or use the CLI: + +```bash +lms server start # Starts on port 1234 +lms load qwen2.5-coder --context-length 32768 +``` + +Then configure Hermes: + +```bash +hermes model +# Select "Custom endpoint (self-hosted / VLLM / etc.)" +# Enter URL: http://localhost:1234/v1 +# Skip API key (LM Studio doesn't require one) +# Enter model name +``` + +:::caution Context length often defaults to 2048 +LM Studio reads context length from the model's metadata, but many GGUF models report low defaults (2048 or 4096). **Always set context length explicitly** in the LM Studio model settings: + +1. Click the gear icon next to the model picker +2. Set "Context Length" to at least 16384 (preferably 32768) +3. Reload the model for the change to take effect + +Alternatively, use the CLI: `lms load model-name --context-length 32768` + +To set persistent per-model defaults: My Models tab → gear icon on the model → set context size. +::: + +**Tool calling:** Supported since LM Studio 0.3.6. Models with native tool-calling training (Qwen 2.5, Llama 3.x, Mistral, Hermes) are auto-detected and shown with a tool badge. Other models use a generic fallback that may be less reliable. + +--- + +### Troubleshooting Local Models + +These issues affect **all** local inference servers when used with Hermes. + +#### Tool calls appear as text instead of executing + +The model outputs something like `{"name": "web_search", "arguments": {...}}` as a message instead of actually calling the tool. + +**Cause:** Your server doesn't have tool calling enabled, or the model doesn't support it through the server's tool calling implementation. + +| Server | Fix | +|--------|-----| +| **llama.cpp** | Add `--jinja` to the startup command | +| **vLLM** | Add `--enable-auto-tool-choice --tool-call-parser hermes` | +| **SGLang** | Add `--tool-call-parser qwen` (or appropriate parser) | +| **Ollama** | Tool calling is enabled by default — make sure your model supports it (check with `ollama show model-name`) | +| **LM Studio** | Update to 0.3.6+ and use a model with native tool support | + +#### Model seems to forget context or give incoherent responses + +**Cause:** Context window is too small. When the conversation exceeds the context limit, most servers silently drop older messages. Hermes's system prompt + tool schemas alone can use 4k–8k tokens. + +**Diagnosis:** + +```bash +# Check what Hermes thinks the context is +# Look at startup line: "Context limit: X tokens" + +# Check your server's actual context +# Ollama: ollama ps (CONTEXT column) +# llama.cpp: curl http://localhost:8080/props | jq '.default_generation_settings.n_ctx' +# vLLM: check --max-model-len in startup args +``` + +**Fix:** Set context to at least **32,768 tokens** for agent use. See each server's section above for the specific flag. + +#### "Context limit: 2048 tokens" at startup + +Hermes auto-detects context length from your server's `/v1/models` endpoint. If the server reports a low value (or doesn't report one at all), Hermes uses the model's declared limit which may be wrong. + +**Fix:** Set it explicitly in `config.yaml`: + +```yaml +model: + default: your-model + provider: custom + base_url: http://localhost:11434/v1 + context_length: 32768 +``` + +#### Responses get cut off mid-sentence + +**Possible causes:** +1. **Low `max_tokens` on the server** — SGLang defaults to 128 tokens per response. Set `--default-max-tokens` on the server or configure Hermes with `model.max_tokens` in config.yaml. +2. **Context exhaustion** — The model filled its context window. Increase context length or enable [context compression](/docs/user-guide/configuration#context-compression) in Hermes. + +--- + ### LiteLLM Proxy — Multi-Provider Gateway [LiteLLM](https://docs.litellm.ai/) is an OpenAI-compatible proxy that unifies 100+ LLM providers behind a single API. Best for: switching between providers without config changes, load balancing, fallback chains, budget controls. @@ -353,13 +547,10 @@ litellm --model anthropic/claude-sonnet-4 --port 4000 # Or with a config file for multiple models: litellm --config litellm_config.yaml --port 4000 - -# Configure Hermes -OPENAI_BASE_URL=http://localhost:4000/v1 -OPENAI_API_KEY=sk-your-litellm-key -LLM_MODEL=anthropic/claude-sonnet-4 ``` +Then configure Hermes with `hermes model` → Custom endpoint → `http://localhost:4000/v1`. + Example `litellm_config.yaml` with fallback: ```yaml model_list: @@ -384,13 +575,10 @@ router_settings: ```bash # Install and start npx @blockrun/clawrouter # Starts on port 8402 - -# Configure Hermes -OPENAI_BASE_URL=http://localhost:8402/v1 -OPENAI_API_KEY=dummy -LLM_MODEL=blockrun/auto # or: blockrun/eco, blockrun/premium, blockrun/agentic ``` +Then configure Hermes with `hermes model` → Custom endpoint → `http://localhost:8402/v1` → model name `blockrun/auto`. + Routing profiles: | Profile | Strategy | Savings | |---------|----------|---------| @@ -423,11 +611,14 @@ Any service with an OpenAI-compatible API works. Some popular options: | [LocalAI](https://localai.io) | `http://localhost:8080/v1` | Self-hosted, multi-model | | [Jan](https://jan.ai) | `http://localhost:1337/v1` | Desktop app with local models | -```bash -# Example: Together AI -OPENAI_BASE_URL=https://api.together.xyz/v1 -OPENAI_API_KEY=your-together-key -LLM_MODEL=meta-llama/Llama-3.1-70B-Instruct-Turbo +Configure any of these with `hermes model` → Custom endpoint, or in `config.yaml`: + +```yaml +model: + default: meta-llama/Llama-3.1-70B-Instruct-Turbo + provider: custom + base_url: https://api.together.xyz/v1 + api_key: your-together-key ``` --- From 143b74ec00b41a7b7e949b9cb4f2b303b27e5fa6 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:42:52 -0700 Subject: [PATCH 017/212] fix: first-run guard stuck in loop when provider configured via config.yaml (#4298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The _has_any_provider_configured() guard only checked env vars, .env file, and auth.json — missing config.yaml model.provider/base_url/api_key entirely. Users who configured a provider through setup (saving to config.yaml) but had empty API key placeholders in .env from the install template were permanently blocked by the 'not configured' message. Changes: - _has_any_provider_configured() now checks config.yaml model section for explicit provider, base_url, or api_key — covers custom endpoints and providers that store credentials in config rather than env vars - .env.example: comment out all empty API key placeholders so they don't pollute the environment when copied to .env by the installer - .env.example: mark LLM_MODEL as deprecated (config.yaml is source of truth) - 4 new tests for the config.yaml detection path Reported by OkadoOP on Discord. --- .env.example | 43 +++++++++--------- hermes_cli/main.py | 11 +++++ tests/test_api_key_providers.py | 77 +++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 21 deletions(-) diff --git a/.env.example b/.env.example index 3df76497e1ab..13aacade6119 100644 --- a/.env.example +++ b/.env.example @@ -7,18 +7,19 @@ # OpenRouter provides access to many models through one API # All LLM calls go through OpenRouter - no direct provider keys needed # Get your key at: https://openrouter.ai/keys -OPENROUTER_API_KEY= +# OPENROUTER_API_KEY= -# Default model to use (OpenRouter format: provider/model) -# Examples: anthropic/claude-opus-4.6, openai/gpt-4o, google/gemini-3-flash-preview, zhipuai/glm-4-plus -LLM_MODEL=anthropic/claude-opus-4.6 +# Default model is configured in ~/.hermes/config.yaml (model.default). +# Use 'hermes model' or 'hermes setup' to change it. +# LLM_MODEL is no longer read from .env — this line is kept for reference only. +# LLM_MODEL=anthropic/claude-opus-4.6 # ============================================================================= # LLM PROVIDER (z.ai / GLM) # ============================================================================= # z.ai provides access to ZhipuAI GLM models (GLM-4-Plus, etc.) # Get your key at: https://z.ai or https://open.bigmodel.cn -GLM_API_KEY= +# GLM_API_KEY= # GLM_BASE_URL=https://api.z.ai/api/paas/v4 # Override default base URL # ============================================================================= @@ -28,7 +29,7 @@ GLM_API_KEY= # Get your key at: https://platform.kimi.ai (Kimi Code console) # Keys prefixed sk-kimi- use the Kimi Code API (api.kimi.com) by default. # Legacy keys from platform.moonshot.ai need KIMI_BASE_URL override below. -KIMI_API_KEY= +# KIMI_API_KEY= # KIMI_BASE_URL=https://api.kimi.com/coding/v1 # Default for sk-kimi- keys # KIMI_BASE_URL=https://api.moonshot.ai/v1 # For legacy Moonshot keys # KIMI_BASE_URL=https://api.moonshot.cn/v1 # For Moonshot China keys @@ -38,11 +39,11 @@ KIMI_API_KEY= # ============================================================================= # MiniMax provides access to MiniMax models (global endpoint) # Get your key at: https://www.minimax.io -MINIMAX_API_KEY= +# MINIMAX_API_KEY= # MINIMAX_BASE_URL=https://api.minimax.io/v1 # Override default base URL # MiniMax China endpoint (for users in mainland China) -MINIMAX_CN_API_KEY= +# MINIMAX_CN_API_KEY= # MINIMAX_CN_BASE_URL=https://api.minimaxi.com/v1 # Override default base URL # ============================================================================= @@ -50,7 +51,7 @@ MINIMAX_CN_API_KEY= # ============================================================================= # OpenCode Zen provides curated, tested models (GPT, Claude, Gemini, MiniMax, GLM, Kimi) # Pay-as-you-go pricing. Get your key at: https://opencode.ai/auth -OPENCODE_ZEN_API_KEY= +# OPENCODE_ZEN_API_KEY= # OPENCODE_ZEN_BASE_URL=https://opencode.ai/zen/v1 # Override default base URL # ============================================================================= @@ -58,7 +59,7 @@ OPENCODE_ZEN_API_KEY= # ============================================================================= # OpenCode Go provides access to open models (GLM-5, Kimi K2.5, MiniMax M2.5) # $10/month subscription. Get your key at: https://opencode.ai/auth -OPENCODE_GO_API_KEY= +# OPENCODE_GO_API_KEY= # ============================================================================= # LLM PROVIDER (Hugging Face Inference Providers) @@ -67,7 +68,7 @@ OPENCODE_GO_API_KEY= # Free tier included ($0.10/month), no markup on provider rates. # Get your token at: https://huggingface.co/settings/tokens # Required permission: "Make calls to Inference Providers" -HF_TOKEN= +# HF_TOKEN= # OPENCODE_GO_BASE_URL=https://opencode.ai/zen/go/v1 # Override default base URL # ============================================================================= @@ -76,26 +77,26 @@ HF_TOKEN= # Exa API Key - AI-native web search and contents # Get at: https://exa.ai -EXA_API_KEY= +# EXA_API_KEY= # Parallel API Key - AI-native web search and extract # Get at: https://parallel.ai -PARALLEL_API_KEY= +# PARALLEL_API_KEY= # Firecrawl API Key - Web search, extract, and crawl # Get at: https://firecrawl.dev/ -FIRECRAWL_API_KEY= +# FIRECRAWL_API_KEY= # FAL.ai API Key - Image generation # Get at: https://fal.ai/ -FAL_KEY= +# FAL_KEY= # Honcho - Cross-session AI-native user modeling (optional) # Builds a persistent understanding of the user across sessions and tools. # Get at: https://app.honcho.dev # Also requires ~/.honcho/config.json with enabled=true (see README). -HONCHO_API_KEY= +# HONCHO_API_KEY= # ============================================================================= # TERMINAL TOOL CONFIGURATION @@ -181,10 +182,10 @@ TERMINAL_LIFETIME_SECONDS=300 # Browserbase API Key - Cloud browser execution # Get at: https://browserbase.com/ -BROWSERBASE_API_KEY= +# BROWSERBASE_API_KEY= # Browserbase Project ID - From your Browserbase dashboard -BROWSERBASE_PROJECT_ID= +# BROWSERBASE_PROJECT_ID= # Enable residential proxies for better CAPTCHA solving (default: true) # Routes traffic through residential IPs, significantly improves success rate @@ -216,7 +217,7 @@ BROWSER_INACTIVITY_TIMEOUT=120 # Uses OpenAI's API directly (not via OpenRouter). # Named VOICE_TOOLS_OPENAI_KEY to avoid interference with OpenRouter. # Get at: https://platform.openai.com/api-keys -VOICE_TOOLS_OPENAI_KEY= +# VOICE_TOOLS_OPENAI_KEY= # ============================================================================= # SLACK INTEGRATION @@ -302,11 +303,11 @@ IMAGE_TOOLS_DEBUG=false # Tinker API Key - RL training service # Get at: https://tinker-console.thinkingmachines.ai/keys -TINKER_API_KEY= +# TINKER_API_KEY= # Weights & Biases API Key - Experiment tracking and metrics # Get at: https://wandb.ai/authorize -WANDB_API_KEY= +# WANDB_API_KEY= # RL API Server URL (default: http://localhost:8080) # Change if running the rl-server on a different host/port diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9b4b3ccac812..315e0f9741f6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -246,6 +246,17 @@ def _has_any_provider_configured() -> bool: pass + # Check config.yaml — if model is a dict with an explicit provider set, + # the user has gone through setup (fresh installs have model as a plain + # string). Also covers custom endpoints that store api_key/base_url in + # config rather than .env. + if isinstance(model_cfg, dict): + cfg_provider = (model_cfg.get("provider") or "").strip() + cfg_base_url = (model_cfg.get("base_url") or "").strip() + cfg_api_key = (model_cfg.get("api_key") or "").strip() + if cfg_provider or cfg_base_url or cfg_api_key: + return True + # Check for Claude Code OAuth credentials (~/.claude/.credentials.json) # Only count these if Hermes has been explicitly configured — Claude Code # being installed doesn't mean the user wants Hermes to use their tokens. diff --git a/tests/test_api_key_providers.py b/tests/test_api_key_providers.py index e250bbb2583c..da191496d599 100644 --- a/tests/test_api_key_providers.py +++ b/tests/test_api_key_providers.py @@ -645,6 +645,83 @@ def test_claude_code_creds_ignored_on_fresh_install(self, monkeypatch, tmp_path) from hermes_cli.main import _has_any_provider_configured assert _has_any_provider_configured() is False + def test_config_provider_counts(self, monkeypatch, tmp_path): + """config.yaml with model.provider set should count as configured.""" + import yaml + from hermes_cli import config as config_module + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_file = hermes_home / "config.yaml" + config_file.write_text(yaml.dump({ + "model": {"default": "anthropic/claude-opus-4.6", "provider": "openrouter"}, + })) + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + # Clear all provider env vars + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is True + + def test_config_base_url_counts(self, monkeypatch, tmp_path): + """config.yaml with model.base_url set (custom endpoint) should count.""" + import yaml + from hermes_cli import config as config_module + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_file = hermes_home / "config.yaml" + config_file.write_text(yaml.dump({ + "model": {"default": "my-model", "base_url": "http://localhost:11434/v1"}, + })) + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is True + + def test_config_api_key_counts(self, monkeypatch, tmp_path): + """config.yaml with model.api_key set should count.""" + import yaml + from hermes_cli import config as config_module + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_file = hermes_home / "config.yaml" + config_file.write_text(yaml.dump({ + "model": {"default": "my-model", "api_key": "sk-test-key"}, + })) + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is True + + def test_config_dict_no_provider_no_creds_still_false(self, monkeypatch, tmp_path): + """config.yaml model dict with only 'default' key and no creds stays false.""" + import yaml + from hermes_cli import config as config_module + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_file = hermes_home / "config.yaml" + config_file.write_text(yaml.dump({ + "model": {"default": "anthropic/claude-opus-4.6"}, + })) + monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env") + monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"): + monkeypatch.delenv(var, raising=False) + from hermes_cli.main import _has_any_provider_configured + assert _has_any_provider_configured() is False + def test_claude_code_creds_counted_when_hermes_configured(self, monkeypatch, tmp_path): """Claude Code credentials should count when Hermes has been explicitly configured.""" import yaml From 161acb0086274e30c806e6abfbcbe0d3a8740873 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:02:29 -0700 Subject: [PATCH 018/212] fix: credential pool 401 recovery rotates to next credential after failed refresh (#4300) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When an OAuth token refresh fails on a 401 error, the pool recovery would return 'not recovered' without trying the next credential in the pool. This meant users who added a second valid credential via 'hermes auth add' would never see it used when the primary credential was dead. Now: try refresh first (handles expired tokens quickly), and if that fails, rotate to the next available credential — same as 429/402 already did. Adds three tests covering 401 refresh success, refresh-fail-then-rotate, and refresh-fail-with-no-remaining-credentials. --- run_agent.py | 7 +++++ tests/test_run_agent.py | 65 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/run_agent.py b/run_agent.py index 717c26b4a7d6..3cfcc12afb82 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3862,6 +3862,13 @@ def _recover_with_credential_pool( logger.info(f"Credential 401 — refreshed pool entry {getattr(refreshed, 'id', '?')}") self._swap_credential(refreshed) return True, has_retried_429 + # Refresh failed — rotate to next credential instead of giving up. + # The failed entry is already marked exhausted by try_refresh_current(). + next_entry = pool.mark_exhausted_and_rotate(status_code=401) + if next_entry is not None: + logger.info(f"Credential 401 (refresh failed) — rotated to pool entry {getattr(next_entry, 'id', '?')}") + self._swap_credential(next_entry) + return True, False return False, has_retried_429 diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index aa74164a7544..99905bb56668 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1848,6 +1848,71 @@ def mark_exhausted_and_rotate(self, *, status_code): agent._swap_credential.assert_called_once_with(next_entry) + def test_recover_with_pool_refreshes_on_401(self, agent): + """401 with successful refresh should swap to refreshed credential.""" + refreshed_entry = SimpleNamespace(label="refreshed-primary", id="abc") + + class _Pool: + def try_refresh_current(self): + return refreshed_entry + + agent._credential_pool = _Pool() + agent._swap_credential = MagicMock() + + recovered, retry_same = agent._recover_with_credential_pool( + status_code=401, + has_retried_429=False, + ) + + assert recovered is True + agent._swap_credential.assert_called_once_with(refreshed_entry) + + def test_recover_with_pool_rotates_on_401_when_refresh_fails(self, agent): + """401 with failed refresh should rotate to next credential.""" + next_entry = SimpleNamespace(label="secondary", id="def") + + class _Pool: + def try_refresh_current(self): + return None # refresh failed + + def mark_exhausted_and_rotate(self, *, status_code): + assert status_code == 401 + return next_entry + + agent._credential_pool = _Pool() + agent._swap_credential = MagicMock() + + recovered, retry_same = agent._recover_with_credential_pool( + status_code=401, + has_retried_429=False, + ) + + assert recovered is True + assert retry_same is False + agent._swap_credential.assert_called_once_with(next_entry) + + def test_recover_with_pool_401_refresh_fails_no_more_credentials(self, agent): + """401 with failed refresh and no other credentials returns not recovered.""" + + class _Pool: + def try_refresh_current(self): + return None + + def mark_exhausted_and_rotate(self, *, status_code): + return None # no more credentials + + agent._credential_pool = _Pool() + agent._swap_credential = MagicMock() + + recovered, retry_same = agent._recover_with_credential_pool( + status_code=401, + has_retried_429=False, + ) + + assert recovered is False + agent._swap_credential.assert_not_called() + + class TestMaxTokensParam: """Verify _max_tokens_param returns the correct key for each provider.""" From e75964d46dad9e95bd4333027a96e8a7bb61f8fb Mon Sep 17 00:00:00 2001 From: curtitoo Date: Tue, 31 Mar 2026 09:25:08 -0700 Subject: [PATCH 019/212] fix: harden codex responses transport handling --- run_agent.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 3cfcc12afb82..670f210074a3 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3238,9 +3238,10 @@ def _preflight_codex_api_kwargs( "model": model, "instructions": instructions, "input": normalized_input, - "tools": normalized_tools, "store": False, } + if normalized_tools is not None: + normalized["tools"] = normalized_tools # Pass through reasoning config reasoning = api_kwargs.get("reasoning") @@ -3583,6 +3584,8 @@ def _close_request_openai_client(self, client: Any, *, reason: str) -> None: def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta: callable = None): """Execute one streaming Responses API request and return the final response.""" + import httpx as _httpx + active_client = client or self._ensure_primary_openai_client(reason="codex_stream_direct") max_stream_retries = 1 has_tool_calls = False @@ -3616,6 +3619,22 @@ def _run_codex_stream(self, api_kwargs: dict, client: Any = None, on_first_delta if reasoning_text: self._fire_reasoning_delta(reasoning_text) return stream.get_final_response() + except (_httpx.RemoteProtocolError, _httpx.ReadTimeout, _httpx.ConnectError, ConnectionError) as exc: + if attempt < max_stream_retries: + logger.debug( + "Codex Responses stream transport failed (attempt %s/%s); retrying. %s error=%s", + attempt + 1, + max_stream_retries + 1, + self._client_log_context(), + exc, + ) + continue + logger.debug( + "Codex Responses stream transport failed; falling back to create(stream=True). %s error=%s", + self._client_log_context(), + exc, + ) + return self._run_codex_create_stream_fallback(api_kwargs, client=active_client) except RuntimeError as exc: err_text = str(exc) missing_completed = "response.completed" in err_text From cac9d20c4f7c9fc1d5176f347595ba124a6c7e1b Mon Sep 17 00:00:00 2001 From: curtitoo Date: Tue, 31 Mar 2026 09:25:31 -0700 Subject: [PATCH 020/212] test: add codex transport drop regression --- tests/test_streaming.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 107a8a4d4a12..37a61ac37084 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -782,3 +782,35 @@ def test_codex_text_delta_fires_callback(self): response = agent._run_codex_stream({}, client=mock_client) assert "Hello from Codex!" in deltas + + def test_codex_remote_protocol_error_falls_back_to_create_stream(self): + from run_agent import AIAgent + import httpx + + fallback_response = SimpleNamespace( + output=[SimpleNamespace( + type="message", + content=[SimpleNamespace(type="output_text", text="fallback from create stream")], + )], + status="completed", + ) + + mock_client = MagicMock() + mock_client.responses.stream.side_effect = httpx.RemoteProtocolError( + "peer closed connection without sending complete message body" + ) + + agent = AIAgent( + model="test/model", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + agent.api_mode = "codex_responses" + agent._interrupt_requested = False + + with patch.object(agent, "_run_codex_create_stream_fallback", return_value=fallback_response) as mock_fallback: + response = agent._run_codex_stream({}, client=mock_client) + + assert response is fallback_response + mock_fallback.assert_called_once_with({}, client=mock_client) From 7f670a06cff300ab0cec44c2dade9fe29fcd7a49 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:10:12 -0700 Subject: [PATCH 021/212] feat: add --max-turns CLI flag to hermes chat Exposes the existing max_turns parameter (cli.py main()) as a CLI flag so programmatic callers (Paperclip adapter, scripts) can control the agent's tool-calling iteration limit without editing config.yaml. Priority chain unchanged: CLI flag > config agent.max_turns > env HERMES_MAX_ITERATIONS > default 90. --- hermes_cli/main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 315e0f9741f6..a420aafcc668 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -643,6 +643,7 @@ def cmd_chat(args): "worktree": getattr(args, "worktree", False), "checkpoints": getattr(args, "checkpoints", False), "pass_session_id": getattr(args, "pass_session_id", False), + "max_turns": getattr(args, "max_turns", None), } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} @@ -3808,6 +3809,13 @@ def main(): default=False, help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)" ) + chat_parser.add_argument( + "--max-turns", + type=int, + default=None, + metavar="N", + help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)" + ) chat_parser.add_argument( "--yolo", action="store_true", From 08171c1c316722b5a38ea3aef38351441613bd26 Mon Sep 17 00:00:00 2001 From: 0xbyt4 <35742124+0xbyt4@users.noreply.github.com> Date: Mon, 30 Mar 2026 11:30:26 +0300 Subject: [PATCH 022/212] fix: allow voice mode in WSL when PulseAudio bridge is configured WSL detection was treated as a hard fail, blocking voice mode even when audio worked via PulseAudio bridge. Now PULSE_SERVER env var presence makes WSL a soft notice instead of a blocking warning. Device query failures in WSL with PULSE_SERVER are also treated as non-blocking. --- tests/tools/test_voice_mode.py | 128 +++++++++++++++++++++++++++++++++ tools/voice_mode.py | 30 ++++++-- 2 files changed, 153 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_voice_mode.py b/tests/tools/test_voice_mode.py index 013ed6635384..933393f85cf4 100644 --- a/tests/tools/test_voice_mode.py +++ b/tests/tools/test_voice_mode.py @@ -56,6 +56,134 @@ def _fake_import_audio(): return mock +# ============================================================================ +# detect_audio_environment — WSL / SSH / Docker detection +# ============================================================================ + +class TestDetectAudioEnvironment: + def test_clean_environment_is_available(self, monkeypatch): + """No SSH, Docker, or WSL — should be available.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (MagicMock(), MagicMock())) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + assert result["available"] is True + assert result["warnings"] == [] + + def test_ssh_blocks_voice(self, monkeypatch): + """SSH environment should block voice mode.""" + monkeypatch.setenv("SSH_CLIENT", "1.2.3.4 54321 22") + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (MagicMock(), MagicMock())) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + assert result["available"] is False + assert any("SSH" in w for w in result["warnings"]) + + def test_wsl_without_pulse_blocks_voice(self, monkeypatch, tmp_path): + """WSL without PULSE_SERVER should block voice mode.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("PULSE_SERVER", raising=False) + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (MagicMock(), MagicMock())) + + proc_version = tmp_path / "proc_version" + proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2") + + _real_open = open + def _fake_open(f, *a, **kw): + if f == "/proc/version": + return _real_open(str(proc_version), *a, **kw) + return _real_open(f, *a, **kw) + + with patch("builtins.open", side_effect=_fake_open): + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any("WSL" in w for w in result["warnings"]) + assert any("PulseAudio" in w for w in result["warnings"]) + + def test_wsl_with_pulse_allows_voice(self, monkeypatch, tmp_path): + """WSL with PULSE_SERVER set should NOT block voice mode.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setenv("PULSE_SERVER", "unix:/mnt/wslg/PulseServer") + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (MagicMock(), MagicMock())) + + proc_version = tmp_path / "proc_version" + proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2") + + _real_open = open + def _fake_open(f, *a, **kw): + if f == "/proc/version": + return _real_open(str(proc_version), *a, **kw) + return _real_open(f, *a, **kw) + + with patch("builtins.open", side_effect=_fake_open): + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True + assert result["warnings"] == [] + assert any("WSL" in n for n in result.get("notices", [])) + + def test_wsl_device_query_fails_with_pulse_continues(self, monkeypatch, tmp_path): + """WSL device query failure should not block if PULSE_SERVER is set.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.setenv("PULSE_SERVER", "unix:/mnt/wslg/PulseServer") + + mock_sd = MagicMock() + mock_sd.query_devices.side_effect = Exception("device query failed") + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (mock_sd, MagicMock())) + + proc_version = tmp_path / "proc_version" + proc_version.write_text("Linux 5.15.0-microsoft-standard-WSL2") + + _real_open = open + def _fake_open(f, *a, **kw): + if f == "/proc/version": + return _real_open(str(proc_version), *a, **kw) + return _real_open(f, *a, **kw) + + with patch("builtins.open", side_effect=_fake_open): + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is True + assert any("device query failed" in n for n in result.get("notices", [])) + + def test_device_query_fails_without_pulse_blocks(self, monkeypatch): + """Device query failure without PULSE_SERVER should block.""" + monkeypatch.delenv("SSH_CLIENT", raising=False) + monkeypatch.delenv("SSH_TTY", raising=False) + monkeypatch.delenv("SSH_CONNECTION", raising=False) + monkeypatch.delenv("PULSE_SERVER", raising=False) + + mock_sd = MagicMock() + mock_sd.query_devices.side_effect = Exception("device query failed") + monkeypatch.setattr("tools.voice_mode._import_audio", + lambda: (mock_sd, MagicMock())) + + from tools.voice_mode import detect_audio_environment + result = detect_audio_environment() + + assert result["available"] is False + assert any("PortAudio" in w for w in result["warnings"]) + + # ============================================================================ # check_voice_requirements # ============================================================================ diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 6df6a54bc682..53d9ecb0075b 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -51,9 +51,12 @@ def _audio_available() -> bool: def detect_audio_environment() -> dict: """Detect if the current environment supports audio I/O. - Returns dict with 'available' (bool) and 'warnings' (list of strings). + Returns dict with 'available' (bool), 'warnings' (list of hard-fail + reasons that block voice mode), and 'notices' (list of informational + messages that do NOT block voice mode). """ - warnings = [] + warnings = [] # hard-fail: these block voice mode + notices = [] # informational: logged but don't block # SSH detection if any(os.environ.get(v) for v in ('SSH_CLIENT', 'SSH_TTY', 'SSH_CONNECTION')): @@ -63,11 +66,20 @@ def detect_audio_environment() -> dict: if os.path.exists('/.dockerenv'): warnings.append("Running inside Docker container -- no audio devices") - # WSL detection + # WSL detection — PulseAudio bridge makes audio work in WSL. + # Only block if PULSE_SERVER is not configured. try: with open('/proc/version', 'r') as f: if 'microsoft' in f.read().lower(): - warnings.append("Running in WSL -- audio requires PulseAudio bridge to Windows") + if os.environ.get('PULSE_SERVER'): + notices.append("Running in WSL with PulseAudio bridge") + else: + warnings.append( + "Running in WSL -- audio requires PulseAudio bridge.\n" + " 1. Set PULSE_SERVER=unix:/mnt/wslg/PulseServer\n" + " 2. Create ~/.asoundrc pointing ALSA at PulseAudio\n" + " 3. Verify with: arecord -d 3 /tmp/test.wav && aplay /tmp/test.wav" + ) except (FileNotFoundError, PermissionError, OSError): pass @@ -79,7 +91,12 @@ def detect_audio_environment() -> dict: if not devices: warnings.append("No audio input/output devices detected") except Exception: - warnings.append("Audio subsystem error (PortAudio cannot query devices)") + # In WSL with PulseAudio, device queries can fail even though + # recording/playback works fine. Don't block if PULSE_SERVER is set. + if os.environ.get('PULSE_SERVER'): + notices.append("Audio device query failed but PULSE_SERVER is set -- continuing") + else: + warnings.append("Audio subsystem error (PortAudio cannot query devices)") except ImportError: warnings.append("Audio libraries not installed (pip install sounddevice numpy)") except OSError: @@ -93,6 +110,7 @@ def detect_audio_environment() -> dict: return { "available": len(warnings) == 0, "warnings": warnings, + "notices": notices, } # --------------------------------------------------------------------------- @@ -748,6 +766,8 @@ def check_voice_requirements() -> Dict[str, Any]: for warning in env_check["warnings"]: details_parts.append(f"Environment: {warning}") + for notice in env_check.get("notices", []): + details_parts.append(f"Environment: {notice}") return { "available": available, From 0f2ea2062bc0041b6c954e1ec8b4be0fbd45734e Mon Sep 17 00:00:00 2001 From: Gutslabs Date: Tue, 31 Mar 2026 12:13:07 -0700 Subject: [PATCH 023/212] fix(profiles): validate tar archive member paths on import Fixes a zip-slip path traversal vulnerability in hermes profile import. shutil.unpack_archive() on untrusted tar members allows entries like ../../escape.txt to write files outside ~/.hermes/profiles/. - Add _normalize_profile_archive_parts() to reject absolute paths (POSIX and Windows), traversal (..), empty paths, backslash tricks - Add _safe_extract_profile_archive() for manual per-member extraction that only allows regular files and directories (rejects symlinks) - Replace shutil.unpack_archive() with the safe extraction path - Add regression tests for traversal and absolute-path attacks Co-authored-by: Gutslabs --- hermes_cli/profiles.py | 69 +++++++++++++++++++++++++++++-- tests/hermes_cli/test_profiles.py | 35 ++++++++++++++++ 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 30da7eb1ac33..5809186f5451 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -27,7 +27,7 @@ import subprocess import sys from dataclasses import dataclass, field -from pathlib import Path +from pathlib import Path, PurePosixPath, PureWindowsPath from typing import List, Optional _PROFILE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$") @@ -702,6 +702,58 @@ def export_profile(name: str, output_path: str) -> Path: return Path(result) +def _normalize_profile_archive_parts(member_name: str) -> List[str]: + """Return safe path parts for a profile archive member.""" + normalized_name = member_name.replace("\\", "/") + posix_path = PurePosixPath(normalized_name) + windows_path = PureWindowsPath(member_name) + + if ( + not normalized_name + or posix_path.is_absolute() + or windows_path.is_absolute() + or windows_path.drive + ): + raise ValueError(f"Unsafe archive member path: {member_name}") + + parts = [part for part in posix_path.parts if part not in ("", ".")] + if not parts or any(part == ".." for part in parts): + raise ValueError(f"Unsafe archive member path: {member_name}") + return parts + + +def _safe_extract_profile_archive(archive: Path, destination: Path) -> None: + """Extract a profile archive without allowing path escapes or links.""" + import tarfile + + with tarfile.open(archive, "r:gz") as tf: + for member in tf.getmembers(): + parts = _normalize_profile_archive_parts(member.name) + target = destination.joinpath(*parts) + + if member.isdir(): + target.mkdir(parents=True, exist_ok=True) + continue + + if not member.isfile(): + raise ValueError( + f"Unsupported archive member type: {member.name}" + ) + + target.parent.mkdir(parents=True, exist_ok=True) + extracted = tf.extractfile(member) + if extracted is None: + raise ValueError(f"Cannot read archive member: {member.name}") + + with extracted, open(target, "wb") as dst: + shutil.copyfileobj(extracted, dst) + + try: + os.chmod(target, member.mode & 0o777) + except OSError: + pass + + def import_profile(archive_path: str, name: Optional[str] = None) -> Path: """Import a profile from a tar.gz archive. @@ -716,9 +768,18 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: # Peek at the archive to find the top-level directory name with tarfile.open(archive, "r:gz") as tf: - top_dirs = {m.name.split("/")[0] for m in tf.getmembers() if "/" in m.name} + top_dirs = { + parts[0] + for member in tf.getmembers() + for parts in [_normalize_profile_archive_parts(member.name)] + if len(parts) > 1 or member.isdir() + } if not top_dirs: - top_dirs = {m.name for m in tf.getmembers() if m.isdir()} + top_dirs = { + _normalize_profile_archive_parts(member.name)[0] + for member in tf.getmembers() + if member.isdir() + } inferred_name = name or (top_dirs.pop() if len(top_dirs) == 1 else None) if not inferred_name: @@ -735,7 +796,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path: profiles_root = _get_profiles_root() profiles_root.mkdir(parents=True, exist_ok=True) - shutil.unpack_archive(str(archive), str(profiles_root)) + _safe_extract_profile_archive(archive, profiles_root) # If the archive extracted under a different name, rename extracted = profiles_root / (top_dirs.pop() if top_dirs else inferred_name) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 80152a4a0365..4e59d250ec15 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -6,6 +6,7 @@ """ import json +import io import os import tarfile from pathlib import Path @@ -449,6 +450,40 @@ def test_import_to_existing_name_raises(self, profile_env, tmp_path): with pytest.raises(FileExistsError): import_profile(str(archive_path), name="coder") + def test_import_rejects_traversal_archive_member(self, profile_env, tmp_path): + archive_path = tmp_path / "export" / "evil.tar.gz" + archive_path.parent.mkdir(parents=True, exist_ok=True) + escape_path = tmp_path / "escape.txt" + + with tarfile.open(archive_path, "w:gz") as tf: + info = tarfile.TarInfo("../../escape.txt") + data = b"pwned" + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + with pytest.raises(ValueError, match="Unsafe archive member path"): + import_profile(str(archive_path), name="coder") + + assert not escape_path.exists() + assert not get_profile_dir("coder").exists() + + def test_import_rejects_absolute_archive_member(self, profile_env, tmp_path): + archive_path = tmp_path / "export" / "evil-abs.tar.gz" + archive_path.parent.mkdir(parents=True, exist_ok=True) + absolute_target = tmp_path / "abs-escape.txt" + + with tarfile.open(archive_path, "w:gz") as tf: + info = tarfile.TarInfo(str(absolute_target)) + data = b"pwned" + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + + with pytest.raises(ValueError, match="Unsafe archive member path"): + import_profile(str(archive_path), name="coder") + + assert not absolute_target.exists() + assert not get_profile_dir("coder").exists() + def test_export_nonexistent_raises(self, profile_env, tmp_path): with pytest.raises(FileNotFoundError): export_profile("nonexistent", str(tmp_path / "out.tar.gz")) From a97641b9f2b90399c81a1242fc7845808611d021 Mon Sep 17 00:00:00 2001 From: maymuneth Date: Mon, 30 Mar 2026 15:06:35 +0300 Subject: [PATCH 024/212] fix(security): reject path traversal in credential file registration --- tests/tools/test_credential_files.py | 107 +++++++++++++++++++++++++++ tools/credential_files.py | 39 +++++++++- 2 files changed, 142 insertions(+), 4 deletions(-) diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index c46f73fae337..b6e43d4a82fa 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -197,3 +197,110 @@ def test_empty_when_no_skills_dir(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}): assert iter_skills_files() == [] + +class TestPathTraversalSecurity: + """Path traversal and absolute path rejection. + + A malicious skill could declare:: + + required_credential_files: + - path: '../../.ssh/id_rsa' + + Without containment checks, this would mount the host's SSH private key + into the container sandbox, leaking it to the skill's execution environment. + """ + + def test_dotdot_traversal_rejected(self, tmp_path, monkeypatch): + """'../sensitive' must not escape HERMES_HOME.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes")) + (tmp_path / ".hermes").mkdir() + + # Create a sensitive file one level above hermes_home + sensitive = tmp_path / "sensitive.json" + sensitive.write_text('{"secret": "value"}') + + result = register_credential_file("../sensitive.json") + + assert result is False + assert get_credential_file_mounts() == [] + + def test_deep_traversal_rejected(self, tmp_path, monkeypatch): + """'../../etc/passwd' style traversal must be rejected.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Create a fake sensitive file outside hermes_home + ssh_dir = tmp_path / ".ssh" + ssh_dir.mkdir() + (ssh_dir / "id_rsa").write_text("PRIVATE KEY") + + result = register_credential_file("../../.ssh/id_rsa") + + assert result is False + assert get_credential_file_mounts() == [] + + def test_absolute_path_rejected(self, tmp_path, monkeypatch): + """Absolute paths must be rejected regardless of whether they exist.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Create a file at an absolute path + sensitive = tmp_path / "absolute.json" + sensitive.write_text("{}") + + result = register_credential_file(str(sensitive)) + + assert result is False + assert get_credential_file_mounts() == [] + + def test_legitimate_file_still_works(self, tmp_path, monkeypatch): + """Normal files inside HERMES_HOME must still be registered.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + (hermes_home / "token.json").write_text('{"token": "abc"}') + + result = register_credential_file("token.json") + + assert result is True + mounts = get_credential_file_mounts() + assert len(mounts) == 1 + assert "token.json" in mounts[0]["container_path"] + + def test_nested_subdir_inside_hermes_home_allowed(self, tmp_path, monkeypatch): + """Files in subdirectories of HERMES_HOME must be allowed.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + subdir = hermes_home / "creds" + subdir.mkdir() + (subdir / "oauth.json").write_text("{}") + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + result = register_credential_file("creds/oauth.json") + + assert result is True + + def test_symlink_traversal_rejected(self, tmp_path, monkeypatch): + """A symlink inside HERMES_HOME pointing outside must be rejected.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + # Create a sensitive file outside hermes_home + sensitive = tmp_path / "sensitive.json" + sensitive.write_text('{"secret": "value"}') + + # Create a symlink inside hermes_home pointing outside + symlink = hermes_home / "evil_link.json" + try: + symlink.symlink_to(sensitive) + except (OSError, NotImplementedError): + pytest.skip("Symlinks not supported on this platform") + + result = register_credential_file("evil_link.json") + + # The resolved path escapes HERMES_HOME — must be rejected + assert result is False + assert get_credential_file_mounts() == [] diff --git a/tools/credential_files.py b/tools/credential_files.py index 53ddd79d5422..95f068a816a3 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -55,16 +55,47 @@ def register_credential_file( *relative_path* is relative to ``HERMES_HOME`` (e.g. ``google_token.json``). Returns True if the file exists on the host and was registered. + + Security: rejects absolute paths and path traversal sequences (``..``). + The resolved host path must remain inside HERMES_HOME so that a malicious + skill cannot declare ``required_credential_files: ['../../.ssh/id_rsa']`` + and exfiltrate sensitive host files into a container sandbox. """ hermes_home = _resolve_hermes_home() + + # Reject absolute paths — they bypass the HERMES_HOME sandbox entirely. + if os.path.isabs(relative_path): + logger.warning( + "credential_files: rejected absolute path %r (must be relative to HERMES_HOME)", + relative_path, + ) + return False + host_path = hermes_home / relative_path - if not host_path.is_file(): - logger.debug("credential_files: skipping %s (not found)", host_path) + + # Resolve symlinks and normalise ``..`` before the containment check so + # that traversal like ``../. ssh/id_rsa`` cannot escape HERMES_HOME. + try: + resolved = host_path.resolve() + hermes_home_resolved = hermes_home.resolve() + resolved.relative_to(hermes_home_resolved) # raises ValueError if outside + except ValueError: + logger.warning( + "credential_files: rejected path traversal %r " + "(resolves to %s, outside HERMES_HOME %s)", + relative_path, + resolved, + hermes_home_resolved, + ) + return False + + if not resolved.is_file(): + logger.debug("credential_files: skipping %s (not found)", resolved) return False container_path = f"{container_base.rstrip('/')}/{relative_path}" - _registered_files[container_path] = str(host_path) - logger.debug("credential_files: registered %s -> %s", host_path, container_path) + _registered_files[container_path] = str(resolved) + logger.debug("credential_files: registered %s -> %s", resolved, container_path) return True From 7f78deebe76447ea218a2363063bddc77edbf274 Mon Sep 17 00:00:00 2001 From: Teknium Date: Tue, 31 Mar 2026 12:06:16 -0700 Subject: [PATCH 025/212] fix: apply same path traversal checks to config-based credential files _load_config_files() had the same hermes_home / item pattern without containment checks. While config.yaml is user-controlled (lower threat than skill frontmatter), defense in depth prevents exploitation via config injection or copy-paste mistakes. --- tests/tools/test_credential_files.py | 54 ++++++++++++++++++++++++++++ tools/credential_files.py | 20 +++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_credential_files.py b/tests/tools/test_credential_files.py index b6e43d4a82fa..7449c1db4b0f 100644 --- a/tests/tools/test_credential_files.py +++ b/tests/tools/test_credential_files.py @@ -304,3 +304,57 @@ def test_symlink_traversal_rejected(self, tmp_path, monkeypatch): # The resolved path escapes HERMES_HOME — must be rejected assert result is False assert get_credential_file_mounts() == [] + + +# --------------------------------------------------------------------------- +# Config-based credential files — same containment checks +# --------------------------------------------------------------------------- + +class TestConfigPathTraversal: + """terminal.credential_files in config.yaml must also reject traversal.""" + + def _write_config(self, hermes_home: Path, cred_files: list): + import yaml + config_path = hermes_home / "config.yaml" + config_path.write_text(yaml.dump({"terminal": {"credential_files": cred_files}})) + + def test_config_traversal_rejected(self, tmp_path, monkeypatch): + """'../secret' in config.yaml must not escape HERMES_HOME.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + sensitive = tmp_path / "secret.json" + sensitive.write_text("{}") + self._write_config(hermes_home, ["../secret.json"]) + + mounts = get_credential_file_mounts() + host_paths = [m["host_path"] for m in mounts] + assert str(sensitive) not in host_paths + assert str(sensitive.resolve()) not in host_paths + + def test_config_absolute_path_rejected(self, tmp_path, monkeypatch): + """Absolute paths in config.yaml must be rejected.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + sensitive = tmp_path / "abs.json" + sensitive.write_text("{}") + self._write_config(hermes_home, [str(sensitive)]) + + mounts = get_credential_file_mounts() + assert mounts == [] + + def test_config_legitimate_file_works(self, tmp_path, monkeypatch): + """Normal files inside HERMES_HOME via config must still mount.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + (hermes_home / "oauth.json").write_text("{}") + self._write_config(hermes_home, ["oauth.json"]) + + mounts = get_credential_file_mounts() + assert len(mounts) == 1 + assert "oauth.json" in mounts[0]["container_path"] diff --git a/tools/credential_files.py b/tools/credential_files.py index 95f068a816a3..af4d13a4e484 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -141,11 +141,27 @@ def _load_config_files() -> List[Dict[str, str]]: cfg = yaml.safe_load(f) or {} cred_files = cfg.get("terminal", {}).get("credential_files") if isinstance(cred_files, list): + hermes_home_resolved = hermes_home.resolve() for item in cred_files: if isinstance(item, str) and item.strip(): - host_path = hermes_home / item.strip() + rel = item.strip() + if os.path.isabs(rel): + logger.warning( + "credential_files: rejected absolute config path %r", rel, + ) + continue + host_path = (hermes_home / rel).resolve() + try: + host_path.relative_to(hermes_home_resolved) + except ValueError: + logger.warning( + "credential_files: rejected config path traversal %r " + "(resolves to %s, outside HERMES_HOME %s)", + rel, host_path, hermes_home_resolved, + ) + continue if host_path.is_file(): - container_path = f"/root/.hermes/{item.strip()}" + container_path = f"/root/.hermes/{rel}" result.append({ "host_path": str(host_path), "container_path": container_path, From c94a5fa1b2cbf6074e6feb56622020647987abe5 Mon Sep 17 00:00:00 2001 From: binhnt92 Date: Tue, 31 Mar 2026 12:19:10 -0700 Subject: [PATCH 026/212] fix(cli): use atomic write in save_config_value to prevent config loss on interrupt save_config_value() used bare open(path, 'w') + yaml.dump() which truncates the file to zero bytes on open. If the process is interrupted mid-write, config.yaml is left empty. Replace with atomic_yaml_write() (temp file + fsync + os.replace), matching the gateway config write path. Co-authored-by: Hermes Agent --- cli.py | 7 +-- tests/test_cli_save_config_value.py | 80 +++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 tests/test_cli_save_config_value.py diff --git a/cli.py b/cli.py index e5f88e752be7..1f72207aa117 100644 --- a/cli.py +++ b/cli.py @@ -991,9 +991,10 @@ def save_config_value(key_path: str, value: any) -> bool: current = current[key] current[keys[-1]] = value - # Save back - with open(config_path, 'w') as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) + # Save back atomically — write to temp file + fsync + os.replace + # so an interrupt never leaves config.yaml truncated or empty. + from utils import atomic_yaml_write + atomic_yaml_write(config_path, config) # Enforce owner-only permissions on config files (contain API keys) try: diff --git a/tests/test_cli_save_config_value.py b/tests/test_cli_save_config_value.py new file mode 100644 index 000000000000..7d030c03c2c0 --- /dev/null +++ b/tests/test_cli_save_config_value.py @@ -0,0 +1,80 @@ +"""Tests for save_config_value() in cli.py — atomic write behavior.""" + +import os +import yaml +from pathlib import Path +from unittest.mock import patch, MagicMock + +import pytest + + +class TestSaveConfigValueAtomic: + """save_config_value() must use atomic_yaml_write to avoid data loss.""" + + @pytest.fixture + def config_env(self, tmp_path, monkeypatch): + """Isolated config environment with a writable config.yaml.""" + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text(yaml.dump({ + "model": {"default": "test-model", "provider": "openrouter"}, + "display": {"skin": "default"}, + })) + monkeypatch.setattr("cli._hermes_home", hermes_home) + return config_path + + def test_calls_atomic_yaml_write(self, config_env, monkeypatch): + """save_config_value must route through atomic_yaml_write, not bare open().""" + mock_atomic = MagicMock() + monkeypatch.setattr("utils.atomic_yaml_write", mock_atomic) + + from cli import save_config_value + save_config_value("display.skin", "mono") + + mock_atomic.assert_called_once() + written_path, written_data = mock_atomic.call_args[0] + assert Path(written_path) == config_env + assert written_data["display"]["skin"] == "mono" + + def test_preserves_existing_keys(self, config_env): + """Writing a new key must not clobber existing config entries.""" + from cli import save_config_value + save_config_value("agent.max_turns", 50) + + result = yaml.safe_load(config_env.read_text()) + assert result["model"]["default"] == "test-model" + assert result["model"]["provider"] == "openrouter" + assert result["display"]["skin"] == "default" + assert result["agent"]["max_turns"] == 50 + + def test_creates_nested_keys(self, config_env): + """Dot-separated paths create intermediate dicts as needed.""" + from cli import save_config_value + save_config_value("compression.summary_model", "google/gemini-3-flash-preview") + + result = yaml.safe_load(config_env.read_text()) + assert result["compression"]["summary_model"] == "google/gemini-3-flash-preview" + + def test_overwrites_existing_value(self, config_env): + """Updating an existing key replaces the value.""" + from cli import save_config_value + save_config_value("display.skin", "ares") + + result = yaml.safe_load(config_env.read_text()) + assert result["display"]["skin"] == "ares" + + def test_file_not_truncated_on_error(self, config_env, monkeypatch): + """If atomic_yaml_write raises, the original file is untouched.""" + original_content = config_env.read_text() + + def exploding_write(*args, **kwargs): + raise OSError("disk full") + + monkeypatch.setattr("utils.atomic_yaml_write", exploding_write) + + from cli import save_config_value + result = save_config_value("display.skin", "broken") + + assert result is False + assert config_env.read_text() == original_content From 655eea2db88e3da31bb7655ffefe291b7abcc24b Mon Sep 17 00:00:00 2001 From: maymuneth Date: Tue, 31 Mar 2026 21:08:06 +0300 Subject: [PATCH 027/212] fix(security): protect .docker, .azure, and .config/gh from read and write --- agent/context_references.py | 2 +- tools/file_operations.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/agent/context_references.py b/agent/context_references.py index 09ba982df1a1..d0985605d80d 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -17,7 +17,7 @@ r"(?diff|staged)\b|(?Pfile|folder|git|url):(?P\S+))" ) TRAILING_PUNCTUATION = ",.;!?" -_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube") +_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure") _SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",) _SENSITIVE_HOME_FILES = ( Path(".ssh") / "authorized_keys", diff --git a/tools/file_operations.py b/tools/file_operations.py index 96bdc2d535e6..d0e3ad3c8ba7 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -71,6 +71,9 @@ os.path.join(_HOME, ".kube"), "/etc/sudoers.d", "/etc/systemd", + os.path.join(_HOME, ".docker"), + os.path.join(_HOME, ".azure"), + os.path.join(_HOME, ".config", "gh"), ] ] From d3f1987a051c8592ded99e5654dfd58c394835e8 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:48:30 -0700 Subject: [PATCH 028/212] fix(security): add .config/gh to read protection for @file references (#4327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #4305 — .config/gh was added to the write-deny list but missed from _SENSITIVE_HOME_DIRS, leaving GitHub CLI OAuth tokens exposed via @file:~/.config/gh/hosts.yml context injection. --- agent/context_references.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agent/context_references.py b/agent/context_references.py index d0985605d80d..8222dc33a305 100644 --- a/agent/context_references.py +++ b/agent/context_references.py @@ -17,7 +17,7 @@ r"(?diff|staged)\b|(?Pfile|folder|git|url):(?P\S+))" ) TRAILING_PUNCTUATION = ",.;!?" -_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure") +_SENSITIVE_HOME_DIRS = (".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure", ".config/gh") _SENSITIVE_HERMES_DIRS = (Path("skills") / ".hub",) _SENSITIVE_HOME_FILES = ( Path(".ssh") / "authorized_keys", From e3f8347be30a068b91662818a70d0c3c42513b96 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:53:19 -0700 Subject: [PATCH 029/212] feat(file_tools): harden read_file with size guard, dedup, and device blocking (#4315) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(file_tools): harden read_file with size guard, dedup, and device blocking Three improvements to read_file_tool to reduce wasted context tokens and prevent process hangs: 1. Character-count guard: reads that produce more than 100K characters (≈25-35K tokens across tokenisers) are rejected with an error that tells the model to use offset+limit for a smaller range. The effective cap is min(file_size, 100K) so small files that happen to have long lines aren't over-penalised. Large truncated files also get a hint nudging toward targeted reads. 2. File-read deduplication: when the same (path, offset, limit) is read a second time and the file hasn't been modified (mtime unchanged), return a lightweight stub instead of re-sending the full content. Writes and patches naturally change mtime, so post-edit reads always return fresh content. The dedup cache is cleared on context compression — after compression the original read content is summarised away, so the model needs the full content again. 3. Device path blocking: paths like /dev/zero, /dev/random, /dev/stdin etc. are rejected before any I/O to prevent process hangs from infinite-output or blocking-input devices. Tests: 17 new tests covering all three features plus the dedup-reset- on-compression integration. All 52 file-read tests pass (35 existing + 17 new). Full tool suite (2124 tests) passes with 0 failures. * feat: make file_read_max_chars configurable, add docs Add file_read_max_chars to DEFAULT_CONFIG (default 100K). read_file_tool reads this on first call and caches for the process lifetime. Users on large-context models can raise it; users on small local models can lower it. Also adds a 'File Read Safety' section to the configuration docs explaining the char limit, dedup behavior, and example values. --- hermes_cli/config.py | 5 + run_agent.py | 9 + tests/tools/test_file_read_guards.py | 378 +++++++++++++++++++++++ tools/file_tools.py | 203 +++++++++++- website/docs/user-guide/configuration.md | 20 ++ 5 files changed, 605 insertions(+), 10 deletions(-) create mode 100644 tests/tools/test_file_read_guards.py diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e62a4cdc1bcc..e5cf73d3f6e6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -256,6 +256,11 @@ def ensure_hermes_home(): "enabled": True, "max_snapshots": 50, # Max checkpoints to keep per directory }, + + # Maximum characters returned by a single read_file call. Reads that + # exceed this are rejected with guidance to use offset+limit. + # 100K chars ≈ 25–35K tokens across typical tokenisers. + "file_read_max_chars": 100_000, "compression": { "enabled": True, diff --git a/run_agent.py b/run_agent.py index 670f210074a3..5ed40500b3e4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5361,6 +5361,15 @@ def _compress_context(self, messages: list, system_message: str, *, approx_token if _post_progress < 0.85: self._context_pressure_warned = False + # Clear the file-read dedup cache. After compression the original + # read content is summarised away — if the model re-reads the same + # file it needs the full content, not a "file unchanged" stub. + try: + from tools.file_tools import reset_file_dedup + reset_file_dedup(task_id) + except Exception: + pass + return compressed, new_system_prompt def _execute_tool_calls(self, assistant_message, messages: list, effective_task_id: str, api_call_count: int = 0) -> None: diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py new file mode 100644 index 000000000000..b4a688aa61c3 --- /dev/null +++ b/tests/tools/test_file_read_guards.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Tests for read_file_tool safety guards: device-path blocking, +character-count limits, file deduplication, and dedup reset on +context compression. + +Run with: python -m pytest tests/tools/test_file_read_guards.py -v +""" + +import json +import os +import tempfile +import time +import unittest +from unittest.mock import patch, MagicMock + +from tools.file_tools import ( + read_file_tool, + clear_read_tracker, + reset_file_dedup, + _is_blocked_device, + _get_max_read_chars, + _DEFAULT_MAX_READ_CHARS, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _FakeReadResult: + """Minimal stand-in for FileOperations.read_file return value.""" + def __init__(self, content="line1\nline2\n", total_lines=2, file_size=100): + self.content = content + self._total_lines = total_lines + self._file_size = file_size + + def to_dict(self): + return { + "content": self.content, + "total_lines": self._total_lines, + "file_size": self._file_size, + } + + +def _make_fake_ops(content="hello\n", total_lines=1, file_size=6): + fake = MagicMock() + fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult( + content=content, total_lines=total_lines, file_size=file_size, + ) + return fake + + +# --------------------------------------------------------------------------- +# Device path blocking +# --------------------------------------------------------------------------- + +class TestDevicePathBlocking(unittest.TestCase): + """Paths like /dev/zero should be rejected before any I/O.""" + + def test_blocked_device_detection(self): + for dev in ("/dev/zero", "/dev/random", "/dev/urandom", "/dev/stdin", + "/dev/tty", "/dev/console", "/dev/stdout", "/dev/stderr", + "/dev/fd/0", "/dev/fd/1", "/dev/fd/2"): + self.assertTrue(_is_blocked_device(dev), f"{dev} should be blocked") + + def test_safe_device_not_blocked(self): + self.assertFalse(_is_blocked_device("/dev/null")) + self.assertFalse(_is_blocked_device("/dev/sda1")) + + def test_proc_fd_blocked(self): + self.assertTrue(_is_blocked_device("/proc/self/fd/0")) + self.assertTrue(_is_blocked_device("/proc/12345/fd/2")) + + def test_proc_fd_other_not_blocked(self): + self.assertFalse(_is_blocked_device("/proc/self/fd/3")) + self.assertFalse(_is_blocked_device("/proc/self/maps")) + + def test_normal_files_not_blocked(self): + self.assertFalse(_is_blocked_device("/tmp/test.py")) + self.assertFalse(_is_blocked_device("/home/user/.bashrc")) + + def test_read_file_tool_rejects_device(self): + """read_file_tool returns an error without any file I/O.""" + result = json.loads(read_file_tool("/dev/zero", task_id="dev_test")) + self.assertIn("error", result) + self.assertIn("device file", result["error"]) + + +# --------------------------------------------------------------------------- +# Character-count limits +# --------------------------------------------------------------------------- + +class TestCharacterCountGuard(unittest.TestCase): + """Large reads should be rejected with guidance to use offset/limit.""" + + def setUp(self): + clear_read_tracker() + + def tearDown(self): + clear_read_tracker() + + @patch("tools.file_tools._get_file_ops") + @patch("tools.file_tools._get_max_read_chars", return_value=_DEFAULT_MAX_READ_CHARS) + def test_oversized_read_rejected(self, _mock_limit, mock_ops): + """A read that returns >max chars is rejected.""" + big_content = "x" * (_DEFAULT_MAX_READ_CHARS + 1) + mock_ops.return_value = _make_fake_ops( + content=big_content, + total_lines=5000, + file_size=len(big_content) + 100, # bigger than content + ) + result = json.loads(read_file_tool("/tmp/huge.txt", task_id="big")) + self.assertIn("error", result) + self.assertIn("safety limit", result["error"]) + self.assertIn("offset and limit", result["error"]) + self.assertIn("total_lines", result) + + @patch("tools.file_tools._get_file_ops") + def test_small_read_not_rejected(self, mock_ops): + """Normal-sized reads pass through fine.""" + mock_ops.return_value = _make_fake_ops(content="short\n", file_size=6) + result = json.loads(read_file_tool("/tmp/small.txt", task_id="small")) + self.assertNotIn("error", result) + self.assertIn("content", result) + + @patch("tools.file_tools._get_file_ops") + @patch("tools.file_tools._get_max_read_chars", return_value=_DEFAULT_MAX_READ_CHARS) + def test_content_under_limit_passes(self, _mock_limit, mock_ops): + """Content just under the limit should pass through fine.""" + mock_ops.return_value = _make_fake_ops( + content="y" * (_DEFAULT_MAX_READ_CHARS - 1), + file_size=_DEFAULT_MAX_READ_CHARS - 1, + ) + result = json.loads(read_file_tool("/tmp/justunder.txt", task_id="under")) + self.assertNotIn("error", result) + self.assertIn("content", result) + + +# --------------------------------------------------------------------------- +# File deduplication +# --------------------------------------------------------------------------- + +class TestFileDedup(unittest.TestCase): + """Re-reading an unchanged file should return a lightweight stub.""" + + def setUp(self): + clear_read_tracker() + self._tmpdir = tempfile.mkdtemp() + self._tmpfile = os.path.join(self._tmpdir, "dedup_test.txt") + with open(self._tmpfile, "w") as f: + f.write("line one\nline two\n") + + def tearDown(self): + clear_read_tracker() + try: + os.unlink(self._tmpfile) + os.rmdir(self._tmpdir) + except OSError: + pass + + @patch("tools.file_tools._get_file_ops") + def test_second_read_returns_dedup_stub(self, mock_ops): + """Second read of same file+range returns dedup stub.""" + mock_ops.return_value = _make_fake_ops( + content="line one\nline two\n", file_size=20, + ) + # First read — full content + r1 = json.loads(read_file_tool(self._tmpfile, task_id="dup")) + self.assertNotIn("dedup", r1) + + # Second read — should get dedup stub + r2 = json.loads(read_file_tool(self._tmpfile, task_id="dup")) + self.assertTrue(r2.get("dedup"), "Second read should return dedup stub") + self.assertIn("unchanged", r2.get("content", "")) + + @patch("tools.file_tools._get_file_ops") + def test_modified_file_not_deduped(self, mock_ops): + """After the file is modified, dedup returns full content.""" + mock_ops.return_value = _make_fake_ops( + content="line one\nline two\n", file_size=20, + ) + read_file_tool(self._tmpfile, task_id="mod") + + # Modify the file — ensure mtime changes + time.sleep(0.05) + with open(self._tmpfile, "w") as f: + f.write("changed content\n") + + r2 = json.loads(read_file_tool(self._tmpfile, task_id="mod")) + self.assertNotEqual(r2.get("dedup"), True, "Modified file should not dedup") + + @patch("tools.file_tools._get_file_ops") + def test_different_range_not_deduped(self, mock_ops): + """Same file but different offset/limit should not dedup.""" + mock_ops.return_value = _make_fake_ops( + content="line one\nline two\n", file_size=20, + ) + read_file_tool(self._tmpfile, offset=1, limit=500, task_id="rng") + + r2 = json.loads(read_file_tool( + self._tmpfile, offset=10, limit=500, task_id="rng", + )) + self.assertNotEqual(r2.get("dedup"), True) + + @patch("tools.file_tools._get_file_ops") + def test_different_task_not_deduped(self, mock_ops): + """Different task_ids have separate dedup caches.""" + mock_ops.return_value = _make_fake_ops( + content="line one\nline two\n", file_size=20, + ) + read_file_tool(self._tmpfile, task_id="task_a") + + r2 = json.loads(read_file_tool(self._tmpfile, task_id="task_b")) + self.assertNotEqual(r2.get("dedup"), True) + + +# --------------------------------------------------------------------------- +# Dedup reset on compression +# --------------------------------------------------------------------------- + +class TestDedupResetOnCompression(unittest.TestCase): + """reset_file_dedup should clear the dedup cache so post-compression + reads return full content.""" + + def setUp(self): + clear_read_tracker() + self._tmpdir = tempfile.mkdtemp() + self._tmpfile = os.path.join(self._tmpdir, "compress_test.txt") + with open(self._tmpfile, "w") as f: + f.write("original content\n") + + def tearDown(self): + clear_read_tracker() + try: + os.unlink(self._tmpfile) + os.rmdir(self._tmpdir) + except OSError: + pass + + @patch("tools.file_tools._get_file_ops") + def test_reset_clears_dedup(self, mock_ops): + """After reset_file_dedup, the same read returns full content.""" + mock_ops.return_value = _make_fake_ops( + content="original content\n", file_size=18, + ) + # First read — populates dedup cache + read_file_tool(self._tmpfile, task_id="comp") + + # Verify dedup works before reset + r_dedup = json.loads(read_file_tool(self._tmpfile, task_id="comp")) + self.assertTrue(r_dedup.get("dedup"), "Should dedup before reset") + + # Simulate compression + reset_file_dedup("comp") + + # Read again — should get full content + r_post = json.loads(read_file_tool(self._tmpfile, task_id="comp")) + self.assertNotEqual(r_post.get("dedup"), True, + "Post-compression read should return full content") + + @patch("tools.file_tools._get_file_ops") + def test_reset_all_tasks(self, mock_ops): + """reset_file_dedup(None) clears all tasks.""" + mock_ops.return_value = _make_fake_ops( + content="original content\n", file_size=18, + ) + read_file_tool(self._tmpfile, task_id="t1") + read_file_tool(self._tmpfile, task_id="t2") + + reset_file_dedup() # no task_id — clear all + + r1 = json.loads(read_file_tool(self._tmpfile, task_id="t1")) + r2 = json.loads(read_file_tool(self._tmpfile, task_id="t2")) + self.assertNotEqual(r1.get("dedup"), True) + self.assertNotEqual(r2.get("dedup"), True) + + @patch("tools.file_tools._get_file_ops") + def test_reset_preserves_loop_detection(self, mock_ops): + """reset_file_dedup does NOT affect the consecutive-read counter.""" + mock_ops.return_value = _make_fake_ops( + content="original content\n", file_size=18, + ) + # Build up consecutive count (read 1 and 2) + read_file_tool(self._tmpfile, task_id="loop") + # 2nd read is deduped — doesn't increment consecutive counter + read_file_tool(self._tmpfile, task_id="loop") + + reset_file_dedup("loop") + + # 3rd read — counter should still be at 2 from before reset + # (dedup was hit for read 2, but consecutive counter was 1 for that) + # After reset, this read goes through full path, incrementing to 2 + r3 = json.loads(read_file_tool(self._tmpfile, task_id="loop")) + # Should NOT be blocked or warned — counter restarted since dedup + # intercepted reads before they reached the counter + self.assertNotIn("error", r3) + + +# --------------------------------------------------------------------------- +# Large-file hint +# --------------------------------------------------------------------------- + +class TestLargeFileHint(unittest.TestCase): + """Large truncated files should include a hint about targeted reads.""" + + def setUp(self): + clear_read_tracker() + + def tearDown(self): + clear_read_tracker() + + @patch("tools.file_tools._get_file_ops") + def test_large_truncated_file_gets_hint(self, mock_ops): + content = "line\n" * 400 # 2000 chars, small enough to pass char guard + fake = _make_fake_ops(content=content, total_lines=10000, file_size=600_000) + # Make to_dict return truncated=True + orig_read = fake.read_file + def patched_read(path, offset=1, limit=500): + r = orig_read(path, offset, limit) + orig_to_dict = r.to_dict + def new_to_dict(): + d = orig_to_dict() + d["truncated"] = True + return d + r.to_dict = new_to_dict + return r + fake.read_file = patched_read + mock_ops.return_value = fake + + result = json.loads(read_file_tool("/tmp/bigfile.log", task_id="hint")) + self.assertIn("_hint", result) + self.assertIn("section you need", result["_hint"]) + + +# --------------------------------------------------------------------------- +# Config override +# --------------------------------------------------------------------------- + +class TestConfigOverride(unittest.TestCase): + """file_read_max_chars in config.yaml should control the char guard.""" + + def setUp(self): + clear_read_tracker() + # Reset the cached value so each test gets a fresh lookup + import tools.file_tools as _ft + _ft._max_read_chars_cached = None + + def tearDown(self): + clear_read_tracker() + import tools.file_tools as _ft + _ft._max_read_chars_cached = None + + @patch("tools.file_tools._get_file_ops") + @patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 50}) + def test_custom_config_lowers_limit(self, _mock_cfg, mock_ops): + """A config value of 50 should reject reads over 50 chars.""" + mock_ops.return_value = _make_fake_ops(content="x" * 60, file_size=60) + result = json.loads(read_file_tool("/tmp/cfgtest.txt", task_id="cfg1")) + self.assertIn("error", result) + self.assertIn("safety limit", result["error"]) + self.assertIn("50", result["error"]) # should show the configured limit + + @patch("tools.file_tools._get_file_ops") + @patch("hermes_cli.config.load_config", return_value={"file_read_max_chars": 500_000}) + def test_custom_config_raises_limit(self, _mock_cfg, mock_ops): + """A config value of 500K should allow reads up to 500K chars.""" + # 200K chars would be rejected at the default 100K but passes at 500K + mock_ops.return_value = _make_fake_ops( + content="y" * 200_000, file_size=200_000, + ) + result = json.loads(read_file_tool("/tmp/cfgtest2.txt", task_id="cfg2")) + self.assertNotIn("error", result) + self.assertIn("content", result) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/file_tools.py b/tools/file_tools.py index 6226e76574f0..1245e68deb1e 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -15,6 +15,80 @@ _EXPECTED_WRITE_ERRNOS = {errno.EACCES, errno.EPERM, errno.EROFS} +# --------------------------------------------------------------------------- +# Read-size guard: cap the character count returned to the model. +# We're model-agnostic so we can't count tokens; characters are a safe proxy. +# 100K chars ≈ 25–35K tokens across typical tokenisers. Files larger than +# this in a single read are a context-window hazard — the model should use +# offset+limit to read the relevant section. +# +# Configurable via config.yaml: file_read_max_chars: 200000 +# --------------------------------------------------------------------------- +_DEFAULT_MAX_READ_CHARS = 100_000 +_max_read_chars_cached: int | None = None + + +def _get_max_read_chars() -> int: + """Return the configured max characters per file read. + + Reads ``file_read_max_chars`` from config.yaml on first call, caches + the result for the lifetime of the process. Falls back to the + built-in default if the config is missing or invalid. + """ + global _max_read_chars_cached + if _max_read_chars_cached is not None: + return _max_read_chars_cached + try: + from hermes_cli.config import load_config + cfg = load_config() + val = cfg.get("file_read_max_chars") + if isinstance(val, (int, float)) and val > 0: + _max_read_chars_cached = int(val) + return _max_read_chars_cached + except Exception: + pass + _max_read_chars_cached = _DEFAULT_MAX_READ_CHARS + return _max_read_chars_cached + +# If the total file size exceeds this AND the caller didn't specify a narrow +# range (limit <= 200), we include a hint encouraging targeted reads. +_LARGE_FILE_HINT_BYTES = 512_000 # 512 KB + +# --------------------------------------------------------------------------- +# Device path blocklist — reading these hangs the process (infinite output +# or blocking on input). Checked by path only (no I/O). +# --------------------------------------------------------------------------- +_BLOCKED_DEVICE_PATHS = frozenset({ + # Infinite output — never reach EOF + "/dev/zero", "/dev/random", "/dev/urandom", "/dev/full", + # Blocks waiting for input + "/dev/stdin", "/dev/tty", "/dev/console", + # Nonsensical to read + "/dev/stdout", "/dev/stderr", + # fd aliases + "/dev/fd/0", "/dev/fd/1", "/dev/fd/2", +}) + + +def _is_blocked_device(filepath: str) -> bool: + """Return True if the path would hang the process (infinite output or blocking input). + + Uses the *literal* path — no symlink resolution — because the model + specifies paths directly and realpath follows symlinks all the way + through (e.g. /dev/stdin → /proc/self/fd/0 → /dev/pts/0), defeating + the check. + """ + normalized = os.path.expanduser(filepath) + if normalized in _BLOCKED_DEVICE_PATHS: + return True + # /proc/self/fd/0-2 and /proc//fd/0-2 are Linux aliases for stdio + if normalized.startswith("/proc/") and normalized.endswith( + ("/fd/0", "/fd/1", "/fd/2") + ): + return True + return False + + # Paths that file tools should refuse to write to without going through the # terminal tool's approval system. These match prefixes after os.path.realpath. _SENSITIVE_PATH_PREFIXES = ("/etc/", "/boot/", "/usr/lib/systemd/") @@ -53,11 +127,15 @@ def _is_expected_write_exception(exc: Exception) -> bool: _file_ops_lock = threading.Lock() _file_ops_cache: dict = {} -# Track files read per task to detect re-read loops after context compression. +# Track files read per task to detect re-read loops and deduplicate reads. # Per task_id we store: # "last_key": the key of the most recent read/search call (or None) # "consecutive": how many times that exact call has been repeated in a row # "read_history": set of (path, offset, limit) tuples for get_read_files_summary +# "dedup": dict mapping (resolved_path, offset, limit) → mtime float +# Used to skip re-reads of unchanged files. Reset on +# context compression (the original content is summarised +# away so the model needs the full content again). _read_tracker_lock = threading.Lock() _read_tracker: dict = {} @@ -195,8 +273,19 @@ def clear_file_ops_cache(task_id: str = None): def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = "default") -> str: """Read a file with pagination and line numbers.""" try: - # Security: block direct reads of internal Hermes cache/index files - # to prevent prompt injection via catalog or hub metadata files. + # ── Device path guard ───────────────────────────────────────── + # Block paths that would hang the process (infinite output, + # blocking on input). Pure path check — no I/O. + if _is_blocked_device(path): + return json.dumps({ + "error": ( + f"Cannot read '{path}': this is a device file that would " + "block or produce infinite output." + ), + }) + + # ── Hermes internal path guard ──────────────────────────────── + # Prevent prompt injection via catalog or hub metadata files. import pathlib as _pathlib from hermes_constants import get_hermes_home as _get_hh _resolved = _pathlib.Path(path).expanduser().resolve() @@ -217,20 +306,83 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = }) except ValueError: pass + + # ── Dedup check ─────────────────────────────────────────────── + # If we already read this exact (path, offset, limit) and the + # file hasn't been modified since, return a lightweight stub + # instead of re-sending the same content. Saves context tokens. + resolved_str = str(_resolved) + dedup_key = (resolved_str, offset, limit) + with _read_tracker_lock: + task_data = _read_tracker.setdefault(task_id, { + "last_key": None, "consecutive": 0, + "read_history": set(), "dedup": {}, + }) + cached_mtime = task_data.get("dedup", {}).get(dedup_key) + + if cached_mtime is not None: + try: + current_mtime = os.path.getmtime(resolved_str) + if current_mtime == cached_mtime: + return json.dumps({ + "content": ( + "File unchanged since last read. The content from " + "the earlier read_file result in this conversation is " + "still current — refer to that instead of re-reading." + ), + "path": path, + "dedup": True, + }, ensure_ascii=False) + except OSError: + pass # stat failed — fall through to full read + + # ── Perform the read ────────────────────────────────────────── file_ops = _get_file_ops(task_id) result = file_ops.read_file(path, offset, limit) if result.content: result.content = redact_sensitive_text(result.content) result_dict = result.to_dict() - # Track reads to detect *consecutive* re-read loops. - # The counter resets whenever any other tool is called in between, - # so only truly back-to-back identical reads trigger warnings/blocks. + # ── Character-count guard ───────────────────────────────────── + # We're model-agnostic so we can't count tokens; characters are + # the best proxy we have. If the read produced an unreasonable + # amount of content, reject it and tell the model to narrow down. + # Note: we check the formatted content (with line-number prefixes), + # not the raw file size, because that's what actually enters context. + content_len = len(result.content or "") + file_size = result_dict.get("file_size", 0) + max_chars = _get_max_read_chars() + if content_len > max_chars: + total_lines = result_dict.get("total_lines", "unknown") + return json.dumps({ + "error": ( + f"Read produced {content_len:,} characters which exceeds " + f"the safety limit ({max_chars:,} chars). " + "Use offset and limit to read a smaller range. " + f"The file has {total_lines} lines total." + ), + "path": path, + "total_lines": total_lines, + "file_size": file_size, + }, ensure_ascii=False) + + # Large-file hint: if the file is big and the caller didn't ask + # for a narrow window, nudge toward targeted reads. + if (file_size and file_size > _LARGE_FILE_HINT_BYTES + and limit > 200 + and result_dict.get("truncated")): + result_dict.setdefault("_hint", ( + f"This file is large ({file_size:,} bytes). " + "Consider reading only the section you need with offset and limit " + "to keep context usage efficient." + )) + + # ── Track for consecutive-loop detection ────────────────────── read_key = ("read", path, offset, limit) with _read_tracker_lock: - task_data = _read_tracker.setdefault(task_id, { - "last_key": None, "consecutive": 0, "read_history": set(), - }) + # Ensure "dedup" key exists (backward compat with old tracker state) + if "dedup" not in task_data: + task_data["dedup"] = {} task_data["read_history"].add((path, offset, limit)) if task_data["last_key"] == read_key: task_data["consecutive"] += 1 @@ -239,6 +391,15 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = task_data["consecutive"] = 1 count = task_data["consecutive"] + # Store dedup entry (mtime at read time). + # Writes/patches will naturally change mtime, so subsequent + # dedup checks after edits will see a different mtime and + # return the full content — no special handling needed. + try: + task_data["dedup"][dedup_key] = os.path.getmtime(resolved_str) + except OSError: + pass # Can't stat — skip dedup for this entry + if count >= 4: # Hard block: stop returning content to break the loop return json.dumps({ @@ -296,6 +457,28 @@ def clear_read_tracker(task_id: str = None): _read_tracker.clear() +def reset_file_dedup(task_id: str = None): + """Clear the deduplication cache for file reads. + + Called after context compression — the original read content has been + summarised away, so the model needs the full content if it reads the + same file again. Without this, reads after compression would return + a "file unchanged" stub pointing at content that no longer exists in + context. + + Call with a task_id to clear just that task, or without to clear all. + """ + with _read_tracker_lock: + if task_id: + task_data = _read_tracker.get(task_id) + if task_data and "dedup" in task_data: + task_data["dedup"].clear() + else: + for task_data in _read_tracker.values(): + if "dedup" in task_data: + task_data["dedup"].clear() + + def notify_other_tool_call(task_id: str = "default"): """Reset consecutive read/search counter for a task. @@ -466,7 +649,7 @@ def _check_file_reqs(): READ_FILE_SCHEMA = { "name": "read_file", - "description": "Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. NOTE: Cannot read images or binary files — use vision_analyze for images.", + "description": "Read a text file with line numbers and pagination. Use this instead of cat/head/tail in terminal. Output format: 'LINE_NUM|CONTENT'. Suggests similar filenames if not found. Use offset and limit for large files. Reads exceeding ~100K characters are rejected; use offset and limit to read specific sections of large files. NOTE: Cannot read images or binary files — use vision_analyze for images.", "parameters": { "type": "object", "properties": { diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 107e82395f8b..d6ef5b05b688 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -360,6 +360,26 @@ memory: user_char_limit: 1375 # ~500 tokens ``` +## File Read Safety + +Controls how much content a single `read_file` call can return. Reads that exceed the limit are rejected with an error telling the agent to use `offset` and `limit` for a smaller range. This prevents a single read of a minified JS bundle or large data file from flooding the context window. + +```yaml +file_read_max_chars: 100000 # default — ~25-35K tokens +``` + +Raise it if you're on a model with a large context window and frequently read big files. Lower it for small-context models to keep reads efficient: + +```yaml +# Large context model (200K+) +file_read_max_chars: 200000 + +# Small local model (16K context) +file_read_max_chars: 30000 +``` + +The agent also deduplicates file reads automatically — if the same file region is read twice and the file hasn't changed, a lightweight stub is returned instead of re-sending the content. This resets on context compression so the agent can re-read files after their content is summarized away. + ## Git Worktree Isolation Enable isolated git worktrees for running multiple agents in parallel on the same repo: From 1b62ad9de71bd769e7a28276979188c05d936e64 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:54:22 -0700 Subject: [PATCH 030/212] fix: root-level provider in config.yaml no longer overrides model.provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_cli_config() had a priority inversion: a stale root-level 'provider' key in config.yaml would OVERRIDE the canonical 'model.provider' set by 'hermes model'. The gateway reads model.provider directly from YAML and worked correctly, but 'hermes chat -q' and the interactive CLI went through the merge logic and picked up the stale root-level key. Fix: root-level provider/base_url are now only used as a fallback when model.provider/model.base_url is not set (never as an override). Also added _normalize_root_model_keys() to config.py load_config() and save_config() — migrates root-level provider/base_url into the model section and removes the root-level keys permanently. Reported by (≧▽≦) in Discord: opencode-go provider persisted as a root-level key and overrode the correct model.provider=openrouter, causing 401 errors. --- cli.py | 25 +++++++------ hermes_cli/config.py | 34 ++++++++++++++++- tests/test_cli_init.py | 85 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 13 deletions(-) diff --git a/cli.py b/cli.py index 1f72207aa117..2f62149896ee 100644 --- a/cli.py +++ b/cli.py @@ -263,17 +263,20 @@ def load_cli_config() -> Dict[str, Any]: # Old format: model is a dict with default/base_url defaults["model"].update(file_config["model"]) - # Root-level provider and base_url override model config. - # Users may write: - # model: kimi-k2.5:cloud - # provider: custom - # base_url: http://localhost:11434/v1 - # These root-level keys must be merged into defaults["model"] so - # they are picked up by CLI provider resolution. - if "provider" in file_config and file_config["provider"]: - defaults["model"]["provider"] = file_config["provider"] - if "base_url" in file_config and file_config["base_url"]: - defaults["model"]["base_url"] = file_config["base_url"] + # Legacy root-level provider/base_url fallback. + # Some users (or old code) put provider: / base_url: at the + # config root instead of inside the model: section. These are + # only used as a FALLBACK when model.provider / model.base_url + # is not already set — never as an override. The canonical + # location is model.provider (written by `hermes model`). + if not defaults["model"].get("provider"): + root_provider = file_config.get("provider") + if root_provider: + defaults["model"]["provider"] = root_provider + if not defaults["model"].get("base_url"): + root_base_url = file_config.get("base_url") + if root_base_url: + defaults["model"]["base_url"] = root_base_url # Deep merge file_config into defaults. # First: merge keys that exist in both (deep-merge dicts, overwrite scalars) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index e5cf73d3f6e6..c2a8774ea875 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1373,6 +1373,36 @@ def _expand_env_vars(obj): return obj +def _normalize_root_model_keys(config: Dict[str, Any]) -> Dict[str, Any]: + """Move stale root-level provider/base_url into model section. + + Some users (or older code) placed ``provider:`` and ``base_url:`` at the + config root instead of inside ``model:``. These root-level keys are only + used as a fallback when the corresponding ``model.*`` key is empty — they + never override an existing ``model.provider`` or ``model.base_url``. + After migration the root-level keys are removed so they can't cause + confusion on subsequent loads. + """ + # Only act if there are root-level keys to migrate + has_root = any(config.get(k) for k in ("provider", "base_url")) + if not has_root: + return config + + config = dict(config) + model = config.get("model") + if not isinstance(model, dict): + model = {"default": model} if model else {} + config["model"] = model + + for key in ("provider", "base_url"): + root_val = config.get(key) + if root_val and not model.get(key): + model[key] = root_val + config.pop(key, None) + + return config + + def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]: """Normalize legacy root-level max_turns into agent.max_turns.""" config = dict(config) @@ -1414,7 +1444,7 @@ def load_config() -> Dict[str, Any]: except Exception as e: print(f"Warning: Failed to load config: {e}") - return _expand_env_vars(_normalize_max_turns_config(config)) + return _expand_env_vars(_normalize_root_model_keys(_normalize_max_turns_config(config))) _SECURITY_COMMENT = """ @@ -1521,7 +1551,7 @@ def save_config(config: Dict[str, Any]): ensure_hermes_home() config_path = get_config_path() - normalized = _normalize_max_turns_config(config) + normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) # Build optional commented-out sections for features that are off by # default or only relevant when explicitly configured. diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index b5598aed17a8..9e04096905c1 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -192,6 +192,91 @@ def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys assert "A" * 250 + "..." not in output +class TestRootLevelProviderOverride: + """Root-level provider/base_url in config.yaml must NOT override model.provider.""" + + def test_model_provider_wins_over_root_provider(self, tmp_path, monkeypatch): + """model.provider takes priority — root-level provider is only a fallback.""" + import yaml + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config_path = hermes_home / "config.yaml" + config_path.write_text(yaml.safe_dump({ + "provider": "opencode-go", # stale root-level key + "model": { + "default": "google/gemini-3-flash-preview", + "provider": "openrouter", # correct canonical key + }, + })) + + import cli + monkeypatch.setattr(cli, "_hermes_home", hermes_home) + cfg = cli.load_cli_config() + + assert cfg["model"]["provider"] == "openrouter" + + def test_root_provider_ignored_when_default_model_provider_exists(self, tmp_path, monkeypatch): + """Even when model.provider is the default 'auto', root-level provider is ignored.""" + import yaml + + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config_path = hermes_home / "config.yaml" + config_path.write_text(yaml.safe_dump({ + "provider": "opencode-go", # stale root key + "model": { + "default": "google/gemini-3-flash-preview", + # no explicit model.provider — defaults provide "auto" + }, + })) + + import cli + monkeypatch.setattr(cli, "_hermes_home", hermes_home) + cfg = cli.load_cli_config() + + # Root-level "opencode-go" must NOT leak through + assert cfg["model"]["provider"] != "opencode-go" + + def test_normalize_root_model_keys_moves_to_model(self): + """_normalize_root_model_keys migrates root keys into model section.""" + from hermes_cli.config import _normalize_root_model_keys + + config = { + "provider": "opencode-go", + "base_url": "https://example.com/v1", + "model": { + "default": "some-model", + }, + } + result = _normalize_root_model_keys(config) + # Root keys removed + assert "provider" not in result + assert "base_url" not in result + # Migrated into model section + assert result["model"]["provider"] == "opencode-go" + assert result["model"]["base_url"] == "https://example.com/v1" + + def test_normalize_root_model_keys_does_not_override_existing(self): + """Existing model.provider is never overridden by root-level key.""" + from hermes_cli.config import _normalize_root_model_keys + + config = { + "provider": "stale-provider", + "model": { + "default": "some-model", + "provider": "correct-provider", + }, + } + result = _normalize_root_model_keys(config) + assert result["model"]["provider"] == "correct-provider" + assert "provider" not in result # root key still cleaned up + + class TestProviderResolution: def test_api_key_is_string_or_none(self): cli = _make_cli() From f5cc597afced7c3ad661ee576f41ebf5e2eb3d19 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 13:38:22 -0700 Subject: [PATCH 031/212] fix: add CAMOFOX_PORT=9377 to Docker commands for camofox-browser (#4340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The camofox-browser image defaults to port 3000 internally, not 9377. Without -e CAMOFOX_PORT=9377, the -p 9377:9377 mapping silently fails because nothing listens on 9377 inside the container. E2E verified: -p 9377:9377 alone → connection reset, -p 9377:9377 -e CAMOFOX_PORT=9377 → healthy and functional. --- hermes_cli/tools_config.py | 4 ++-- tools/browser_camofox.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 8b443d5dc282..2150420f19ec 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -364,10 +364,10 @@ def _run_post_setup(post_setup_key: str): _print_info(" Start the Camofox server:") _print_info(" npx @askjo/camoufox-browser") _print_info(" First run downloads the Camoufox engine (~300MB)") - _print_info(" Or use Docker: docker run -p 9377:9377 jo-inc/camofox-browser") + _print_info(" Or use Docker: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif not shutil.which("npm"): _print_warning(" Node.js not found. Install Camofox via Docker:") - _print_info(" docker run -p 9377:9377 jo-inc/camofox-browser") + _print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif post_setup_key == "rl_training": try: diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index b1925d2c622a..9b11ef0d04c5 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -15,7 +15,7 @@ npm install && npm start # downloads Camoufox (~300MB) on first run # Option 2: Docker - docker run -p 9377:9377 jo-inc/camofox-browser + docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser Then set ``CAMOFOX_URL=http://localhost:9377`` in ``~/.hermes/.env``. """ @@ -184,7 +184,7 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: "success": False, "error": f"Cannot connect to Camofox at {get_camofox_url()}. " "Is the server running? Start with: npm start (in camofox-browser dir) " - "or: docker run -p 9377:9377 jo-inc/camofox-browser", + "or: docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser", }) except Exception as e: return json.dumps({"success": False, "error": str(e)}) From f04986029c55bb570f78a1051ea18f8d1619e2dd Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:49:00 -0700 Subject: [PATCH 032/212] feat(file_tools): detect stale files on write and patch (#4345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track file mtime when read_file is called. When write_file or patch subsequently targets the same file, compare the current mtime against the recorded one. If they differ (external edit, concurrent agent, user change), include a _warning in the result advising the agent to re-read. The write still proceeds — this is a soft signal, not a hard block. Key design points: - Per-task isolation: task A's reads don't affect task B's writes. - Files never read produce no warning (not enforcing read-before-write). - mtime naturally updates after the agent's own writes, so the warning only fires on external changes, not the agent's own edits. - V4A multi-file patches check all target paths. Tests: 10 new tests covering write staleness, patch staleness, never-read files, cross-task isolation, and the helper function. --- tests/tools/test_file_staleness.py | 241 +++++++++++++++++++++++++++++ tools/file_tools.py | 63 +++++++- 2 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 tests/tools/test_file_staleness.py diff --git a/tests/tools/test_file_staleness.py b/tests/tools/test_file_staleness.py new file mode 100644 index 000000000000..46e7aac9ffac --- /dev/null +++ b/tests/tools/test_file_staleness.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Tests for file staleness detection in write_file and patch. + +When a file is modified externally between the agent's read and write, +the write should include a warning so the agent can re-read and verify. + +Run with: python -m pytest tests/tools/test_file_staleness.py -v +""" + +import json +import os +import tempfile +import time +import unittest +from unittest.mock import patch, MagicMock + +from tools.file_tools import ( + read_file_tool, + write_file_tool, + patch_tool, + clear_read_tracker, + _check_file_staleness, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +class _FakeReadResult: + def __init__(self, content="line1\nline2\n", total_lines=2, file_size=100): + self.content = content + self._total_lines = total_lines + self._file_size = file_size + + def to_dict(self): + return { + "content": self.content, + "total_lines": self._total_lines, + "file_size": self._file_size, + } + + +class _FakeWriteResult: + def __init__(self): + self.bytes_written = 10 + + def to_dict(self): + return {"bytes_written": self.bytes_written} + + +class _FakePatchResult: + def __init__(self): + self.success = True + + def to_dict(self): + return {"success": True, "diff": "--- a\n+++ b\n@@ ...\n"} + + +def _make_fake_ops(read_content="hello\n", file_size=6): + fake = MagicMock() + fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult( + content=read_content, total_lines=1, file_size=file_size, + ) + fake.write_file = lambda path, content: _FakeWriteResult() + fake.patch_replace = lambda path, old, new, replace_all=False: _FakePatchResult() + return fake + + +# --------------------------------------------------------------------------- +# Core staleness check +# --------------------------------------------------------------------------- + +class TestStalenessCheck(unittest.TestCase): + + def setUp(self): + clear_read_tracker() + self._tmpdir = tempfile.mkdtemp() + self._tmpfile = os.path.join(self._tmpdir, "stale_test.txt") + with open(self._tmpfile, "w") as f: + f.write("original content\n") + + def tearDown(self): + clear_read_tracker() + try: + os.unlink(self._tmpfile) + os.rmdir(self._tmpdir) + except OSError: + pass + + @patch("tools.file_tools._get_file_ops") + def test_no_warning_when_file_unchanged(self, mock_ops): + """Read then write with no external modification — no warning.""" + mock_ops.return_value = _make_fake_ops("original content\n", 18) + read_file_tool(self._tmpfile, task_id="t1") + + result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t1")) + self.assertNotIn("_warning", result) + + @patch("tools.file_tools._get_file_ops") + def test_warning_when_file_modified_externally(self, mock_ops): + """Read, then external modify, then write — should warn.""" + mock_ops.return_value = _make_fake_ops("original content\n", 18) + read_file_tool(self._tmpfile, task_id="t1") + + # Simulate external modification + time.sleep(0.05) + with open(self._tmpfile, "w") as f: + f.write("someone else changed this\n") + + result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t1")) + self.assertIn("_warning", result) + self.assertIn("modified since you last read", result["_warning"]) + + @patch("tools.file_tools._get_file_ops") + def test_no_warning_when_file_never_read(self, mock_ops): + """Writing a file that was never read — no warning.""" + mock_ops.return_value = _make_fake_ops() + result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t2")) + self.assertNotIn("_warning", result) + + @patch("tools.file_tools._get_file_ops") + def test_no_warning_for_new_file(self, mock_ops): + """Creating a new file — no warning.""" + mock_ops.return_value = _make_fake_ops() + new_path = os.path.join(self._tmpdir, "brand_new.txt") + result = json.loads(write_file_tool(new_path, "content", task_id="t3")) + self.assertNotIn("_warning", result) + try: + os.unlink(new_path) + except OSError: + pass + + @patch("tools.file_tools._get_file_ops") + def test_different_task_isolated(self, mock_ops): + """Task A reads, file changes, Task B writes — no warning for B.""" + mock_ops.return_value = _make_fake_ops("original content\n", 18) + read_file_tool(self._tmpfile, task_id="task_a") + + time.sleep(0.05) + with open(self._tmpfile, "w") as f: + f.write("changed\n") + + result = json.loads(write_file_tool(self._tmpfile, "new", task_id="task_b")) + self.assertNotIn("_warning", result) + + +# --------------------------------------------------------------------------- +# Staleness in patch +# --------------------------------------------------------------------------- + +class TestPatchStaleness(unittest.TestCase): + + def setUp(self): + clear_read_tracker() + self._tmpdir = tempfile.mkdtemp() + self._tmpfile = os.path.join(self._tmpdir, "patch_test.txt") + with open(self._tmpfile, "w") as f: + f.write("original line\n") + + def tearDown(self): + clear_read_tracker() + try: + os.unlink(self._tmpfile) + os.rmdir(self._tmpdir) + except OSError: + pass + + @patch("tools.file_tools._get_file_ops") + def test_patch_warns_on_stale_file(self, mock_ops): + """Patch should warn if the target file changed since last read.""" + mock_ops.return_value = _make_fake_ops("original line\n", 15) + read_file_tool(self._tmpfile, task_id="p1") + + time.sleep(0.05) + with open(self._tmpfile, "w") as f: + f.write("externally modified\n") + + result = json.loads(patch_tool( + mode="replace", path=self._tmpfile, + old_string="original", new_string="patched", + task_id="p1", + )) + self.assertIn("_warning", result) + self.assertIn("modified since you last read", result["_warning"]) + + @patch("tools.file_tools._get_file_ops") + def test_patch_no_warning_when_fresh(self, mock_ops): + """Patch with no external changes — no warning.""" + mock_ops.return_value = _make_fake_ops("original line\n", 15) + read_file_tool(self._tmpfile, task_id="p2") + + result = json.loads(patch_tool( + mode="replace", path=self._tmpfile, + old_string="original", new_string="patched", + task_id="p2", + )) + self.assertNotIn("_warning", result) + + +# --------------------------------------------------------------------------- +# Unit test for the helper +# --------------------------------------------------------------------------- + +class TestCheckFileStalenessHelper(unittest.TestCase): + + def setUp(self): + clear_read_tracker() + + def tearDown(self): + clear_read_tracker() + + def test_returns_none_for_unknown_task(self): + self.assertIsNone(_check_file_staleness("/tmp/x.py", "nonexistent")) + + def test_returns_none_for_unread_file(self): + # Populate tracker with a different file + from tools.file_tools import _read_tracker, _read_tracker_lock + with _read_tracker_lock: + _read_tracker["t1"] = { + "last_key": None, "consecutive": 0, + "read_history": set(), "dedup": {}, + "file_mtimes": {"/tmp/other.py": 12345.0}, + } + self.assertIsNone(_check_file_staleness("/tmp/x.py", "t1")) + + def test_returns_none_when_stat_fails(self): + from tools.file_tools import _read_tracker, _read_tracker_lock + with _read_tracker_lock: + _read_tracker["t1"] = { + "last_key": None, "consecutive": 0, + "read_history": set(), "dedup": {}, + "file_mtimes": {"/nonexistent/path": 99999.0}, + } + # File doesn't exist → stat fails → returns None (let write handle it) + self.assertIsNone(_check_file_staleness("/nonexistent/path", "t1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/file_tools.py b/tools/file_tools.py index 1245e68deb1e..07fb86d1ac4d 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -136,6 +136,9 @@ def _is_expected_write_exception(exc: Exception) -> bool: # Used to skip re-reads of unchanged files. Reset on # context compression (the original content is summarised # away so the model needs the full content again). +# "file_mtimes": dict mapping resolved_path → mtime float at last read. +# Used by write_file and patch to detect when a file was +# modified externally between the agent's read and write. _read_tracker_lock = threading.Lock() _read_tracker: dict = {} @@ -391,14 +394,16 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = task_data["consecutive"] = 1 count = task_data["consecutive"] - # Store dedup entry (mtime at read time). - # Writes/patches will naturally change mtime, so subsequent - # dedup checks after edits will see a different mtime and - # return the full content — no special handling needed. + # Store mtime at read time for two purposes: + # 1. Dedup: skip identical re-reads of unchanged files. + # 2. Staleness: warn on write/patch if the file changed since + # the agent last read it (external edit, concurrent agent, etc.). try: - task_data["dedup"][dedup_key] = os.path.getmtime(resolved_str) + _mtime_now = os.path.getmtime(resolved_str) + task_data["dedup"][dedup_key] = _mtime_now + task_data.setdefault("file_mtimes", {})[resolved_str] = _mtime_now except OSError: - pass # Can't stat — skip dedup for this entry + pass # Can't stat — skip tracking for this entry if count >= 4: # Hard block: stop returning content to break the loop @@ -495,15 +500,50 @@ def notify_other_tool_call(task_id: str = "default"): task_data["consecutive"] = 0 +def _check_file_staleness(filepath: str, task_id: str) -> str | None: + """Check whether a file was modified since the agent last read it. + + Returns a warning string if the file is stale (mtime changed since + the last read_file call for this task), or None if the file is fresh + or was never read. Does not block — the write still proceeds. + """ + try: + resolved = str(Path(filepath).expanduser().resolve()) + except (OSError, ValueError): + return None + with _read_tracker_lock: + task_data = _read_tracker.get(task_id) + if not task_data: + return None + read_mtime = task_data.get("file_mtimes", {}).get(resolved) + if read_mtime is None: + return None # File was never read — nothing to compare against + try: + current_mtime = os.path.getmtime(resolved) + except OSError: + return None # Can't stat — file may have been deleted, let write handle it + if current_mtime != read_mtime: + return ( + f"Warning: {filepath} was modified since you last read it " + "(external edit or concurrent agent). The content you read may be " + "stale. Consider re-reading the file to verify before writing." + ) + return None + + def write_file_tool(path: str, content: str, task_id: str = "default") -> str: """Write content to a file.""" sensitive_err = _check_sensitive_path(path) if sensitive_err: return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: + stale_warning = _check_file_staleness(path, task_id) file_ops = _get_file_ops(task_id) result = file_ops.write_file(path, content) - return json.dumps(result.to_dict(), ensure_ascii=False) + result_dict = result.to_dict() + if stale_warning: + result_dict["_warning"] = stale_warning + return json.dumps(result_dict, ensure_ascii=False) except Exception as e: if _is_expected_write_exception(e): logger.debug("write_file expected denial: %s: %s", type(e).__name__, e) @@ -529,6 +569,13 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, if sensitive_err: return json.dumps({"error": sensitive_err}, ensure_ascii=False) try: + # Check staleness for all files this patch will touch. + stale_warnings = [] + for _p in _paths_to_check: + _sw = _check_file_staleness(_p, task_id) + if _sw: + stale_warnings.append(_sw) + file_ops = _get_file_ops(task_id) if mode == "replace": @@ -545,6 +592,8 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, return json.dumps({"error": f"Unknown mode: {mode}"}) result_dict = result.to_dict() + if stale_warnings: + result_dict["_warning"] = stale_warnings[0] if len(stale_warnings) == 1 else " | ".join(stale_warnings) result_json = json.dumps(result_dict, ensure_ascii=False) # Hint when old_string not found — saves iterations where the agent # retries with stale content instead of re-reading the file. From b118f607b2a0be299c4d45d62bc87764ccfb3d6f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:49:20 -0700 Subject: [PATCH 033/212] feat(skills): unify hermes-agent and hermes-agent-setup into single skill (#4332) Merges the hermes-agent-spawning skill (autonomous-ai-agents/) and hermes-agent-setup skill (dogfood/) into a single comprehensive skills/hermes-agent/ skill. The unified skill covers: - What Hermes Agent is and how it compares to Claude Code/Codex/OpenClaw - Complete CLI reference (all subcommands and flags) - Slash command reference - Configuration guide (providers, toolsets, config sections) - Voice/STT/TTS setup - Spawning additional agent instances (one-shot and interactive PTY) - Multi-agent coordination patterns - Troubleshooting guide - Where-to-find-things lookup table with docs links - Concise contributor quick reference Removes: - skills/autonomous-ai-agents/hermes-agent/ (hermes-agent-spawning) - skills/dogfood/hermes-agent-setup/ --- .../hermes-agent/SKILL.md | 203 ------ skills/dogfood/hermes-agent-setup/SKILL.md | 300 -------- skills/hermes-agent/SKILL.md | 655 ++++++++++++++++++ 3 files changed, 655 insertions(+), 503 deletions(-) delete mode 100644 skills/autonomous-ai-agents/hermes-agent/SKILL.md delete mode 100644 skills/dogfood/hermes-agent-setup/SKILL.md create mode 100644 skills/hermes-agent/SKILL.md diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md deleted file mode 100644 index a0678b0a2925..000000000000 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ /dev/null @@ -1,203 +0,0 @@ ---- -name: hermes-agent-spawning -description: Spawn additional Hermes Agent instances as autonomous subprocesses for independent long-running tasks. Supports non-interactive one-shot mode (-q) and interactive PTY mode for multi-turn collaboration. Different from delegate_task — this runs a full separate hermes process. -version: 1.1.0 -author: Hermes Agent -license: MIT -metadata: - hermes: - tags: [Agent, Hermes, Multi-Agent, Orchestration, Subprocess, Interactive] - homepage: https://github.com/NousResearch/hermes-agent - related_skills: [claude-code, codex] ---- - -# Spawning Hermes Agent Instances - -Run additional Hermes Agent processes as autonomous subprocesses. Unlike `delegate_task` (which spawns lightweight subagents sharing the same process), this launches fully independent `hermes` CLI processes with their own sessions, tools, and terminal environments. - -## When to Use This vs delegate_task - -| Feature | `delegate_task` | Spawning `hermes` process | -|---------|-----------------|--------------------------| -| Context isolation | Separate conversation, shared process | Fully independent process | -| Tool access | Subset of parent's tools | Full tool access (all toolsets) | -| Session persistence | Ephemeral (no DB entry) | Full session logging + DB | -| Duration | Minutes (bounded by parent's loop) | Hours/days (runs independently) | -| Monitoring | Parent waits for result | Background process, monitor via `process` tool | -| Interactive | No | Yes (PTY mode supports back-and-forth) | -| Use case | Quick parallel subtasks | Long autonomous missions, interactive collaboration | - -## Prerequisites - -- `hermes` CLI installed and on PATH -- API key configured in `~/.hermes/.env` - -### Installation - -Requires an interactive shell (the installer runs a setup wizard): - -``` -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -``` - -This installs uv, Python 3.11, clones the repo, sets up the venv, and launches an interactive setup wizard to configure your API provider and model. See the [GitHub repo](https://github.com/NousResearch/hermes-agent) for details. - -## Resuming Previous Sessions - -Resume a prior CLI session instead of starting fresh. Useful for continuing long tasks across process restarts: - -``` -# Resume the most recent CLI session -terminal(command="hermes --continue", background=true, pty=true) - -# Resume a specific session by ID (shown on exit) -terminal(command="hermes --resume 20260225_143052_a1b2c3", background=true, pty=true) -``` - -The full conversation history (messages, tool calls, responses) is restored from SQLite. The agent sees everything from the previous session. - -## Mode 1: One-Shot Query (-q flag) - -Run a single query non-interactively. The agent executes, does its work, and exits: - -``` -terminal(command="hermes chat -q 'Research the latest GRPO training papers and write a summary to ~/research/grpo.md'", timeout=300) -``` - -Background for long tasks: -``` -terminal(command="hermes chat -q 'Set up CI/CD for ~/myapp'", background=true) -# Returns session_id, monitor with process tool -``` - -## Mode 2: Interactive PTY Session - -Launch a full interactive Hermes session with PTY for back-and-forth collaboration. You can send messages, review its work, give feedback, and steer it. - -Note: Hermes uses prompt_toolkit for its CLI UI. Through a PTY, this works because ptyprocess provides a real terminal — input sent via `submit` arrives as keystrokes. The output log will contain ANSI escape sequences from the UI rendering — focus on the text content, not the formatting. - -``` -# Start interactive hermes in background with PTY -terminal(command="hermes", workdir="~/project", background=true, pty=true) -# Returns session_id - -# Send it a task -process(action="submit", session_id="", data="Set up a Python project with FastAPI, add auth endpoints, and write tests") - -# Wait for it to work, then check progress -process(action="log", session_id="") - -# Give feedback on what it produced -process(action="submit", session_id="", data="The tests look good but add edge cases for invalid tokens") - -# Check its response -process(action="log", session_id="") - -# Ask it to iterate -process(action="submit", session_id="", data="Now add rate limiting middleware") - -# When done, exit the session -process(action="submit", session_id="", data="/exit") -``` - -### Interactive Collaboration Patterns - -**Code review loop** — spawn hermes, send code for review, iterate on feedback: -``` -terminal(command="hermes", workdir="~/project", background=true, pty=true) -process(action="submit", session_id="", data="Review the changes in src/auth.py and suggest improvements") -# ... read its review ... -process(action="submit", session_id="", data="Good points. Go ahead and implement suggestions 1 and 3") -# ... it makes changes ... -process(action="submit", session_id="", data="Run the tests to make sure nothing broke") -``` - -**Research with steering** — start broad, narrow down based on findings: -``` -terminal(command="hermes", background=true, pty=true) -process(action="submit", session_id="", data="Search for the latest papers on KV cache compression techniques") -# ... read its findings ... -process(action="submit", session_id="", data="The MQA approach looks promising. Dig deeper into that one and compare with GQA") -# ... more detailed research ... -process(action="submit", session_id="", data="Write up everything you found to ~/research/kv-cache-compression.md") -``` - -**Multi-agent coordination** — spawn two agents working on related tasks, pass context between them: -``` -# Agent A: backend -terminal(command="hermes", workdir="~/project/backend", background=true, pty=true) -process(action="submit", session_id="", data="Build a REST API for user management with CRUD endpoints") - -# Agent B: frontend -terminal(command="hermes", workdir="~/project/frontend", background=true, pty=true) -process(action="submit", session_id="", data="Build a React dashboard that will connect to a REST API at localhost:8000/api/users") - -# Check Agent A's progress, relay API schema to Agent B -process(action="log", session_id="") -process(action="submit", session_id="", data="Here's the API schema Agent A built: GET /api/users, POST /api/users, etc. Update your fetch calls to match.") -``` - -## Parallel Non-Interactive Instances - -Spawn multiple independent agents for unrelated tasks: - -``` -terminal(command="hermes chat -q 'Research competitor landing pages and write a report to ~/research/competitors.md'", background=true) -terminal(command="hermes chat -q 'Audit security of ~/myapp and write findings to ~/myapp/SECURITY_AUDIT.md'", background=true) -process(action="list") -``` - -## With Custom Model - -``` -terminal(command="hermes chat -q 'Summarize this codebase' --model google/gemini-2.5-pro", workdir="~/project", background=true) -``` - -## Gateway Cron Integration - -For scheduled autonomous tasks, use the unified `cronjob` tool instead of spawning processes — cron jobs handle delivery, retry, and persistence automatically. - -## Key Differences Between Modes - -| | `-q` (one-shot) | Interactive (PTY) | `--continue` / `--resume` | -|---|---|---|---| -| User interaction | None | Full back-and-forth | Full back-and-forth | -| PTY required | No | Yes (`pty=true`) | Yes (`pty=true`) | -| Multi-turn | Single query | Unlimited turns | Continues previous turns | -| Best for | Fire-and-forget tasks | Iterative work, steering | Picking up where you left off | -| Exit | Automatic after completion | Send `/exit` or kill | Send `/exit` or kill | - -## Known Issues - -- **Interactive PTY + prompt_toolkit**: The `submit` action sends `\n` (line feed) but prompt_toolkit in raw mode expects `\r` (carriage return) for Enter. Text appears in the prompt but never submits. **Workaround**: Use **tmux** instead of raw PTY mode. tmux's `send-keys Enter` sends the correct `\r`: - -``` -# Start hermes inside tmux -tmux new-session -d -s hermes-session -x 120 -y 40 "hermes" -sleep 10 # Wait for banner/startup - -# Send messages -tmux send-keys -t hermes-session "your message here" Enter - -# Read output -sleep 15 # Wait for LLM response -tmux capture-pane -t hermes-session -p - -# Multi-turn: just send more messages and capture again -tmux send-keys -t hermes-session "follow-up message" Enter - -# Exit when done -tmux send-keys -t hermes-session "/exit" Enter -tmux kill-session -t hermes-session -``` - -## Rules - -1. **Use `-q` for autonomous tasks** — agent works independently and exits -2. **Use `pty=true` for interactive sessions** — required for the full CLI UI -3. **Use `submit` not `write`** — `submit` adds a newline (Enter), `write` doesn't -4. **Read logs before sending more** — check what the agent produced before giving next instruction -5. **Set timeouts for `-q` mode** — complex tasks may take 5-10 minutes -6. **Prefer `delegate_task` for quick subtasks** — spawning a full process has more overhead -7. **Each instance is independent** — they don't share conversation context with the parent -8. **Check results** — after completion, read the output files or logs the agent produced diff --git a/skills/dogfood/hermes-agent-setup/SKILL.md b/skills/dogfood/hermes-agent-setup/SKILL.md deleted file mode 100644 index 73980a1e61f4..000000000000 --- a/skills/dogfood/hermes-agent-setup/SKILL.md +++ /dev/null @@ -1,300 +0,0 @@ ---- -name: hermes-agent-setup -description: Help users configure Hermes Agent — CLI usage, setup wizard, model/provider selection, tools, skills, voice/STT/TTS, gateway, and troubleshooting. Use when someone asks to enable features, configure settings, or needs help with Hermes itself. -version: 1.1.0 -author: Hermes Agent -tags: [setup, configuration, tools, stt, tts, voice, hermes, cli, skills] ---- - -# Hermes Agent Setup & Configuration - -Use this skill when a user asks about configuring Hermes, enabling features, setting up voice, managing tools/skills, or troubleshooting. - -## Key Paths - -- Config: `~/.hermes/config.yaml` -- API keys: `~/.hermes/.env` -- Skills: `~/.hermes/skills/` -- Hermes install: `~/.hermes/hermes-agent/` -- Venv: `~/.hermes/hermes-agent/venv/` - -## CLI Overview - -Hermes is used via the `hermes` command (or `python -m hermes_cli.main` from the repo). - -### Core commands: - -``` -hermes Interactive chat (default) -hermes chat -q "question" Single query, then exit -hermes chat -m MODEL Chat with a specific model -hermes -c Resume most recent session -hermes -c "project name" Resume session by name -hermes --resume SESSION_ID Resume by exact ID -hermes -w Isolated git worktree mode -hermes -s skill1,skill2 Preload skills for the session -hermes --yolo Skip dangerous command approval -``` - -### Configuration & setup: - -``` -hermes setup Interactive setup wizard (provider, API keys, model) -hermes model Interactive model/provider selection -hermes config View current configuration -hermes config edit Open config.yaml in $EDITOR -hermes config set KEY VALUE Set a config value directly -hermes login Authenticate with a provider -hermes logout Clear stored auth -hermes doctor Check configuration and dependencies -``` - -### Tools & skills: - -``` -hermes tools Interactive tool enable/disable per platform -hermes skills list List installed skills -hermes skills search QUERY Search the skills hub -hermes skills install NAME Install a skill from the hub -hermes skills config Enable/disable skills per platform -``` - -### Gateway (messaging platforms): - -``` -hermes gateway run Start the messaging gateway -hermes gateway install Install gateway as background service -hermes gateway status Check gateway status -``` - -### Session management: - -``` -hermes sessions list List past sessions -hermes sessions browse Interactive session picker -hermes sessions rename ID TITLE Rename a session -hermes sessions export ID Export session as markdown -hermes sessions prune Clean up old sessions -``` - -### Other: - -``` -hermes status Show status of all components -hermes cron list List cron jobs -hermes insights Usage analytics -hermes update Update to latest version -hermes pairing Manage DM authorization codes -``` - -## Setup Wizard (`hermes setup`) - -The interactive setup wizard walks through: -1. **Provider selection** — OpenRouter, Anthropic, OpenAI, Google, DeepSeek, and many more -2. **API key entry** — stores securely in the env file -3. **Model selection** — picks from available models for the chosen provider -4. **Basic settings** — reasoning effort, tool preferences - -Run it from terminal: -```bash -cd ~/.hermes/hermes-agent -source venv/bin/activate -python -m hermes_cli.main setup -``` - -To change just the model/provider later: `hermes model` - -## Skills Configuration (`hermes skills`) - -Skills are reusable instruction sets that extend what Hermes can do. - -### Managing skills: - -```bash -hermes skills list # Show installed skills -hermes skills search "docker" # Search the hub -hermes skills install NAME # Install from hub -hermes skills config # Enable/disable per platform -``` - -### Per-platform skill control: - -`hermes skills config` opens an interactive UI where you can enable or disable specific skills for each platform (cli, telegram, discord, etc.). Disabled skills won't appear in the agent's available skills list for that platform. - -### Loading skills in a session: - -- CLI: `hermes -s skill-name` or `hermes -s skill1,skill2` -- Chat: `/skill skill-name` -- Gateway: type `/skill skill-name` in any chat - -## Voice Messages (STT) - -Voice messages from Telegram/Discord/WhatsApp/Slack/Signal are auto-transcribed when an STT provider is available. - -### Provider priority (auto-detected): -1. **Local faster-whisper** — free, no API key, runs on CPU/GPU -2. **Groq Whisper** — free tier, needs GROQ_API_KEY -3. **OpenAI Whisper** — paid, needs VOICE_TOOLS_OPENAI_KEY - -### Setup local STT (recommended): - -```bash -cd ~/.hermes/hermes-agent -source venv/bin/activate -pip install faster-whisper -``` - -Add to config.yaml under the `stt:` section: -```yaml -stt: - enabled: true - provider: local - local: - model: base # Options: tiny, base, small, medium, large-v3 -``` - -Model downloads automatically on first use (~150 MB for base). - -### Setup Groq STT (free cloud): - -1. Get free key from https://console.groq.com -2. Add GROQ_API_KEY to the env file -3. Set provider to groq in config.yaml stt section - -### Verify STT: - -After config changes, restart the gateway (send /restart in chat, or restart `hermes gateway run`). Then send a voice message. - -## Voice Replies (TTS) - -Hermes can reply with voice when users send voice messages. - -### TTS providers (set API key in env file): - -| Provider | Env var | Free? | -|----------|---------|-------| -| ElevenLabs | ELEVENLABS_API_KEY | Free tier | -| OpenAI | VOICE_TOOLS_OPENAI_KEY | Paid | -| Kokoro (local) | None needed | Free | -| Fish Audio | FISH_AUDIO_API_KEY | Free tier | - -### Voice commands (in any chat): -- `/voice on` — voice reply to voice messages only -- `/voice tts` — voice reply to all messages -- `/voice off` — text only (default) - -## Enabling/Disabling Tools (`hermes tools`) - -### Interactive tool config: - -```bash -cd ~/.hermes/hermes-agent -source venv/bin/activate -python -m hermes_cli.main tools -``` - -This opens a curses UI to enable/disable toolsets per platform (cli, telegram, discord, slack, etc.). - -### After changing tools: - -Use `/reset` in the chat to start a fresh session with the new toolset. Tool changes do NOT take effect mid-conversation (this preserves prompt caching and avoids cost spikes). - -### Common toolsets: - -| Toolset | What it provides | -|---------|-----------------| -| terminal | Shell command execution | -| file | File read/write/search/patch | -| web | Web search and extraction | -| browser | Browser automation (needs Browserbase) | -| image_gen | AI image generation | -| mcp | MCP server connections | -| voice | Text-to-speech output | -| cronjob | Scheduled tasks | - -## Installing Dependencies - -Some tools need extra packages: - -```bash -cd ~/.hermes/hermes-agent && source venv/bin/activate - -pip install faster-whisper # Local STT (voice transcription) -pip install browserbase # Browser automation -pip install mcp # MCP server connections -``` - -## Config File Reference - -The main config file is `~/.hermes/config.yaml`. Key sections: - -```yaml -# Model and provider -model: - default: anthropic/claude-opus-4.6 - provider: openrouter - -# Agent behavior -agent: - max_turns: 90 - reasoning_effort: high # xhigh, high, medium, low, minimal, none - -# Voice -stt: - enabled: true - provider: local # local, groq, openai -tts: - provider: elevenlabs # elevenlabs, openai, kokoro, fish - -# Display -display: - skin: default # default, ares, mono, slate - tool_progress: full # full, compact, off - background_process_notifications: all # all, result, error, off -``` - -Edit with `hermes config edit` or `hermes config set KEY VALUE`. - -## Gateway Commands (Messaging Platforms) - -| Command | What it does | -|---------|-------------| -| /reset or /new | Fresh session (picks up new tool config) | -| /help | Show all commands | -| /model [name] | Show or change model | -| /compact | Compress conversation to save context | -| /voice [mode] | Configure voice replies | -| /reasoning [effort] | Set reasoning level | -| /sethome | Set home channel for cron/notifications | -| /restart | Restart the gateway (picks up config changes) | -| /status | Show session info | -| /retry | Retry last message | -| /undo | Remove last exchange | -| /personality [name] | Set agent personality | -| /skill [name] | Load a skill | - -## Troubleshooting - -### Voice messages not working -1. Check stt.enabled is true in config.yaml -2. Check a provider is available (faster-whisper installed, or API key set) -3. Restart gateway after config changes (/restart) - -### Tool not available -1. Run `hermes tools` to check if the toolset is enabled for your platform -2. Some tools need env vars — check the env file -3. Use /reset after enabling tools - -### Model/provider issues -1. Run `hermes doctor` to check configuration -2. Run `hermes login` to re-authenticate -3. Check the env file has the right API key - -### Changes not taking effect -- Gateway: /reset for tool changes, /restart for config changes -- CLI: start a new session - -### Skills not showing up -1. Check `hermes skills list` shows the skill -2. Check `hermes skills config` has it enabled for your platform -3. Load explicitly with `/skill name` or `hermes -s name` diff --git a/skills/hermes-agent/SKILL.md b/skills/hermes-agent/SKILL.md new file mode 100644 index 000000000000..8d93e3fb793f --- /dev/null +++ b/skills/hermes-agent/SKILL.md @@ -0,0 +1,655 @@ +--- +name: hermes-agent +description: Complete guide to using and extending Hermes Agent — CLI usage, setup, configuration, spawning additional agents, gateway platforms, skills, voice, tools, profiles, and a concise contributor reference. Load this skill when helping users configure Hermes, troubleshoot issues, spawn agent instances, or make code contributions. +version: 2.0.0 +author: Hermes Agent + Teknium +license: MIT +metadata: + hermes: + tags: [hermes, setup, configuration, multi-agent, spawning, cli, gateway, development] + homepage: https://github.com/NousResearch/hermes-agent + related_skills: [claude-code, codex, opencode] +--- + +# Hermes Agent + +Hermes Agent is an open-source AI agent framework by Nous Research that runs in your terminal, messaging platforms, and IDEs. It belongs to the same category as Claude Code (Anthropic), Codex (OpenAI), and OpenClaw — autonomous coding and task-execution agents that use tool calling to interact with your system. Hermes works with any LLM provider (OpenRouter, Anthropic, OpenAI, DeepSeek, local models, and 15+ others) and runs on Linux, macOS, and WSL. + +What makes Hermes different: + +- **Self-improving through skills** — Hermes learns from experience by saving reusable procedures as skills. When it solves a complex problem, discovers a workflow, or gets corrected, it can persist that knowledge as a skill document that loads into future sessions. Skills accumulate over time, making the agent better at your specific tasks and environment. +- **Persistent memory across sessions** — remembers who you are, your preferences, environment details, and lessons learned. Pluggable memory backends (built-in, Honcho, Mem0, and more) let you choose how memory works. +- **Multi-platform gateway** — the same agent runs on Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Email, and 8+ other platforms with full tool access, not just chat. +- **Provider-agnostic** — swap models and providers mid-workflow without changing anything else. Credential pools rotate across multiple API keys automatically. +- **Profiles** — run multiple independent Hermes instances with isolated configs, sessions, skills, and memory. +- **Extensible** — plugins, MCP servers, custom tools, webhook triggers, cron scheduling, and the full Python ecosystem. + +People use Hermes for software development, research, system administration, data analysis, content creation, home automation, and anything else that benefits from an AI agent with persistent context and full system access. + +**This skill helps you work with Hermes Agent effectively** — setting it up, configuring features, spawning additional agent instances, troubleshooting issues, finding the right commands and settings, and understanding how the system works when you need to extend or contribute to it. + +**Docs:** https://hermes-agent.nousresearch.com/docs/ + +## Quick Start + +```bash +# Install +curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + +# Interactive chat (default) +hermes + +# Single query +hermes chat -q "What is the capital of France?" + +# Setup wizard +hermes setup + +# Change model/provider +hermes model + +# Check health +hermes doctor +``` + +--- + +## CLI Reference + +### Global Flags + +``` +hermes [flags] [command] + + --version, -V Show version + --resume, -r SESSION Resume session by ID or title + --continue, -c [NAME] Resume by name, or most recent session + --worktree, -w Isolated git worktree mode (parallel agents) + --skills, -s SKILL Preload skills (comma-separate or repeat) + --profile, -p NAME Use a named profile + --yolo Skip dangerous command approval + --pass-session-id Include session ID in system prompt +``` + +No subcommand defaults to `chat`. + +### Chat + +``` +hermes chat [flags] + -q, --query TEXT Single query, non-interactive + -m, --model MODEL Model (e.g. anthropic/claude-sonnet-4) + -t, --toolsets LIST Comma-separated toolsets + --provider PROVIDER Force provider (openrouter, anthropic, nous, etc.) + -v, --verbose Verbose output + -Q, --quiet Suppress banner, spinner, tool previews + --checkpoints Enable filesystem checkpoints (/rollback) + --source TAG Session source tag (default: cli) +``` + +### Configuration + +``` +hermes setup [section] Interactive wizard (model|terminal|gateway|tools|agent) +hermes model Interactive model/provider picker +hermes config View current config +hermes config edit Open config.yaml in $EDITOR +hermes config set KEY VAL Set a config value +hermes config path Print config.yaml path +hermes config env-path Print .env path +hermes config check Check for missing/outdated config +hermes config migrate Update config with new options +hermes login [--provider P] OAuth login (nous, openai-codex) +hermes logout Clear stored auth +hermes doctor [--fix] Check dependencies and config +hermes status [--all] Show component status +``` + +### Tools & Skills + +``` +hermes tools Interactive tool enable/disable (curses UI) +hermes tools list Show all tools and status +hermes tools enable NAME Enable a toolset +hermes tools disable NAME Disable a toolset + +hermes skills list List installed skills +hermes skills search QUERY Search the skills hub +hermes skills install ID Install a skill +hermes skills inspect ID Preview without installing +hermes skills config Enable/disable skills per platform +hermes skills check Check for updates +hermes skills update Update outdated skills +hermes skills uninstall N Remove a hub skill +hermes skills publish PATH Publish to registry +hermes skills browse Browse all available skills +hermes skills tap add REPO Add a GitHub repo as skill source +``` + +### MCP Servers + +``` +hermes mcp serve Run Hermes as an MCP server +hermes mcp add NAME Add an MCP server (--url or --command) +hermes mcp remove NAME Remove an MCP server +hermes mcp list List configured servers +hermes mcp test NAME Test connection +hermes mcp configure NAME Toggle tool selection +``` + +### Gateway (Messaging Platforms) + +``` +hermes gateway run Start gateway foreground +hermes gateway install Install as background service +hermes gateway start/stop Control the service +hermes gateway restart Restart the service +hermes gateway status Check status +hermes gateway setup Configure platforms +``` + +Supported platforms: Telegram, Discord, Slack, WhatsApp, Signal, Email, SMS, Matrix, Mattermost, Home Assistant, DingTalk, Feishu, WeCom, API Server, Webhooks, Open WebUI. + +Platform docs: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/ + +### Sessions + +``` +hermes sessions list List recent sessions +hermes sessions browse Interactive picker +hermes sessions export OUT Export to JSONL +hermes sessions rename ID T Rename a session +hermes sessions delete ID Delete a session +hermes sessions prune Clean up old sessions (--older-than N days) +hermes sessions stats Session store statistics +``` + +### Cron Jobs + +``` +hermes cron list List jobs (--all for disabled) +hermes cron create SCHED Create: '30m', 'every 2h', '0 9 * * *' +hermes cron edit ID Edit schedule, prompt, delivery +hermes cron pause/resume ID Control job state +hermes cron run ID Trigger on next tick +hermes cron remove ID Delete a job +hermes cron status Scheduler status +``` + +### Webhooks + +``` +hermes webhook subscribe N Create route at /webhooks/ +hermes webhook list List subscriptions +hermes webhook remove NAME Remove a subscription +hermes webhook test NAME Send a test POST +``` + +### Profiles + +``` +hermes profile list List all profiles +hermes profile create NAME Create (--clone, --clone-all, --clone-from) +hermes profile use NAME Set sticky default +hermes profile delete NAME Delete a profile +hermes profile show NAME Show details +hermes profile alias NAME Manage wrapper scripts +hermes profile rename A B Rename a profile +hermes profile export NAME Export to tar.gz +hermes profile import FILE Import from archive +``` + +### Credential Pools + +``` +hermes auth add Interactive credential wizard +hermes auth list [PROVIDER] List pooled credentials +hermes auth remove P INDEX Remove by provider + index +hermes auth reset PROVIDER Clear exhaustion status +``` + +### Other + +``` +hermes insights [--days N] Usage analytics +hermes update Update to latest version +hermes pairing list/approve/revoke DM authorization +hermes plugins list/install/remove Plugin management +hermes honcho setup/status Honcho memory integration +hermes memory setup/status/off Memory provider config +hermes completion bash|zsh Shell completions +hermes acp ACP server (IDE integration) +hermes claw migrate Migrate from OpenClaw +hermes uninstall Uninstall Hermes +``` + +--- + +## Slash Commands (In-Session) + +Type these during an interactive chat session. + +### Session Control +``` +/new (/reset) Fresh session +/clear Clear screen + new session (CLI) +/retry Resend last message +/undo Remove last exchange +/title [name] Name the session +/compress Manually compress context +/stop Kill background processes +/rollback [N] Restore filesystem checkpoint +/background Run prompt in background +/queue Queue for next turn +/resume [name] Resume a named session +``` + +### Configuration +``` +/config Show config (CLI) +/model [name] Show or change model +/provider Show provider info +/prompt [text] View/set system prompt (CLI) +/personality [name] Set personality +/reasoning [level] Set reasoning (none|low|medium|high|xhigh|show|hide) +/verbose Cycle: off → new → all → verbose +/voice [on|off|tts] Voice mode +/yolo Toggle approval bypass +/skin [name] Change theme (CLI) +/statusbar Toggle status bar (CLI) +``` + +### Tools & Skills +``` +/tools Manage tools (CLI) +/toolsets List toolsets (CLI) +/skills Search/install skills (CLI) +/skill Load a skill into session +/cron Manage cron jobs (CLI) +/reload-mcp Reload MCP servers +/plugins List plugins (CLI) +``` + +### Info +``` +/help Show commands +/commands [page] Browse all commands (gateway) +/usage Token usage +/insights [days] Usage analytics +/status Session info (gateway) +/profile Active profile info +``` + +### Exit +``` +/quit (/exit, /q) Exit CLI +``` + +--- + +## Key Paths & Config + +``` +~/.hermes/config.yaml Main configuration +~/.hermes/.env API keys and secrets +~/.hermes/skills/ Installed skills +~/.hermes/sessions/ Session transcripts +~/.hermes/logs/ Gateway and error logs +~/.hermes/auth.json OAuth tokens and credential pools +~/.hermes/hermes-agent/ Source code (if git-installed) +``` + +Profiles use `~/.hermes/profiles//` with the same layout. + +### Config Sections + +Edit with `hermes config edit` or `hermes config set section.key value`. + +| Section | Key options | +|---------|-------------| +| `model` | `default`, `provider`, `base_url`, `api_key`, `context_length` | +| `agent` | `max_turns` (90), `tool_use_enforcement` | +| `terminal` | `backend` (local/docker/ssh/modal), `cwd`, `timeout` (180) | +| `compression` | `enabled`, `threshold` (0.50), `target_ratio` (0.20) | +| `display` | `skin`, `tool_progress`, `show_reasoning`, `show_cost` | +| `stt` | `enabled`, `provider` (local/groq/openai) | +| `tts` | `provider` (edge/elevenlabs/openai/kokoro/fish) | +| `memory` | `memory_enabled`, `user_profile_enabled`, `provider` | +| `security` | `tirith_enabled`, `website_blocklist` | +| `delegation` | `model`, `provider`, `max_iterations` (50) | +| `smart_model_routing` | `enabled`, `cheap_model` | +| `checkpoints` | `enabled`, `max_snapshots` (50) | + +Full config reference: https://hermes-agent.nousresearch.com/docs/user-guide/configuration + +### Providers + +18 providers supported. Set via `hermes model` or `hermes setup`. + +| Provider | Auth | Key env var | +|----------|------|-------------| +| OpenRouter | API key | `OPENROUTER_API_KEY` | +| Anthropic | API key | `ANTHROPIC_API_KEY` | +| Nous Portal | OAuth | `hermes login --provider nous` | +| OpenAI Codex | OAuth | `hermes login --provider openai-codex` | +| GitHub Copilot | Token | `COPILOT_GITHUB_TOKEN` | +| DeepSeek | API key | `DEEPSEEK_API_KEY` | +| Hugging Face | Token | `HF_TOKEN` | +| Z.AI / GLM | API key | `GLM_API_KEY` | +| MiniMax | API key | `MINIMAX_API_KEY` | +| Kimi / Moonshot | API key | `KIMI_API_KEY` | +| Alibaba / DashScope | API key | `DASHSCOPE_API_KEY` | +| Kilo Code | API key | `KILOCODE_API_KEY` | +| Custom endpoint | Config | `model.base_url` + `model.api_key` in config.yaml | + +Plus: AI Gateway, OpenCode Zen, OpenCode Go, MiniMax CN, GitHub Copilot ACP. + +Full provider docs: https://hermes-agent.nousresearch.com/docs/integrations/providers + +### Toolsets + +Enable/disable via `hermes tools` (interactive) or `hermes tools enable/disable NAME`. + +| Toolset | What it provides | +|---------|-----------------| +| `web` | Web search and content extraction | +| `browser` | Browser automation (Browserbase, Camofox, or local Chromium) | +| `terminal` | Shell commands and process management | +| `file` | File read/write/search/patch | +| `code_execution` | Sandboxed Python execution | +| `vision` | Image analysis | +| `image_gen` | AI image generation | +| `tts` | Text-to-speech | +| `skills` | Skill browsing and management | +| `memory` | Persistent cross-session memory | +| `session_search` | Search past conversations | +| `delegation` | Subagent task delegation | +| `cronjob` | Scheduled task management | +| `clarify` | Ask user clarifying questions | +| `moa` | Mixture of Agents (off by default) | +| `homeassistant` | Smart home control (off by default) | + +Tool changes take effect on `/reset` (new session). They do NOT apply mid-conversation to preserve prompt caching. + +--- + +## Voice & Transcription + +### STT (Voice → Text) + +Voice messages from messaging platforms are auto-transcribed. + +Provider priority (auto-detected): +1. **Local faster-whisper** — free, no API key: `pip install faster-whisper` +2. **Groq Whisper** — free tier: set `GROQ_API_KEY` +3. **OpenAI Whisper** — paid: set `VOICE_TOOLS_OPENAI_KEY` + +Config: +```yaml +stt: + enabled: true + provider: local # local, groq, openai + local: + model: base # tiny, base, small, medium, large-v3 +``` + +### TTS (Text → Voice) + +| Provider | Env var | Free? | +|----------|---------|-------| +| Edge TTS | None | Yes (default) | +| ElevenLabs | `ELEVENLABS_API_KEY` | Free tier | +| OpenAI | `VOICE_TOOLS_OPENAI_KEY` | Paid | +| Kokoro (local) | None | Free | +| Fish Audio | `FISH_AUDIO_API_KEY` | Free tier | + +Voice commands: `/voice on` (voice-to-voice), `/voice tts` (always voice), `/voice off`. + +--- + +## Spawning Additional Hermes Instances + +Run additional Hermes processes as fully independent subprocesses — separate sessions, tools, and environments. + +### When to Use This vs delegate_task + +| | `delegate_task` | Spawning `hermes` process | +|-|-----------------|--------------------------| +| Isolation | Separate conversation, shared process | Fully independent process | +| Duration | Minutes (bounded by parent loop) | Hours/days | +| Tool access | Subset of parent's tools | Full tool access | +| Interactive | No | Yes (PTY mode) | +| Use case | Quick parallel subtasks | Long autonomous missions | + +### One-Shot Mode + +``` +terminal(command="hermes chat -q 'Research GRPO papers and write summary to ~/research/grpo.md'", timeout=300) + +# Background for long tasks: +terminal(command="hermes chat -q 'Set up CI/CD for ~/myapp'", background=true) +``` + +### Interactive PTY Mode (via tmux) + +Hermes uses prompt_toolkit, which requires a real terminal. Use tmux for interactive spawning: + +``` +# Start +terminal(command="tmux new-session -d -s agent1 -x 120 -y 40 'hermes'", timeout=10) + +# Wait for startup, then send a message +terminal(command="sleep 8 && tmux send-keys -t agent1 'Build a FastAPI auth service' Enter", timeout=15) + +# Read output +terminal(command="sleep 20 && tmux capture-pane -t agent1 -p", timeout=5) + +# Send follow-up +terminal(command="tmux send-keys -t agent1 'Add rate limiting middleware' Enter", timeout=5) + +# Exit +terminal(command="tmux send-keys -t agent1 '/exit' Enter && sleep 2 && tmux kill-session -t agent1", timeout=10) +``` + +### Multi-Agent Coordination + +``` +# Agent A: backend +terminal(command="tmux new-session -d -s backend -x 120 -y 40 'hermes -w'", timeout=10) +terminal(command="sleep 8 && tmux send-keys -t backend 'Build REST API for user management' Enter", timeout=15) + +# Agent B: frontend +terminal(command="tmux new-session -d -s frontend -x 120 -y 40 'hermes -w'", timeout=10) +terminal(command="sleep 8 && tmux send-keys -t frontend 'Build React dashboard for user management' Enter", timeout=15) + +# Check progress, relay context between them +terminal(command="tmux capture-pane -t backend -p | tail -30", timeout=5) +terminal(command="tmux send-keys -t frontend 'Here is the API schema from the backend agent: ...' Enter", timeout=5) +``` + +### Session Resume + +``` +# Resume most recent session +terminal(command="tmux new-session -d -s resumed 'hermes --continue'", timeout=10) + +# Resume specific session +terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_143052_a1b2c3'", timeout=10) +``` + +### Tips + +- **Prefer `delegate_task` for quick subtasks** — less overhead than spawning a full process +- **Use `-w` (worktree mode)** when spawning agents that edit code — prevents git conflicts +- **Set timeouts** for one-shot mode — complex tasks can take 5-10 minutes +- **Use `hermes chat -q` for fire-and-forget** — no PTY needed +- **Use tmux for interactive sessions** — raw PTY mode has `\r` vs `\n` issues with prompt_toolkit +- **For scheduled tasks**, use the `cronjob` tool instead of spawning — handles delivery and retry + +--- + +## Troubleshooting + +### Voice not working +1. Check `stt.enabled: true` in config.yaml +2. Verify provider: `pip install faster-whisper` or set API key +3. Restart gateway: `/restart` + +### Tool not available +1. `hermes tools` — check if toolset is enabled for your platform +2. Some tools need env vars (check `.env`) +3. `/reset` after enabling tools + +### Model/provider issues +1. `hermes doctor` — check config and dependencies +2. `hermes login` — re-authenticate OAuth providers +3. Check `.env` has the right API key + +### Changes not taking effect +- **Tools/skills:** `/reset` starts a new session with updated toolset +- **Config changes:** `/restart` reloads gateway config +- **Code changes:** Restart the CLI or gateway process + +### Skills not showing +1. `hermes skills list` — verify installed +2. `hermes skills config` — check platform enablement +3. Load explicitly: `/skill name` or `hermes -s name` + +### Gateway issues +Check logs first: +```bash +grep -i "failed to send\|error" ~/.hermes/logs/gateway.log | tail -20 +``` + +--- + +## Where to Find Things + +| Looking for... | Location | +|----------------|----------| +| Config options | `hermes config edit` or [Configuration docs](https://hermes-agent.nousresearch.com/docs/user-guide/configuration) | +| Available tools | `hermes tools list` or [Tools reference](https://hermes-agent.nousresearch.com/docs/reference/tools-reference) | +| Slash commands | `/help` in session or [Slash commands reference](https://hermes-agent.nousresearch.com/docs/reference/slash-commands) | +| Skills catalog | `hermes skills browse` or [Skills catalog](https://hermes-agent.nousresearch.com/docs/reference/skills-catalog) | +| Provider setup | `hermes model` or [Providers guide](https://hermes-agent.nousresearch.com/docs/integrations/providers) | +| Platform setup | `hermes gateway setup` or [Messaging docs](https://hermes-agent.nousresearch.com/docs/user-guide/messaging/) | +| MCP servers | `hermes mcp list` or [MCP guide](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp) | +| Profiles | `hermes profile list` or [Profiles docs](https://hermes-agent.nousresearch.com/docs/user-guide/profiles) | +| Cron jobs | `hermes cron list` or [Cron docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/cron) | +| Memory | `hermes memory status` or [Memory docs](https://hermes-agent.nousresearch.com/docs/user-guide/features/memory) | +| Env variables | `hermes config env-path` or [Env vars reference](https://hermes-agent.nousresearch.com/docs/reference/environment-variables) | +| CLI commands | `hermes --help` or [CLI reference](https://hermes-agent.nousresearch.com/docs/reference/cli-commands) | +| Gateway logs | `~/.hermes/logs/gateway.log` | +| Session files | `~/.hermes/sessions/` or `hermes sessions browse` | +| Source code | `~/.hermes/hermes-agent/` | + +--- + +## Contributor Quick Reference + +For occasional contributors and PR authors. Full developer docs: https://hermes-agent.nousresearch.com/docs/developer-guide/ + +### Project Layout + +``` +hermes-agent/ +├── run_agent.py # AIAgent — core conversation loop +├── model_tools.py # Tool discovery and dispatch +├── toolsets.py # Toolset definitions +├── cli.py # Interactive CLI (HermesCLI) +├── hermes_state.py # SQLite session store +├── agent/ # Prompt builder, compression, display, adapters +├── hermes_cli/ # CLI subcommands, config, setup, commands +│ ├── commands.py # Slash command registry (CommandDef) +│ ├── config.py # DEFAULT_CONFIG, env var definitions +│ └── main.py # CLI entry point and argparse +├── tools/ # One file per tool +│ └── registry.py # Central tool registry +├── gateway/ # Messaging gateway +│ └── platforms/ # Platform adapters (telegram, discord, etc.) +├── cron/ # Job scheduler +├── tests/ # ~3000 pytest tests +└── website/ # Docusaurus docs site +``` + +Config: `~/.hermes/config.yaml` (settings), `~/.hermes/.env` (API keys). + +### Adding a Tool (3 files) + +**1. Create `tools/your_tool.py`:** +```python +import json, os +from tools.registry import registry + +def check_requirements() -> bool: + return bool(os.getenv("EXAMPLE_API_KEY")) + +def example_tool(param: str, task_id: str = None) -> str: + return json.dumps({"success": True, "data": "..."}) + +registry.register( + name="example_tool", + toolset="example", + schema={"name": "example_tool", "description": "...", "parameters": {...}}, + handler=lambda args, **kw: example_tool( + param=args.get("param", ""), task_id=kw.get("task_id")), + check_fn=check_requirements, + requires_env=["EXAMPLE_API_KEY"], +) +``` + +**2. Add import** in `model_tools.py` → `_discover_tools()` list. + +**3. Add to `toolsets.py`** → `_HERMES_CORE_TOOLS` list. + +All handlers must return JSON strings. Use `get_hermes_home()` for paths, never hardcode `~/.hermes`. + +### Adding a Slash Command + +1. Add `CommandDef` to `COMMAND_REGISTRY` in `hermes_cli/commands.py` +2. Add handler in `cli.py` → `process_command()` +3. (Optional) Add gateway handler in `gateway/run.py` + +All consumers (help text, autocomplete, Telegram menu, Slack mapping) derive from the central registry automatically. + +### Agent Loop (High Level) + +``` +run_conversation(): + 1. Build system prompt + 2. Loop while iterations < max: + a. Call LLM (OpenAI-format messages + tool schemas) + b. If tool_calls → dispatch each via handle_function_call() → append results → continue + c. If text response → return + 3. Context compression triggers automatically near token limit +``` + +### Testing + +```bash +source venv/bin/activate # or .venv/bin/activate +python -m pytest tests/ -o 'addopts=' -q # Full suite +python -m pytest tests/tools/ -q # Specific area +``` + +- Tests auto-redirect `HERMES_HOME` to temp dirs — never touch real `~/.hermes/` +- Run full suite before pushing any change +- Use `-o 'addopts='` to clear any baked-in pytest flags + +### Commit Conventions + +``` +type: concise subject line + +Optional body. +``` + +Types: `fix:`, `feat:`, `refactor:`, `docs:`, `chore:` + +### Key Rules + +- **Never break prompt caching** — don't change context, tools, or system prompt mid-conversation +- **Message role alternation** — never two assistant or two user messages in a row +- Use `get_hermes_home()` from `hermes_constants` for all paths (profile-safe) +- Config values go in `config.yaml`, secrets go in `.env` +- New tools need a `check_fn` so they only appear when requirements are met From f8cb54ba0421ceac8518c6df90b7043fd15f00c5 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:56:35 -0700 Subject: [PATCH 034/212] fix(cli): anchor input prompt near bottom of terminal after responses (#4359) After short agent responses, the prompt_toolkit input area sat mid-screen with empty terminal space below it. Now prints padding newlines (half terminal height) after each response to push the prompt toward the bottom. patch_stdout renders the padding above the input area. --- cli.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cli.py b/cli.py index 2f62149896ee..b18e53077546 100644 --- a/cli.py +++ b/cli.py @@ -7568,6 +7568,19 @@ def _expand_ref(m): finally: self._agent_running = False self._spinner_text = "" + + # Push the input prompt toward the bottom of the + # terminal so it doesn't sit mid-screen after short + # responses. patch_stdout renders these newlines + # above the input area, creating visual separation + # and anchoring the prompt near the bottom. + try: + _pad = shutil.get_terminal_size().lines // 2 + if _pad > 2: + _cprint("\n" * _pad) + except Exception: + pass + app.invalidate() # Refresh status line # Continuous voice: auto-restart recording after agent responds. From 3604665e44817e735beeab6e9261a785059420bf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:05:40 -0700 Subject: [PATCH 035/212] feat: add qwen/qwen3.6-plus-preview:free to OpenRouter and Nous model lists (#4376) --- hermes_cli/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index c8bd106b6628..df58df02f8bf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -28,6 +28,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("anthropic/claude-opus-4.6", "recommended"), ("anthropic/claude-sonnet-4.6", ""), + ("qwen/qwen3.6-plus-preview:free", "free"), ("anthropic/claude-sonnet-4.5", ""), ("anthropic/claude-haiku-4.5", ""), ("openai/gpt-5.4", ""), @@ -58,6 +59,7 @@ "nous": [ "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", + "qwen/qwen3.6-plus-preview:free", "anthropic/claude-sonnet-4.5", "anthropic/claude-haiku-4.5", "openai/gpt-5.4", From 0a6d366327432f9ac3c3463839af7238a2d3fe9a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:52:11 -0700 Subject: [PATCH 036/212] fix(security): redact secrets from execute_code sandbox output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: root-level provider in config.yaml no longer overrides model.provider load_cli_config() had a priority inversion: a stale root-level 'provider' key in config.yaml would OVERRIDE the canonical 'model.provider' set by 'hermes model'. The gateway reads model.provider directly from YAML and worked correctly, but 'hermes chat -q' and the interactive CLI went through the merge logic and picked up the stale root-level key. Fix: root-level provider/base_url are now only used as a fallback when model.provider/model.base_url is not set (never as an override). Also added _normalize_root_model_keys() to config.py load_config() and save_config() — migrates root-level provider/base_url into the model section and removes the root-level keys permanently. Reported by (≧▽≦) in Discord: opencode-go provider persisted as a root-level key and overrode the correct model.provider=openrouter, causing 401 errors. * fix(security): redact secrets from execute_code sandbox output The execute_code sandbox stripped env vars with secret-like names from the child process (preventing os.environ access), but scripts could still read secrets from disk (e.g. open('~/.hermes/.env')) and print them to stdout. The raw values entered the model context unredacted. terminal_tool and file_tools already applied redact_sensitive_text() to their output — execute_code was the only tool that skipped this step. Now the same redaction runs on both stdout and stderr after ANSI stripping. Reported via Discord (not filed on GitHub to avoid public disclosure of the reproduction steps). --- tools/code_execution_tool.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 19270c6fe93c..ce78c90610f6 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -596,6 +596,14 @@ def _drain_head_tail(pipe, head_chunks, tail_chunks, head_bytes, tail_bytes, tot stdout_text = strip_ansi(stdout_text) stderr_text = strip_ansi(stderr_text) + # Redact secrets (API keys, tokens, etc.) from sandbox output. + # The sandbox env-var filter (lines 434-454) blocks os.environ access, + # but scripts can still read secrets from disk (e.g. open('~/.hermes/.env')). + # This ensures leaked secrets never enter the model context. + from agent.redact import redact_sensitive_text + stdout_text = redact_sensitive_text(stdout_text) + stderr_text = redact_sensitive_text(stderr_text) + # Build response result: Dict[str, Any] = { "status": status, From f4d44c777b0661b4e254be4d1081fe56be893b31 Mon Sep 17 00:00:00 2001 From: Laura Batalha <5883822+lbatalha@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:39:40 +0100 Subject: [PATCH 037/212] feat(discord): only create threads and reactions for authorized users --- gateway/platforms/discord.py | 104 ++++++++++++++++++----------------- 1 file changed, 54 insertions(+), 50 deletions(-) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 168919b09011..6146bb2bc538 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -408,7 +408,7 @@ def pcm_to_wav(pcm_data: bytes, output_path: str, class DiscordAdapter(BasePlatformAdapter): """ Discord bot adapter. - + Handles: - Receiving messages from servers and DMs - Sending responses with Discord markdown @@ -418,10 +418,10 @@ class DiscordAdapter(BasePlatformAdapter): - Auto-threading for long conversations - Reaction-based feedback """ - + # Discord message limits MAX_MESSAGE_LENGTH = 2000 - + # Auto-disconnect from voice channel after this many seconds of inactivity VOICE_TIMEOUT = 300 @@ -449,7 +449,7 @@ def __init__(self, config: PlatformConfig): self._bot_task: Optional[asyncio.Task] = None # Cap to prevent unbounded growth (Discord threads get archived). self._MAX_TRACKED_THREADS = 500 - + async def connect(self) -> bool: """Connect to Discord and start receiving events.""" if not DISCORD_AVAILABLE: @@ -480,11 +480,11 @@ async def connect(self) -> bool: logger.warning("Opus codec found at %s but failed to load", opus_path) if not discord.opus.is_loaded(): logger.warning("Opus codec not found — voice channel playback disabled") - + if not self.config.token: logger.error("[%s] No bot token configured", self.name) return False - + try: # Acquire scoped lock to prevent duplicate bot token usage from gateway.status import acquire_scoped_lock @@ -504,13 +504,13 @@ async def connect(self) -> bool: intents.guild_messages = True intents.members = True intents.voice_states = True - + # Create bot self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, ) - + # Parse allowed user entries (may contain usernames or IDs) allowed_env = os.getenv("DISCORD_ALLOWED_USERS", "") if allowed_env: @@ -518,17 +518,17 @@ async def connect(self) -> bool: _clean_discord_id(uid) for uid in allowed_env.split(",") if uid.strip() } - + adapter_self = self # capture for closure - + # Register event handlers @self._client.event async def on_ready(): logger.info("[%s] Connected as %s", adapter_self.name, adapter_self._client.user) - + # Resolve any usernames in the allowed list to numeric IDs await adapter_self._resolve_allowed_usernames() - + # Sync slash commands with Discord try: synced = await adapter_self._client.tree.sync() @@ -536,18 +536,22 @@ async def on_ready(): except Exception as e: # pragma: no cover - defensive logging logger.warning("[%s] Slash command sync failed: %s", adapter_self.name, e, exc_info=True) adapter_self._ready_event.set() - + @self._client.event async def on_message(message: DiscordMessage): # Always ignore our own messages if message.author == self._client.user: return - + # Ignore Discord system messages (thread renames, pins, member joins, etc.) # Allow both default and reply types — replies have a distinct MessageType. if message.type not in (discord.MessageType.default, discord.MessageType.reply): return - + + # Check if the message author is in the allowed user list + if not self._is_allowed_user(str(message.author.id)): + return + # Bot message filtering (DISCORD_ALLOW_BOTS): # "none" — ignore all other bots (default) # "mentions" — accept bot messages only when they @mention us @@ -560,7 +564,7 @@ async def on_message(message: DiscordMessage): if not self._client.user or self._client.user not in message.mentions: return # "all" falls through to handle_message - + # If the message @mentions other users but NOT the bot, the # sender is talking to someone else — stay silent. Only # applies in server channels; in DMs the user is always @@ -614,23 +618,23 @@ async def on_voice_state_update(member, before, after): # Register slash commands self._register_slash_commands() - + # Start the bot in background self._bot_task = asyncio.create_task(self._client.start(self.config.token)) - + # Wait for ready await asyncio.wait_for(self._ready_event.wait(), timeout=30) - + self._running = True return True - + except asyncio.TimeoutError: logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True) return False except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True) return False - + async def disconnect(self) -> None: """Disconnect from Discord.""" # Clean up all active voice connections before closing the client @@ -703,7 +707,7 @@ async def on_processing_complete(self, event: MessageEvent, success: bool) -> No if hasattr(message, "add_reaction"): await self._remove_reaction(message, "👀") await self._add_reaction(message, "✅" if success else "❌") - + async def send( self, chat_id: str, @@ -720,24 +724,24 @@ async def send( channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) - + if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - + # Format and split message if needed formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) - + message_ids = [] reference = None - + if reply_to: try: ref_msg = await channel.fetch_message(int(reply_to)) reference = ref_msg except Exception as e: logger.debug("Could not fetch reply-to message: %s", e) - + for i, chunk in enumerate(chunks): chunk_reference = reference if i == 0 else None try: @@ -764,13 +768,13 @@ async def send( else: raise message_ids.append(str(msg.id)) - + return SendResult( success=True, message_id=message_ids[0] if message_ids else None, raw_response={"message_ids": message_ids} ) - + except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send Discord message: %s", self.name, e, exc_info=True) return SendResult(success=False, error=str(e)) @@ -1242,25 +1246,25 @@ async def send_image( """Send an image natively as a Discord file attachment.""" if not self._client: return SendResult(success=False, error="Not connected") - + try: import aiohttp - + channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") - + # Download the image and send as a Discord file attachment # (Discord renders attachments inline, unlike plain URLs) async with aiohttp.ClientSession() as session: async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: raise Exception(f"Failed to download image: HTTP {resp.status}") - + image_data = await resp.read() - + # Determine filename from URL or content type content_type = resp.headers.get("content-type", "image/png") ext = "png" @@ -1270,16 +1274,16 @@ async def send_image( ext = "gif" elif "webp" in content_type: ext = "webp" - + import io file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}") - + msg = await channel.send( content=caption if caption else None, file=file, ) return SendResult(success=True, message_id=str(msg.id)) - + except ImportError: logger.warning( "[%s] aiohttp not installed, falling back to URL. Run: pip install aiohttp", @@ -1330,7 +1334,7 @@ async def send_document( except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send document, falling back to base adapter: %s", self.name, e, exc_info=True) return await super().send_document(chat_id, file_path, caption, file_name, reply_to, metadata=metadata) - + async def send_typing(self, chat_id: str, metadata=None) -> None: """Start a persistent typing indicator for a channel. @@ -1374,20 +1378,20 @@ async def stop_typing(self, chat_id: str) -> None: await task except (asyncio.CancelledError, Exception): pass - + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a Discord channel.""" if not self._client: return {"name": "Unknown", "type": "dm"} - + try: channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) - + if not channel: return {"name": str(chat_id), "type": "dm"} - + # Determine channel type if isinstance(channel, discord.DMChannel): chat_type = "dm" @@ -1403,7 +1407,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: else: chat_type = "channel" name = getattr(channel, "name", str(chat_id)) - + return { "name": name, "type": chat_type, @@ -1413,7 +1417,7 @@ async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to get chat info for %s: %s", self.name, chat_id, e, exc_info=True) return {"name": str(chat_id), "type": "dm", "error": str(e)} - + async def _resolve_allowed_usernames(self) -> None: """ Resolve non-numeric entries in DISCORD_ALLOWED_USERS to Discord user IDs. @@ -1481,7 +1485,7 @@ async def _resolve_allowed_usernames(self) -> None: def format_message(self, content: str) -> str: """ Format message for Discord. - + Discord uses its own markdown variant. """ # Discord markdown is fairly standard, no special escaping needed @@ -1647,7 +1651,7 @@ def _build_slash_event(self, interaction: discord.Interaction, text: str) -> Mes chat_name = interaction.channel.name if hasattr(interaction.channel, "guild") and interaction.channel.guild: chat_name = f"{interaction.channel.guild.name} / #{chat_name}" - + # Get channel topic (if available) chat_topic = getattr(interaction.channel, "topic", None) @@ -2051,7 +2055,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: if doc_ext in SUPPORTED_DOCUMENT_TYPES: msg_type = MessageType.DOCUMENT break - + # When auto-threading kicked in, route responses to the new thread effective_channel = auto_threaded_channel or message.channel @@ -2070,7 +2074,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: # Get channel topic (if available - TextChannels have topics, DMs/threads don't) chat_topic = getattr(message.channel, "topic", None) - + # Build source source = self.build_source( chat_id=str(effective_channel.id), @@ -2081,7 +2085,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: thread_id=thread_id, chat_topic=chat_topic, ) - + # Build media URLs -- download image attachments to local cache so the # vision tool can access them reliably (Discord CDN URLs can expire). media_urls = [] @@ -2175,7 +2179,7 @@ async def _handle_message(self, message: DiscordMessage) -> None: "[Discord] Failed to cache document %s: %s", att.filename, e, exc_info=True, ) - + event_text = message.content if pending_text_injection: event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection From 83dec2b3ec0f6d0ddc5750f9a9e811a6a355a49f Mon Sep 17 00:00:00 2001 From: SHL0MS Date: Tue, 31 Mar 2026 12:07:28 -0400 Subject: [PATCH 038/212] fix: skip empty/whitespace text in Telegram send to prevent 400 errors Telegram API returns HTTP 400 when sent whitespace-only or empty text. Add a guard at the top of send() to silently succeed on blank content instead of crashing. Equivalent to OpenClaw #56620. --- gateway/platforms/telegram.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index db1b19431ce3..e5e2885c7509 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -742,6 +742,10 @@ async def send( if not self._bot: return SendResult(success=False, error="Not connected") + # Skip whitespace-only text to prevent Telegram 400 empty-text errors. + if not content or not content.strip(): + return SendResult(success=True, message_id=None) + try: # Format and split message if needed formatted = self.format_message(content) From ef2ae3e48fe08a59f377f03b402826763b1d26ab Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Apr 2026 00:50:08 -0700 Subject: [PATCH 039/212] fix(file_tools): refresh staleness timestamp after writes (#4390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a successful write_file or patch, update the stored read timestamp to match the file's new modification time. Without this, consecutive edits by the same task (read → write → write) would false-warn on the second write because the stored timestamp still reflected the original read, not the first write. Also renames the internal tracker key from 'file_mtimes' to 'read_timestamps' for clarity. --- tests/tools/test_file_staleness.py | 4 +-- tools/file_tools.py | 39 ++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/tests/tools/test_file_staleness.py b/tests/tools/test_file_staleness.py index 46e7aac9ffac..230493e332d7 100644 --- a/tests/tools/test_file_staleness.py +++ b/tests/tools/test_file_staleness.py @@ -221,7 +221,7 @@ def test_returns_none_for_unread_file(self): _read_tracker["t1"] = { "last_key": None, "consecutive": 0, "read_history": set(), "dedup": {}, - "file_mtimes": {"/tmp/other.py": 12345.0}, + "read_timestamps": {"/tmp/other.py": 12345.0}, } self.assertIsNone(_check_file_staleness("/tmp/x.py", "t1")) @@ -231,7 +231,7 @@ def test_returns_none_when_stat_fails(self): _read_tracker["t1"] = { "last_key": None, "consecutive": 0, "read_history": set(), "dedup": {}, - "file_mtimes": {"/nonexistent/path": 99999.0}, + "read_timestamps": {"/nonexistent/path": 99999.0}, } # File doesn't exist → stat fails → returns None (let write handle it) self.assertIsNone(_check_file_staleness("/nonexistent/path", "t1")) diff --git a/tools/file_tools.py b/tools/file_tools.py index 07fb86d1ac4d..79a111cb7961 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -136,9 +136,12 @@ def _is_expected_write_exception(exc: Exception) -> bool: # Used to skip re-reads of unchanged files. Reset on # context compression (the original content is summarised # away so the model needs the full content again). -# "file_mtimes": dict mapping resolved_path → mtime float at last read. -# Used by write_file and patch to detect when a file was -# modified externally between the agent's read and write. +# "read_timestamps": dict mapping resolved_path → modification-time float +# recorded when the file was last read (or written) by +# this task. Used by write_file and patch to detect +# external changes between the agent's read and write. +# Updated after successful writes so consecutive edits +# by the same task don't trigger false warnings. _read_tracker_lock = threading.Lock() _read_tracker: dict = {} @@ -401,7 +404,7 @@ def read_file_tool(path: str, offset: int = 1, limit: int = 500, task_id: str = try: _mtime_now = os.path.getmtime(resolved_str) task_data["dedup"][dedup_key] = _mtime_now - task_data.setdefault("file_mtimes", {})[resolved_str] = _mtime_now + task_data.setdefault("read_timestamps", {})[resolved_str] = _mtime_now except OSError: pass # Can't stat — skip tracking for this entry @@ -500,6 +503,24 @@ def notify_other_tool_call(task_id: str = "default"): task_data["consecutive"] = 0 +def _update_read_timestamp(filepath: str, task_id: str) -> None: + """Record the file's current modification time after a successful write. + + Called after write_file and patch so that consecutive edits by the + same task don't trigger false staleness warnings — each write + refreshes the stored timestamp to match the file's new state. + """ + try: + resolved = str(Path(filepath).expanduser().resolve()) + current_mtime = os.path.getmtime(resolved) + except (OSError, ValueError): + return + with _read_tracker_lock: + task_data = _read_tracker.get(task_id) + if task_data is not None: + task_data.setdefault("read_timestamps", {})[resolved] = current_mtime + + def _check_file_staleness(filepath: str, task_id: str) -> str | None: """Check whether a file was modified since the agent last read it. @@ -515,7 +536,7 @@ def _check_file_staleness(filepath: str, task_id: str) -> str | None: task_data = _read_tracker.get(task_id) if not task_data: return None - read_mtime = task_data.get("file_mtimes", {}).get(resolved) + read_mtime = task_data.get("read_timestamps", {}).get(resolved) if read_mtime is None: return None # File was never read — nothing to compare against try: @@ -543,6 +564,9 @@ def write_file_tool(path: str, content: str, task_id: str = "default") -> str: result_dict = result.to_dict() if stale_warning: result_dict["_warning"] = stale_warning + # Refresh the stored timestamp so consecutive writes by this + # task don't trigger false staleness warnings. + _update_read_timestamp(path, task_id) return json.dumps(result_dict, ensure_ascii=False) except Exception as e: if _is_expected_write_exception(e): @@ -594,6 +618,11 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None, result_dict = result.to_dict() if stale_warnings: result_dict["_warning"] = stale_warnings[0] if len(stale_warnings) == 1 else " | ".join(stale_warnings) + # Refresh stored timestamps for all successfully-patched paths so + # consecutive edits by this task don't trigger false warnings. + if not result_dict.get("error"): + for _p in _paths_to_check: + _update_read_timestamp(_p, task_id) result_json = json.dumps(result_dict, ensure_ascii=False) # Hint when old_string not found — saves iterations where the agent # retries with stale content instead of re-reading the file. From a7f7e870705eb4eba8c47805094afdab102ee36d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 1 Apr 2026 01:02:34 -0700 Subject: [PATCH 040/212] fix: preserve credential_pool through smart routing and defer eager fallback on 429 (#4361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs prevented credential pool rotation from working when multiple Codex OAuth tokens were configured: 1. credential_pool was dropped during smart model turn routing. resolve_turn_route() constructed runtime dicts without it, so the AIAgent was created without pool access. Fixed in smart_model_routing.py (no-route and fallback paths), cli.py, and gateway/run.py. 2. Eager fallback fired before pool rotation on 429. The rate-limit handler at line ~7180 switched to a fallback provider immediately, before _recover_with_credential_pool got a chance to rotate to the next credential. Now deferred when the pool still has credentials. 3. (Non-issue) Retry budget was reported as too small, but successful pool rotations already skip retry_count increment — no change needed. Reported by community member Schinsly who identified all three root causes and verified the fix locally with multiple Codex accounts. --- agent/credential_pool.py | 4 + agent/smart_model_routing.py | 2 + cli.py | 1 + gateway/run.py | 1 + run_agent.py | 15 +- tests/test_credential_pool_routing.py | 350 ++++++++++++++++++++++++++ 6 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 tests/test_credential_pool_routing.py diff --git a/agent/credential_pool.py b/agent/credential_pool.py index ad4dbcfc13f1..003a5a8e7633 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -267,6 +267,10 @@ def __init__(self, provider: str, entries: List[PooledCredential]): def has_credentials(self) -> bool: return bool(self._entries) + def has_available(self) -> bool: + """True if at least one entry is not currently in exhaustion cooldown.""" + return bool(self._available_entries()) + def entries(self) -> List[PooledCredential]: return list(self._entries) diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index d57cd1b83a83..ada865af03f2 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -127,6 +127,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "api_mode": primary.get("api_mode"), "command": primary.get("command"), "args": list(primary.get("args") or []), + "credential_pool": primary.get("credential_pool"), }, "label": None, "signature": ( @@ -162,6 +163,7 @@ def resolve_turn_route(user_message: str, routing_config: Optional[Dict[str, Any "api_mode": primary.get("api_mode"), "command": primary.get("command"), "args": list(primary.get("args") or []), + "credential_pool": primary.get("credential_pool"), }, "label": None, "signature": ( diff --git a/cli.py b/cli.py index b18e53077546..151ae4615727 100644 --- a/cli.py +++ b/cli.py @@ -2024,6 +2024,7 @@ def _resolve_turn_agent_config(self, user_message: str) -> dict: "api_mode": self.api_mode, "command": self.acp_command, "args": list(self.acp_args or []), + "credential_pool": getattr(self, "_credential_pool", None), }, ) diff --git a/gateway/run.py b/gateway/run.py index cc1a6666fdd7..49135ce5af2c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -788,6 +788,7 @@ def _resolve_turn_agent_config(self, user_message: str, model: str, runtime_kwar "api_mode": runtime_kwargs.get("api_mode"), "command": runtime_kwargs.get("command"), "args": list(runtime_kwargs.get("args") or []), + "credential_pool": runtime_kwargs.get("credential_pool"), } return resolve_turn_route(user_message, getattr(self, "_smart_model_routing", {}), primary) diff --git a/run_agent.py b/run_agent.py index 5ed40500b3e4..558a894576dd 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7178,10 +7178,17 @@ def _stop_spinner(): or "quota" in error_msg ) if is_rate_limited and self._fallback_index < len(self._fallback_chain): - self._emit_status("⚠️ Rate limited — switching to fallback provider...") - if self._try_activate_fallback(): - retry_count = 0 - continue + # Don't eagerly fallback if credential pool rotation may + # still recover. The pool's retry-then-rotate cycle needs + # at least one more attempt to fire — jumping to a fallback + # provider here short-circuits it. + pool = self._credential_pool + pool_may_recover = pool is not None and pool.has_available() + if not pool_may_recover: + self._emit_status("⚠️ Rate limited — switching to fallback provider...") + if self._try_activate_fallback(): + retry_count = 0 + continue is_payload_too_large = ( status_code == 413 diff --git a/tests/test_credential_pool_routing.py b/tests/test_credential_pool_routing.py new file mode 100644 index 000000000000..f4006a236ff3 --- /dev/null +++ b/tests/test_credential_pool_routing.py @@ -0,0 +1,350 @@ +"""Tests for credential pool preservation through smart routing and 429 recovery. + +Covers: +1. credential_pool flows through resolve_turn_route (no-route and fallback paths) +2. CLI _resolve_turn_agent_config passes credential_pool to primary dict +3. Gateway _resolve_turn_agent_config passes credential_pool to primary dict +4. Eager fallback deferred when credential pool has credentials +5. Eager fallback fires when no credential pool exists +6. Full 429 rotation cycle: retry-same → rotate → exhaust → fallback +""" + +import os +import time +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + + +# --------------------------------------------------------------------------- +# 1. smart_model_routing: credential_pool preserved in no-route path +# --------------------------------------------------------------------------- + +class TestSmartRoutingPoolPreservation: + def test_no_route_preserves_credential_pool(self): + from agent.smart_model_routing import resolve_turn_route + + fake_pool = MagicMock(name="CredentialPool") + primary = { + "model": "gpt-5.4", + "api_key": "sk-test", + "base_url": None, + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + "credential_pool": fake_pool, + } + # routing disabled + result = resolve_turn_route("hello", None, primary) + assert result["runtime"]["credential_pool"] is fake_pool + + def test_no_route_none_pool(self): + from agent.smart_model_routing import resolve_turn_route + + primary = { + "model": "gpt-5.4", + "api_key": "sk-test", + "base_url": None, + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + } + result = resolve_turn_route("hello", None, primary) + assert result["runtime"]["credential_pool"] is None + + def test_routing_disabled_preserves_pool(self): + from agent.smart_model_routing import resolve_turn_route + + fake_pool = MagicMock(name="CredentialPool") + primary = { + "model": "gpt-5.4", + "api_key": "sk-test", + "base_url": None, + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + "credential_pool": fake_pool, + } + # routing explicitly disabled + result = resolve_turn_route("hello", {"enabled": False}, primary) + assert result["runtime"]["credential_pool"] is fake_pool + + def test_route_fallback_on_resolve_error_preserves_pool(self, monkeypatch): + """When smart routing picks a cheap model but resolve_runtime_provider + fails, the fallback to primary must still include credential_pool.""" + from agent.smart_model_routing import resolve_turn_route + + fake_pool = MagicMock(name="CredentialPool") + primary = { + "model": "gpt-5.4", + "api_key": "sk-test", + "base_url": None, + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + "credential_pool": fake_pool, + } + routing_config = { + "enabled": True, + "cheap_model": "openai/gpt-4.1-mini", + "cheap_provider": "openrouter", + "max_tokens": 200, + "patterns": ["^(hi|hello|hey)"], + } + # Force resolve_runtime_provider to fail so it falls back to primary + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + MagicMock(side_effect=RuntimeError("no credentials")), + ) + result = resolve_turn_route("hi", routing_config, primary) + assert result["runtime"]["credential_pool"] is fake_pool + + +# --------------------------------------------------------------------------- +# 2 & 3. CLI and Gateway _resolve_turn_agent_config include credential_pool +# --------------------------------------------------------------------------- + +class TestCliTurnRoutePool: + def test_resolve_turn_includes_pool(self, monkeypatch, tmp_path): + """CLI's _resolve_turn_agent_config must pass credential_pool to primary.""" + from agent.smart_model_routing import resolve_turn_route + captured = {} + + def spy_resolve(user_message, routing_config, primary): + captured["primary"] = primary + return resolve_turn_route(user_message, routing_config, primary) + + monkeypatch.setattr( + "agent.smart_model_routing.resolve_turn_route", spy_resolve + ) + + # Build a minimal HermesCLI-like object with the method + shell = SimpleNamespace( + model="gpt-5.4", + api_key="sk-test", + base_url=None, + provider="openai-codex", + api_mode="codex_responses", + acp_command=None, + acp_args=[], + _credential_pool=MagicMock(name="FakePool"), + _smart_model_routing={"enabled": False}, + ) + + # Import and bind the real method + from cli import HermesCLI + bound = HermesCLI._resolve_turn_agent_config.__get__(shell) + bound("test message") + + assert "credential_pool" in captured["primary"] + assert captured["primary"]["credential_pool"] is shell._credential_pool + + +class TestGatewayTurnRoutePool: + def test_resolve_turn_includes_pool(self, monkeypatch): + """Gateway's _resolve_turn_agent_config must pass credential_pool.""" + from agent.smart_model_routing import resolve_turn_route + captured = {} + + def spy_resolve(user_message, routing_config, primary): + captured["primary"] = primary + return resolve_turn_route(user_message, routing_config, primary) + + monkeypatch.setattr( + "agent.smart_model_routing.resolve_turn_route", spy_resolve + ) + + from gateway.run import GatewayRunner + + runner = SimpleNamespace( + _smart_model_routing={"enabled": False}, + ) + + runtime_kwargs = { + "api_key": "sk-test", + "base_url": None, + "provider": "openai-codex", + "api_mode": "codex_responses", + "command": None, + "args": [], + "credential_pool": MagicMock(name="FakePool"), + } + + bound = GatewayRunner._resolve_turn_agent_config.__get__(runner) + bound("test message", "gpt-5.4", runtime_kwargs) + + assert "credential_pool" in captured["primary"] + assert captured["primary"]["credential_pool"] is runtime_kwargs["credential_pool"] + + +# --------------------------------------------------------------------------- +# 4 & 5. Eager fallback deferred/fires based on credential pool +# --------------------------------------------------------------------------- + +class TestEagerFallbackWithPool: + """Test the eager fallback guard in run_agent.py's error handling loop.""" + + def _make_agent(self, has_pool=True, pool_has_creds=True, has_fallback=True): + """Create a minimal AIAgent mock with the fields needed.""" + from run_agent import AIAgent + + with patch.object(AIAgent, "__init__", lambda self, **kw: None): + agent = AIAgent() + + agent._credential_pool = None + if has_pool: + pool = MagicMock() + pool.has_available.return_value = pool_has_creds + agent._credential_pool = pool + + agent._fallback_chain = [{"model": "fallback/model"}] if has_fallback else [] + agent._fallback_index = 0 + agent._try_activate_fallback = MagicMock(return_value=True) + agent._emit_status = MagicMock() + + return agent + + def test_eager_fallback_deferred_when_pool_has_credentials(self): + """429 with active pool should NOT trigger eager fallback.""" + agent = self._make_agent(has_pool=True, pool_has_creds=True, has_fallback=True) + + # Simulate the check from run_agent.py lines 7180-7191 + is_rate_limited = True + if is_rate_limited and agent._fallback_index < len(agent._fallback_chain): + pool = agent._credential_pool + pool_may_recover = pool is not None and pool.has_available() + if not pool_may_recover: + agent._try_activate_fallback() + + agent._try_activate_fallback.assert_not_called() + + def test_eager_fallback_fires_when_no_pool(self): + """429 without pool should trigger eager fallback.""" + agent = self._make_agent(has_pool=False, has_fallback=True) + + is_rate_limited = True + if is_rate_limited and agent._fallback_index < len(agent._fallback_chain): + pool = agent._credential_pool + pool_may_recover = pool is not None and pool.has_available() + if not pool_may_recover: + agent._try_activate_fallback() + + agent._try_activate_fallback.assert_called_once() + + def test_eager_fallback_fires_when_pool_exhausted(self): + """429 with exhausted pool should trigger eager fallback.""" + agent = self._make_agent(has_pool=True, pool_has_creds=False, has_fallback=True) + + is_rate_limited = True + if is_rate_limited and agent._fallback_index < len(agent._fallback_chain): + pool = agent._credential_pool + pool_may_recover = pool is not None and pool.has_available() + if not pool_may_recover: + agent._try_activate_fallback() + + agent._try_activate_fallback.assert_called_once() + + +# --------------------------------------------------------------------------- +# 6. Full 429 rotation cycle via _recover_with_credential_pool +# --------------------------------------------------------------------------- + +class TestPoolRotationCycle: + """Verify the retry-same → rotate → exhaust flow in _recover_with_credential_pool.""" + + def _make_agent_with_pool(self, pool_entries=3): + from run_agent import AIAgent + + with patch.object(AIAgent, "__init__", lambda self, **kw: None): + agent = AIAgent() + + entries = [] + for i in range(pool_entries): + e = MagicMock(name=f"entry_{i}") + e.id = f"cred-{i}" + entries.append(e) + + pool = MagicMock() + pool.has_credentials.return_value = True + + # mark_exhausted_and_rotate returns next entry until exhausted + self._rotation_index = 0 + + def rotate(status_code=None): + self._rotation_index += 1 + if self._rotation_index < pool_entries: + return entries[self._rotation_index] + pool.has_credentials.return_value = False + return None + + pool.mark_exhausted_and_rotate = MagicMock(side_effect=rotate) + agent._credential_pool = pool + agent._swap_credential = MagicMock() + agent.log_prefix = "" + + return agent, pool, entries + + def test_first_429_sets_retry_flag_no_rotation(self): + """First 429 should just set has_retried_429=True, no rotation.""" + agent, pool, _ = self._make_agent_with_pool(3) + recovered, has_retried = agent._recover_with_credential_pool( + status_code=429, has_retried_429=False + ) + assert recovered is False + assert has_retried is True + pool.mark_exhausted_and_rotate.assert_not_called() + + def test_second_429_rotates_to_next(self): + """Second consecutive 429 should rotate to next credential.""" + agent, pool, entries = self._make_agent_with_pool(3) + recovered, has_retried = agent._recover_with_credential_pool( + status_code=429, has_retried_429=True + ) + assert recovered is True + assert has_retried is False # reset after rotation + pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=429) + agent._swap_credential.assert_called_once_with(entries[1]) + + def test_pool_exhaustion_returns_false(self): + """When all credentials exhausted, recovery should return False.""" + agent, pool, _ = self._make_agent_with_pool(1) + # First 429 sets flag + _, has_retried = agent._recover_with_credential_pool( + status_code=429, has_retried_429=False + ) + assert has_retried is True + + # Second 429 tries to rotate but pool is exhausted (only 1 entry) + recovered, _ = agent._recover_with_credential_pool( + status_code=429, has_retried_429=True + ) + assert recovered is False + + def test_402_immediate_rotation(self): + """402 (billing) should immediately rotate, no retry-first.""" + agent, pool, entries = self._make_agent_with_pool(3) + recovered, has_retried = agent._recover_with_credential_pool( + status_code=402, has_retried_429=False + ) + assert recovered is True + assert has_retried is False + pool.mark_exhausted_and_rotate.assert_called_once_with(status_code=402) + + def test_no_pool_returns_false(self): + """No pool should return (False, unchanged).""" + from run_agent import AIAgent + + with patch.object(AIAgent, "__init__", lambda self, **kw: None): + agent = AIAgent() + agent._credential_pool = None + + recovered, has_retried = agent._recover_with_credential_pool( + status_code=429, has_retried_429=False + ) + assert recovered is False + assert has_retried is False From 9b99ea176e52c5daf319d1fe4e81689b29834807 Mon Sep 17 00:00:00 2001 From: Johannnnn506 Date: Tue, 31 Mar 2026 16:08:29 -0400 Subject: [PATCH 041/212] fix(cli): initialize ctx_len before compact banner path --- cli.py | 11 ++++++----- tests/test_cli_context_warning.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cli.py b/cli.py index 151ae4615727..0469d09b4a1c 100644 --- a/cli.py +++ b/cli.py @@ -2163,6 +2163,12 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No def show_banner(self): """Display the welcome banner in Claude Code style.""" self.console.clear() + + # Get context length for display before branching so it remains + # available to the low-context warning logic in compact mode too. + ctx_len = None + if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): + ctx_len = self.agent.context_compressor.context_length # Auto-compact for narrow terminals — the full banner with caduceus # + tool list needs ~80 columns minimum to render without wrapping. @@ -2179,11 +2185,6 @@ def show_banner(self): # Get terminal working directory (where commands will execute) cwd = os.getenv("TERMINAL_CWD", os.getcwd()) - # Get context length for display - ctx_len = None - if hasattr(self, 'agent') and self.agent and hasattr(self.agent, 'context_compressor'): - ctx_len = self.agent.context_compressor.context_length - # Build and display the banner build_welcome_banner( console=self.console, diff --git a/tests/test_cli_context_warning.py b/tests/test_cli_context_warning.py index fa0305a270c7..abf9c13499f0 100644 --- a/tests/test_cli_context_warning.py +++ b/tests/test_cli_context_warning.py @@ -145,3 +145,15 @@ def test_no_warning_when_no_context_length(self, cli_obj): calls = [str(c) for c in cli_obj.console.print.call_args_list] warning_calls = [c for c in calls if "too low" in c] assert len(warning_calls) == 0 + + def test_compact_banner_does_not_crash_on_narrow_terminal(self, cli_obj): + """Compact mode should still have ctx_len defined for warning logic.""" + cli_obj.agent.context_compressor.context_length = 4096 + + with patch("shutil.get_terminal_size", return_value=os.terminal_size((70, 40))), \ + patch("cli._build_compact_banner", return_value="compact banner"): + cli_obj.show_banner() + + calls = [str(c) for c in cli_obj.console.print.call_args_list] + warning_calls = [c for c in calls if "too low" in c] + assert len(warning_calls) == 1 From efa327a99806c6857660ea511721ab9cf3226cef Mon Sep 17 00:00:00 2001 From: Teknium Date: Wed, 1 Apr 2026 01:06:21 -0700 Subject: [PATCH 042/212] fix: add missing provider attrs to cli_obj test fixture _show_status() now references self.provider and self._provider_source, added after the original PR was submitted. --- tests/test_cli_context_warning.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cli_context_warning.py b/tests/test_cli_context_warning.py index abf9c13499f0..bf0c5aac43a0 100644 --- a/tests/test_cli_context_warning.py +++ b/tests/test_cli_context_warning.py @@ -32,6 +32,8 @@ def cli_obj(_isolate): obj.session_id = None obj.api_key = "test" obj.base_url = "" + obj.provider = "test" + obj._provider_source = None # Mock agent with context compressor obj.agent = SimpleNamespace( context_compressor=SimpleNamespace(context_length=None) From 7baee0b023394d38360c4518f2ce70bc71aee8c3 Mon Sep 17 00:00:00 2001 From: Smyile <84925446+davidetacchini@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:52:57 +0200 Subject: [PATCH 043/212] fix(docs): restrict backdrop-filter to desktop to fix mobile sidebar backdrop-filter on .navbar creates a new CSS stacking context that hides .navbar-sidebar menu content on mobile (only the close button is visible). Scope the blur effect to min-width: 997px so it only applies on desktop where the sidebar is not rendered inside the navbar. Ref: facebook/docusaurus#6996, facebook/docusaurus#6853 --- website/src/css/custom.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 7c70003917ff..469c6792e001 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -63,10 +63,18 @@ /* Navbar styling */ .navbar { - backdrop-filter: blur(12px); border-bottom: 1px solid rgba(255, 215, 0, 0.08); } +/* Frosted-glass blur — desktop only. + On mobile, backdrop-filter creates a stacking context that hides + the navbar-sidebar menu content (Docusaurus #6996). */ +@media (min-width: 997px) { + .navbar { + backdrop-filter: blur(12px); + } +} + .navbar__title { font-weight: 600; letter-spacing: -0.02em; From 8327f7cc611a874d7a009275766ac0335bc66403 Mon Sep 17 00:00:00 2001 From: Smyile <84925446+davidetacchini@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:56:23 +0200 Subject: [PATCH 044/212] fix(docs): use compound selector instead of media query Target the exact state that breaks: when .navbar-sidebar--show is active on the same