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
|
# Get at: https://parallel.ai
|
||||||
PARALLEL_API_KEY=
|
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
|
# Firecrawl API Key - Web search, extract, and crawl
|
||||||
# Get at: https://firecrawl.dev/
|
# Get at: https://firecrawl.dev/
|
||||||
FIRECRAWL_API_KEY=
|
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."""
|
"""Build a compact Nous subscription capability block for the system prompt."""
|
||||||
try:
|
try:
|
||||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
if not managed_nous_tools_enabled():
|
||||||
|
return ""
|
||||||
|
|
||||||
valid_names = set(valid_tool_names or set())
|
valid_names = set(valid_tool_names or set())
|
||||||
relevant_tool_names = {
|
relevant_tool_names = {
|
||||||
"web_search",
|
"web_search",
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from utils import is_truthy_value
|
||||||
|
|
||||||
_COMPLEX_KEYWORDS = {
|
_COMPLEX_KEYWORDS = {
|
||||||
"debug",
|
"debug",
|
||||||
"debugging",
|
"debugging",
|
||||||
|
|
@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||||
if value is None:
|
return is_truthy_value(value, default=default)
|
||||||
return default
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_int(value: Any, default: int) -> int:
|
def _coerce_int(value: Any, default: int) -> int:
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,14 @@ from typing import Dict, List, Optional, Any
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from hermes_cli.config import get_hermes_home
|
from hermes_cli.config import get_hermes_home
|
||||||
|
from utils import is_truthy_value
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||||
"""Coerce bool-ish config values, preserving a caller-provided default."""
|
"""Coerce bool-ish config values, preserving a caller-provided default."""
|
||||||
if value is None:
|
return is_truthy_value(value, default=default)
|
||||||
return default
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, str):
|
|
||||||
return value.strip().lower() in ("true", "1", "yes", "on")
|
|
||||||
return bool(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||||
|
|
@ -818,4 +813,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
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"
|
_IS_WINDOWS = platform.system() == "Windows"
|
||||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
_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
|
# 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",
|
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
|
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
|
||||||
})
|
})
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from hermes_cli.colors import Colors, color
|
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]]:
|
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.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||||
from tools.tool_backend_helpers import (
|
from tools.tool_backend_helpers import (
|
||||||
has_direct_modal_credentials,
|
has_direct_modal_credentials,
|
||||||
|
managed_nous_tools_enabled,
|
||||||
normalize_browser_cloud_provider,
|
normalize_browser_cloud_provider,
|
||||||
normalize_modal_mode,
|
normalize_modal_mode,
|
||||||
resolve_openai_audio_api_key,
|
resolve_openai_audio_api_key,
|
||||||
|
|
@ -156,6 +157,7 @@ def get_nous_subscription_features(
|
||||||
except Exception:
|
except Exception:
|
||||||
nous_status = {}
|
nous_status = {}
|
||||||
|
|
||||||
|
managed_tools_flag = managed_nous_tools_enabled()
|
||||||
nous_auth_present = bool(nous_status.get("logged_in"))
|
nous_auth_present = bool(nous_status.get("logged_in"))
|
||||||
subscribed = provider_is_nous or nous_auth_present
|
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_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
|
||||||
direct_modal = has_direct_modal_credentials()
|
direct_modal = has_direct_modal_credentials()
|
||||||
|
|
||||||
managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
managed_web_available = managed_tools_flag and 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_image_available = managed_tools_flag and 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_tts_available = managed_tools_flag and 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_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase")
|
||||||
managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal")
|
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_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
|
||||||
web_active = bool(
|
web_active = bool(
|
||||||
|
|
@ -355,6 +357,9 @@ def get_nous_subscription_features(
|
||||||
|
|
||||||
|
|
||||||
def get_nous_subscription_explainer_lines() -> list[str]:
|
def get_nous_subscription_explainer_lines() -> list[str]:
|
||||||
|
if not managed_nous_tools_enabled():
|
||||||
|
return []
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
|
"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.",
|
"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]:
|
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
|
||||||
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
|
"""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)
|
features = get_nous_subscription_features(config)
|
||||||
if not features.provider_is_nous:
|
if not features.provider_is_nous:
|
||||||
return set()
|
return set()
|
||||||
|
|
@ -386,6 +394,9 @@ def apply_nous_managed_defaults(
|
||||||
*,
|
*,
|
||||||
enabled_toolsets: Optional[Iterable[str]] = None,
|
enabled_toolsets: Optional[Iterable[str]] = None,
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
|
if not managed_nous_tools_enabled():
|
||||||
|
return set()
|
||||||
|
|
||||||
features = get_nous_subscription_features(config)
|
features = get_nous_subscription_features(config)
|
||||||
if not features.provider_is_nous:
|
if not features.provider_is_nous:
|
||||||
return set()
|
return set()
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,8 @@ from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional, Set
|
from typing import Any, Callable, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
from utils import env_var_enabled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml
|
import yaml
|
||||||
except ImportError: # pragma: no cover – yaml is optional at import time
|
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:
|
def _env_enabled(name: str) -> bool:
|
||||||
"""Return True when an env var is set to a truthy opt-in value."""
|
"""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_explainer_lines,
|
||||||
get_nous_subscription_features,
|
get_nous_subscription_features,
|
||||||
)
|
)
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def _print_nous_subscription_guidance() -> None:
|
||||||
|
lines = get_nous_subscription_explainer_lines()
|
||||||
|
if not lines:
|
||||||
|
return
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print_header("Nous Subscription Tools")
|
print_header("Nous Subscription Tools")
|
||||||
for line in get_nous_subscription_explainer_lines():
|
for line in lines:
|
||||||
print_info(line)
|
print_info(line)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -663,7 +668,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
tool_status.append(("Modal Execution (direct Modal)", True, None))
|
tool_status.append(("Modal Execution (direct Modal)", True, None))
|
||||||
else:
|
else:
|
||||||
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
|
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))
|
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
|
||||||
|
|
||||||
# Tinker + WandB (RL training)
|
# Tinker + WandB (RL training)
|
||||||
|
|
@ -1912,7 +1917,7 @@ def _setup_tts_provider(config: dict):
|
||||||
|
|
||||||
choices = []
|
choices = []
|
||||||
providers = []
|
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)")
|
choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
|
||||||
providers.append("nous-openai")
|
providers.append("nous-openai")
|
||||||
choices.extend(
|
choices.extend(
|
||||||
|
|
@ -2137,6 +2142,8 @@ def setup_terminal_backend(config: dict):
|
||||||
from tools.tool_backend_helpers import normalize_modal_mode
|
from tools.tool_backend_helpers import normalize_modal_mode
|
||||||
|
|
||||||
managed_modal_available = bool(
|
managed_modal_available = bool(
|
||||||
|
managed_nous_tools_enabled()
|
||||||
|
and
|
||||||
get_nous_subscription_features(config).nous_auth_present
|
get_nous_subscription_features(config).nous_auth_present
|
||||||
and is_managed_tool_gateway_ready("modal")
|
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.nous_subscription import get_nous_subscription_features
|
||||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||||
from hermes_constants import OPENROUTER_MODELS_URL
|
from hermes_constants import OPENROUTER_MODELS_URL
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
def check_mark(ok: bool) -> str:
|
def check_mark(ok: bool) -> str:
|
||||||
if ok:
|
if ok:
|
||||||
|
|
@ -190,26 +191,27 @@ def show_status(args):
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Nous Subscription Features
|
# Nous Subscription Features
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
features = get_nous_subscription_features(config)
|
if managed_nous_tools_enabled():
|
||||||
print()
|
features = get_nous_subscription_features(config)
|
||||||
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
|
print()
|
||||||
if not features.nous_auth_present:
|
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
|
||||||
print(" Nous Portal ✗ not logged in")
|
if not features.nous_auth_present:
|
||||||
else:
|
print(" Nous Portal ✗ not logged in")
|
||||||
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:
|
else:
|
||||||
state = "not configured"
|
print(" Nous Portal ✓ managed tools available")
|
||||||
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
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
|
# API-Key Providers
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from hermes_cli.nous_subscription import (
|
||||||
apply_nous_managed_defaults,
|
apply_nous_managed_defaults,
|
||||||
get_nous_subscription_features,
|
get_nous_subscription_features,
|
||||||
)
|
)
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
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)
|
features = get_nous_subscription_features(config)
|
||||||
visible = []
|
visible = []
|
||||||
for provider in cat.get("providers", []):
|
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:
|
if provider.get("requires_nous_auth") and not features.nous_auth_present:
|
||||||
continue
|
continue
|
||||||
visible.append(provider)
|
visible.append(provider)
|
||||||
|
|
@ -1234,9 +1237,10 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
config,
|
config,
|
||||||
enabled_toolsets=new_enabled,
|
enabled_toolsets=new_enabled,
|
||||||
)
|
)
|
||||||
for ts_key in sorted(auto_configured):
|
if managed_nous_tools_enabled():
|
||||||
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
|
for ts_key in sorted(auto_configured):
|
||||||
print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
|
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
|
# Walk through ALL selected tools that have provider options or
|
||||||
# need API keys. This ensures browser (Local vs Browserbase),
|
# need API keys. This ensures browser (Local vs Browserbase),
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ from agent.trajectory import (
|
||||||
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
||||||
save_trajectory as _save_trajectory_to_file,
|
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_TOOL_NAMES = {
|
||||||
"honcho_context",
|
"honcho_context",
|
||||||
|
|
@ -2005,7 +2005,7 @@ class AIAgent:
|
||||||
|
|
||||||
self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
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))
|
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||||
|
|
||||||
return dump_file
|
return dump_file
|
||||||
|
|
@ -6052,7 +6052,7 @@ class AIAgent:
|
||||||
if self.api_mode == "codex_responses":
|
if self.api_mode == "codex_responses":
|
||||||
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
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")
|
self._dump_api_request_debug(api_kwargs, reason="preflight")
|
||||||
|
|
||||||
# Always prefer the streaming path — even without stream
|
# Always prefer the streaming path — even without stream
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,7 @@ class TestBuildSkillsSystemPrompt:
|
||||||
|
|
||||||
class TestBuildNousSubscriptionPrompt:
|
class TestBuildNousSubscriptionPrompt:
|
||||||
def test_includes_active_subscription_features(self, monkeypatch):
|
def test_includes_active_subscription_features(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
lambda config=None: NousSubscriptionFeatures(
|
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
|
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):
|
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
lambda config=None: NousSubscriptionFeatures(
|
lambda config=None: NousSubscriptionFeatures(
|
||||||
|
|
@ -445,6 +447,13 @@ class TestBuildNousSubscriptionPrompt:
|
||||||
assert "suggest Nous subscription as one option" in prompt
|
assert "suggest Nous subscription as one option" in prompt
|
||||||
assert "Do not mention subscription unless" 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
|
# 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):
|
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))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
_clear_provider_env(monkeypatch)
|
_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):
|
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))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
config = load_config()
|
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):
|
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.setenv("HERMES_HOME", str(tmp_path))
|
||||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", 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):
|
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
|
from hermes_cli import status as status_mod
|
||||||
|
|
||||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
_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 "Nous Subscription Features" in out
|
||||||
assert "Browser automation" in out
|
assert "Browser automation" in out
|
||||||
assert "active via Nous subscription" 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):
|
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
config = {"model": {"provider": "nous"}}
|
config = {"model": {"provider": "nous"}}
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -260,6 +261,20 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
|
||||||
assert providers[0]["name"].startswith("Nous Subscription")
|
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):
|
def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||||
config = {}
|
config = {}
|
||||||
local_provider = next(
|
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):
|
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
config = {
|
config = {
|
||||||
"model": {"provider": "nous"},
|
"model": {"provider": "nous"},
|
||||||
"platform_toolsets": {"cli": []},
|
"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):
|
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
config = {
|
config = {
|
||||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
"tts": {"provider": "elevenlabs"},
|
"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):
|
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
config = {
|
config = {
|
||||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
"tts": {"provider": "edge"},
|
"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)
|
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():
|
def _install_fake_tools_package():
|
||||||
_reset_modules(("tools", "agent"))
|
_reset_modules(("tools", "agent"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,11 @@ def _restore_tool_and_agent_modules():
|
||||||
sys.modules.update(original_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():
|
def _install_fake_tools_package():
|
||||||
tools_package = types.ModuleType("tools")
|
tools_package = types.ModuleType("tools")
|
||||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
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():
|
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(
|
result = resolve_managed_tool_gateway(
|
||||||
"firecrawl",
|
"firecrawl",
|
||||||
token_reader=lambda: "nous-token",
|
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():
|
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(
|
result = resolve_managed_tool_gateway(
|
||||||
"browserbase",
|
"browserbase",
|
||||||
token_reader=lambda: "nous-token",
|
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():
|
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(
|
result = resolve_managed_tool_gateway(
|
||||||
"firecrawl",
|
"firecrawl",
|
||||||
token_reader=lambda: None,
|
token_reader=lambda: None,
|
||||||
|
|
@ -49,6 +70,16 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||||
assert result is None
|
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):
|
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
||||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
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):
|
def _clear_terminal_env(monkeypatch):
|
||||||
"""Remove terminal env vars that could affect requirements checks."""
|
"""Remove terminal env vars that could affect requirements checks."""
|
||||||
keys = [
|
keys = [
|
||||||
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||||
"TERMINAL_ENV",
|
"TERMINAL_ENV",
|
||||||
"TERMINAL_MODAL_MODE",
|
"TERMINAL_MODAL_MODE",
|
||||||
"TERMINAL_SSH_HOST",
|
"TERMINAL_SSH_HOST",
|
||||||
|
|
@ -73,13 +74,14 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert any(
|
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
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
||||||
_clear_terminal_env(monkeypatch)
|
_clear_terminal_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", 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()
|
"TERMINAL_MODAL_MODE=direct" in record.getMessage()
|
||||||
for record in caplog.records
|
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)
|
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):
|
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("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ Coverage:
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock, AsyncMock
|
from unittest.mock import patch, MagicMock, AsyncMock
|
||||||
|
|
||||||
|
|
@ -24,6 +26,7 @@ class TestFirecrawlClientConfig:
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
tools.web_tools._firecrawl_client_config = None
|
tools.web_tools._firecrawl_client_config = None
|
||||||
for key in (
|
for key in (
|
||||||
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
|
@ -32,6 +35,7 @@ class TestFirecrawlClientConfig:
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
):
|
):
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
"""Reset client after each test."""
|
"""Reset client after each test."""
|
||||||
|
|
@ -39,6 +43,7 @@ class TestFirecrawlClientConfig:
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
tools.web_tools._firecrawl_client_config = None
|
tools.web_tools._firecrawl_client_config = None
|
||||||
for key in (
|
for key in (
|
||||||
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
|
@ -293,6 +298,7 @@ class TestBackendSelection:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_ENV_KEYS = (
|
_ENV_KEYS = (
|
||||||
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||||
"PARALLEL_API_KEY",
|
"PARALLEL_API_KEY",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
|
|
@ -304,8 +310,10 @@ class TestBackendSelection:
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
|
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||||
for key in self._ENV_KEYS:
|
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):
|
def teardown_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
|
|
@ -417,11 +425,25 @@ class TestParallelClientConfig:
|
||||||
import tools.web_tools
|
import tools.web_tools
|
||||||
tools.web_tools._parallel_client = None
|
tools.web_tools._parallel_client = None
|
||||||
os.environ.pop("PARALLEL_API_KEY", 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):
|
def teardown_method(self):
|
||||||
import tools.web_tools
|
import tools.web_tools
|
||||||
tools.web_tools._parallel_client = None
|
tools.web_tools._parallel_client = None
|
||||||
os.environ.pop("PARALLEL_API_KEY", None)
|
os.environ.pop("PARALLEL_API_KEY", None)
|
||||||
|
sys.modules.pop("parallel", None)
|
||||||
|
|
||||||
def test_creates_client_with_key(self):
|
def test_creates_client_with_key(self):
|
||||||
"""PARALLEL_API_KEY set → creates Parallel client."""
|
"""PARALLEL_API_KEY set → creates Parallel client."""
|
||||||
|
|
@ -479,6 +501,7 @@ class TestCheckWebApiKey:
|
||||||
"""Test suite for check_web_api_key() unified availability check."""
|
"""Test suite for check_web_api_key() unified availability check."""
|
||||||
|
|
||||||
_ENV_KEYS = (
|
_ENV_KEYS = (
|
||||||
|
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||||
"PARALLEL_API_KEY",
|
"PARALLEL_API_KEY",
|
||||||
"FIRECRAWL_API_KEY",
|
"FIRECRAWL_API_KEY",
|
||||||
"FIRECRAWL_API_URL",
|
"FIRECRAWL_API_URL",
|
||||||
|
|
@ -490,8 +513,10 @@ class TestCheckWebApiKey:
|
||||||
)
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
|
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||||
for key in self._ENV_KEYS:
|
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):
|
def teardown_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import requests
|
||||||
|
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_pending_create_keys: Dict[str, str] = {}
|
_pending_create_keys: Dict[str, str] = {}
|
||||||
|
|
@ -93,10 +94,15 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
def _get_config(self) -> Dict[str, Any]:
|
def _get_config(self) -> Dict[str, Any]:
|
||||||
config = self._get_config_or_none()
|
config = self._get_config_or_none()
|
||||||
if config is None:
|
if config is None:
|
||||||
raise ValueError(
|
message = (
|
||||||
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials "
|
"Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials."
|
||||||
"or a managed Browserbase gateway configuration."
|
|
||||||
)
|
)
|
||||||
|
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
|
return config
|
||||||
|
|
||||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ from urllib.parse import urlencode
|
||||||
import fal_client
|
import fal_client
|
||||||
from tools.debug_helpers import DebugSession
|
from tools.debug_helpers import DebugSession
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -416,9 +417,10 @@ def image_generate_tool(
|
||||||
|
|
||||||
# Check API key availability
|
# Check API key availability
|
||||||
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
|
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
|
||||||
raise ValueError(
|
message = "FAL_KEY environment variable not set"
|
||||||
"FAL_KEY environment variable not set and managed FAL gateway is unavailable"
|
if managed_nous_tools_enabled():
|
||||||
)
|
message += " and managed FAL gateway is unavailable"
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
# Validate other parameters
|
# Validate other parameters
|
||||||
validated_params = _validate_parameters(
|
validated_params = _validate_parameters(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from dataclasses import dataclass
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from hermes_cli.config import get_hermes_home
|
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_DOMAIN = "nousresearch.com"
|
||||||
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
|
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
|
||||||
|
|
@ -131,6 +132,9 @@ def resolve_managed_tool_gateway(
|
||||||
token_reader: Optional[Callable[[], Optional[str]]] = None,
|
token_reader: Optional[Callable[[], Optional[str]]] = None,
|
||||||
) -> Optional[ManagedToolGatewayConfig]:
|
) -> Optional[ManagedToolGatewayConfig]:
|
||||||
"""Resolve shared managed-tool gateway config for a vendor."""
|
"""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_gateway_builder = gateway_builder or build_vendor_gateway_url
|
||||||
resolved_token_reader = token_reader or read_nous_access_token
|
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
|
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
|
||||||
from tools.environments.singularity import _get_scratch_dir
|
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)
|
# Disk usage warning threshold (in GB)
|
||||||
|
|
@ -506,7 +511,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"env_type": env_type,
|
"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_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||||
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
|
"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}"),
|
"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]:
|
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
|
||||||
"""Resolve direct vs managed Modal backend selection."""
|
"""Resolve direct vs managed Modal backend selection."""
|
||||||
|
requested_mode = coerce_modal_mode(modal_mode)
|
||||||
normalized_mode = normalize_modal_mode(modal_mode)
|
normalized_mode = normalize_modal_mode(modal_mode)
|
||||||
has_direct = has_direct_modal_credentials()
|
has_direct = has_direct_modal_credentials()
|
||||||
managed_ready = is_managed_tool_gateway_ready("modal")
|
managed_ready = is_managed_tool_gateway_ready("modal")
|
||||||
|
managed_mode_blocked = (
|
||||||
|
requested_mode == "managed" and not managed_nous_tools_enabled()
|
||||||
|
)
|
||||||
|
|
||||||
if normalized_mode == "managed":
|
if normalized_mode == "managed":
|
||||||
selected_backend = "managed" if managed_ready else None
|
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
|
selected_backend = "direct" if has_direct else "managed" if managed_ready else None
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
"requested_mode": requested_mode,
|
||||||
"mode": normalized_mode,
|
"mode": normalized_mode,
|
||||||
"has_direct": has_direct,
|
"has_direct": has_direct,
|
||||||
"managed_ready": managed_ready,
|
"managed_ready": managed_ready,
|
||||||
|
"managed_mode_blocked": managed_mode_blocked,
|
||||||
"selected_backend": selected_backend,
|
"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["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":
|
if modal_state["mode"] == "managed":
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
|
"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(
|
raise ValueError(
|
||||||
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
|
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
|
||||||
)
|
)
|
||||||
raise ValueError(
|
message = "Modal backend selected but no direct Modal credentials/config was found."
|
||||||
"Modal backend selected but no direct Modal credentials/config or managed tool gateway 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(
|
return _ModalEnvironment(
|
||||||
image=image, cwd=cwd, timeout=timeout,
|
image=image, cwd=cwd, timeout=timeout,
|
||||||
|
|
@ -1283,25 +1304,48 @@ def check_terminal_requirements() -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if modal_state["selected_backend"] != "direct":
|
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":
|
if modal_state["mode"] == "managed":
|
||||||
logger.error(
|
logger.error(
|
||||||
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
|
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
|
||||||
"tool gateway is unavailable. Configure the managed gateway or choose "
|
"tool gateway is unavailable. Configure the managed gateway or choose "
|
||||||
"TERMINAL_MODAL_MODE=direct/auto."
|
"TERMINAL_MODAL_MODE=direct/auto."
|
||||||
)
|
)
|
||||||
|
return False
|
||||||
elif modal_state["mode"] == "direct":
|
elif modal_state["mode"] == "direct":
|
||||||
logger.error(
|
if managed_nous_tools_enabled():
|
||||||
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
|
logger.error(
|
||||||
"Modal credentials/config were found. Configure Modal or choose "
|
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
|
||||||
"TERMINAL_MODAL_MODE=managed/auto."
|
"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:
|
else:
|
||||||
logger.error(
|
if managed_nous_tools_enabled():
|
||||||
"Modal backend selected but no direct Modal credentials/config or managed "
|
logger.error(
|
||||||
"tool gateway was found. Configure Modal, set up the managed gateway, "
|
"Modal backend selected but no direct Modal credentials/config or managed "
|
||||||
"or choose a different TERMINAL_ENV."
|
"tool gateway was found. Configure Modal, set up the managed gateway, "
|
||||||
)
|
"or choose a different TERMINAL_ENV."
|
||||||
return False
|
)
|
||||||
|
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:
|
if importlib.util.find_spec("swerex") is None:
|
||||||
logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'")
|
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
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from utils import env_var_enabled
|
||||||
|
|
||||||
_DEFAULT_BROWSER_PROVIDER = "local"
|
_DEFAULT_BROWSER_PROVIDER = "local"
|
||||||
_DEFAULT_MODAL_MODE = "auto"
|
_DEFAULT_MODAL_MODE = "auto"
|
||||||
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
|
_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:
|
def normalize_browser_cloud_provider(value: object | None) -> str:
|
||||||
"""Return a normalized browser provider key."""
|
"""Return a normalized browser provider key."""
|
||||||
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
|
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
|
||||||
return provider or _DEFAULT_BROWSER_PROVIDER
|
return provider or _DEFAULT_BROWSER_PROVIDER
|
||||||
|
|
||||||
|
|
||||||
def normalize_modal_mode(value: object | None) -> str:
|
def coerce_modal_mode(value: object | None) -> str:
|
||||||
"""Return a normalized modal execution mode."""
|
"""Return the requested modal mode when valid, else the default."""
|
||||||
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
|
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
|
||||||
if mode in _VALID_MODAL_MODES:
|
if mode in _VALID_MODAL_MODES:
|
||||||
return mode
|
return mode
|
||||||
return _DEFAULT_MODAL_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:
|
def has_direct_modal_credentials() -> bool:
|
||||||
"""Return True when direct Modal credentials/config are available."""
|
"""Return True when direct Modal credentials/config are available."""
|
||||||
return bool(
|
return bool(
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,9 @@ from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from utils import is_truthy_value
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
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
|
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:
|
if stt_config is None:
|
||||||
stt_config = _load_stt_config()
|
stt_config = _load_stt_config()
|
||||||
enabled = stt_config.get("enabled", True)
|
enabled = stt_config.get("enabled", True)
|
||||||
if isinstance(enabled, str):
|
return is_truthy_value(enabled, default=True)
|
||||||
return enabled.strip().lower() in ("true", "1", "yes", "on")
|
|
||||||
if enabled is None:
|
|
||||||
return True
|
|
||||||
return bool(enabled)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_openai_audio_backend() -> bool:
|
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")
|
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||||
if managed_gateway is None:
|
if managed_gateway is None:
|
||||||
raise ValueError(
|
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
|
||||||
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
|
if managed_nous_tools_enabled():
|
||||||
)
|
message += ", and the managed OpenAI audio gateway is unavailable"
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
return managed_gateway.nous_user_token, urljoin(
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ from urllib.parse import urljoin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
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
|
# 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")
|
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||||
if managed_gateway is None:
|
if managed_gateway is None:
|
||||||
raise ValueError(
|
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
|
||||||
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
|
if managed_nous_tools_enabled():
|
||||||
)
|
message += ", and the managed OpenAI audio gateway is unavailable"
|
||||||
|
raise ValueError(message)
|
||||||
|
|
||||||
return managed_gateway.nous_user_token, urljoin(
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
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,
|
read_nous_access_token as _read_nous_access_token,
|
||||||
resolve_managed_tool_gateway,
|
resolve_managed_tool_gateway,
|
||||||
)
|
)
|
||||||
|
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||||
from tools.url_safety import is_safe_url
|
from tools.url_safety import is_safe_url
|
||||||
from tools.website_policy import check_website_access
|
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:
|
def _raise_web_backend_configuration_error() -> None:
|
||||||
"""Raise a clear error for unsupported web backend configuration."""
|
"""Raise a clear error for unsupported web backend configuration."""
|
||||||
raise ValueError(
|
message = (
|
||||||
"Web tools are not configured. "
|
"Web tools are not configured. "
|
||||||
"Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, "
|
"Set FIRECRAWL_API_KEY for cloud Firecrawl or 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."
|
|
||||||
)
|
)
|
||||||
|
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():
|
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
|
# web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
|
||||||
if not check_firecrawl_api_key():
|
if not check_firecrawl_api_key():
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
|
"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, "
|
f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.",
|
||||||
"or TOOL_GATEWAY_DOMAIN, "
|
|
||||||
"or use web_search + web_extract instead.",
|
|
||||||
"success": False,
|
"success": False,
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
@ -1754,9 +1787,8 @@ if __name__ == "__main__":
|
||||||
else:
|
else:
|
||||||
print("❌ No web search backend configured")
|
print("❌ No web search backend configured")
|
||||||
print(
|
print(
|
||||||
"Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
|
"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 "
|
f"{_firecrawl_backend_help_suffix()}"
|
||||||
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not nous_available:
|
if not nous_available:
|
||||||
|
|
@ -1867,16 +1899,7 @@ registry.register(
|
||||||
schema=WEB_SEARCH_SCHEMA,
|
schema=WEB_SEARCH_SCHEMA,
|
||||||
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
|
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
|
||||||
check_fn=check_web_api_key,
|
check_fn=check_web_api_key,
|
||||||
requires_env=[
|
requires_env=_web_requires_env(),
|
||||||
"PARALLEL_API_KEY",
|
|
||||||
"TAVILY_API_KEY",
|
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
|
||||||
"TOOL_GATEWAY_DOMAIN",
|
|
||||||
"TOOL_GATEWAY_SCHEME",
|
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
|
||||||
"FIRECRAWL_API_KEY",
|
|
||||||
"FIRECRAWL_API_URL",
|
|
||||||
],
|
|
||||||
emoji="🔍",
|
emoji="🔍",
|
||||||
)
|
)
|
||||||
registry.register(
|
registry.register(
|
||||||
|
|
@ -1886,16 +1909,7 @@ registry.register(
|
||||||
handler=lambda args, **kw: web_extract_tool(
|
handler=lambda args, **kw: web_extract_tool(
|
||||||
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
||||||
check_fn=check_web_api_key,
|
check_fn=check_web_api_key,
|
||||||
requires_env=[
|
requires_env=_web_requires_env(),
|
||||||
"PARALLEL_API_KEY",
|
|
||||||
"TAVILY_API_KEY",
|
|
||||||
"FIRECRAWL_GATEWAY_URL",
|
|
||||||
"TOOL_GATEWAY_DOMAIN",
|
|
||||||
"TOOL_GATEWAY_SCHEME",
|
|
||||||
"TOOL_GATEWAY_USER_TOKEN",
|
|
||||||
"FIRECRAWL_API_KEY",
|
|
||||||
"FIRECRAWL_API_URL",
|
|
||||||
],
|
|
||||||
is_async=True,
|
is_async=True,
|
||||||
emoji="📄",
|
emoji="📄",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
19
utils.py
19
utils.py
|
|
@ -9,6 +9,25 @@ from typing import Any, Union
|
||||||
import yaml
|
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(
|
def atomic_json_write(
|
||||||
path: Union[str, Path],
|
path: Union[str, Path],
|
||||||
data: Any,
|
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_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) |
|
||||||
| `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) |
|
| `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)) |
|
| `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_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) |
|
||||||
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
|
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
|
||||||
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) |
|
| `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.
|
- If either value is missing, Hermes will log a clear error and refuse to use the SSH backend.
|
||||||
|
|
||||||
- **Modal 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.
|
- 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.
|
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 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
|
### Container Resources
|
||||||
|
|
||||||
Configure CPU, memory, disk, and persistence for all container backends:
|
Configure CPU, memory, disk, and persistence for all container backends:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue