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.

This commit is contained in:
Robin Fernandes 2026-03-30 13:28:10 +09:00
parent e95965d76a
commit 1cbb1b99cc
35 changed files with 426 additions and 147 deletions

View file

@ -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=

View file

@ -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",

View file

@ -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:

View file

@ -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

View file

@ -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]]:
"""

View file

@ -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()

View file

@ -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)
# ---------------------------------------------------------------------------

View file

@ -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")
)

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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": []},

View file

@ -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"},

View file

@ -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

View file

@ -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"))

View file

@ -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]

View file

@ -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))

View file

@ -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
)

View file

@ -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)

View file

@ -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:

View file

@ -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]:

View file

@ -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(

View file

@ -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

View file

@ -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]'")

View file

@ -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(

View file

@ -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"

View file

@ -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"

View file

@ -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="📄",
)

View file

@ -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,

View file

@ -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/)) |

View file

@ -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.

View file

@ -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: