mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
e95965d76a
commit
1cbb1b99cc
35 changed files with 426 additions and 147 deletions
11
.env.example
11
.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=
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": []},
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
29
tests/test_utils_truthy_values.py
Normal file
29
tests/test_utils_truthy_values.py
Normal 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
|
||||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]'")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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="📄",
|
||||
)
|
||||
|
|
|
|||
19
utils.py
19
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,
|
||||
|
|
|
|||
|
|
@ -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/)) |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue