Gate tool-gateway behind an env var, so it's not in users' faces until we're ready. Even if users enable it, it'll be blocked server-side for now, until we unlock for non-admin users on tool-gateway.

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

View File

@@ -10,6 +10,7 @@ import requests
from tools.browser_providers.base import CloudBrowserProvider
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {}
@@ -93,10 +94,15 @@ class BrowserbaseProvider(CloudBrowserProvider):
def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none()
if config is None:
raise ValueError(
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials "
"or a managed Browserbase gateway configuration."
message = (
"Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials."
)
if managed_nous_tools_enabled():
message = (
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID "
"credentials or a managed Browserbase gateway configuration."
)
raise ValueError(message)
return config
def create_session(self, task_id: str) -> Dict[str, object]:

View File

@@ -39,6 +39,7 @@ from urllib.parse import urlencode
import fal_client
from tools.debug_helpers import DebugSession
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
logger = logging.getLogger(__name__)
@@ -416,9 +417,10 @@ def image_generate_tool(
# Check API key availability
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
raise ValueError(
"FAL_KEY environment variable not set and managed FAL gateway is unavailable"
)
message = "FAL_KEY environment variable not set"
if managed_nous_tools_enabled():
message += " and managed FAL gateway is unavailable"
raise ValueError(message)
# Validate other parameters
validated_params = _validate_parameters(

View File

@@ -9,6 +9,7 @@ from dataclasses import dataclass
from typing import Callable, Optional
from hermes_cli.config import get_hermes_home
from tools.tool_backend_helpers import managed_nous_tools_enabled
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
@@ -131,6 +132,9 @@ def resolve_managed_tool_gateway(
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[ManagedToolGatewayConfig]:
"""Resolve shared managed-tool gateway config for a vendor."""
if not managed_nous_tools_enabled():
return None
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
resolved_token_reader = token_reader or read_nous_access_token

View File

@@ -65,7 +65,12 @@ def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None:
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
from tools.environments.singularity import _get_scratch_dir
from tools.tool_backend_helpers import has_direct_modal_credentials, normalize_modal_mode
from tools.tool_backend_helpers import (
coerce_modal_mode,
has_direct_modal_credentials,
managed_nous_tools_enabled,
normalize_modal_mode,
)
# Disk usage warning threshold (in GB)
@@ -506,7 +511,7 @@ def _get_env_config() -> Dict[str, Any]:
return {
"env_type": env_type,
"modal_mode": normalize_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
@@ -541,9 +546,13 @@ def _get_env_config() -> Dict[str, Any]:
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
"""Resolve direct vs managed Modal backend selection."""
requested_mode = coerce_modal_mode(modal_mode)
normalized_mode = normalize_modal_mode(modal_mode)
has_direct = has_direct_modal_credentials()
managed_ready = is_managed_tool_gateway_ready("modal")
managed_mode_blocked = (
requested_mode == "managed" and not managed_nous_tools_enabled()
)
if normalized_mode == "managed":
selected_backend = "managed" if managed_ready else None
@@ -553,9 +562,11 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
selected_backend = "direct" if has_direct else "managed" if managed_ready else None
return {
"requested_mode": requested_mode,
"mode": normalized_mode,
"has_direct": has_direct,
"managed_ready": managed_ready,
"managed_mode_blocked": managed_mode_blocked,
"selected_backend": selected_backend,
}
@@ -636,6 +647,13 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
)
if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag or "
"choose TERMINAL_MODAL_MODE=direct/auto."
)
if modal_state["mode"] == "managed":
raise ValueError(
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
@@ -644,9 +662,12 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
raise ValueError(
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
)
raise ValueError(
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
)
message = "Modal backend selected but no direct Modal credentials/config was found."
if managed_nous_tools_enabled():
message = (
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
)
raise ValueError(message)
return _ModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
@@ -1283,25 +1304,48 @@ def check_terminal_requirements() -> bool:
return True
if modal_state["selected_backend"] != "direct":
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag "
"or choose TERMINAL_MODAL_MODE=direct/auto."
)
return False
if modal_state["mode"] == "managed":
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
"tool gateway is unavailable. Configure the managed gateway or choose "
"TERMINAL_MODAL_MODE=direct/auto."
)
return False
elif modal_state["mode"] == "direct":
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=managed/auto."
)
if managed_nous_tools_enabled():
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=managed/auto."
)
else:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
"Modal credentials/config were found. Configure Modal or choose "
"TERMINAL_MODAL_MODE=auto."
)
return False
else:
logger.error(
"Modal backend selected but no direct Modal credentials/config or managed "
"tool gateway was found. Configure Modal, set up the managed gateway, "
"or choose a different TERMINAL_ENV."
)
return False
if managed_nous_tools_enabled():
logger.error(
"Modal backend selected but no direct Modal credentials/config or managed "
"tool gateway was found. Configure Modal, set up the managed gateway, "
"or choose a different TERMINAL_ENV."
)
else:
logger.error(
"Modal backend selected but no direct Modal credentials/config was found. "
"Configure Modal or choose a different TERMINAL_ENV."
)
return False
if importlib.util.find_spec("swerex") is None:
logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'")

View File

@@ -5,26 +5,40 @@ from __future__ import annotations
import os
from pathlib import Path
from utils import env_var_enabled
_DEFAULT_BROWSER_PROVIDER = "local"
_DEFAULT_MODAL_MODE = "auto"
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled() -> bool:
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
def normalize_browser_cloud_provider(value: object | None) -> str:
"""Return a normalized browser provider key."""
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
return provider or _DEFAULT_BROWSER_PROVIDER
def normalize_modal_mode(value: object | None) -> str:
"""Return a normalized modal execution mode."""
def coerce_modal_mode(value: object | None) -> str:
"""Return the requested modal mode when valid, else the default."""
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
if mode in _VALID_MODAL_MODES:
return mode
return _DEFAULT_MODAL_MODE
def normalize_modal_mode(value: object | None) -> str:
"""Return a normalized modal execution mode."""
mode = coerce_modal_mode(value)
if mode == "managed" and not managed_nous_tools_enabled():
return "direct"
return mode
def has_direct_modal_credentials() -> bool:
"""Return True when direct Modal credentials/config are available."""
return bool(

View File

@@ -33,8 +33,9 @@ from pathlib import Path
from typing import Optional, Dict, Any
from urllib.parse import urljoin
from utils import is_truthy_value
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import resolve_openai_audio_api_key
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
from hermes_constants import get_hermes_home
@@ -122,11 +123,7 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
if stt_config is None:
stt_config = _load_stt_config()
enabled = stt_config.get("enabled", True)
if isinstance(enabled, str):
return enabled.strip().lower() in ("true", "1", "yes", "on")
if enabled is None:
return True
return bool(enabled)
return is_truthy_value(enabled, default=True)
def _has_openai_audio_backend() -> bool:
@@ -586,9 +583,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
raise ValueError(
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
)
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
if managed_nous_tools_enabled():
message += ", and the managed OpenAI audio gateway is unavailable"
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"

View File

@@ -40,7 +40,7 @@ from urllib.parse import urljoin
logger = logging.getLogger(__name__)
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import resolve_openai_audio_api_key
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
# ---------------------------------------------------------------------------
# Lazy imports -- providers are imported only when actually used to avoid
@@ -565,9 +565,10 @@ def _resolve_openai_audio_client_config() -> tuple[str, str]:
managed_gateway = resolve_managed_tool_gateway("openai-audio")
if managed_gateway is None:
raise ValueError(
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
)
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
if managed_nous_tools_enabled():
message += ", and the managed OpenAI audio gateway is unavailable"
raise ValueError(message)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"

View File

@@ -54,6 +54,7 @@ from tools.managed_tool_gateway import (
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
@@ -152,12 +153,46 @@ def _has_direct_firecrawl_config() -> bool:
def _raise_web_backend_configuration_error() -> None:
"""Raise a clear error for unsupported web backend configuration."""
raise ValueError(
message = (
"Web tools are not configured. "
"Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, "
"or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
"Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance."
)
if managed_nous_tools_enabled():
message += (
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
)
raise ValueError(message)
def _firecrawl_backend_help_suffix() -> str:
"""Return optional managed-gateway guidance for Firecrawl help text."""
if not managed_nous_tools_enabled():
return ""
return (
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
)
def _web_requires_env() -> list[str]:
"""Return tool metadata env vars for the currently enabled web backends."""
requires = [
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
]
if managed_nous_tools_enabled():
requires.extend(
[
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
]
)
return requires
def _get_firecrawl_client():
@@ -1410,10 +1445,8 @@ async def web_crawl_tool(
# web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
if not check_firecrawl_api_key():
return json.dumps({
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
"or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, "
"or TOOL_GATEWAY_DOMAIN, "
"or use web_search + web_extract instead.",
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.",
"success": False,
}, ensure_ascii=False)
@@ -1754,9 +1787,8 @@ if __name__ == "__main__":
else:
print("❌ No web search backend configured")
print(
"Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
"or, if you are a Nous Subscriber, login to Nous and use "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
"Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
f"{_firecrawl_backend_help_suffix()}"
)
if not nous_available:
@@ -1867,16 +1899,7 @@ registry.register(
schema=WEB_SEARCH_SCHEMA,
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
check_fn=check_web_api_key,
requires_env=[
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
],
requires_env=_web_requires_env(),
emoji="🔍",
)
registry.register(
@@ -1886,16 +1909,7 @@ registry.register(
handler=lambda args, **kw: web_extract_tool(
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
check_fn=check_web_api_key,
requires_env=[
"PARALLEL_API_KEY",
"TAVILY_API_KEY",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
],
requires_env=_web_requires_env(),
is_async=True,
emoji="📄",
)