diff --git a/.env.example b/.env.example index 5567ca7ef..d273a6966 100644 --- a/.env.example +++ b/.env.example @@ -69,17 +69,6 @@ OPENCODE_GO_API_KEY= # Get at: https://parallel.ai PARALLEL_API_KEY= -# Tool-gateway config (Nous Subscribers only; preferred when available) -# Uses your Nous Subscriber OAuth access token from the Hermes auth store by default. -# Defaults to the Nous production gateway. Override for local dev. -# -# Derive vendor gateway URLs from a shared domain suffix: -# TOOL_GATEWAY_DOMAIN=nousresearch.com -# TOOL_GATEWAY_SCHEME=https -# -# Override the subscriber token (defaults to ~/.hermes/auth.json): -# TOOL_GATEWAY_USER_TOKEN= - # Firecrawl API Key - Web search, extract, and crawl # Get at: https://firecrawl.dev/ FIRECRAWL_API_KEY= diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 7a8d6d707..878c8658c 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -426,10 +426,14 @@ def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) - """Build a compact Nous subscription capability block for the system prompt.""" try: from hermes_cli.nous_subscription import get_nous_subscription_features + from tools.tool_backend_helpers import managed_nous_tools_enabled except Exception as exc: logger.debug("Failed to import Nous subscription helper: %s", exc) return "" + if not managed_nous_tools_enabled(): + return "" + valid_names = set(valid_tool_names or set()) relevant_tool_names = { "web_search", diff --git a/agent/smart_model_routing.py b/agent/smart_model_routing.py index d57cd1b83..dd445a03f 100644 --- a/agent/smart_model_routing.py +++ b/agent/smart_model_routing.py @@ -6,6 +6,8 @@ import os import re from typing import Any, Dict, Optional +from utils import is_truthy_value + _COMPLEX_KEYWORDS = { "debug", "debugging", @@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE) def _coerce_bool(value: Any, default: bool = False) -> bool: - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return bool(value) + return is_truthy_value(value, default=default) def _coerce_int(value: Any, default: int) -> int: diff --git a/gateway/config.py b/gateway/config.py index 935a50d74..1f84c7689 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -17,19 +17,14 @@ from typing import Dict, List, Optional, Any from enum import Enum from hermes_cli.config import get_hermes_home +from utils import is_truthy_value logger = logging.getLogger(__name__) def _coerce_bool(value: Any, default: bool = True) -> bool: """Coerce bool-ish config values, preserving a caller-provided default.""" - if value is None: - return default - if isinstance(value, bool): - return value - if isinstance(value, str): - return value.strip().lower() in ("true", "1", "yes", "on") - return bool(value) + return is_truthy_value(value, default=default) def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str: @@ -818,4 +813,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None: except ValueError: pass - diff --git a/hermes_cli/config.py b/hermes_cli/config.py index b5ed25d6d..211e264e4 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -22,6 +22,8 @@ import tempfile from pathlib import Path from typing import Dict, Any, Optional, List, Tuple +from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled + _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") # Env var names written to .env that aren't in OPTIONAL_ENV_VARS @@ -39,7 +41,6 @@ _EXTRA_ENV_KEYS = frozenset({ "MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM", }) - import yaml from hermes_cli.colors import Colors, color @@ -959,6 +960,15 @@ OPTIONAL_ENV_VARS = { }, } +if not _managed_nous_tools_enabled(): + for _hidden_var in ( + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ): + OPTIONAL_ENV_VARS.pop(_hidden_var, None) + def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]: """ diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f5f8e8615..063732235 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -11,6 +11,7 @@ from hermes_cli.config import get_env_value, load_config from tools.managed_tool_gateway import is_managed_tool_gateway_ready from tools.tool_backend_helpers import ( has_direct_modal_credentials, + managed_nous_tools_enabled, normalize_browser_cloud_provider, normalize_modal_mode, resolve_openai_audio_api_key, @@ -156,6 +157,7 @@ def get_nous_subscription_features( except Exception: nous_status = {} + managed_tools_flag = managed_nous_tools_enabled() nous_auth_present = bool(nous_status.get("logged_in")) subscribed = provider_is_nous or nous_auth_present @@ -193,11 +195,11 @@ def get_nous_subscription_features( direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY")) direct_modal = has_direct_modal_credentials() - managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl") - managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue") - managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio") - managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase") - managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal") + managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl") + managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue") + managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio") + managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase") + managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal") web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl web_active = bool( @@ -355,6 +357,9 @@ def get_nous_subscription_features( def get_nous_subscription_explainer_lines() -> list[str]: + if not managed_nous_tools_enabled(): + return [] + return [ "Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.", "Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.", @@ -364,6 +369,9 @@ def get_nous_subscription_explainer_lines() -> list[str]: def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]: """Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`.""" + if not managed_nous_tools_enabled(): + return set() + features = get_nous_subscription_features(config) if not features.provider_is_nous: return set() @@ -386,6 +394,9 @@ def apply_nous_managed_defaults( *, enabled_toolsets: Optional[Iterable[str]] = None, ) -> set[str]: + if not managed_nous_tools_enabled(): + return set() + features = get_nous_subscription_features(config) if not features.provider_is_nous: return set() diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5e27535a0..c5195ffa7 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -38,6 +38,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Set +from utils import env_var_enabled + try: import yaml except ImportError: # pragma: no cover – yaml is optional at import time @@ -65,7 +67,7 @@ _NS_PARENT = "hermes_plugins" def _env_enabled(name: str) -> bool: """Return True when an env var is set to a truthy opt-in value.""" - return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + return env_var_enabled(name) # --------------------------------------------------------------------------- diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 59c8d92c1..1abf37610 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -23,6 +23,7 @@ from hermes_cli.nous_subscription import ( get_nous_subscription_explainer_lines, get_nous_subscription_features, ) +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -59,9 +60,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None: def _print_nous_subscription_guidance() -> None: + lines = get_nous_subscription_explainer_lines() + if not lines: + return + print() print_header("Nous Subscription Tools") - for line in get_nous_subscription_explainer_lines(): + for line in lines: print_info(line) @@ -663,7 +668,7 @@ def _print_setup_summary(config: dict, hermes_home): tool_status.append(("Modal Execution (direct Modal)", True, None)) else: tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'")) - elif subscription_features.nous_auth_present: + elif managed_nous_tools_enabled() and subscription_features.nous_auth_present: tool_status.append(("Modal Execution (optional via Nous subscription)", True, None)) # Tinker + WandB (RL training) @@ -1912,7 +1917,7 @@ def _setup_tts_provider(config: dict): choices = [] providers = [] - if subscription_features.nous_auth_present: + if managed_nous_tools_enabled() and subscription_features.nous_auth_present: choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)") providers.append("nous-openai") choices.extend( @@ -2137,6 +2142,8 @@ def setup_terminal_backend(config: dict): from tools.tool_backend_helpers import normalize_modal_mode managed_modal_available = bool( + managed_nous_tools_enabled() + and get_nous_subscription_features(config).nous_auth_present and is_managed_tool_gateway_ready("modal") ) diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 649d41231..4b68c084b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -18,6 +18,7 @@ from hermes_cli.models import provider_label from hermes_cli.nous_subscription import get_nous_subscription_features from hermes_cli.runtime_provider import resolve_requested_provider from hermes_constants import OPENROUTER_MODELS_URL +from tools.tool_backend_helpers import managed_nous_tools_enabled def check_mark(ok: bool) -> str: if ok: @@ -190,26 +191,27 @@ def show_status(args): # ========================================================================= # Nous Subscription Features # ========================================================================= - features = get_nous_subscription_features(config) - print() - print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD)) - if not features.nous_auth_present: - print(" Nous Portal ✗ not logged in") - else: - print(" Nous Portal ✓ managed tools available") - for feature in features.items(): - if feature.managed_by_nous: - state = "active via Nous subscription" - elif feature.active: - current = feature.current_provider or "configured provider" - state = f"active via {current}" - elif feature.included_by_default and features.nous_auth_present: - state = "included by subscription, not currently selected" - elif feature.key == "modal" and features.nous_auth_present: - state = "available via subscription (optional)" + if managed_nous_tools_enabled(): + features = get_nous_subscription_features(config) + print() + print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD)) + if not features.nous_auth_present: + print(" Nous Portal ✗ not logged in") else: - state = "not configured" - print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") + print(" Nous Portal ✓ managed tools available") + for feature in features.items(): + if feature.managed_by_nous: + state = "active via Nous subscription" + elif feature.active: + current = feature.current_provider or "configured provider" + state = f"active via {current}" + elif feature.included_by_default and features.nous_auth_present: + state = "included by subscription, not currently selected" + elif feature.key == "modal" and features.nous_auth_present: + state = "available via subscription (optional)" + else: + state = "not configured" + print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}") # ========================================================================= # API-Key Providers diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 2226d5173..4046f40ac 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -22,6 +22,7 @@ from hermes_cli.nous_subscription import ( apply_nous_managed_defaults, get_nous_subscription_features, ) +from tools.tool_backend_helpers import managed_nous_tools_enabled PROJECT_ROOT = Path(__file__).parent.parent.resolve() @@ -737,6 +738,8 @@ def _visible_providers(cat: dict, config: dict) -> list[dict]: features = get_nous_subscription_features(config) visible = [] for provider in cat.get("providers", []): + if provider.get("managed_nous_feature") and not managed_nous_tools_enabled(): + continue if provider.get("requires_nous_auth") and not features.nous_auth_present: continue visible.append(provider) @@ -1234,9 +1237,10 @@ def tools_command(args=None, first_install: bool = False, config: dict = None): config, enabled_toolsets=new_enabled, ) - for ts_key in sorted(auto_configured): - label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) - print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) + if managed_nous_tools_enabled(): + for ts_key in sorted(auto_configured): + label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key) + print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN)) # Walk through ALL selected tools that have provider options or # need API keys. This ensures browser (Local vs Browserbase), diff --git a/run_agent.py b/run_agent.py index 186e20711..cd3884c52 100644 --- a/run_agent.py +++ b/run_agent.py @@ -96,7 +96,7 @@ from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, ) -from utils import atomic_json_write +from utils import atomic_json_write, env_var_enabled HONCHO_TOOL_NAMES = { "honcho_context", @@ -2005,7 +2005,7 @@ class AIAgent: self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}") - if os.getenv("HERMES_DUMP_REQUEST_STDOUT", "").strip().lower() in {"1", "true", "yes", "on"}: + if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"): print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str)) return dump_file @@ -6052,7 +6052,7 @@ class AIAgent: if self.api_mode == "codex_responses": api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False) - if os.getenv("HERMES_DUMP_REQUESTS", "").strip().lower() in {"1", "true", "yes", "on"}: + if env_var_enabled("HERMES_DUMP_REQUESTS"): self._dump_api_request_debug(api_kwargs, reason="preflight") # Always prefer the streaming path — even without stream diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index f1859b036..deeac8990 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -401,6 +401,7 @@ class TestBuildSkillsSystemPrompt: class TestBuildNousSubscriptionPrompt: def test_includes_active_subscription_features(self, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setattr( "hermes_cli.nous_subscription.get_nous_subscription_features", lambda config=None: NousSubscriptionFeatures( @@ -424,6 +425,7 @@ class TestBuildNousSubscriptionPrompt: assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys" in prompt def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setattr( "hermes_cli.nous_subscription.get_nous_subscription_features", lambda config=None: NousSubscriptionFeatures( @@ -445,6 +447,13 @@ class TestBuildNousSubscriptionPrompt: assert "suggest Nous subscription as one option" in prompt assert "Do not mention subscription unless" in prompt + def test_feature_flag_off_returns_empty_prompt(self, monkeypatch): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + + prompt = build_nous_subscription_prompt({"web_search"}) + + assert prompt == "" + # ========================================================================= # Context files prompt builder diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 66af7faf0..1a4839de4 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -183,6 +183,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) _clear_provider_env(monkeypatch) @@ -270,6 +271,7 @@ def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch): def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) config = load_config() @@ -311,6 +313,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False) diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index 2056aac4f..1e6531d37 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -64,6 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") from hermes_cli import status as status_mod _patch_common_status_deps(monkeypatch, status_mod, tmp_path) @@ -100,3 +101,24 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path assert "Nous Subscription Features" in out assert "Browser automation" in out assert "active via Nous subscription" in out + + +def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + from hermes_cli import status as status_mod + + _patch_common_status_deps(monkeypatch, status_mod, tmp_path) + monkeypatch.setattr( + status_mod, + "load_config", + lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}}, + raising=False, + ) + monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False) + monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False) + monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False) + + status_mod.show_status(SimpleNamespace(all=False, deep=False)) + + out = capsys.readouterr().out + assert "Nous Subscription Features" not in out diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index ebcef8327..dccbce9d3 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -248,6 +248,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present() def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") config = {"model": {"provider": "nous"}} monkeypatch.setattr( @@ -260,6 +261,20 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch) assert providers[0]["name"].startswith("Nous Subscription") +def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch): + monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False) + config = {"model": {"provider": "nous"}} + + monkeypatch.setattr( + "hermes_cli.nous_subscription.get_nous_auth_status", + lambda: {"logged_in": True}, + ) + + providers = _visible_providers(TOOL_CATEGORIES["browser"], config) + + assert all(not provider["name"].startswith("Nous Subscription") for provider in providers) + + def test_local_browser_provider_is_saved_explicitly(monkeypatch): config = {} local_provider = next( @@ -275,6 +290,7 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch): def test_first_install_nous_auto_configures_managed_defaults(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") config = { "model": {"provider": "nous"}, "platform_toolsets": {"cli": []}, diff --git a/tests/test_cli_provider_resolution.py b/tests/test_cli_provider_resolution.py index 65bcdf5c7..cef89cf16 100644 --- a/tests/test_cli_provider_resolution.py +++ b/tests/test_cli_provider_resolution.py @@ -277,6 +277,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch): def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") config = { "model": {"provider": "nous", "default": "claude-opus-4-6"}, "tts": {"provider": "elevenlabs"}, @@ -315,6 +316,7 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") config = { "model": {"provider": "nous", "default": "claude-opus-4-6"}, "tts": {"provider": "edge"}, diff --git a/tests/test_utils_truthy_values.py b/tests/test_utils_truthy_values.py new file mode 100644 index 000000000..f6d2856f4 --- /dev/null +++ b/tests/test_utils_truthy_values.py @@ -0,0 +1,29 @@ +"""Tests for shared truthy-value helpers.""" + +from utils import env_var_enabled, is_truthy_value + + +def test_is_truthy_value_accepts_common_truthy_strings(): + assert is_truthy_value("true") is True + assert is_truthy_value(" YES ") is True + assert is_truthy_value("on") is True + assert is_truthy_value("1") is True + + +def test_is_truthy_value_respects_default_for_none(): + assert is_truthy_value(None, default=True) is True + assert is_truthy_value(None, default=False) is False + + +def test_is_truthy_value_rejects_falsey_strings(): + assert is_truthy_value("false") is False + assert is_truthy_value("0") is False + assert is_truthy_value("off") is False + + +def test_env_var_enabled_uses_shared_truthy_rules(monkeypatch): + monkeypatch.setenv("HERMES_TEST_BOOL", "YeS") + assert env_var_enabled("HERMES_TEST_BOOL") is True + + monkeypatch.setenv("HERMES_TEST_BOOL", "no") + assert env_var_enabled("HERMES_TEST_BOOL") is False diff --git a/tests/tools/test_managed_browserbase_and_modal.py b/tests/tools/test_managed_browserbase_and_modal.py index 3d97a4373..085f19cfd 100644 --- a/tests/tools/test_managed_browserbase_and_modal.py +++ b/tests/tools/test_managed_browserbase_and_modal.py @@ -45,6 +45,11 @@ def _restore_tool_and_agent_modules(): sys.modules.update(original_modules) +@pytest.fixture(autouse=True) +def _enable_managed_nous_tools(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + + def _install_fake_tools_package(): _reset_modules(("tools", "agent")) diff --git a/tests/tools/test_managed_media_gateways.py b/tests/tools/test_managed_media_gateways.py index 48cd5f41f..9a2d8391c 100644 --- a/tests/tools/test_managed_media_gateways.py +++ b/tests/tools/test_managed_media_gateways.py @@ -44,6 +44,11 @@ def _restore_tool_and_agent_modules(): sys.modules.update(original_modules) +@pytest.fixture(autouse=True) +def _enable_managed_nous_tools(monkeypatch): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") + + def _install_fake_tools_package(): tools_package = types.ModuleType("tools") tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined] diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 591708345..39b9125e1 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -16,7 +16,14 @@ resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain(): - with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ): result = resolve_managed_tool_gateway( "firecrawl", token_reader=lambda: "nous-token", @@ -29,7 +36,14 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain() def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): - with patch.dict(os.environ, {"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/"}, clear=False): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/", + }, + clear=False, + ): result = resolve_managed_tool_gateway( "browserbase", token_reader=lambda: "nous-token", @@ -40,7 +54,14 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override(): def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): - with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + with patch.dict( + os.environ, + { + "HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1", + "TOOL_GATEWAY_DOMAIN": "nousresearch.com", + }, + clear=False, + ): result = resolve_managed_tool_gateway( "firecrawl", token_reader=lambda: None, @@ -49,6 +70,16 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token(): assert result is None +def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag(): + with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False): + result = resolve_managed_tool_gateway( + "firecrawl", + token_reader=lambda: "nous-token", + ) + + assert result is None + + def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch): monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False) monkeypatch.setenv("HERMES_HOME", str(tmp_path)) diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index c93d68e17..c55fc8310 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -7,6 +7,7 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool") def _clear_terminal_env(monkeypatch): """Remove terminal env vars that could affect requirements checks.""" keys = [ + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "TERMINAL_ENV", "TERMINAL_MODAL_MODE", "TERMINAL_SSH_HOST", @@ -73,13 +74,14 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, assert ok is False assert any( - "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" in record.getMessage() + "Modal backend selected but no direct Modal credentials/config was found" in record.getMessage() for record in caplog.records ) def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path): _clear_terminal_env(monkeypatch) + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("TERMINAL_ENV", "modal") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) @@ -115,3 +117,21 @@ def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, ca "TERMINAL_MODAL_MODE=direct" in record.getMessage() for record in caplog.records ) + + +def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkeypatch, caplog, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "modal") + monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("USERPROFILE", str(tmp_path)) + monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False) + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage() + for record in caplog.records + ) diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index 216284932..d0ce42735 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -28,6 +28,7 @@ class TestTerminalRequirements: assert {"read_file", "write_file", "patch", "search_files"}.issubset(names) def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1") monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.delenv("MODAL_TOKEN_ID", raising=False) diff --git a/tests/tools/test_web_tools_config.py b/tests/tools/test_web_tools_config.py index 1354c2431..93ab6846f 100644 --- a/tests/tools/test_web_tools_config.py +++ b/tests/tools/test_web_tools_config.py @@ -11,6 +11,8 @@ Coverage: import importlib import json import os +import sys +import types import pytest from unittest.mock import patch, MagicMock, AsyncMock @@ -24,6 +26,7 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -32,6 +35,7 @@ class TestFirecrawlClientConfig: "TOOL_GATEWAY_USER_TOKEN", ): os.environ.pop(key, None) + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" def teardown_method(self): """Reset client after each test.""" @@ -39,6 +43,7 @@ class TestFirecrawlClientConfig: tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client_config = None for key in ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "FIRECRAWL_GATEWAY_URL", @@ -293,6 +298,7 @@ class TestBackendSelection: """ _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", @@ -304,8 +310,10 @@ class TestBackendSelection: ) def setup_method(self): + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - os.environ.pop(key, None) + if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": + os.environ.pop(key, None) def teardown_method(self): for key in self._ENV_KEYS: @@ -417,11 +425,25 @@ class TestParallelClientConfig: import tools.web_tools tools.web_tools._parallel_client = None os.environ.pop("PARALLEL_API_KEY", None) + fake_parallel = types.ModuleType("parallel") + + class Parallel: + def __init__(self, api_key): + self.api_key = api_key + + class AsyncParallel: + def __init__(self, api_key): + self.api_key = api_key + + fake_parallel.Parallel = Parallel + fake_parallel.AsyncParallel = AsyncParallel + sys.modules["parallel"] = fake_parallel def teardown_method(self): import tools.web_tools tools.web_tools._parallel_client = None os.environ.pop("PARALLEL_API_KEY", None) + sys.modules.pop("parallel", None) def test_creates_client_with_key(self): """PARALLEL_API_KEY set → creates Parallel client.""" @@ -479,6 +501,7 @@ class TestCheckWebApiKey: """Test suite for check_web_api_key() unified availability check.""" _ENV_KEYS = ( + "HERMES_ENABLE_NOUS_MANAGED_TOOLS", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", @@ -490,8 +513,10 @@ class TestCheckWebApiKey: ) def setup_method(self): + os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1" for key in self._ENV_KEYS: - os.environ.pop(key, None) + if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS": + os.environ.pop(key, None) def teardown_method(self): for key in self._ENV_KEYS: diff --git a/tools/browser_providers/browserbase.py b/tools/browser_providers/browserbase.py index 342b430b1..5c580c3f3 100644 --- a/tools/browser_providers/browserbase.py +++ b/tools/browser_providers/browserbase.py @@ -10,6 +10,7 @@ import requests from tools.browser_providers.base import CloudBrowserProvider from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) _pending_create_keys: Dict[str, str] = {} @@ -93,10 +94,15 @@ class BrowserbaseProvider(CloudBrowserProvider): def _get_config(self) -> Dict[str, Any]: config = self._get_config_or_none() if config is None: - raise ValueError( - "Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials " - "or a managed Browserbase gateway configuration." + message = ( + "Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials." ) + if managed_nous_tools_enabled(): + message = ( + "Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID " + "credentials or a managed Browserbase gateway configuration." + ) + raise ValueError(message) return config def create_session(self, task_id: str) -> Dict[str, object]: diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 84edb93fe..77e090529 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -39,6 +39,7 @@ from urllib.parse import urlencode import fal_client from tools.debug_helpers import DebugSession from tools.managed_tool_gateway import resolve_managed_tool_gateway +from tools.tool_backend_helpers import managed_nous_tools_enabled logger = logging.getLogger(__name__) @@ -416,9 +417,10 @@ def image_generate_tool( # Check API key availability if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()): - raise ValueError( - "FAL_KEY environment variable not set and managed FAL gateway is unavailable" - ) + message = "FAL_KEY environment variable not set" + if managed_nous_tools_enabled(): + message += " and managed FAL gateway is unavailable" + raise ValueError(message) # Validate other parameters validated_params = _validate_parameters( diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 96dd27b30..4d9da52bf 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -9,6 +9,7 @@ from dataclasses import dataclass from typing import Callable, Optional from hermes_cli.config import get_hermes_home +from tools.tool_backend_helpers import managed_nous_tools_enabled _DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com" _DEFAULT_TOOL_GATEWAY_SCHEME = "https" @@ -131,6 +132,9 @@ def resolve_managed_tool_gateway( token_reader: Optional[Callable[[], Optional[str]]] = None, ) -> Optional[ManagedToolGatewayConfig]: """Resolve shared managed-tool gateway config for a vendor.""" + if not managed_nous_tools_enabled(): + return None + resolved_gateway_builder = gateway_builder or build_vendor_gateway_url resolved_token_reader = token_reader or read_nous_access_token diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 13b724bf5..d9d2fa4f7 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -65,7 +65,12 @@ def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None: # Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py from tools.environments.singularity import _get_scratch_dir -from tools.tool_backend_helpers import has_direct_modal_credentials, normalize_modal_mode +from tools.tool_backend_helpers import ( + coerce_modal_mode, + has_direct_modal_credentials, + managed_nous_tools_enabled, + normalize_modal_mode, +) # Disk usage warning threshold (in GB) @@ -506,7 +511,7 @@ def _get_env_config() -> Dict[str, Any]: return { "env_type": env_type, - "modal_mode": normalize_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), + "modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")), "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), @@ -541,9 +546,13 @@ def _get_env_config() -> Dict[str, Any]: def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: """Resolve direct vs managed Modal backend selection.""" + requested_mode = coerce_modal_mode(modal_mode) normalized_mode = normalize_modal_mode(modal_mode) has_direct = has_direct_modal_credentials() managed_ready = is_managed_tool_gateway_ready("modal") + managed_mode_blocked = ( + requested_mode == "managed" and not managed_nous_tools_enabled() + ) if normalized_mode == "managed": selected_backend = "managed" if managed_ready else None @@ -553,9 +562,11 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: selected_backend = "direct" if has_direct else "managed" if managed_ready else None return { + "requested_mode": requested_mode, "mode": normalized_mode, "has_direct": has_direct, "managed_ready": managed_ready, + "managed_mode_blocked": managed_mode_blocked, "selected_backend": selected_backend, } @@ -636,6 +647,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) if modal_state["selected_backend"] != "direct": + if modal_state["managed_mode_blocked"]: + raise ValueError( + "Modal backend is configured for managed mode, but " + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct " + "Modal credentials/config were found. Enable the feature flag or " + "choose TERMINAL_MODAL_MODE=direct/auto." + ) if modal_state["mode"] == "managed": raise ValueError( "Modal backend is configured for managed mode, but the managed tool gateway is unavailable." @@ -644,9 +662,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, raise ValueError( "Modal backend is configured for direct mode, but no direct Modal credentials/config were found." ) - raise ValueError( - "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found." - ) + message = "Modal backend selected but no direct Modal credentials/config was found." + if managed_nous_tools_enabled(): + message = ( + "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found." + ) + raise ValueError(message) return _ModalEnvironment( image=image, cwd=cwd, timeout=timeout, @@ -1283,25 +1304,48 @@ def check_terminal_requirements() -> bool: return True if modal_state["selected_backend"] != "direct": + if modal_state["managed_mode_blocked"]: + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=managed, but " + "HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct " + "Modal credentials/config were found. Enable the feature flag " + "or choose TERMINAL_MODAL_MODE=direct/auto." + ) + return False if modal_state["mode"] == "managed": logger.error( "Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed " "tool gateway is unavailable. Configure the managed gateway or choose " "TERMINAL_MODAL_MODE=direct/auto." ) + return False elif modal_state["mode"] == "direct": - logger.error( - "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " - "Modal credentials/config were found. Configure Modal or choose " - "TERMINAL_MODAL_MODE=managed/auto." - ) + if managed_nous_tools_enabled(): + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " + "Modal credentials/config were found. Configure Modal or choose " + "TERMINAL_MODAL_MODE=managed/auto." + ) + else: + logger.error( + "Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct " + "Modal credentials/config were found. Configure Modal or choose " + "TERMINAL_MODAL_MODE=auto." + ) + return False else: - logger.error( - "Modal backend selected but no direct Modal credentials/config or managed " - "tool gateway was found. Configure Modal, set up the managed gateway, " - "or choose a different TERMINAL_ENV." - ) - return False + if managed_nous_tools_enabled(): + logger.error( + "Modal backend selected but no direct Modal credentials/config or managed " + "tool gateway was found. Configure Modal, set up the managed gateway, " + "or choose a different TERMINAL_ENV." + ) + else: + logger.error( + "Modal backend selected but no direct Modal credentials/config was found. " + "Configure Modal or choose a different TERMINAL_ENV." + ) + return False if importlib.util.find_spec("swerex") is None: logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'") diff --git a/tools/tool_backend_helpers.py b/tools/tool_backend_helpers.py index bcf93e849..4b8d9d157 100644 --- a/tools/tool_backend_helpers.py +++ b/tools/tool_backend_helpers.py @@ -5,26 +5,40 @@ from __future__ import annotations import os from pathlib import Path +from utils import env_var_enabled _DEFAULT_BROWSER_PROVIDER = "local" _DEFAULT_MODAL_MODE = "auto" _VALID_MODAL_MODES = {"auto", "direct", "managed"} +def managed_nous_tools_enabled() -> bool: + """Return True when the hidden Nous-managed tools feature flag is enabled.""" + return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS") + + def normalize_browser_cloud_provider(value: object | None) -> str: """Return a normalized browser provider key.""" provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower() return provider or _DEFAULT_BROWSER_PROVIDER -def normalize_modal_mode(value: object | None) -> str: - """Return a normalized modal execution mode.""" +def coerce_modal_mode(value: object | None) -> str: + """Return the requested modal mode when valid, else the default.""" mode = str(value or _DEFAULT_MODAL_MODE).strip().lower() if mode in _VALID_MODAL_MODES: return mode return _DEFAULT_MODAL_MODE +def normalize_modal_mode(value: object | None) -> str: + """Return a normalized modal execution mode.""" + mode = coerce_modal_mode(value) + if mode == "managed" and not managed_nous_tools_enabled(): + return "direct" + return mode + + def has_direct_modal_credentials() -> bool: """Return True when direct Modal credentials/config are available.""" return bool( diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index ae05358b8..4a1f7ed51 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -33,8 +33,9 @@ from pathlib import Path from typing import Optional, Dict, Any from urllib.parse import urljoin +from utils import is_truthy_value from tools.managed_tool_gateway import resolve_managed_tool_gateway -from tools.tool_backend_helpers import resolve_openai_audio_api_key +from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key from hermes_constants import get_hermes_home @@ -122,11 +123,7 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool: if stt_config is None: stt_config = _load_stt_config() enabled = stt_config.get("enabled", True) - if isinstance(enabled, str): - return enabled.strip().lower() in ("true", "1", "yes", "on") - if enabled is None: - return True - return bool(enabled) + return is_truthy_value(enabled, default=True) def _has_openai_audio_backend() -> bool: @@ -586,9 +583,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]: managed_gateway = resolve_managed_tool_gateway("openai-audio") if managed_gateway is None: - raise ValueError( - "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" - ) + message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set" + if managed_nous_tools_enabled(): + message += ", and the managed OpenAI audio gateway is unavailable" + raise ValueError(message) return managed_gateway.nous_user_token, urljoin( f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c71cdb1e8..9210c3318 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -40,7 +40,7 @@ from urllib.parse import urljoin logger = logging.getLogger(__name__) from tools.managed_tool_gateway import resolve_managed_tool_gateway -from tools.tool_backend_helpers import resolve_openai_audio_api_key +from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key # --------------------------------------------------------------------------- # Lazy imports -- providers are imported only when actually used to avoid @@ -565,9 +565,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]: managed_gateway = resolve_managed_tool_gateway("openai-audio") if managed_gateway is None: - raise ValueError( - "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable" - ) + message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set" + if managed_nous_tools_enabled(): + message += ", and the managed OpenAI audio gateway is unavailable" + raise ValueError(message) return managed_gateway.nous_user_token, urljoin( f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1" diff --git a/tools/web_tools.py b/tools/web_tools.py index 1ebf36d77..7e9e84483 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -54,6 +54,7 @@ from tools.managed_tool_gateway import ( read_nous_access_token as _read_nous_access_token, resolve_managed_tool_gateway, ) +from tools.tool_backend_helpers import managed_nous_tools_enabled from tools.url_safety import is_safe_url from tools.website_policy import check_website_access @@ -152,12 +153,46 @@ def _has_direct_firecrawl_config() -> bool: def _raise_web_backend_configuration_error() -> None: """Raise a clear error for unsupported web backend configuration.""" - raise ValueError( + message = ( "Web tools are not configured. " - "Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, " - "or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide " - "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN." + "Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance." ) + if managed_nous_tools_enabled(): + message += ( + " If you have the hidden Nous-managed tools flag enabled, you can also login to Nous " + "(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN." + ) + raise ValueError(message) + + +def _firecrawl_backend_help_suffix() -> str: + """Return optional managed-gateway guidance for Firecrawl help text.""" + if not managed_nous_tools_enabled(): + return "" + return ( + ", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use " + "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN" + ) + + +def _web_requires_env() -> list[str]: + """Return tool metadata env vars for the currently enabled web backends.""" + requires = [ + "PARALLEL_API_KEY", + "TAVILY_API_KEY", + "FIRECRAWL_API_KEY", + "FIRECRAWL_API_URL", + ] + if managed_nous_tools_enabled(): + requires.extend( + [ + "FIRECRAWL_GATEWAY_URL", + "TOOL_GATEWAY_DOMAIN", + "TOOL_GATEWAY_SCHEME", + "TOOL_GATEWAY_USER_TOKEN", + ] + ) + return requires def _get_firecrawl_client(): @@ -1410,10 +1445,8 @@ async def web_crawl_tool( # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API if not check_firecrawl_api_key(): return json.dumps({ - "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " - "or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, " - "or TOOL_GATEWAY_DOMAIN, " - "or use web_search + web_extract instead.", + "error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL" + f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.", "success": False, }, ensure_ascii=False) @@ -1754,9 +1787,8 @@ if __name__ == "__main__": else: print("❌ No web search backend configured") print( - "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, " - "or, if you are a Nous Subscriber, login to Nous and use " - "FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN" + "Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL" + f"{_firecrawl_backend_help_suffix()}" ) if not nous_available: @@ -1867,16 +1899,7 @@ registry.register( schema=WEB_SEARCH_SCHEMA, handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5), check_fn=check_web_api_key, - requires_env=[ - "PARALLEL_API_KEY", - "TAVILY_API_KEY", - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - "FIRECRAWL_API_KEY", - "FIRECRAWL_API_URL", - ], + requires_env=_web_requires_env(), emoji="🔍", ) registry.register( @@ -1886,16 +1909,7 @@ registry.register( handler=lambda args, **kw: web_extract_tool( args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"), check_fn=check_web_api_key, - requires_env=[ - "PARALLEL_API_KEY", - "TAVILY_API_KEY", - "FIRECRAWL_GATEWAY_URL", - "TOOL_GATEWAY_DOMAIN", - "TOOL_GATEWAY_SCHEME", - "TOOL_GATEWAY_USER_TOKEN", - "FIRECRAWL_API_KEY", - "FIRECRAWL_API_URL", - ], + requires_env=_web_requires_env(), is_async=True, emoji="📄", ) diff --git a/utils.py b/utils.py index 66d552909..9a2105d54 100644 --- a/utils.py +++ b/utils.py @@ -9,6 +9,25 @@ from typing import Any, Union import yaml +TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"}) + + +def is_truthy_value(value: Any, default: bool = False) -> bool: + """Coerce bool-ish values using the project's shared truthy string set.""" + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in TRUTHY_STRINGS + return bool(value) + + +def env_var_enabled(name: str, default: str = "") -> bool: + """Return True when an environment variable is set to a truthy value.""" + return is_truthy_value(os.getenv(name, default), default=False) + + def atomic_json_write( path: Union[str, Path], data: Any, diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index d7d689580..d228c3927 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -78,9 +78,6 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) | | `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) | | `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) | -| `TOOL_GATEWAY_DOMAIN` | Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, for example `nousresearch.com` -> `firecrawl-gateway.nousresearch.com` | -| `TOOL_GATEWAY_SCHEME` | Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts, `https` by default and `http` for local gateway testing | -| `TOOL_GATEWAY_USER_TOKEN` | Explicit Nous Subscriber access token for tool-gateway calls (optional; otherwise Hermes reads `~/.hermes/auth.json`) | | `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) | | `BROWSERBASE_PROJECT_ID` | Browserbase project ID | | `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 1d3085798..4aa5afb0b 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -725,9 +725,9 @@ If terminal commands fail immediately or the terminal tool is reported as disabl - If either value is missing, Hermes will log a clear error and refuse to use the SSH backend. - **Modal backend** - - Hermes can use either direct Modal credentials (`MODAL_TOKEN_ID` plus `MODAL_TOKEN_SECRET`, or `~/.modal.toml`) or a configured managed tool gateway with a Nous user token. + - You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox. - - If neither direct credentials nor a managed gateway is present, Hermes will report that the Modal backend is not available. + - If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index bbea0a262..981d2caf2 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -109,13 +109,6 @@ modal setup hermes config set terminal.backend modal ``` -Hermes can use Modal in two modes: - -- **Direct Modal**: Hermes talks to your Modal account directly. -- **Managed Modal**: Hermes talks to a gateway that owns the vendor credentials. - -In both cases, Modal is best treated as a task sandbox, not a deployment target. Persistent mode preserves filesystem state so later turns can resume your work, but Hermes may still clean up or recreate the live sandbox. Long-running servers and background processes are not guaranteed to survive idle cleanup, session teardown, or Hermes exit. - ### Container Resources Configure CPU, memory, disk, and persistence for all container backends: