feat: add managed tool gateway and Nous subscription support

- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
This commit is contained in:
Robin Fernandes 2026-03-26 15:27:27 -07:00
parent cbf195e806
commit 95dc9aaa75
44 changed files with 4567 additions and 423 deletions

View file

@ -69,6 +69,17 @@ 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=

View file

@ -422,6 +422,69 @@ def build_skills_system_prompt(
) )
def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
"""Build a compact Nous subscription capability block for the system prompt."""
try:
from hermes_cli.nous_subscription import get_nous_subscription_features
except Exception as exc:
logger.debug("Failed to import Nous subscription helper: %s", exc)
return ""
valid_names = set(valid_tool_names or set())
relevant_tool_names = {
"web_search",
"web_extract",
"browser_navigate",
"browser_snapshot",
"browser_click",
"browser_type",
"browser_scroll",
"browser_console",
"browser_close",
"browser_press",
"browser_get_images",
"browser_vision",
"image_generate",
"text_to_speech",
"terminal",
"process",
"execute_code",
}
if valid_names and not (valid_names & relevant_tool_names):
return ""
features = get_nous_subscription_features()
def _status_line(feature) -> str:
if feature.managed_by_nous:
return f"- {feature.label}: active via Nous subscription"
if feature.active:
current = feature.current_provider or "configured provider"
return f"- {feature.label}: currently using {current}"
if feature.included_by_default and features.nous_auth_present:
return f"- {feature.label}: included with Nous subscription, not currently selected"
if feature.key == "modal" and features.nous_auth_present:
return f"- {feature.label}: optional via Nous subscription"
return f"- {feature.label}: not currently available"
lines = [
"# Nous Subscription",
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browserbase) by default. Modal execution is optional.",
"Current capability status:",
]
lines.extend(_status_line(feature) for feature in features.items())
lines.extend(
[
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys.",
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
]
)
return "\n".join(lines)
# ========================================================================= # =========================================================================
# Context files (SOUL.md, AGENTS.md, .cursorrules) # Context files (SOUL.md, AGENTS.md, .cursorrules)
# ========================================================================= # =========================================================================

View file

@ -11,11 +11,11 @@ Solution:
_AsyncWorker thread internally, making it safe for both CLI and Atropos use. _AsyncWorker thread internally, making it safe for both CLI and Atropos use.
No monkey-patching is required. No monkey-patching is required.
This module is kept for backward compatibility apply_patches() is now a no-op. This module is kept for backward compatibility. apply_patches() is a no-op.
Usage: Usage:
Call apply_patches() once at import time (done automatically by hermes_base_env.py). Call apply_patches() once at import time (done automatically by hermes_base_env.py).
This is idempotent calling it multiple times is safe. This is idempotent and safe to call multiple times.
""" """
import logging import logging
@ -26,17 +26,10 @@ _patches_applied = False
def apply_patches(): def apply_patches():
"""Apply all monkey patches needed for Atropos compatibility. """Apply all monkey patches needed for Atropos compatibility."""
Now a no-op Modal async safety is built directly into ModalEnvironment.
Safe to call multiple times.
"""
global _patches_applied global _patches_applied
if _patches_applied: if _patches_applied:
return return
# Modal async-safety is now built into tools/environments/modal.py logger.debug("apply_patches() called; no patches needed (async safety is built-in)")
# via the _AsyncWorker class. No monkey-patching needed.
logger.debug("apply_patches() called — no patches needed (async safety is built-in)")
_patches_applied = True _patches_applied = True

View file

@ -1295,6 +1295,89 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds) return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
def resolve_nous_access_token(
*,
timeout_seconds: float = 15.0,
insecure: Optional[bool] = None,
ca_bundle: Optional[str] = None,
refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
) -> str:
"""Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
with _auth_store_lock():
auth_store = _load_auth_store()
state = _load_provider_state(auth_store, "nous")
if not state:
raise AuthError(
"Hermes is not logged into Nous Portal.",
provider="nous",
relogin_required=True,
)
portal_base_url = (
_optional_base_url(state.get("portal_base_url"))
or os.getenv("HERMES_PORTAL_BASE_URL")
or os.getenv("NOUS_PORTAL_BASE_URL")
or DEFAULT_NOUS_PORTAL_URL
).rstrip("/")
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
access_token = state.get("access_token")
refresh_token = state.get("refresh_token")
if not isinstance(access_token, str) or not access_token:
raise AuthError(
"No access token found for Nous Portal login.",
provider="nous",
relogin_required=True,
)
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
return access_token
if not isinstance(refresh_token, str) or not refresh_token:
raise AuthError(
"Session expired and no refresh token is available.",
provider="nous",
relogin_required=True,
)
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
with httpx.Client(
timeout=timeout,
headers={"Accept": "application/json"},
verify=verify,
) as client:
refreshed = _refresh_access_token(
client=client,
portal_base_url=portal_base_url,
client_id=client_id,
refresh_token=refresh_token,
)
now = datetime.now(timezone.utc)
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
state["access_token"] = refreshed["access_token"]
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
state["scope"] = refreshed.get("scope") or state.get("scope")
state["obtained_at"] = now.isoformat()
state["expires_in"] = access_ttl
state["expires_at"] = datetime.fromtimestamp(
now.timestamp() + access_ttl,
tz=timezone.utc,
).isoformat()
state["portal_base_url"] = portal_base_url
state["client_id"] = client_id
state["tls"] = {
"insecure": verify is False,
"ca_bundle": verify if isinstance(verify, str) else None,
}
_save_provider_state(auth_store, "nous", state)
_save_auth_store(auth_store)
return state["access_token"]
def resolve_nous_runtime_credentials( def resolve_nous_runtime_credentials(
*, *,
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS, min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,

View file

@ -142,6 +142,7 @@ DEFAULT_CONFIG = {
"terminal": { "terminal": {
"backend": "local", "backend": "local",
"modal_mode": "auto",
"cwd": ".", # Use current directory "cwd": ".", # Use current directory
"timeout": 180, "timeout": 180,
# Environment variables to pass through to sandboxed execution # Environment variables to pass through to sandboxed execution
@ -407,7 +408,7 @@ DEFAULT_CONFIG = {
}, },
# Config schema version - bump this when adding new required fields # Config schema version - bump this when adding new required fields
"_config_version": 10, "_config_version": 11,
} }
# ============================================================================= # =============================================================================
@ -422,6 +423,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS", 5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"], "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
10: ["TAVILY_API_KEY"], 10: ["TAVILY_API_KEY"],
11: ["TERMINAL_MODAL_MODE"],
} }
# Required environment variables with metadata for migration prompts. # Required environment variables with metadata for migration prompts.
@ -617,6 +619,38 @@ OPTIONAL_ENV_VARS = {
"category": "tool", "category": "tool",
"advanced": True, "advanced": True,
}, },
"FIRECRAWL_GATEWAY_URL": {
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_DOMAIN": {
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
"prompt": "Tool-gateway domain suffix",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_SCHEME": {
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
"prompt": "Tool-gateway URL scheme",
"url": None,
"password": False,
"category": "tool",
"advanced": True,
},
"TOOL_GATEWAY_USER_TOKEN": {
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
"prompt": "Tool-gateway user token",
"url": None,
"password": True,
"category": "tool",
"advanced": True,
},
"TAVILY_API_KEY": { "TAVILY_API_KEY": {
"description": "Tavily API key for AI-native web search, extract, and crawl", "description": "Tavily API key for AI-native web search, extract, and crawl",
"prompt": "Tavily API key", "prompt": "Tavily API key",
@ -1808,7 +1842,9 @@ def set_config_value(key: str, value: str):
# Check if it's an API key (goes to .env) # Check if it's an API key (goes to .env)
api_keys = [ api_keys = [
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY', 'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN', 'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY', 'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
@ -1864,6 +1900,7 @@ def set_config_value(key: str, value: str):
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc. # config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
_config_to_env_sync = { _config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV", "terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE", "terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE",

View file

@ -872,7 +872,7 @@ def cmd_model(args):
if selected_provider == "openrouter": if selected_provider == "openrouter":
_model_flow_openrouter(config, current_model) _model_flow_openrouter(config, current_model)
elif selected_provider == "nous": elif selected_provider == "nous":
_model_flow_nous(config, current_model) _model_flow_nous(config, current_model, args=args)
elif selected_provider == "openai-codex": elif selected_provider == "openai-codex":
_model_flow_openai_codex(config, current_model) _model_flow_openai_codex(config, current_model)
elif selected_provider == "copilot-acp": elif selected_provider == "copilot-acp":
@ -981,7 +981,7 @@ def _model_flow_openrouter(config, current_model=""):
print("No change.") print("No change.")
def _model_flow_nous(config, current_model=""): def _model_flow_nous(config, current_model="", args=None):
"""Nous Portal provider: ensure logged in, then pick model.""" """Nous Portal provider: ensure logged in, then pick model."""
from hermes_cli.auth import ( from hermes_cli.auth import (
get_provider_auth_state, _prompt_model_selection, _save_model_choice, get_provider_auth_state, _prompt_model_selection, _save_model_choice,
@ -989,7 +989,11 @@ def _model_flow_nous(config, current_model=""):
fetch_nous_models, AuthError, format_auth_error, fetch_nous_models, AuthError, format_auth_error,
_login_nous, PROVIDER_REGISTRY, _login_nous, PROVIDER_REGISTRY,
) )
from hermes_cli.config import get_env_value, save_env_value from hermes_cli.config import get_env_value, save_config, save_env_value
from hermes_cli.nous_subscription import (
apply_nous_provider_defaults,
get_nous_subscription_explainer_lines,
)
import argparse import argparse
state = get_provider_auth_state("nous") state = get_provider_auth_state("nous")
@ -998,11 +1002,19 @@ def _model_flow_nous(config, current_model=""):
print() print()
try: try:
mock_args = argparse.Namespace( mock_args = argparse.Namespace(
portal_url=None, inference_url=None, client_id=None, portal_url=getattr(args, "portal_url", None),
scope=None, no_browser=False, timeout=15.0, inference_url=getattr(args, "inference_url", None),
ca_bundle=None, insecure=False, client_id=getattr(args, "client_id", None),
scope=getattr(args, "scope", None),
no_browser=bool(getattr(args, "no_browser", False)),
timeout=getattr(args, "timeout", None) or 15.0,
ca_bundle=getattr(args, "ca_bundle", None),
insecure=bool(getattr(args, "insecure", False)),
) )
_login_nous(mock_args, PROVIDER_REGISTRY["nous"]) _login_nous(mock_args, PROVIDER_REGISTRY["nous"])
print()
for line in get_nous_subscription_explainer_lines():
print(line)
except SystemExit: except SystemExit:
print("Login cancelled or failed.") print("Login cancelled or failed.")
return return
@ -1049,11 +1061,36 @@ def _model_flow_nous(config, current_model=""):
# Reactivate Nous as the provider and update config # Reactivate Nous as the provider and update config
inference_url = creds.get("base_url", "") inference_url = creds.get("base_url", "")
_update_config_for_provider("nous", inference_url) _update_config_for_provider("nous", inference_url)
current_model_cfg = config.get("model")
if isinstance(current_model_cfg, dict):
model_cfg = dict(current_model_cfg)
elif isinstance(current_model_cfg, str) and current_model_cfg.strip():
model_cfg = {"default": current_model_cfg.strip()}
else:
model_cfg = {}
model_cfg["provider"] = "nous"
model_cfg["default"] = selected
if inference_url and inference_url.strip():
model_cfg["base_url"] = inference_url.rstrip("/")
else:
model_cfg.pop("base_url", None)
config["model"] = model_cfg
# Clear any custom endpoint that might conflict # Clear any custom endpoint that might conflict
if get_env_value("OPENAI_BASE_URL"): if get_env_value("OPENAI_BASE_URL"):
save_env_value("OPENAI_BASE_URL", "") save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "") save_env_value("OPENAI_API_KEY", "")
changed_defaults = apply_nous_provider_defaults(config)
save_config(config)
print(f"Default model set to: {selected} (via Nous Portal)") print(f"Default model set to: {selected} (via Nous Portal)")
if "tts" in changed_defaults:
print("TTS provider set to: OpenAI TTS via your Nous subscription")
else:
current_tts = str(config.get("tts", {}).get("provider") or "edge")
if current_tts.lower() not in {"", "edge"}:
print(f"Keeping your existing TTS provider: {current_tts}")
print()
for line in get_nous_subscription_explainer_lines():
print(line)
else: else:
print("No change.") print("No change.")
@ -3174,6 +3211,44 @@ For more help on a command:
help="Select default model and provider", help="Select default model and provider",
description="Interactively select your inference provider and default model" description="Interactively select your inference provider and default model"
) )
model_parser.add_argument(
"--portal-url",
help="Portal base URL for Nous login (default: production portal)"
)
model_parser.add_argument(
"--inference-url",
help="Inference API base URL for Nous login (default: production inference API)"
)
model_parser.add_argument(
"--client-id",
default=None,
help="OAuth client id to use for Nous login (default: hermes-cli)"
)
model_parser.add_argument(
"--scope",
default=None,
help="OAuth scope to request for Nous login"
)
model_parser.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically during Nous login"
)
model_parser.add_argument(
"--timeout",
type=float,
default=15.0,
help="HTTP request timeout in seconds for Nous login (default: 15)"
)
model_parser.add_argument(
"--ca-bundle",
help="Path to CA bundle PEM file for Nous TLS verification"
)
model_parser.add_argument(
"--insecure",
action="store_true",
help="Disable TLS verification for Nous login (testing only)"
)
model_parser.set_defaults(func=cmd_model) model_parser.set_defaults(func=cmd_model)
# ========================================================================= # =========================================================================

View file

@ -0,0 +1,437 @@
"""Helpers for Nous subscription managed-tool capabilities."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Optional, Set
from hermes_cli.auth import get_nous_auth_status
from hermes_cli.config import get_env_value, load_config
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import (
has_direct_modal_credentials,
normalize_browser_cloud_provider,
normalize_modal_mode,
resolve_openai_audio_api_key,
)
_DEFAULT_PLATFORM_TOOLSETS = {
"cli": "hermes-cli",
}
@dataclass(frozen=True)
class NousFeatureState:
key: str
label: str
included_by_default: bool
available: bool
active: bool
managed_by_nous: bool
direct_override: bool
toolset_enabled: bool
current_provider: str = ""
explicit_configured: bool = False
@dataclass(frozen=True)
class NousSubscriptionFeatures:
subscribed: bool
nous_auth_present: bool
provider_is_nous: bool
features: Dict[str, NousFeatureState]
@property
def web(self) -> NousFeatureState:
return self.features["web"]
@property
def image_gen(self) -> NousFeatureState:
return self.features["image_gen"]
@property
def tts(self) -> NousFeatureState:
return self.features["tts"]
@property
def browser(self) -> NousFeatureState:
return self.features["browser"]
@property
def modal(self) -> NousFeatureState:
return self.features["modal"]
def items(self) -> Iterable[NousFeatureState]:
ordered = ("web", "image_gen", "tts", "browser", "modal")
for key in ordered:
yield self.features[key]
def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]:
model_cfg = config.get("model")
if isinstance(model_cfg, dict):
return dict(model_cfg)
if isinstance(model_cfg, str) and model_cfg.strip():
return {"default": model_cfg.strip()}
return {}
def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
from toolsets import resolve_toolset
platform_toolsets = config.get("platform_toolsets")
if not isinstance(platform_toolsets, dict) or not platform_toolsets:
platform_toolsets = {"cli": [_DEFAULT_PLATFORM_TOOLSETS["cli"]]}
target_tools = set(resolve_toolset(toolset_key))
if not target_tools:
return False
for platform, raw_toolsets in platform_toolsets.items():
if isinstance(raw_toolsets, list):
toolset_names = list(raw_toolsets)
else:
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
toolset_names = [default_toolset] if default_toolset else []
if not toolset_names:
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
if default_toolset:
toolset_names = [default_toolset]
available_tools: Set[str] = set()
for toolset_name in toolset_names:
if not isinstance(toolset_name, str) or not toolset_name:
continue
try:
available_tools.update(resolve_toolset(toolset_name))
except Exception:
continue
if target_tools and target_tools.issubset(available_tools):
return True
return False
def _has_agent_browser() -> bool:
import shutil
agent_browser_bin = shutil.which("agent-browser")
local_bin = (
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
)
return bool(agent_browser_bin or local_bin.exists())
def _browser_label(current_provider: str) -> str:
mapping = {
"browserbase": "Browserbase",
"browser-use": "Browser Use",
"local": "Local browser",
}
return mapping.get(current_provider or "local", current_provider or "Local browser")
def _tts_label(current_provider: str) -> str:
mapping = {
"openai": "OpenAI TTS",
"elevenlabs": "ElevenLabs",
"edge": "Edge TTS",
"neutts": "NeuTTS",
}
return mapping.get(current_provider or "edge", current_provider or "Edge TTS")
def get_nous_subscription_features(
config: Optional[Dict[str, object]] = None,
) -> NousSubscriptionFeatures:
if config is None:
config = load_config() or {}
config = dict(config)
model_cfg = _model_config_dict(config)
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
try:
nous_status = get_nous_auth_status()
except Exception:
nous_status = {}
nous_auth_present = bool(nous_status.get("logged_in"))
subscribed = provider_is_nous or nous_auth_present
web_tool_enabled = _toolset_enabled(config, "web")
image_tool_enabled = _toolset_enabled(config, "image_gen")
tts_tool_enabled = _toolset_enabled(config, "tts")
browser_tool_enabled = _toolset_enabled(config, "browser")
modal_tool_enabled = _toolset_enabled(config, "terminal")
web_backend = str(config.get("web", {}).get("backend") or "").strip().lower() if isinstance(config.get("web"), dict) else ""
tts_provider = str(config.get("tts", {}).get("provider") or "edge").strip().lower() if isinstance(config.get("tts"), dict) else "edge"
browser_provider = normalize_browser_cloud_provider(
config.get("browser", {}).get("cloud_provider")
if isinstance(config.get("browser"), dict)
else None
)
terminal_backend = (
str(config.get("terminal", {}).get("backend") or "local").strip().lower()
if isinstance(config.get("terminal"), dict)
else "local"
)
modal_mode = normalize_modal_mode(
config.get("terminal", {}).get("modal_mode")
if isinstance(config.get("terminal"), dict)
else None
)
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
direct_fal = bool(get_env_value("FAL_KEY"))
direct_openai_tts = bool(resolve_openai_audio_api_key())
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
direct_modal = has_direct_modal_credentials()
managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase")
managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal")
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
web_active = bool(
web_tool_enabled
and (
web_managed
or (web_backend == "firecrawl" and direct_firecrawl)
or (web_backend == "parallel" and direct_parallel)
or (web_backend == "tavily" and direct_tavily)
)
)
web_available = bool(
managed_web_available or direct_firecrawl or direct_parallel or direct_tavily
)
image_managed = image_tool_enabled and managed_image_available and not direct_fal
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
image_available = bool(managed_image_available or direct_fal)
tts_current_provider = tts_provider or "edge"
tts_managed = (
tts_tool_enabled
and tts_current_provider == "openai"
and managed_tts_available
and not direct_openai_tts
)
tts_available = bool(
tts_current_provider in {"edge", "neutts"}
or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts))
or (tts_current_provider == "elevenlabs" and direct_elevenlabs)
)
tts_active = bool(tts_tool_enabled and tts_available)
browser_current_provider = browser_provider or "local"
browser_local_available = _has_agent_browser()
browser_managed = (
browser_tool_enabled
and browser_current_provider == "browserbase"
and managed_browser_available
and not direct_browserbase
)
browser_available = bool(
browser_local_available
or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase))
or (browser_current_provider == "browser-use" and direct_browser_use)
)
browser_active = bool(
browser_tool_enabled
and (
(browser_current_provider == "local" and browser_local_available)
or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase))
or (browser_current_provider == "browser-use" and direct_browser_use)
)
)
if terminal_backend != "modal":
modal_managed = False
modal_available = True
modal_active = bool(modal_tool_enabled)
modal_direct_override = False
elif modal_mode == "managed":
modal_managed = bool(modal_tool_enabled and managed_modal_available)
modal_available = bool(managed_modal_available)
modal_active = bool(modal_tool_enabled and managed_modal_available)
modal_direct_override = False
elif modal_mode == "direct":
modal_managed = False
modal_available = bool(direct_modal)
modal_active = bool(modal_tool_enabled and direct_modal)
modal_direct_override = bool(direct_modal)
else:
modal_managed = bool(
modal_tool_enabled
and managed_modal_available
and not direct_modal
)
modal_available = bool(managed_modal_available or direct_modal)
modal_active = bool(modal_tool_enabled and (direct_modal or managed_modal_available))
modal_direct_override = bool(direct_modal)
tts_explicit_configured = False
raw_tts_cfg = config.get("tts")
if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg:
tts_explicit_configured = tts_provider not in {"", "edge"}
features = {
"web": NousFeatureState(
key="web",
label="Web tools",
included_by_default=True,
available=web_available,
active=web_active,
managed_by_nous=web_managed,
direct_override=web_active and not web_managed,
toolset_enabled=web_tool_enabled,
current_provider=web_backend or "",
explicit_configured=bool(web_backend),
),
"image_gen": NousFeatureState(
key="image_gen",
label="Image generation",
included_by_default=True,
available=image_available,
active=image_active,
managed_by_nous=image_managed,
direct_override=image_active and not image_managed,
toolset_enabled=image_tool_enabled,
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
explicit_configured=direct_fal,
),
"tts": NousFeatureState(
key="tts",
label="OpenAI TTS",
included_by_default=True,
available=tts_available,
active=tts_active,
managed_by_nous=tts_managed,
direct_override=tts_active and not tts_managed,
toolset_enabled=tts_tool_enabled,
current_provider=_tts_label(tts_current_provider),
explicit_configured=tts_explicit_configured,
),
"browser": NousFeatureState(
key="browser",
label="Browser automation",
included_by_default=True,
available=browser_available,
active=browser_active,
managed_by_nous=browser_managed,
direct_override=browser_active and not browser_managed,
toolset_enabled=browser_tool_enabled,
current_provider=_browser_label(browser_current_provider),
explicit_configured=isinstance(config.get("browser"), dict) and "cloud_provider" in config.get("browser", {}),
),
"modal": NousFeatureState(
key="modal",
label="Modal execution",
included_by_default=False,
available=modal_available,
active=modal_active,
managed_by_nous=modal_managed,
direct_override=terminal_backend == "modal" and modal_direct_override,
toolset_enabled=modal_tool_enabled,
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
explicit_configured=terminal_backend == "modal",
),
}
return NousSubscriptionFeatures(
subscribed=subscribed,
nous_auth_present=nous_auth_present,
provider_is_nous=provider_is_nous,
features=features,
)
def get_nous_subscription_explainer_lines() -> list[str]:
return [
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
"Change these later with: hermes setup tools, hermes setup terminal, or hermes status.",
]
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
tts_cfg = config.get("tts")
if not isinstance(tts_cfg, dict):
tts_cfg = {}
config["tts"] = tts_cfg
current_tts = str(tts_cfg.get("provider") or "edge").strip().lower()
if current_tts not in {"", "edge"}:
return set()
tts_cfg["provider"] = "openai"
return {"tts"}
def apply_nous_managed_defaults(
config: Dict[str, object],
*,
enabled_toolsets: Optional[Iterable[str]] = None,
) -> set[str]:
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
selected_toolsets = set(enabled_toolsets or ())
changed: set[str] = set()
web_cfg = config.get("web")
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
tts_cfg = config.get("tts")
if not isinstance(tts_cfg, dict):
tts_cfg = {}
config["tts"] = tts_cfg
browser_cfg = config.get("browser")
if not isinstance(browser_cfg, dict):
browser_cfg = {}
config["browser"] = browser_cfg
if "web" in selected_toolsets and not features.web.explicit_configured and not (
get_env_value("PARALLEL_API_KEY")
or get_env_value("TAVILY_API_KEY")
or get_env_value("FIRECRAWL_API_KEY")
or get_env_value("FIRECRAWL_API_URL")
):
web_cfg["backend"] = "firecrawl"
changed.add("web")
if "tts" in selected_toolsets and not features.tts.explicit_configured and not (
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
):
tts_cfg["provider"] = "openai"
changed.add("tts")
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
get_env_value("BROWSERBASE_API_KEY")
or get_env_value("BROWSER_USE_API_KEY")
):
browser_cfg["cloud_provider"] = "browserbase"
changed.add("browser")
if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"):
changed.add("image_gen")
return changed

View file

@ -18,6 +18,12 @@ import sys
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from hermes_cli.nous_subscription import (
apply_nous_provider_defaults,
get_nous_subscription_explainer_lines,
get_nous_subscription_features,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@ -52,6 +58,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
config["model"] = model_cfg config["model"] = model_cfg
def _print_nous_subscription_guidance() -> None:
print()
print_header("Nous Subscription Tools")
for line in get_nous_subscription_explainer_lines():
print_info(line)
# Default model lists per provider — used as fallback when the live # Default model lists per provider — used as fallback when the live
# /models endpoint can't be reached. # /models endpoint can't be reached.
_DEFAULT_PROVIDER_MODELS = { _DEFAULT_PROVIDER_MODELS = {
@ -560,6 +573,7 @@ def _print_setup_summary(config: dict, hermes_home):
print_header("Tool Availability Summary") print_header("Tool Availability Summary")
tool_status = [] tool_status = []
subscription_features = get_nous_subscription_features(config)
# Vision — use the same runtime resolver as the actual vision tools # Vision — use the same runtime resolver as the actual vision tools
try: try:
@ -581,8 +595,13 @@ def _print_setup_summary(config: dict, hermes_home):
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY")) tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
# Web tools (Parallel, Firecrawl, or Tavily) # Web tools (Parallel, Firecrawl, or Tavily)
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"): if subscription_features.web.managed_by_nous:
tool_status.append(("Web Search & Extract", True, None)) tool_status.append(("Web Search & Extract (Nous subscription)", True, None))
elif subscription_features.web.available:
label = "Web Search & Extract"
if subscription_features.web.current_provider:
label = f"Web Search & Extract ({subscription_features.web.current_provider})"
tool_status.append((label, True, None))
else: else:
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY")) tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
@ -595,7 +614,9 @@ def _print_setup_summary(config: dict, hermes_home):
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
).exists() ).exists()
) )
if get_env_value("BROWSERBASE_API_KEY"): if subscription_features.browser.managed_by_nous:
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
elif subscription_features.browser.current_provider == "Browserbase" and subscription_features.browser.available:
tool_status.append(("Browser Automation (Browserbase)", True, None)) tool_status.append(("Browser Automation (Browserbase)", True, None))
elif _ab_found: elif _ab_found:
tool_status.append(("Browser Automation (local)", True, None)) tool_status.append(("Browser Automation (local)", True, None))
@ -605,16 +626,22 @@ def _print_setup_summary(config: dict, hermes_home):
) )
# FAL (image generation) # FAL (image generation)
if get_env_value("FAL_KEY"): if subscription_features.image_gen.managed_by_nous:
tool_status.append(("Image Generation (Nous subscription)", True, None))
elif subscription_features.image_gen.available:
tool_status.append(("Image Generation", True, None)) tool_status.append(("Image Generation", True, None))
else: else:
tool_status.append(("Image Generation", False, "FAL_KEY")) tool_status.append(("Image Generation", False, "FAL_KEY"))
# TTS — show configured provider # TTS — show configured provider
tts_provider = config.get("tts", {}).get("provider", "edge") tts_provider = config.get("tts", {}).get("provider", "edge")
if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"): if subscription_features.tts.managed_by_nous:
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None)) tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"): elif tts_provider == "openai" and (
get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
):
tool_status.append(("Text-to-Speech (OpenAI)", True, None)) tool_status.append(("Text-to-Speech (OpenAI)", True, None))
elif tts_provider == "neutts": elif tts_provider == "neutts":
try: try:
@ -629,6 +656,16 @@ def _print_setup_summary(config: dict, hermes_home):
else: else:
tool_status.append(("Text-to-Speech (Edge TTS)", True, None)) tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
if subscription_features.modal.managed_by_nous:
tool_status.append(("Modal Execution (Nous subscription)", True, None))
elif config.get("terminal", {}).get("backend") == "modal":
if subscription_features.modal.direct_override:
tool_status.append(("Modal Execution (direct Modal)", True, None))
else:
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
elif subscription_features.nous_auth_present:
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
# Tinker + WandB (RL training) # Tinker + WandB (RL training)
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"): if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
tool_status.append(("RL Training (Tinker)", True, None)) tool_status.append(("RL Training (Tinker)", True, None))
@ -905,6 +942,7 @@ def setup_model_provider(config: dict):
) )
selected_base_url = None # deferred until after model selection selected_base_url = None # deferred until after model selection
nous_models = [] # populated if Nous login succeeds nous_models = [] # populated if Nous login succeeds
nous_subscription_selected = False
if provider_idx == 0: # OpenRouter if provider_idx == 0: # OpenRouter
selected_provider = "openrouter" selected_provider = "openrouter"
@ -1000,6 +1038,9 @@ def setup_model_provider(config: dict):
except Exception as e: except Exception as e:
logger.debug("Could not fetch Nous models after login: %s", e) logger.debug("Could not fetch Nous models after login: %s", e)
nous_subscription_selected = True
_print_nous_subscription_guidance()
except SystemExit: except SystemExit:
print_warning("Nous Portal login was cancelled or failed.") print_warning("Nous Portal login was cancelled or failed.")
print_info("You can try again later with: hermes model") print_info("You can try again later with: hermes model")
@ -1773,10 +1814,20 @@ def setup_model_provider(config: dict):
if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None: if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None:
_update_config_for_provider(selected_provider, selected_base_url) _update_config_for_provider(selected_provider, selected_base_url)
if selected_provider == "nous" and nous_subscription_selected:
changed_defaults = apply_nous_provider_defaults(config)
current_tts = str(config.get("tts", {}).get("provider") or "edge")
if "tts" in changed_defaults:
print_success("TTS provider set to: OpenAI TTS via your Nous subscription")
else:
print_info(f"Keeping your existing TTS provider: {current_tts}")
save_config(config) save_config(config)
# Offer TTS provider selection at the end of model setup # Offer TTS provider selection at the end of model setup, except when
_setup_tts_provider(config) # Nous subscription defaults are already being applied.
if selected_provider != "nous":
_setup_tts_provider(config)
# ============================================================================= # =============================================================================
@ -1844,6 +1895,7 @@ def _setup_tts_provider(config: dict):
"""Interactive TTS provider selection with install flow for NeuTTS.""" """Interactive TTS provider selection with install flow for NeuTTS."""
tts_config = config.get("tts", {}) tts_config = config.get("tts", {})
current_provider = tts_config.get("provider", "edge") current_provider = tts_config.get("provider", "edge")
subscription_features = get_nous_subscription_features(config)
provider_labels = { provider_labels = {
"edge": "Edge TTS", "edge": "Edge TTS",
@ -1858,20 +1910,36 @@ def _setup_tts_provider(config: dict):
print_info(f"Current: {current_label}") print_info(f"Current: {current_label}")
print() print()
choices = [ choices = []
"Edge TTS (free, cloud-based, no setup needed)", providers = []
"ElevenLabs (premium quality, needs API key)", if subscription_features.nous_auth_present:
"OpenAI TTS (good quality, needs API key)", choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
"NeuTTS (local on-device, free, ~300MB model download)", providers.append("nous-openai")
f"Keep current ({current_label})", choices.extend(
] [
idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1) "Edge TTS (free, cloud-based, no setup needed)",
"ElevenLabs (premium quality, needs API key)",
"OpenAI TTS (good quality, needs API key)",
"NeuTTS (local on-device, free, ~300MB model download)",
]
)
providers.extend(["edge", "elevenlabs", "openai", "neutts"])
choices.append(f"Keep current ({current_label})")
keep_current_idx = len(choices) - 1
idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
if idx == 4: # Keep current if idx == keep_current_idx:
return return
providers = ["edge", "elevenlabs", "openai", "neutts"]
selected = providers[idx] selected = providers[idx]
selected_via_nous = selected == "nous-openai"
if selected == "nous-openai":
selected = "openai"
print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.")
if get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY"):
print_warning(
"Direct OpenAI credentials are still configured and may take precedence until removed from ~/.hermes/.env."
)
if selected == "neutts": if selected == "neutts":
# Check if already installed # Check if already installed
@ -1909,8 +1977,8 @@ def _setup_tts_provider(config: dict):
print_warning("No API key provided. Falling back to Edge TTS.") print_warning("No API key provided. Falling back to Edge TTS.")
selected = "edge" selected = "edge"
elif selected == "openai": elif selected == "openai" and not selected_via_nous:
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
if not existing: if not existing:
print() print()
api_key = prompt("OpenAI API key for TTS", password=True) api_key = prompt("OpenAI API key for TTS", password=True)
@ -2065,63 +2133,99 @@ def setup_terminal_backend(config: dict):
elif selected_backend == "modal": elif selected_backend == "modal":
print_success("Terminal backend: Modal") print_success("Terminal backend: Modal")
print_info("Serverless cloud sandboxes. Each session gets its own container.") print_info("Serverless cloud sandboxes. Each session gets its own container.")
print_info("Requires a Modal account: https://modal.com") from tools.managed_tool_gateway import is_managed_tool_gateway_ready
from tools.tool_backend_helpers import normalize_modal_mode
# Check if swe-rex[modal] is installed managed_modal_available = bool(
try: get_nous_subscription_features(config).nous_auth_present
__import__("swe_rex") and is_managed_tool_gateway_ready("modal")
except ImportError: )
print_info("Installing swe-rex[modal]...") modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode"))
import subprocess use_managed_modal = False
if managed_modal_available:
uv_bin = shutil.which("uv") modal_choices = [
if uv_bin: "Use my Nous subscription",
result = subprocess.run( "Use my own Modal account",
[ ]
uv_bin, if modal_mode == "managed":
"pip", default_modal_idx = 0
"install", elif modal_mode == "direct":
"--python", default_modal_idx = 1
sys.executable,
"swe-rex[modal]",
],
capture_output=True,
text=True,
)
else: else:
result = subprocess.run( default_modal_idx = 1 if get_env_value("MODAL_TOKEN_ID") else 0
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"], modal_mode_idx = prompt_choice(
capture_output=True, "Select how Modal execution should be billed:",
text=True, modal_choices,
) default_modal_idx,
if result.returncode == 0: )
print_success("swe-rex[modal] installed") use_managed_modal = modal_mode_idx == 0
else:
print_warning(
"Install failed — run manually: pip install 'swe-rex[modal]'"
)
# Modal token if use_managed_modal:
print() config["terminal"]["modal_mode"] = "managed"
print_info("Modal authentication:") print_info("Modal execution will use the managed Nous gateway and bill to your subscription.")
print_info(" Get your token at: https://modal.com/settings") if get_env_value("MODAL_TOKEN_ID") or get_env_value("MODAL_TOKEN_SECRET"):
existing_token = get_env_value("MODAL_TOKEN_ID") print_info(
if existing_token: "Direct Modal credentials are still configured, but this backend is pinned to managed mode."
print_info(" Modal token: already configured") )
if prompt_yes_no(" Update Modal credentials?", False): else:
config["terminal"]["modal_mode"] = "direct"
print_info("Requires a Modal account: https://modal.com")
# Check if swe-rex[modal] is installed
try:
__import__("swe_rex")
except ImportError:
print_info("Installing swe-rex[modal]...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[
uv_bin,
"pip",
"install",
"--python",
sys.executable,
"swe-rex[modal]",
],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "swe-rex[modal]"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("swe-rex[modal] installed")
else:
print_warning(
"Install failed — run manually: pip install 'swe-rex[modal]'"
)
# Modal token
print()
print_info("Modal authentication:")
print_info(" Get your token at: https://modal.com/settings")
existing_token = get_env_value("MODAL_TOKEN_ID")
if existing_token:
print_info(" Modal token: already configured")
if prompt_yes_no(" Update Modal credentials?", False):
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
else:
token_id = prompt(" Modal Token ID", password=True) token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True) token_secret = prompt(" Modal Token Secret", password=True)
if token_id: if token_id:
save_env_value("MODAL_TOKEN_ID", token_id) save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret: if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret) save_env_value("MODAL_TOKEN_SECRET", token_secret)
else:
token_id = prompt(" Modal Token ID", password=True)
token_secret = prompt(" Modal Token Secret", password=True)
if token_id:
save_env_value("MODAL_TOKEN_ID", token_id)
if token_secret:
save_env_value("MODAL_TOKEN_SECRET", token_secret)
_prompt_container_resources(config) _prompt_container_resources(config)
@ -2235,6 +2339,8 @@ def setup_terminal_backend(config: dict):
# Sync terminal backend to .env so terminal_tool picks it up directly. # Sync terminal backend to .env so terminal_tool picks it up directly.
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV. # config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
save_env_value("TERMINAL_ENV", selected_backend) save_env_value("TERMINAL_ENV", selected_backend)
if selected_backend == "modal":
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
save_config(config) save_config(config)
print() print()
print_success(f"Terminal backend set to: {selected_backend}") print_success(f"Terminal backend set to: {selected_backend}")
@ -3089,6 +3195,17 @@ SETUP_SECTIONS = [
("agent", "Agent Settings", setup_agent_settings), ("agent", "Agent Settings", setup_agent_settings),
] ]
# The returning-user menu intentionally omits standalone TTS because model setup
# already includes TTS selection and tools setup covers the rest of the provider
# configuration. Keep this list in the same order as the visible menu entries.
RETURNING_USER_MENU_SECTION_KEYS = [
"model",
"terminal",
"gateway",
"tools",
"agent",
]
def run_setup_wizard(args): def run_setup_wizard(args):
"""Run the interactive setup wizard. """Run the interactive setup wizard.
@ -3237,8 +3354,7 @@ def run_setup_wizard(args):
# Individual section — map by key, not by position. # Individual section — map by key, not by position.
# SETUP_SECTIONS includes TTS but the returning-user menu skips it, # SETUP_SECTIONS includes TTS but the returning-user menu skips it,
# so positional indexing (choice - 3) would dispatch the wrong section. # so positional indexing (choice - 3) would dispatch the wrong section.
_RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"] section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3]
section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None) section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
if section: if section:
_, label, func = section _, label, func = section

View file

@ -15,6 +15,7 @@ from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
from hermes_cli.models import provider_label from hermes_cli.models import provider_label
from hermes_cli.nous_subscription import get_nous_subscription_features
from hermes_cli.runtime_provider import resolve_requested_provider from hermes_cli.runtime_provider import resolve_requested_provider
from hermes_constants import OPENROUTER_MODELS_URL from hermes_constants import OPENROUTER_MODELS_URL
@ -186,6 +187,30 @@ def show_status(args):
if codex_status.get("error") and not codex_logged_in: if codex_status.get("error") and not codex_logged_in:
print(f" Error: {codex_status.get('error')}") print(f" Error: {codex_status.get('error')}")
# =========================================================================
# Nous Subscription Features
# =========================================================================
features = get_nous_subscription_features(config)
print()
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
if not features.nous_auth_present:
print(" Nous Portal ✗ not logged in")
else:
print(" Nous Portal ✓ managed tools available")
for feature in features.items():
if feature.managed_by_nous:
state = "active via Nous subscription"
elif feature.active:
current = feature.current_provider or "configured provider"
state = f"active via {current}"
elif feature.included_by_default and features.nous_auth_present:
state = "included by subscription, not currently selected"
elif feature.key == "modal" and features.nous_auth_present:
state = "available via subscription (optional)"
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
# ========================================================================= # =========================================================================

View file

@ -18,6 +18,10 @@ from hermes_cli.config import (
load_config, save_config, get_env_value, save_env_value, load_config, save_config, get_env_value, save_env_value,
) )
from hermes_cli.colors import Colors, color from hermes_cli.colors import Colors, color
from hermes_cli.nous_subscription import (
apply_nous_managed_defaults,
get_nous_subscription_features,
)
PROJECT_ROOT = Path(__file__).parent.parent.resolve() PROJECT_ROOT = Path(__file__).parent.parent.resolve()
@ -146,6 +150,15 @@ TOOL_CATEGORIES = {
"name": "Text-to-Speech", "name": "Text-to-Speech",
"icon": "🔊", "icon": "🔊",
"providers": [ "providers": [
{
"name": "Nous Subscription",
"tag": "Managed OpenAI TTS billed to your subscription",
"env_vars": [],
"tts_provider": "openai",
"requires_nous_auth": True,
"managed_nous_feature": "tts",
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
},
{ {
"name": "Microsoft Edge TTS", "name": "Microsoft Edge TTS",
"tag": "Free - no API key needed", "tag": "Free - no API key needed",
@ -176,6 +189,15 @@ TOOL_CATEGORIES = {
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.", "setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
"icon": "🔍", "icon": "🔍",
"providers": [ "providers": [
{
"name": "Nous Subscription",
"tag": "Managed Firecrawl billed to your subscription",
"web_backend": "firecrawl",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "web",
"override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"],
},
{ {
"name": "Firecrawl Cloud", "name": "Firecrawl Cloud",
"tag": "Hosted service - search, extract, and crawl", "tag": "Hosted service - search, extract, and crawl",
@ -214,6 +236,14 @@ TOOL_CATEGORIES = {
"name": "Image Generation", "name": "Image Generation",
"icon": "🎨", "icon": "🎨",
"providers": [ "providers": [
{
"name": "Nous Subscription",
"tag": "Managed FAL image generation billed to your subscription",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "image_gen",
"override_env_vars": ["FAL_KEY"],
},
{ {
"name": "FAL.ai", "name": "FAL.ai",
"tag": "FLUX 2 Pro with auto-upscaling", "tag": "FLUX 2 Pro with auto-upscaling",
@ -227,11 +257,21 @@ TOOL_CATEGORIES = {
"name": "Browser Automation", "name": "Browser Automation",
"icon": "🌐", "icon": "🌐",
"providers": [ "providers": [
{
"name": "Nous Subscription (Browserbase cloud)",
"tag": "Managed Browserbase billed to your subscription",
"env_vars": [],
"browser_provider": "browserbase",
"requires_nous_auth": True,
"managed_nous_feature": "browser",
"override_env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
"post_setup": "browserbase",
},
{ {
"name": "Local Browser", "name": "Local Browser",
"tag": "Free headless Chromium (no API key needed)", "tag": "Free headless Chromium (no API key needed)",
"env_vars": [], "env_vars": [],
"browser_provider": None, "browser_provider": "local",
"post_setup": "browserbase", # Same npm install for agent-browser "post_setup": "browserbase", # Same npm install for agent-browser
}, },
{ {
@ -475,8 +515,11 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
save_config(config) save_config(config)
def _toolset_has_keys(ts_key: str) -> bool: def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
"""Check if a toolset's required API keys are configured.""" """Check if a toolset's required API keys are configured."""
if config is None:
config = load_config()
if ts_key == "vision": if ts_key == "vision":
try: try:
from agent.auxiliary_client import resolve_vision_provider_client from agent.auxiliary_client import resolve_vision_provider_client
@ -486,10 +529,16 @@ def _toolset_has_keys(ts_key: str) -> bool:
except Exception: except Exception:
return False return False
if ts_key in {"web", "image_gen", "tts", "browser"}:
features = get_nous_subscription_features(config)
feature = features.features.get(ts_key)
if feature and (feature.available or feature.managed_by_nous):
return True
# Check TOOL_CATEGORIES first (provider-aware) # Check TOOL_CATEGORIES first (provider-aware)
cat = TOOL_CATEGORIES.get(ts_key) cat = TOOL_CATEGORIES.get(ts_key)
if cat: if cat:
for provider in cat.get("providers", []): for provider in _visible_providers(cat, config):
env_vars = provider.get("env_vars", []) env_vars = provider.get("env_vars", [])
if env_vars and all(get_env_value(e["key"]) for e in env_vars): if env_vars and all(get_env_value(e["key"]) for e in env_vars):
return True return True
@ -629,11 +678,43 @@ def _configure_toolset(ts_key: str, config: dict):
_configure_simple_requirements(ts_key) _configure_simple_requirements(ts_key)
def _visible_providers(cat: dict, config: dict) -> list[dict]:
"""Return provider entries visible for the current auth/config state."""
features = get_nous_subscription_features(config)
visible = []
for provider in cat.get("providers", []):
if provider.get("requires_nous_auth") and not features.nous_auth_present:
continue
visible.append(provider)
return visible
def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
"""Return True when enabling this toolset should open provider setup."""
cat = TOOL_CATEGORIES.get(ts_key)
if not cat:
return not _toolset_has_keys(ts_key, config)
if ts_key == "tts":
tts_cfg = config.get("tts", {})
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
if ts_key == "web":
web_cfg = config.get("web", {})
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
if ts_key == "browser":
browser_cfg = config.get("browser", {})
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
if ts_key == "image_gen":
return not get_env_value("FAL_KEY")
return not _toolset_has_keys(ts_key, config)
def _configure_tool_category(ts_key: str, cat: dict, config: dict): def _configure_tool_category(ts_key: str, cat: dict, config: dict):
"""Configure a tool category with provider selection.""" """Configure a tool category with provider selection."""
icon = cat.get("icon", "") icon = cat.get("icon", "")
name = cat["name"] name = cat["name"]
providers = cat["providers"] providers = _visible_providers(cat, config)
# Check Python version requirement # Check Python version requirement
if cat.get("requires_python"): if cat.get("requires_python"):
@ -698,6 +779,27 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
def _is_provider_active(provider: dict, config: dict) -> bool: def _is_provider_active(provider: dict, config: dict) -> bool:
"""Check if a provider entry matches the currently active config.""" """Check if a provider entry matches the currently active config."""
managed_feature = provider.get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config)
feature = features.features.get(managed_feature)
if feature is None:
return False
if managed_feature == "image_gen":
return feature.managed_by_nous
if provider.get("tts_provider"):
return (
feature.managed_by_nous
and config.get("tts", {}).get("provider") == provider["tts_provider"]
)
if "browser_provider" in provider:
current = config.get("browser", {}).get("cloud_provider")
return feature.managed_by_nous and provider["browser_provider"] == current
if provider.get("web_backend"):
current = config.get("web", {}).get("backend")
return feature.managed_by_nous and current == provider["web_backend"]
return feature.managed_by_nous
if provider.get("tts_provider"): if provider.get("tts_provider"):
return config.get("tts", {}).get("provider") == provider["tts_provider"] return config.get("tts", {}).get("provider") == provider["tts_provider"]
if "browser_provider" in provider: if "browser_provider" in provider:
@ -724,6 +826,13 @@ def _detect_active_provider_index(providers: list, config: dict) -> int:
def _configure_provider(provider: dict, config: dict): def _configure_provider(provider: dict, config: dict):
"""Configure a single provider - prompt for API keys and set config.""" """Configure a single provider - prompt for API keys and set config."""
env_vars = provider.get("env_vars", []) env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
features = get_nous_subscription_features(config)
if not features.nous_auth_present:
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
return
# Set TTS provider in config if applicable # Set TTS provider in config if applicable
if provider.get("tts_provider"): if provider.get("tts_provider"):
@ -732,11 +841,12 @@ def _configure_provider(provider: dict, config: dict):
# Set browser cloud provider in config if applicable # Set browser cloud provider in config if applicable
if "browser_provider" in provider: if "browser_provider" in provider:
bp = provider["browser_provider"] bp = provider["browser_provider"]
if bp: if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp config.setdefault("browser", {})["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}") _print_success(f" Browser cloud provider set to: {bp}")
else:
config.get("browser", {}).pop("cloud_provider", None)
# Set web search backend in config if applicable # Set web search backend in config if applicable
if provider.get("web_backend"): if provider.get("web_backend"):
@ -744,7 +854,16 @@ def _configure_provider(provider: dict, config: dict):
_print_success(f" Web backend set to: {provider['web_backend']}") _print_success(f" Web backend set to: {provider['web_backend']}")
if not env_vars: if not env_vars:
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
_print_success(f" {provider['name']} - no configuration needed!") _print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return return
# Prompt for each required env var # Prompt for each required env var
@ -847,7 +966,7 @@ def _reconfigure_tool(config: dict):
cat = TOOL_CATEGORIES.get(ts_key) cat = TOOL_CATEGORIES.get(ts_key)
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key) reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
if cat or reqs: if cat or reqs:
if _toolset_has_keys(ts_key): if _toolset_has_keys(ts_key, config):
configurable.append((ts_key, ts_label)) configurable.append((ts_key, ts_label))
if not configurable: if not configurable:
@ -877,7 +996,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
"""Reconfigure a tool category - provider selection + API key update.""" """Reconfigure a tool category - provider selection + API key update."""
icon = cat.get("icon", "") icon = cat.get("icon", "")
name = cat["name"] name = cat["name"]
providers = cat["providers"] providers = _visible_providers(cat, config)
if len(providers) == 1: if len(providers) == 1:
provider = providers[0] provider = providers[0]
@ -912,6 +1031,13 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
def _reconfigure_provider(provider: dict, config: dict): def _reconfigure_provider(provider: dict, config: dict):
"""Reconfigure a provider - update API keys.""" """Reconfigure a provider - update API keys."""
env_vars = provider.get("env_vars", []) env_vars = provider.get("env_vars", [])
managed_feature = provider.get("managed_nous_feature")
if provider.get("requires_nous_auth"):
features = get_nous_subscription_features(config)
if not features.nous_auth_present:
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
return
if provider.get("tts_provider"): if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"] config.setdefault("tts", {})["provider"] = provider["tts_provider"]
@ -919,12 +1045,12 @@ def _reconfigure_provider(provider: dict, config: dict):
if "browser_provider" in provider: if "browser_provider" in provider:
bp = provider["browser_provider"] bp = provider["browser_provider"]
if bp: if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp config.setdefault("browser", {})["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}") _print_success(f" Browser cloud provider set to: {bp}")
else:
config.get("browser", {}).pop("cloud_provider", None)
_print_success(" Browser set to local mode")
# Set web search backend in config if applicable # Set web search backend in config if applicable
if provider.get("web_backend"): if provider.get("web_backend"):
@ -932,7 +1058,16 @@ def _reconfigure_provider(provider: dict, config: dict):
_print_success(f" Web backend set to: {provider['web_backend']}") _print_success(f" Web backend set to: {provider['web_backend']}")
if not env_vars: if not env_vars:
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
_print_success(f" {provider['name']} - no configuration needed!") _print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return return
for var in env_vars: for var in env_vars:
@ -1041,13 +1176,22 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts) label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
print(color(f" - {label}", Colors.RED)) print(color(f" - {label}", Colors.RED))
auto_configured = apply_nous_managed_defaults(
config,
enabled_toolsets=new_enabled,
)
for ts_key in sorted(auto_configured):
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
print(color(f"{label}: using your Nous subscription defaults", Colors.GREEN))
# 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),
# TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when # TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
# a free provider exists. # a free provider exists.
to_configure = [ to_configure = [
ts_key for ts_key in sorted(new_enabled) ts_key for ts_key in sorted(new_enabled)
if TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key) if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
and ts_key not in auto_configured
] ]
if to_configure: if to_configure:
@ -1140,7 +1284,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Configure API keys for newly enabled tools # Configure API keys for newly enabled tools
for ts_key in sorted(added): for ts_key in sorted(added):
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
if not _toolset_has_keys(ts_key): if _toolset_needs_configuration_prompt(ts_key, config):
_configure_toolset(ts_key, config) _configure_toolset(ts_key, config)
_save_platform_tools(config, pk, new_enabled) _save_platform_tools(config, pk, new_enabled)
save_config(config) save_config(config)
@ -1180,7 +1324,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
# Configure newly enabled toolsets that need API keys # Configure newly enabled toolsets that need API keys
for ts_key in sorted(added): for ts_key in sorted(added):
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)): if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
if not _toolset_has_keys(ts_key): if _toolset_needs_configuration_prompt(ts_key, config):
_configure_toolset(ts_key, config) _configure_toolset(ts_key, config)
_save_platform_tools(config, pkey, new_enabled) _save_platform_tools(config, pkey, new_enabled)

View file

@ -39,7 +39,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
modal = ["swe-rex[modal]>=1.4.0,<2"] modal = ["swe-rex[modal]>=1.4.0,<2"]
daytona = ["daytona>=0.148.0,<1"] daytona = ["daytona>=0.148.0,<1"]
dev = ["pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"] dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"]
messaging = ["python-telegram-bot>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] messaging = ["python-telegram-bot>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
cron = ["croniter>=6.0.0,<7"] cron = ["croniter>=6.0.0,<7"]
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"] slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]

View file

@ -15,6 +15,7 @@ requests
jinja2 jinja2
pydantic>=2.0 pydantic>=2.0
PyJWT[crypto] PyJWT[crypto]
debugpy
# Web tools # Web tools
firecrawl-py firecrawl-py

View file

@ -74,6 +74,7 @@ from hermes_constants import OPENROUTER_BASE_URL
from agent.prompt_builder import ( from agent.prompt_builder import (
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS, DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE, MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
build_nous_subscription_prompt,
) )
from agent.model_metadata import ( from agent.model_metadata import (
fetch_model_metadata, fetch_model_metadata,
@ -2388,6 +2389,10 @@ class AIAgent:
if tool_guidance: if tool_guidance:
prompt_parts.append(" ".join(tool_guidance)) prompt_parts.append(" ".join(tool_guidance))
nous_subscription_prompt = build_nous_subscription_prompt(self.valid_tool_names)
if nous_subscription_prompt:
prompt_parts.append(nous_subscription_prompt)
# Honcho CLI awareness: tell Hermes about its own management commands # Honcho CLI awareness: tell Hermes about its own management commands
# so it can refer the user to them rather than reinventing answers. # so it can refer the user to them rather than reinventing answers.
if self._honcho and self._honcho_session_key: if self._honcho and self._honcho_session_key:

View file

@ -5,6 +5,8 @@ import importlib
import logging import logging
import sys import sys
import pytest
from agent.prompt_builder import ( from agent.prompt_builder import (
_scan_context_content, _scan_context_content,
_truncate_content, _truncate_content,
@ -15,6 +17,7 @@ from agent.prompt_builder import (
_find_git_root, _find_git_root,
_strip_yaml_frontmatter, _strip_yaml_frontmatter,
build_skills_system_prompt, build_skills_system_prompt,
build_nous_subscription_prompt,
build_context_files_prompt, build_context_files_prompt,
CONTEXT_FILE_MAX_CHARS, CONTEXT_FILE_MAX_CHARS,
DEFAULT_AGENT_IDENTITY, DEFAULT_AGENT_IDENTITY,
@ -22,6 +25,7 @@ from agent.prompt_builder import (
SESSION_SEARCH_GUIDANCE, SESSION_SEARCH_GUIDANCE,
PLATFORM_HINTS, PLATFORM_HINTS,
) )
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
# ========================================================================= # =========================================================================
@ -395,6 +399,53 @@ class TestBuildSkillsSystemPrompt:
assert "backend-skill" in result assert "backend-skill" in result
class TestBuildNousSubscriptionPrompt:
def test_includes_active_subscription_features(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
features={
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
},
),
)
prompt = build_nous_subscription_prompt({"web_search", "browser_navigate"})
assert "Browserbase" in prompt
assert "Modal execution is optional" 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):
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures(
subscribed=False,
nous_auth_present=False,
provider_is_nous=False,
features={
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
},
),
)
prompt = build_nous_subscription_prompt({"image_generate"})
assert "suggest Nous subscription as one option" in prompt
assert "Do not mention subscription unless" in prompt
# ========================================================================= # =========================================================================
# Context files prompt builder # Context files prompt builder
# ========================================================================= # =========================================================================
@ -562,8 +613,12 @@ class TestBuildContextFilesPrompt:
assert "Lowercase claude rules" in result assert "Lowercase claude rules" in result
def test_claude_md_uppercase_takes_priority(self, tmp_path): def test_claude_md_uppercase_takes_priority(self, tmp_path):
(tmp_path / "CLAUDE.md").write_text("From uppercase.") uppercase = tmp_path / "CLAUDE.md"
(tmp_path / "claude.md").write_text("From lowercase.") lowercase = tmp_path / "claude.md"
uppercase.write_text("From uppercase.")
lowercase.write_text("From lowercase.")
if uppercase.samefile(lowercase):
pytest.skip("filesystem is case-insensitive")
result = build_context_files_prompt(cwd=str(tmp_path)) result = build_context_files_prompt(cwd=str(tmp_path))
assert "From uppercase" in result assert "From uppercase" in result
assert "From lowercase" not in result assert "From lowercase" not in result

View file

@ -1,4 +1,6 @@
import json import json
import sys
import types
from hermes_cli.auth import _update_config_for_provider, get_active_provider from hermes_cli.auth import _update_config_for_provider, get_active_provider
from hermes_cli.config import load_config, save_config from hermes_cli.config import load_config, save_config
@ -136,6 +138,8 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
def fake_prompt_choice(question, choices, default=0): def fake_prompt_choice(question, choices, default=0):
if question == "Select your inference provider:": if question == "Select your inference provider:":
return 2 # OpenAI Codex return 2 # OpenAI Codex
if question == "Configure vision:":
return len(choices) - 1
if question == "Select default model:": if question == "Select default model:":
return 0 return 0
tts_idx = _maybe_keep_current_tts(question, choices) tts_idx = _maybe_keep_current_tts(question, choices)
@ -176,3 +180,171 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
assert reloaded["model"]["provider"] == "openai-codex" assert reloaded["model"]["provider"] == "openai-codex"
assert reloaded["model"]["default"] == "gpt-5.2-codex" assert reloaded["model"]["default"] == "gpt-5.2-codex"
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex" assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
def fake_prompt_choice(question, choices, default=0):
if question == "Select your inference provider:":
return 1
if question == "Configure vision:":
return len(choices) - 1
if question == "Select default model:":
return len(choices) - 1
raise AssertionError(f"Unexpected prompt_choice call: {question}")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
def _fake_login_nous(*args, **kwargs):
auth_path = tmp_path / "auth.json"
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {"nous": {"access_token": "nous-token"}}}))
_update_config_for_provider("nous", "https://inference.example.com/v1")
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["gemini-3-flash"],
)
setup_model_provider(config)
out = capsys.readouterr().out
assert config["tts"]["provider"] == "openai"
assert "Nous subscription enables managed web tools" in out
assert "OpenAI TTS via your Nous subscription" in out
def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
_clear_provider_env(monkeypatch)
config = load_config()
config["tts"] = {"provider": "elevenlabs"}
def fake_prompt_choice(question, choices, default=0):
if question == "Select your inference provider:":
return 1
if question == "Configure vision:":
return len(choices) - 1
if question == "Select default model:":
return len(choices) - 1
raise AssertionError(f"Unexpected prompt_choice call: {question}")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
monkeypatch.setattr(
"hermes_cli.auth._login_nous",
lambda *args, **kwargs: (tmp_path / "auth.json").write_text(
json.dumps({"active_provider": "nous", "providers": {"nous": {"access_token": "nous-token"}}})
),
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["gemini-3-flash"],
)
setup_model_provider(config)
assert config["tts"]["provider"] == "elevenlabs"
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = load_config()
def fake_prompt_choice(question, choices, default=0):
if question == "Select terminal backend:":
return 2
if question == "Select how Modal execution should be billed:":
return 0
raise AssertionError(f"Unexpected prompt_choice call: {question}")
def fake_prompt(message, *args, **kwargs):
assert "Modal Token" not in message
raise AssertionError(f"Unexpected prompt call: {message}")
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
monkeypatch.setattr(
"hermes_cli.setup.get_nous_subscription_features",
lambda config: type("Features", (), {"nous_auth_present": True})(),
)
monkeypatch.setitem(
sys.modules,
"tools.managed_tool_gateway",
types.SimpleNamespace(
is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
resolve_managed_tool_gateway=lambda vendor: None,
),
)
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
out = capsys.readouterr().out
assert config["terminal"]["backend"] == "modal"
assert config["terminal"]["modal_mode"] == "managed"
assert "bill to your subscription" in out
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
config = load_config()
def fake_prompt_choice(question, choices, default=0):
if question == "Select terminal backend:":
return 2
if question == "Select how Modal execution should be billed:":
return 1
raise AssertionError(f"Unexpected prompt_choice call: {question}")
prompt_values = iter(["token-id", "token-secret", ""])
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
monkeypatch.setattr(
"hermes_cli.setup.get_nous_subscription_features",
lambda config: type("Features", (), {"nous_auth_present": True})(),
)
monkeypatch.setitem(
sys.modules,
"tools.managed_tool_gateway",
types.SimpleNamespace(
is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
resolve_managed_tool_gateway=lambda vendor: None,
),
)
monkeypatch.setitem(sys.modules, "swe_rex", object())
from hermes_cli.setup import setup_terminal_backend
setup_terminal_backend(config)
assert config["terminal"]["backend"] == "modal"
assert config["terminal"]["modal_mode"] == "direct"

View file

@ -1,7 +1,7 @@
"""Tests for non-interactive setup and first-run headless behavior.""" """Tests for non-interactive setup and first-run headless behavior."""
from argparse import Namespace from argparse import Namespace
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
@ -92,3 +92,48 @@ class TestNonInteractiveSetup:
mock_setup.assert_not_called() mock_setup.assert_not_called()
out = capsys.readouterr().out out = capsys.readouterr().out
assert "hermes config set model.provider custom" in out assert "hermes config set model.provider custom" in out
def test_returning_user_terminal_menu_choice_dispatches_terminal_section(self, tmp_path):
"""Returning-user menu should map Terminal Backend to the terminal setup, not TTS."""
from hermes_cli import setup as setup_mod
args = _make_setup_args()
config = {}
model_section = MagicMock()
tts_section = MagicMock()
terminal_section = MagicMock()
gateway_section = MagicMock()
tools_section = MagicMock()
agent_section = MagicMock()
with (
patch.object(setup_mod, "ensure_hermes_home"),
patch.object(setup_mod, "load_config", return_value=config),
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
patch.object(
setup_mod,
"get_env_value",
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
),
patch("hermes_cli.auth.get_active_provider", return_value=None),
patch.object(setup_mod, "prompt_choice", return_value=4),
patch.object(
setup_mod,
"SETUP_SECTIONS",
[
("model", "Model & Provider", model_section),
("tts", "Text-to-Speech", tts_section),
("terminal", "Terminal Backend", terminal_section),
("gateway", "Messaging Platforms (Gateway)", gateway_section),
("tools", "Tools", tools_section),
("agent", "Agent Settings", agent_section),
],
),
patch.object(setup_mod, "save_config"),
patch.object(setup_mod, "_print_setup_summary"),
):
setup_mod.run_setup_wizard(args)
terminal_section.assert_called_once_with(config)
tts_section.assert_not_called()

View file

@ -2,6 +2,8 @@
from types import SimpleNamespace from types import SimpleNamespace
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""): def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""):
import hermes_cli.auth as auth_mod import hermes_cli.auth as auth_mod
@ -59,3 +61,42 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Model: qwen3:latest" in out assert "Model: qwen3:latest" in out
assert "Provider: Custom endpoint" in out assert "Provider: Custom endpoint" in out
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
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)
monkeypatch.setattr(
status_mod,
"get_nous_subscription_features",
lambda config: NousSubscriptionFeatures(
subscribed=True,
nous_auth_present=True,
provider_is_nous=True,
features={
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
},
),
raising=False,
)
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Subscription Features" in out
assert "Browser automation" in out
assert "active via Nous subscription" in out

View file

@ -3,10 +3,14 @@
from unittest.mock import patch from unittest.mock import patch
from hermes_cli.tools_config import ( from hermes_cli.tools_config import (
_configure_provider,
_get_platform_tools, _get_platform_tools,
_platform_toolset_summary, _platform_toolset_summary,
_save_platform_tools, _save_platform_tools,
_toolset_has_keys, _toolset_has_keys,
TOOL_CATEGORIES,
_visible_providers,
tools_command,
) )
@ -45,6 +49,10 @@ def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False) monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False)
monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False) monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False)
monkeypatch.setattr(
"agent.auxiliary_client.resolve_vision_provider_client",
lambda: ("openai-codex", object(), "gpt-4.1"),
)
assert _toolset_has_keys("vision") is True assert _toolset_has_keys("vision") is True
@ -204,3 +212,74 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
# Deselected configurable toolset removed # Deselected configurable toolset removed
assert "terminal" not in saved assert "terminal" not in saved
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
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 providers[0]["name"].startswith("Nous Subscription")
def test_local_browser_provider_is_saved_explicitly(monkeypatch):
config = {}
local_provider = next(
provider
for provider in TOOL_CATEGORIES["browser"]["providers"]
if provider.get("browser_provider") == "local"
)
monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None)
_configure_provider(local_provider, config)
assert config["browser"]["cloud_provider"] == "local"
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
config = {
"model": {"provider": "nous"},
"platform_toolsets": {"cli": []},
}
for env_var in (
"VOICE_TOOLS_OPENAI_KEY",
"OPENAI_API_KEY",
"ELEVENLABS_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"TAVILY_API_KEY",
"PARALLEL_API_KEY",
"BROWSERBASE_API_KEY",
"BROWSERBASE_PROJECT_ID",
"BROWSER_USE_API_KEY",
"FAL_KEY",
):
monkeypatch.delenv(env_var, raising=False)
monkeypatch.setattr(
"hermes_cli.tools_config._prompt_toolset_checklist",
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
)
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_auth_status",
lambda: {"logged_in": True},
)
configured = []
monkeypatch.setattr(
"hermes_cli.tools_config._configure_toolset",
lambda ts_key, config: configured.append(ts_key),
)
tools_command(first_install=True, config=config)
assert config["web"]["backend"] == "firecrawl"
assert config["tts"]["provider"] == "openai"
assert config["browser"]["cloud_provider"] == "browserbase"
assert configured == []

View file

@ -78,6 +78,13 @@ def _install_prompt_toolkit_stubs():
def _import_cli(): def _import_cli():
for name in list(sys.modules):
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
sys.modules.pop(name, None)
if "firecrawl" not in sys.modules:
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
try: try:
importlib.import_module("prompt_toolkit") importlib.import_module("prompt_toolkit")
except ModuleNotFoundError: except ModuleNotFoundError:
@ -269,6 +276,81 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
assert shell.model == "gpt-5.2-codex" assert shell.model == "gpt-5.2-codex"
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "elevenlabs"},
"browser": {"cloud_provider": "browser-use"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "nous-token"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
lambda: ["Nous subscription enables managed web tools."],
)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
out = capsys.readouterr().out
assert "Nous subscription enables managed web tools." in out
assert config["tts"]["provider"] == "elevenlabs"
assert config["browser"]["cloud_provider"] == "browser-use"
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"},
}
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "nous-token"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
},
)
monkeypatch.setattr(
"hermes_cli.auth.fetch_nous_models",
lambda *args, **kwargs: ["claude-opus-4-6"],
)
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6")
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
lambda: ["Nous subscription enables managed web tools."],
)
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
out = capsys.readouterr().out
assert "Nous subscription enables managed web tools." in out
assert "OpenAI TTS via your Nous subscription" in out
assert config["tts"]["provider"] == "openai"
def test_codex_provider_uses_config_model(monkeypatch): def test_codex_provider_uses_config_model(monkeypatch):
"""Model comes from config.yaml, not LLM_MODEL env var. """Model comes from config.yaml, not LLM_MODEL env var.
Config.yaml is the single source of truth to avoid multi-agent conflicts.""" Config.yaml is the single source of truth to avoid multi-agent conflicts."""
@ -469,3 +551,54 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1" assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1"
assert saved_env["OPENAI_API_KEY"] == "local-key" assert saved_env["OPENAI_API_KEY"] == "local-key"
assert saved_env["MODEL"] == "llm" assert saved_env["MODEL"] == "llm"
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
)
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: 0)
captured = {}
def _fake_login(login_args, provider_config):
captured["portal_url"] = login_args.portal_url
captured["inference_url"] = login_args.inference_url
captured["client_id"] = login_args.client_id
captured["scope"] = login_args.scope
captured["no_browser"] = login_args.no_browser
captured["timeout"] = login_args.timeout
captured["ca_bundle"] = login_args.ca_bundle
captured["insecure"] = login_args.insecure
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
hermes_main.cmd_model(
SimpleNamespace(
portal_url="https://portal.nousresearch.com",
inference_url="https://inference.nousresearch.com/v1",
client_id="hermes-local",
scope="openid profile",
no_browser=True,
timeout=7.5,
ca_bundle="/tmp/local-ca.pem",
insecure=True,
)
)
assert captured == {
"portal_url": "https://portal.nousresearch.com",
"inference_url": "https://inference.nousresearch.com/v1",
"client_id": "hermes-local",
"scope": "openid profile",
"no_browser": True,
"timeout": 7.5,
"ca_bundle": "/tmp/local-ca.pem",
"insecure": True,
}

View file

@ -584,6 +584,11 @@ class TestBuildSystemPrompt:
# Should contain current date info like "Conversation started:" # Should contain current date info like "Conversation started:"
assert "Conversation started:" in prompt assert "Conversation started:" in prompt
def test_includes_nous_subscription_prompt(self, agent, monkeypatch):
monkeypatch.setattr(run_agent, "build_nous_subscription_prompt", lambda tool_names: "NOUS SUBSCRIPTION BLOCK")
prompt = agent._build_system_prompt()
assert "NOUS SUBSCRIPTION BLOCK" in prompt
class TestInvalidateSystemPrompt: class TestInvalidateSystemPrompt:
def test_clears_cache(self, agent): def test_clears_cache(self, agent):

View file

@ -0,0 +1,418 @@
import os
import sys
import tempfile
import threading
import types
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
from unittest.mock import patch
import pytest
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
def _load_tool_module(module_name: str, filename: str):
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if name.startswith(prefixes):
sys.modules.pop(name, None)
@pytest.fixture(autouse=True)
def _restore_tool_and_agent_modules():
original_modules = {
name: module
for name, module in sys.modules.items()
if name == "tools"
or name.startswith("tools.")
or name == "agent"
or name.startswith("agent.")
}
try:
yield
finally:
_reset_modules(("tools", "agent"))
sys.modules.update(original_modules)
def _install_fake_tools_package():
_reset_modules(("tools", "agent"))
tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
sys.modules["tools"] = tools_package
env_package = types.ModuleType("tools.environments")
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
sys.modules["tools.environments"] = env_package
agent_package = types.ModuleType("agent")
agent_package.__path__ = [] # type: ignore[attr-defined]
sys.modules["agent"] = agent_package
sys.modules["agent.auxiliary_client"] = types.SimpleNamespace(
call_llm=lambda *args, **kwargs: "",
)
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",
"managed_tool_gateway.py",
)
interrupt_event = threading.Event()
sys.modules["tools.interrupt"] = types.SimpleNamespace(
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
is_interrupted=lambda: interrupt_event.is_set(),
_interrupt_event=interrupt_event,
)
sys.modules["tools.approval"] = types.SimpleNamespace(
detect_dangerous_command=lambda *args, **kwargs: None,
check_dangerous_command=lambda *args, **kwargs: {"approved": True},
check_all_command_guards=lambda *args, **kwargs: {"approved": True},
load_permanent_allowlist=lambda *args, **kwargs: [],
DANGEROUS_PATTERNS=[],
)
class _Registry:
def register(self, **kwargs):
return None
sys.modules["tools.registry"] = types.SimpleNamespace(registry=_Registry())
class _DummyEnvironment:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def cleanup(self):
return None
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyEnvironment)
sys.modules["tools.environments.local"] = types.SimpleNamespace(LocalEnvironment=_DummyEnvironment)
sys.modules["tools.environments.singularity"] = types.SimpleNamespace(
_get_scratch_dir=lambda: Path(tempfile.gettempdir()),
SingularityEnvironment=_DummyEnvironment,
)
sys.modules["tools.environments.ssh"] = types.SimpleNamespace(SSHEnvironment=_DummyEnvironment)
sys.modules["tools.environments.docker"] = types.SimpleNamespace(DockerEnvironment=_DummyEnvironment)
sys.modules["tools.environments.modal"] = types.SimpleNamespace(ModalEnvironment=_DummyEnvironment)
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
def test_browserbase_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
_install_fake_tools_package()
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"HERMES_HOME": str(tmp_path),
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
with patch.dict(os.environ, env, clear=True):
browser_tool = _load_tool_module("tools.browser_tool", "browser_tool.py")
local_mode = browser_tool._is_local_mode()
provider = browser_tool._get_cloud_provider()
assert local_mode is True
assert provider is None
def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
class _Response:
status_code = 200
ok = True
text = ""
headers = {"x-external-call-id": "call-browserbase-1"}
def json(self):
return {
"id": "bb_local_session_1",
"connectUrl": "wss://connect.browserbase.example/session",
}
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_tool_module(
"tools.browser_providers.browserbase",
"browser_providers/browserbase.py",
)
with patch.object(browserbase_module.requests, "post", return_value=_Response()) as post:
provider = browserbase_module.BrowserbaseProvider()
session = provider.create_session("task-browserbase-managed")
sent_headers = post.call_args.kwargs["headers"]
assert sent_headers["X-BB-API-Key"] == "nous-token"
assert sent_headers["X-Idempotency-Key"].startswith("browserbase-session-create:")
assert session["external_call_id"] == "call-browserbase-1"
def test_browserbase_managed_gateway_reuses_pending_idempotency_key_after_timeout():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
class _Response:
status_code = 200
ok = True
text = ""
headers = {"x-external-call-id": "call-browserbase-2"}
def json(self):
return {
"id": "bb_local_session_2",
"connectUrl": "wss://connect.browserbase.example/session2",
}
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_tool_module(
"tools.browser_providers.browserbase",
"browser_providers/browserbase.py",
)
provider = browserbase_module.BrowserbaseProvider()
timeout = browserbase_module.requests.Timeout("timed out")
with patch.object(
browserbase_module.requests,
"post",
side_effect=[timeout, _Response()],
) as post:
try:
provider.create_session("task-browserbase-timeout")
except browserbase_module.requests.Timeout:
pass
else:
raise AssertionError("Expected Browserbase create_session to propagate timeout")
provider.create_session("task-browserbase-timeout")
first_headers = post.call_args_list[0].kwargs["headers"]
second_headers = post.call_args_list[1].kwargs["headers"]
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
class _ConflictResponse:
status_code = 409
ok = False
text = '{"error":{"code":"CONFLICT","message":"Managed Browserbase session creation is already in progress for this idempotency key"}}'
headers = {}
def json(self):
return {
"error": {
"code": "CONFLICT",
"message": "Managed Browserbase session creation is already in progress for this idempotency key",
}
}
class _SuccessResponse:
status_code = 200
ok = True
text = ""
headers = {"x-external-call-id": "call-browserbase-4"}
def json(self):
return {
"id": "bb_local_session_4",
"connectUrl": "wss://connect.browserbase.example/session4",
}
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_tool_module(
"tools.browser_providers.browserbase",
"browser_providers/browserbase.py",
)
provider = browserbase_module.BrowserbaseProvider()
with patch.object(
browserbase_module.requests,
"post",
side_effect=[_ConflictResponse(), _SuccessResponse()],
) as post:
try:
provider.create_session("task-browserbase-conflict")
except RuntimeError:
pass
else:
raise AssertionError("Expected Browserbase create_session to propagate the in-progress conflict")
provider.create_session("task-browserbase-conflict")
first_headers = post.call_args_list[0].kwargs["headers"]
second_headers = post.call_args_list[1].kwargs["headers"]
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
def test_browserbase_managed_gateway_uses_new_idempotency_key_for_a_new_session_after_success():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("BROWSERBASE_API_KEY", None)
env.pop("BROWSERBASE_PROJECT_ID", None)
env.update({
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
})
class _Response:
status_code = 200
ok = True
text = ""
headers = {"x-external-call-id": "call-browserbase-3"}
def json(self):
return {
"id": "bb_local_session_3",
"connectUrl": "wss://connect.browserbase.example/session3",
}
with patch.dict(os.environ, env, clear=True):
browserbase_module = _load_tool_module(
"tools.browser_providers.browserbase",
"browser_providers/browserbase.py",
)
provider = browserbase_module.BrowserbaseProvider()
with patch.object(browserbase_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
provider.create_session("task-browserbase-new")
provider.create_session("task-browserbase-new")
first_headers = post.call_args_list[0].kwargs["headers"]
second_headers = post.call_args_list[1].kwargs["headers"]
assert first_headers["X-Idempotency-Key"] != second_headers["X-Idempotency-Key"]
def test_terminal_tool_prefers_managed_modal_when_gateway_ready_and_no_direct_creds():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("MODAL_TOKEN_ID", None)
env.pop("MODAL_TOKEN_SECRET", None)
with patch.dict(os.environ, env, clear=True):
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
patch.object(Path, "exists", return_value=False),
):
result = terminal_tool._create_environment(
env_type="modal",
image="python:3.11",
cwd="/root",
timeout=60,
container_config={
"container_cpu": 1,
"container_memory": 2048,
"container_disk": 1024,
"container_persistent": True,
"modal_mode": "auto",
},
task_id="task-modal-managed",
)
assert result == "managed-modal-env"
assert managed_ctor.called
assert not direct_ctor.called
def test_terminal_tool_keeps_direct_modal_when_direct_credentials_exist():
_install_fake_tools_package()
env = os.environ.copy()
env.update({
"MODAL_TOKEN_ID": "tok-id",
"MODAL_TOKEN_SECRET": "tok-secret",
})
with patch.dict(os.environ, env, clear=True):
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
):
result = terminal_tool._create_environment(
env_type="modal",
image="python:3.11",
cwd="/root",
timeout=60,
container_config={
"container_cpu": 1,
"container_memory": 2048,
"container_disk": 1024,
"container_persistent": True,
"modal_mode": "auto",
},
task_id="task-modal-direct",
)
assert result == "direct-modal-env"
assert direct_ctor.called
assert not managed_ctor.called
def test_terminal_tool_respects_direct_modal_mode_without_falling_back_to_managed():
_install_fake_tools_package()
env = os.environ.copy()
env.pop("MODAL_TOKEN_ID", None)
env.pop("MODAL_TOKEN_SECRET", None)
with patch.dict(os.environ, env, clear=True):
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
with (
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
patch.object(Path, "exists", return_value=False),
):
with pytest.raises(ValueError, match="direct Modal credentials"):
terminal_tool._create_environment(
env_type="modal",
image="python:3.11",
cwd="/root",
timeout=60,
container_config={
"container_cpu": 1,
"container_memory": 2048,
"container_disk": 1024,
"container_persistent": True,
"modal_mode": "direct",
},
task_id="task-modal-direct-only",
)

View file

@ -0,0 +1,288 @@
import sys
import types
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import pytest
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
def _load_tool_module(module_name: str, filename: str):
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(autouse=True)
def _restore_tool_and_agent_modules():
original_modules = {
name: module
for name, module in sys.modules.items()
if name == "tools"
or name.startswith("tools.")
or name == "agent"
or name.startswith("agent.")
or name in {"fal_client", "openai"}
}
try:
yield
finally:
for name in list(sys.modules):
if (
name == "tools"
or name.startswith("tools.")
or name == "agent"
or name.startswith("agent.")
or name in {"fal_client", "openai"}
):
sys.modules.pop(name, None)
sys.modules.update(original_modules)
def _install_fake_tools_package():
tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
sys.modules["tools"] = tools_package
sys.modules["tools.debug_helpers"] = types.SimpleNamespace(
DebugSession=lambda *args, **kwargs: types.SimpleNamespace(
active=False,
session_id="debug-session",
log_call=lambda *a, **k: None,
save=lambda: None,
get_session_info=lambda: {},
)
)
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
"tools.managed_tool_gateway",
"managed_tool_gateway.py",
)
def _install_fake_fal_client(captured):
def submit(model, arguments=None, headers=None):
raise AssertionError("managed FAL gateway mode should use fal_client.SyncClient")
class FakeResponse:
def json(self):
return {
"request_id": "req-123",
"response_url": "http://127.0.0.1:3009/requests/req-123",
"status_url": "http://127.0.0.1:3009/requests/req-123/status",
"cancel_url": "http://127.0.0.1:3009/requests/req-123/cancel",
}
def _maybe_retry_request(client, method, url, json=None, timeout=None, headers=None):
captured["submit_via"] = "managed_client"
captured["http_client"] = client
captured["method"] = method
captured["submit_url"] = url
captured["arguments"] = json
captured["timeout"] = timeout
captured["headers"] = headers
return FakeResponse()
class SyncRequestHandle:
def __init__(self, request_id, response_url, status_url, cancel_url, client):
captured["request_id"] = request_id
captured["response_url"] = response_url
captured["status_url"] = status_url
captured["cancel_url"] = cancel_url
captured["handle_client"] = client
class SyncClient:
def __init__(self, key=None, default_timeout=120.0):
captured["sync_client_inits"] = captured.get("sync_client_inits", 0) + 1
captured["client_key"] = key
captured["client_timeout"] = default_timeout
self.default_timeout = default_timeout
self._client = object()
fal_client_module = types.SimpleNamespace(
submit=submit,
SyncClient=SyncClient,
client=types.SimpleNamespace(
_maybe_retry_request=_maybe_retry_request,
_raise_for_status=lambda response: None,
SyncRequestHandle=SyncRequestHandle,
),
)
sys.modules["fal_client"] = fal_client_module
return fal_client_module
def _install_fake_openai_module(captured, transcription_response=None):
class FakeSpeechResponse:
def stream_to_file(self, output_path):
captured["stream_to_file"] = output_path
class FakeOpenAI:
def __init__(self, api_key, base_url, **kwargs):
captured["api_key"] = api_key
captured["base_url"] = base_url
captured["client_kwargs"] = kwargs
captured["close_calls"] = captured.get("close_calls", 0)
def create_speech(**kwargs):
captured["speech_kwargs"] = kwargs
return FakeSpeechResponse()
def create_transcription(**kwargs):
captured["transcription_kwargs"] = kwargs
return transcription_response
self.audio = types.SimpleNamespace(
speech=types.SimpleNamespace(
create=create_speech
),
transcriptions=types.SimpleNamespace(
create=create_transcription
),
)
def close(self):
captured["close_calls"] += 1
fake_module = types.SimpleNamespace(
OpenAI=FakeOpenAI,
APIError=Exception,
APIConnectionError=Exception,
APITimeoutError=Exception,
)
sys.modules["openai"] = fake_module
def test_managed_fal_submit_uses_gateway_origin_and_nous_token(monkeypatch):
captured = {}
_install_fake_tools_package()
_install_fake_fal_client(captured)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
image_generation_tool = _load_tool_module(
"tools.image_generation_tool",
"image_generation_tool.py",
)
monkeypatch.setattr(image_generation_tool.uuid, "uuid4", lambda: "fal-submit-123")
image_generation_tool._submit_fal_request(
"fal-ai/flux-2-pro",
{"prompt": "test prompt", "num_images": 1},
)
assert captured["submit_via"] == "managed_client"
assert captured["client_key"] == "nous-token"
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/flux-2-pro"
assert captured["method"] == "POST"
assert captured["arguments"] == {"prompt": "test prompt", "num_images": 1}
assert captured["headers"] == {"x-idempotency-key": "fal-submit-123"}
assert captured["sync_client_inits"] == 1
def test_managed_fal_submit_reuses_cached_sync_client(monkeypatch):
captured = {}
_install_fake_tools_package()
_install_fake_fal_client(captured)
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
image_generation_tool = _load_tool_module(
"tools.image_generation_tool",
"image_generation_tool.py",
)
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "first"})
first_client = captured["http_client"]
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "second"})
assert captured["sync_client_inits"] == 1
assert captured["http_client"] is first_client
def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatch, tmp_path):
captured = {}
_install_fake_tools_package()
_install_fake_openai_module(captured)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
monkeypatch.setattr(tts_tool.uuid, "uuid4", lambda: "tts-call-123")
output_path = tmp_path / "speech.mp3"
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
assert captured["api_key"] == "nous-token"
assert captured["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
assert captured["speech_kwargs"]["model"] == "gpt-4o-mini-tts"
assert captured["speech_kwargs"]["extra_headers"] == {"x-idempotency-key": "tts-call-123"}
assert captured["stream_to_file"] == str(output_path)
assert captured["close_calls"] == 1
def test_openai_tts_accepts_openai_api_key_as_direct_fallback(monkeypatch, tmp_path):
captured = {}
_install_fake_tools_package()
_install_fake_openai_module(captured)
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "openai-direct-key")
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
output_path = tmp_path / "speech.mp3"
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
assert captured["api_key"] == "openai-direct-key"
assert captured["base_url"] == "https://api.openai.com/v1"
assert captured["close_calls"] == 1
def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_path):
whisper_capture = {}
_install_fake_tools_package()
_install_fake_openai_module(whisper_capture, transcription_response="hello from whisper")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
transcription_tools = _load_tool_module(
"tools.transcription_tools",
"transcription_tools.py",
)
transcription_tools._load_stt_config = lambda: {"provider": "openai"}
audio_path = tmp_path / "audio.wav"
audio_path.write_bytes(b"RIFF0000WAVEfmt ")
whisper_result = transcription_tools.transcribe_audio(str(audio_path), model="whisper-1")
assert whisper_result["success"] is True
assert whisper_capture["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
assert whisper_capture["transcription_kwargs"]["response_format"] == "text"
assert whisper_capture["close_calls"] == 1
json_capture = {}
_install_fake_openai_module(
json_capture,
transcription_response=types.SimpleNamespace(text="hello from gpt-4o"),
)
transcription_tools = _load_tool_module(
"tools.transcription_tools",
"transcription_tools.py",
)
json_result = transcription_tools.transcribe_audio(
str(audio_path),
model="gpt-4o-mini-transcribe",
)
assert json_result["success"] is True
assert json_result["transcript"] == "hello from gpt-4o"
assert json_capture["transcription_kwargs"]["response_format"] == "json"
assert json_capture["close_calls"] == 1

View file

@ -0,0 +1,213 @@
import json
import sys
import tempfile
import threading
import types
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
def _load_tool_module(module_name: str, filename: str):
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if name.startswith(prefixes):
sys.modules.pop(name, None)
def _install_fake_tools_package():
_reset_modules(("tools", "agent", "hermes_cli"))
hermes_cli = types.ModuleType("hermes_cli")
hermes_cli.__path__ = [] # type: ignore[attr-defined]
sys.modules["hermes_cli"] = hermes_cli
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
get_hermes_home=lambda: Path(tempfile.gettempdir()) / "hermes-home",
)
tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
sys.modules["tools"] = tools_package
env_package = types.ModuleType("tools.environments")
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
sys.modules["tools.environments"] = env_package
interrupt_event = threading.Event()
sys.modules["tools.interrupt"] = types.SimpleNamespace(
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
is_interrupted=lambda: interrupt_event.is_set(),
_interrupt_event=interrupt_event,
)
class _DummyBaseEnvironment:
def __init__(self, cwd: str, timeout: int, env=None):
self.cwd = cwd
self.timeout = timeout
self.env = env or {}
def _prepare_command(self, command: str):
return command, None
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
sys.modules["tools.managed_tool_gateway"] = types.SimpleNamespace(
resolve_managed_tool_gateway=lambda vendor: types.SimpleNamespace(
vendor=vendor,
gateway_origin="https://modal-gateway.example.com",
nous_user_token="user-token",
managed_mode=True,
)
)
return interrupt_event
class _FakeResponse:
def __init__(self, status_code: int, payload=None, text: str = ""):
self.status_code = status_code
self._payload = payload
self.text = text
def json(self):
if isinstance(self._payload, Exception):
raise self._payload
return self._payload
def test_managed_modal_execute_polls_until_completed(monkeypatch):
_install_fake_tools_package()
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
calls = []
poll_count = {"value": 0}
def fake_request(method, url, headers=None, json=None, timeout=None):
calls.append((method, url, json, timeout))
if method == "POST" and url.endswith("/v1/sandboxes"):
return _FakeResponse(200, {"id": "sandbox-1"})
if method == "POST" and url.endswith("/execs"):
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
if method == "GET" and "/execs/" in url:
poll_count["value"] += 1
if poll_count["value"] == 1:
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
return _FakeResponse(200, {
"execId": url.rsplit("/", 1)[-1],
"status": "completed",
"output": "hello",
"returncode": 0,
})
if method == "POST" and url.endswith("/terminate"):
return _FakeResponse(200, {"status": "terminated"})
raise AssertionError(f"Unexpected request: {method} {url}")
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
result = env.execute("echo hello")
env.cleanup()
assert result == {"output": "hello", "returncode": 0}
assert any(call[0] == "POST" and call[1].endswith("/execs") for call in calls)
def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch):
_install_fake_tools_package()
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
create_headers = []
def fake_request(method, url, headers=None, json=None, timeout=None):
if method == "POST" and url.endswith("/v1/sandboxes"):
create_headers.append(headers or {})
return _FakeResponse(200, {"id": "sandbox-1"})
if method == "POST" and url.endswith("/terminate"):
return _FakeResponse(200, {"status": "terminated"})
raise AssertionError(f"Unexpected request: {method} {url}")
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
env.cleanup()
assert len(create_headers) == 1
assert isinstance(create_headers[0].get("x-idempotency-key"), str)
assert create_headers[0]["x-idempotency-key"]
def test_managed_modal_execute_cancels_on_interrupt(monkeypatch):
interrupt_event = _install_fake_tools_package()
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
calls = []
def fake_request(method, url, headers=None, json=None, timeout=None):
calls.append((method, url, json, timeout))
if method == "POST" and url.endswith("/v1/sandboxes"):
return _FakeResponse(200, {"id": "sandbox-1"})
if method == "POST" and url.endswith("/execs"):
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
if method == "GET" and "/execs/" in url:
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
if method == "POST" and url.endswith("/cancel"):
return _FakeResponse(202, {"status": "cancelling"})
if method == "POST" and url.endswith("/terminate"):
return _FakeResponse(200, {"status": "terminated"})
raise AssertionError(f"Unexpected request: {method} {url}")
def fake_sleep(_seconds):
interrupt_event.set()
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
monkeypatch.setattr(managed_modal.time, "sleep", fake_sleep)
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
result = env.execute("sleep 30")
env.cleanup()
assert result == {
"output": "[Command interrupted - Modal sandbox exec cancelled]",
"returncode": 130,
}
assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls)
poll_calls = [call for call in calls if call[0] == "GET" and "/execs/" in call[1]]
cancel_calls = [call for call in calls if call[0] == "POST" and call[1].endswith("/cancel")]
assert poll_calls[0][3] == (1.0, 5.0)
assert cancel_calls[0][3] == (1.0, 5.0)
def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeypatch):
_install_fake_tools_package()
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
def fake_request(method, url, headers=None, json=None, timeout=None):
if method == "POST" and url.endswith("/v1/sandboxes"):
return _FakeResponse(200, {"id": "sandbox-1"})
if method == "POST" and url.endswith("/execs"):
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
if method == "GET" and "/execs/" in url:
return _FakeResponse(404, {"error": "not found"}, text="not found")
if method == "POST" and url.endswith("/terminate"):
return _FakeResponse(200, {"status": "terminated"})
raise AssertionError(f"Unexpected request: {method} {url}")
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
result = env.execute("echo hello")
env.cleanup()
assert result["returncode"] == 1
assert "not found" in result["output"].lower()

View file

@ -0,0 +1,70 @@
import os
import json
from datetime import datetime, timedelta, timezone
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
import sys
from unittest.mock import patch
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
assert MODULE_SPEC and MODULE_SPEC.loader
managed_tool_gateway = module_from_spec(MODULE_SPEC)
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
)
assert result is not None
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
assert result.nous_user_token == "nous-token"
assert result.managed_mode is True
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):
result = resolve_managed_tool_gateway(
"browserbase",
token_reader=lambda: "nous-token",
)
assert result is not None
assert result.gateway_origin == "http://browserbase-gateway.localhost:3009"
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: None,
)
assert result is None
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
(tmp_path / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "stale-token",
"refresh_token": "refresh-token",
"expires_at": expires_at,
}
}
}))
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_access_token",
lambda refresh_skew_seconds=120: "fresh-token",
)
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"

View file

@ -0,0 +1,188 @@
import json
import sys
import types
from importlib.util import module_from_spec, spec_from_file_location
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
TOOLS_DIR = REPO_ROOT / "tools"
def _load_module(module_name: str, path: Path):
spec = spec_from_file_location(module_name, path)
assert spec and spec.loader
module = module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
def _reset_modules(prefixes: tuple[str, ...]):
for name in list(sys.modules):
if name.startswith(prefixes):
sys.modules.pop(name, None)
def _install_modal_test_modules(
tmp_path: Path,
*,
fail_on_snapshot_ids: set[str] | None = None,
snapshot_id: str = "im-fresh",
):
_reset_modules(("tools", "hermes_cli", "swerex", "modal"))
hermes_cli = types.ModuleType("hermes_cli")
hermes_cli.__path__ = [] # type: ignore[attr-defined]
sys.modules["hermes_cli"] = hermes_cli
hermes_home = tmp_path / "hermes-home"
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
get_hermes_home=lambda: hermes_home,
)
tools_package = types.ModuleType("tools")
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
sys.modules["tools"] = tools_package
env_package = types.ModuleType("tools.environments")
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
sys.modules["tools.environments"] = env_package
class _DummyBaseEnvironment:
def __init__(self, cwd: str, timeout: int, env=None):
self.cwd = cwd
self.timeout = timeout
self.env = env or {}
def _prepare_command(self, command: str):
return command, None
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
sys.modules["tools.interrupt"] = types.SimpleNamespace(is_interrupted=lambda: False)
from_id_calls: list[str] = []
registry_calls: list[tuple[str, list[str] | None]] = []
deployment_calls: list[dict] = []
class _FakeImage:
@staticmethod
def from_id(image_id: str):
from_id_calls.append(image_id)
return {"kind": "snapshot", "image_id": image_id}
@staticmethod
def from_registry(image: str, setup_dockerfile_commands=None):
registry_calls.append((image, setup_dockerfile_commands))
return {"kind": "registry", "image": image}
class _FakeRuntime:
async def execute(self, _command):
return types.SimpleNamespace(stdout="ok", exit_code=0)
class _FakeModalDeployment:
def __init__(self, **kwargs):
deployment_calls.append(dict(kwargs))
self.image = kwargs["image"]
self.runtime = _FakeRuntime()
async def _snapshot_aio():
return types.SimpleNamespace(object_id=snapshot_id)
self._sandbox = types.SimpleNamespace(
snapshot_filesystem=types.SimpleNamespace(aio=_snapshot_aio),
)
async def start(self):
image = self.image if isinstance(self.image, dict) else {}
image_id = image.get("image_id")
if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids:
raise RuntimeError(f"cannot restore {image_id}")
async def stop(self):
return None
class _FakeRexCommand:
def __init__(self, **kwargs):
self.kwargs = kwargs
sys.modules["modal"] = types.SimpleNamespace(Image=_FakeImage)
swerex = types.ModuleType("swerex")
swerex.__path__ = [] # type: ignore[attr-defined]
sys.modules["swerex"] = swerex
swerex_deployment = types.ModuleType("swerex.deployment")
swerex_deployment.__path__ = [] # type: ignore[attr-defined]
sys.modules["swerex.deployment"] = swerex_deployment
sys.modules["swerex.deployment.modal"] = types.SimpleNamespace(ModalDeployment=_FakeModalDeployment)
swerex_runtime = types.ModuleType("swerex.runtime")
swerex_runtime.__path__ = [] # type: ignore[attr-defined]
sys.modules["swerex.runtime"] = swerex_runtime
sys.modules["swerex.runtime.abstract"] = types.SimpleNamespace(Command=_FakeRexCommand)
return {
"snapshot_store": hermes_home / "modal_snapshots.json",
"deployment_calls": deployment_calls,
"from_id_calls": from_id_calls,
"registry_calls": registry_calls,
}
def test_modal_environment_migrates_legacy_snapshot_key_and_uses_snapshot_id(tmp_path):
state = _install_modal_test_modules(tmp_path)
snapshot_store = state["snapshot_store"]
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
snapshot_store.write_text(json.dumps({"task-legacy": "im-legacy123"}))
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-legacy")
try:
assert state["from_id_calls"] == ["im-legacy123"]
assert state["deployment_calls"][0]["image"] == {"kind": "snapshot", "image_id": "im-legacy123"}
assert json.loads(snapshot_store.read_text()) == {"direct:task-legacy": "im-legacy123"}
finally:
env.cleanup()
def test_modal_environment_prunes_stale_direct_snapshot_and_retries_base_image(tmp_path):
state = _install_modal_test_modules(tmp_path, fail_on_snapshot_ids={"im-stale123"})
snapshot_store = state["snapshot_store"]
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
snapshot_store.write_text(json.dumps({"direct:task-stale": "im-stale123"}))
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-stale")
try:
assert [call["image"] for call in state["deployment_calls"]] == [
{"kind": "snapshot", "image_id": "im-stale123"},
{"kind": "registry", "image": "python:3.11"},
]
assert json.loads(snapshot_store.read_text()) == {}
finally:
env.cleanup()
def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path):
state = _install_modal_test_modules(tmp_path, snapshot_id="im-cleanup456")
snapshot_store = state["snapshot_store"]
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-cleanup")
env.cleanup()
assert json.loads(snapshot_store.read_text()) == {"direct:task-cleanup": "im-cleanup456"}
def test_resolve_modal_image_uses_snapshot_ids_and_registry_images(tmp_path):
state = _install_modal_test_modules(tmp_path)
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
snapshot_image = modal_module._resolve_modal_image("im-snapshot123")
registry_image = modal_module._resolve_modal_image("python:3.11")
assert snapshot_image == {"kind": "snapshot", "image_id": "im-snapshot123"}
assert registry_image == {"kind": "registry", "image": "python:3.11"}
assert state["from_id_calls"] == ["im-snapshot123"]
assert state["registry_calls"][0][0] == "python:3.11"
assert "ensurepip" in state["registry_calls"][0][1][0]

View file

@ -8,9 +8,11 @@ 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 = [
"TERMINAL_ENV", "TERMINAL_ENV",
"TERMINAL_MODAL_MODE",
"TERMINAL_SSH_HOST", "TERMINAL_SSH_HOST",
"TERMINAL_SSH_USER", "TERMINAL_SSH_USER",
"MODAL_TOKEN_ID", "MODAL_TOKEN_ID",
"MODAL_TOKEN_SECRET",
"HOME", "HOME",
"USERPROFILE", "USERPROFILE",
] ]
@ -63,7 +65,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
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))
# Pretend swerex is installed monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object()) monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
with caplog.at_level(logging.ERROR): with caplog.at_level(logging.ERROR):
@ -71,6 +73,45 @@ 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 MODAL_TOKEN_ID environment variable" in record.getMessage() "Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" in record.getMessage()
for record in caplog.records
)
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
monkeypatch.setattr(
terminal_tool_module,
"ensure_minisweagent_on_path",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
)
monkeypatch.setattr(
terminal_tool_module.importlib.util,
"find_spec",
lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")),
)
assert terminal_tool_module.check_terminal_requirements() is True
def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, caplog, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("TERMINAL_MODAL_MODE", "direct")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
with caplog.at_level(logging.ERROR):
ok = terminal_tool_module.check_terminal_requirements()
assert ok is False
assert any(
"TERMINAL_MODAL_MODE=direct" in record.getMessage()
for record in caplog.records for record in caplog.records
) )

View file

@ -26,3 +26,30 @@ class TestTerminalRequirements:
names = {tool["function"]["name"] for tool in tools} names = {tool["function"]["name"] for tool in tools}
assert "terminal" in names assert "terminal" in names
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):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
monkeypatch.setattr(
terminal_tool_module,
"_get_env_config",
lambda: {"env_type": "modal", "modal_mode": "managed"},
)
monkeypatch.setattr(
terminal_tool_module,
"is_managed_tool_gateway_ready",
lambda _vendor: True,
)
monkeypatch.setattr(
terminal_tool_module,
"ensure_minisweagent_on_path",
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
)
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
names = {tool["function"]["name"] for tool in tools}
assert "terminal" in names
assert "execute_code" in names

View file

@ -231,6 +231,7 @@ class TestTranscribeGroq:
assert result["success"] is True assert result["success"] is True
assert result["transcript"] == "hello world" assert result["transcript"] == "hello world"
assert result["provider"] == "groq" assert result["provider"] == "groq"
mock_client.close.assert_called_once()
def test_whitespace_stripped(self, monkeypatch, sample_wav): def test_whitespace_stripped(self, monkeypatch, sample_wav):
monkeypatch.setenv("GROQ_API_KEY", "gsk-test") monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
@ -272,6 +273,7 @@ class TestTranscribeGroq:
assert result["success"] is False assert result["success"] is False
assert "API error" in result["error"] assert "API error" in result["error"]
mock_client.close.assert_called_once()
def test_permission_error(self, monkeypatch, sample_wav): def test_permission_error(self, monkeypatch, sample_wav):
monkeypatch.setenv("GROQ_API_KEY", "gsk-test") monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
@ -327,6 +329,7 @@ class TestTranscribeOpenAIExtended:
result = _transcribe_openai(sample_wav, "whisper-1") result = _transcribe_openai(sample_wav, "whisper-1")
assert result["transcript"] == "hello" assert result["transcript"] == "hello"
mock_client.close.assert_called_once()
def test_permission_error(self, monkeypatch, sample_wav): def test_permission_error(self, monkeypatch, sample_wav):
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test") monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
@ -341,6 +344,7 @@ class TestTranscribeOpenAIExtended:
assert result["success"] is False assert result["success"] is False
assert "Permission denied" in result["error"] assert "Permission denied" in result["error"]
mock_client.close.assert_called_once()
class TestTranscribeLocalCommand: class TestTranscribeLocalCommand:

View file

@ -5,12 +5,14 @@ Coverage:
constructor failure recovery, return value verification, edge cases. constructor failure recovery, return value verification, edge cases.
_get_backend() backend selection logic with env var combinations. _get_backend() backend selection logic with env var combinations.
_get_parallel_client() Parallel client configuration, singleton caching. _get_parallel_client() Parallel client configuration, singleton caching.
check_web_api_key() unified availability check. check_web_api_key() unified availability check across all web backends.
""" """
import importlib
import json
import os import os
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock, AsyncMock
class TestFirecrawlClientConfig: class TestFirecrawlClientConfig:
@ -20,14 +22,30 @@ class TestFirecrawlClientConfig:
"""Reset client and env vars before each test.""" """Reset client and env vars before each test."""
import tools.web_tools import tools.web_tools
tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"): tools.web_tools._firecrawl_client_config = None
for key in (
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None) os.environ.pop(key, None)
def teardown_method(self): def teardown_method(self):
"""Reset client after each test.""" """Reset client after each test."""
import tools.web_tools import tools.web_tools
tools.web_tools._firecrawl_client = None tools.web_tools._firecrawl_client = None
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"): tools.web_tools._firecrawl_client_config = None
for key in (
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None) os.environ.pop(key, None)
# ── Configuration matrix ───────────────────────────────────────── # ── Configuration matrix ─────────────────────────────────────────
@ -67,9 +85,152 @@ class TestFirecrawlClientConfig:
def test_no_config_raises_with_helpful_message(self): def test_no_config_raises_with_helpful_message(self):
"""Neither key nor URL → ValueError with guidance.""" """Neither key nor URL → ValueError with guidance."""
with patch("tools.web_tools.Firecrawl"): with patch("tools.web_tools.Firecrawl"):
from tools.web_tools import _get_firecrawl_client with patch("tools.web_tools._read_nous_access_token", return_value=None):
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"): from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
_get_firecrawl_client()
def test_tool_gateway_domain_builds_firecrawl_gateway_origin(self):
"""Shared gateway domain should derive the Firecrawl vendor hostname."""
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools.Firecrawl") as mock_fc:
from tools.web_tools import _get_firecrawl_client
result = _get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.nousresearch.com",
)
assert result is mock_fc.return_value
def test_tool_gateway_scheme_can_switch_derived_gateway_origin_to_http(self):
"""Shared gateway scheme should allow local plain-http vendor hosts."""
with patch.dict(os.environ, {
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_SCHEME": "http",
}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools.Firecrawl") as mock_fc:
from tools.web_tools import _get_firecrawl_client
result = _get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="http://firecrawl-gateway.nousresearch.com",
)
assert result is mock_fc.return_value
def test_invalid_tool_gateway_scheme_raises(self):
"""Unexpected shared gateway schemes should fail fast."""
with patch.dict(os.environ, {
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_SCHEME": "ftp",
}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
from tools.web_tools import _get_firecrawl_client
with pytest.raises(ValueError, match="TOOL_GATEWAY_SCHEME"):
_get_firecrawl_client()
def test_explicit_firecrawl_gateway_url_takes_precedence(self):
"""An explicit Firecrawl gateway origin should override the shared domain."""
with patch.dict(os.environ, {
"FIRECRAWL_GATEWAY_URL": "https://firecrawl-gateway.localhost:3009/",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools.Firecrawl") as mock_fc:
from tools.web_tools import _get_firecrawl_client
_get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.localhost:3009",
)
def test_default_gateway_domain_targets_nous_production_origin(self):
"""Default gateway origin should point at the Firecrawl vendor hostname."""
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools.Firecrawl") as mock_fc:
from tools.web_tools import _get_firecrawl_client
_get_firecrawl_client() _get_firecrawl_client()
mock_fc.assert_called_once_with(
api_key="nous-token",
api_url="https://firecrawl-gateway.nousresearch.com",
)
def test_direct_mode_is_preferred_over_tool_gateway(self):
"""Explicit Firecrawl config should win over the gateway fallback."""
with patch.dict(os.environ, {
"FIRECRAWL_API_KEY": "fc-test",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch("tools.web_tools.Firecrawl") as mock_fc:
from tools.web_tools import _get_firecrawl_client
_get_firecrawl_client()
mock_fc.assert_called_once_with(api_key="fc-test")
def test_nous_auth_token_respects_hermes_home_override(self, tmp_path):
"""Auth lookup should read from HERMES_HOME/auth.json, not ~/.hermes/auth.json."""
real_home = tmp_path / "real-home"
(real_home / ".hermes").mkdir(parents=True)
hermes_home = tmp_path / "hermes-home"
hermes_home.mkdir()
(hermes_home / "auth.json").write_text(json.dumps({
"providers": {
"nous": {
"access_token": "nous-token",
}
}
}))
with patch.dict(os.environ, {
"HOME": str(real_home),
"HERMES_HOME": str(hermes_home),
}, clear=False):
import tools.web_tools
importlib.reload(tools.web_tools)
assert tools.web_tools._read_nous_access_token() == "nous-token"
def test_check_auxiliary_model_re_resolves_backend_each_call(self):
"""Availability checks should not be pinned to module import state."""
import tools.web_tools
# Simulate the pre-fix import-time cache slot for regression coverage.
tools.web_tools.__dict__["_aux_async_client"] = None
with patch(
"tools.web_tools.get_async_text_auxiliary_client",
side_effect=[(None, None), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model")],
):
assert tools.web_tools.check_auxiliary_model() is False
assert tools.web_tools.check_auxiliary_model() is True
@pytest.mark.asyncio
async def test_summarizer_re_resolves_backend_after_initial_unavailable_state(self):
"""Summarization should pick up a backend that becomes available later in-process."""
import tools.web_tools
tools.web_tools.__dict__["_aux_async_client"] = None
response = MagicMock()
response.choices = [MagicMock(message=MagicMock(content="summary text"))]
fake_client = MagicMock(base_url="https://api.openrouter.ai/v1")
fake_client.chat.completions.create = AsyncMock(return_value=response)
with patch(
"tools.web_tools.get_async_text_auxiliary_client",
side_effect=[(None, None), (fake_client, "test-model")],
):
assert tools.web_tools.check_auxiliary_model() is False
result = await tools.web_tools._call_summarizer_llm(
"Some content worth summarizing",
"Source: https://example.com\n\n",
None,
)
assert result == "summary text"
fake_client.chat.completions.create.assert_awaited_once()
# ── Singleton caching ──────────────────────────────────────────── # ── Singleton caching ────────────────────────────────────────────
@ -117,9 +278,10 @@ class TestFirecrawlClientConfig:
"""FIRECRAWL_API_KEY='' with no URL → should raise.""" """FIRECRAWL_API_KEY='' with no URL → should raise."""
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}): with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
with patch("tools.web_tools.Firecrawl"): with patch("tools.web_tools.Firecrawl"):
from tools.web_tools import _get_firecrawl_client with patch("tools.web_tools._read_nous_access_token", return_value=None):
with pytest.raises(ValueError): from tools.web_tools import _get_firecrawl_client
_get_firecrawl_client() with pytest.raises(ValueError):
_get_firecrawl_client()
class TestBackendSelection: class TestBackendSelection:
@ -130,7 +292,16 @@ class TestBackendSelection:
setups. setups.
""" """
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY") _ENV_KEYS = (
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"TAVILY_API_KEY",
)
def setup_method(self): def setup_method(self):
for key in self._ENV_KEYS: for key in self._ENV_KEYS:
@ -276,10 +447,47 @@ class TestParallelClientConfig:
assert client1 is client2 assert client1 is client2
class TestWebSearchErrorHandling:
"""Test suite for web_search_tool() error responses."""
def test_search_error_response_does_not_expose_diagnostics(self):
import tools.web_tools
firecrawl_client = MagicMock()
firecrawl_client.search.side_effect = RuntimeError("boom")
with patch("tools.web_tools._get_backend", return_value="firecrawl"), \
patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \
patch("tools.interrupt.is_interrupted", return_value=False), \
patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \
patch.object(tools.web_tools._debug, "save"):
result = json.loads(tools.web_tools.web_search_tool("test query", limit=3))
assert result == {"error": "Error searching web: boom"}
debug_payload = mock_log_call.call_args.args[1]
assert debug_payload["error"] == "Error searching web: boom"
assert "traceback" not in debug_payload["error"]
assert "exception_type" not in debug_payload["error"]
assert "config" not in result
assert "exception_type" not in result
assert "exception_chain" not in result
assert "traceback" not in result
class TestCheckWebApiKey: class TestCheckWebApiKey:
"""Test suite for check_web_api_key() unified availability check.""" """Test suite for check_web_api_key() unified availability check."""
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY") _ENV_KEYS = (
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
"TAVILY_API_KEY",
)
def setup_method(self): def setup_method(self):
for key in self._ENV_KEYS: for key in self._ENV_KEYS:
@ -329,3 +537,22 @@ class TestCheckWebApiKey:
}): }):
from tools.web_tools import check_web_api_key from tools.web_tools import check_web_api_key
assert check_web_api_key() is True assert check_web_api_key() is True
def test_tool_gateway_returns_true(self):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True
def test_configured_backend_must_match_available_provider(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is False
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
from tools.web_tools import check_web_api_key
assert check_web_api_key() is True

View file

@ -2,14 +2,57 @@
import logging import logging
import os import os
import threading
import uuid import uuid
from typing import Dict from typing import Any, Dict, Optional
import requests 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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {}
_pending_create_keys_lock = threading.Lock()
def _get_or_create_pending_create_key(task_id: str) -> str:
with _pending_create_keys_lock:
existing = _pending_create_keys.get(task_id)
if existing:
return existing
created = f"browserbase-session-create:{uuid.uuid4().hex}"
_pending_create_keys[task_id] = created
return created
def _clear_pending_create_key(task_id: str) -> None:
with _pending_create_keys_lock:
_pending_create_keys.pop(task_id, None)
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
if response.status_code >= 500:
return True
if response.status_code != 409:
return False
try:
payload = response.json()
except Exception:
return False
if not isinstance(payload, dict):
return False
error = payload.get("error")
if not isinstance(error, dict):
return False
message = str(error.get("message") or "").lower()
return "already in progress" in message
class BrowserbaseProvider(CloudBrowserProvider): class BrowserbaseProvider(CloudBrowserProvider):
@ -19,28 +62,46 @@ class BrowserbaseProvider(CloudBrowserProvider):
return "Browserbase" return "Browserbase"
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool( return self._get_config_or_none() is not None
os.environ.get("BROWSERBASE_API_KEY")
and os.environ.get("BROWSERBASE_PROJECT_ID")
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Session lifecycle # Session lifecycle
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _get_config(self) -> Dict[str, str]: def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
api_key = os.environ.get("BROWSERBASE_API_KEY") api_key = os.environ.get("BROWSERBASE_API_KEY")
project_id = os.environ.get("BROWSERBASE_PROJECT_ID") project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
if not api_key or not project_id: if api_key and project_id:
return {
"api_key": api_key,
"project_id": project_id,
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
"managed_mode": False,
}
managed = resolve_managed_tool_gateway("browserbase")
if managed is None:
return None
return {
"api_key": managed.nous_user_token,
"project_id": "managed",
"base_url": managed.gateway_origin.rstrip("/"),
"managed_mode": True,
}
def _get_config(self) -> Dict[str, Any]:
config = self._get_config_or_none()
if config is None:
raise ValueError( raise ValueError(
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment " "Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials "
"variables are required. Get your credentials at " "or a managed Browserbase gateway configuration."
"https://browserbase.com"
) )
return {"api_key": api_key, "project_id": project_id} return config
def create_session(self, task_id: str) -> Dict[str, object]: def create_session(self, task_id: str) -> Dict[str, object]:
config = self._get_config() config = self._get_config()
managed_mode = bool(config.get("managed_mode"))
# Optional env-var knobs # Optional env-var knobs
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false" enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
@ -80,8 +141,11 @@ class BrowserbaseProvider(CloudBrowserProvider):
"Content-Type": "application/json", "Content-Type": "application/json",
"X-BB-API-Key": config["api_key"], "X-BB-API-Key": config["api_key"],
} }
if managed_mode:
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
response = requests.post( response = requests.post(
"https://api.browserbase.com/v1/sessions", f"{config['base_url']}/v1/sessions",
headers=headers, headers=headers,
json=session_config, json=session_config,
timeout=30, timeout=30,
@ -91,7 +155,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
keepalive_fallback = False keepalive_fallback = False
# Handle 402 — paid features unavailable # Handle 402 — paid features unavailable
if response.status_code == 402: if response.status_code == 402 and not managed_mode:
if enable_keep_alive: if enable_keep_alive:
keepalive_fallback = True keepalive_fallback = True
logger.warning( logger.warning(
@ -100,7 +164,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
) )
session_config.pop("keepAlive", None) session_config.pop("keepAlive", None)
response = requests.post( response = requests.post(
"https://api.browserbase.com/v1/sessions", f"{config['base_url']}/v1/sessions",
headers=headers, headers=headers,
json=session_config, json=session_config,
timeout=30, timeout=30,
@ -114,20 +178,25 @@ class BrowserbaseProvider(CloudBrowserProvider):
) )
session_config.pop("proxies", None) session_config.pop("proxies", None)
response = requests.post( response = requests.post(
"https://api.browserbase.com/v1/sessions", f"{config['base_url']}/v1/sessions",
headers=headers, headers=headers,
json=session_config, json=session_config,
timeout=30, timeout=30,
) )
if not response.ok: if not response.ok:
if managed_mode and not _should_preserve_pending_create_key(response):
_clear_pending_create_key(task_id)
raise RuntimeError( raise RuntimeError(
f"Failed to create Browserbase session: " f"Failed to create Browserbase session: "
f"{response.status_code} {response.text}" f"{response.status_code} {response.text}"
) )
session_data = response.json() session_data = response.json()
if managed_mode:
_clear_pending_create_key(task_id)
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}" session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
external_call_id = response.headers.get("x-external-call-id") if managed_mode else None
if enable_proxies and not proxies_fallback: if enable_proxies and not proxies_fallback:
features_enabled["proxies"] = True features_enabled["proxies"] = True
@ -146,6 +215,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
"bb_session_id": session_data["id"], "bb_session_id": session_data["id"],
"cdp_url": session_data["connectUrl"], "cdp_url": session_data["connectUrl"],
"features": features_enabled, "features": features_enabled,
"external_call_id": external_call_id,
} }
def close_session(self, session_id: str) -> bool: def close_session(self, session_id: str) -> bool:
@ -157,7 +227,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
try: try:
response = requests.post( response = requests.post(
f"https://api.browserbase.com/v1/sessions/{session_id}", f"{config['base_url']}/v1/sessions/{session_id}",
headers={ headers={
"X-BB-API-Key": config["api_key"], "X-BB-API-Key": config["api_key"],
"Content-Type": "application/json", "Content-Type": "application/json",
@ -184,20 +254,19 @@ class BrowserbaseProvider(CloudBrowserProvider):
return False return False
def emergency_cleanup(self, session_id: str) -> None: def emergency_cleanup(self, session_id: str) -> None:
api_key = os.environ.get("BROWSERBASE_API_KEY") config = self._get_config_or_none()
project_id = os.environ.get("BROWSERBASE_PROJECT_ID") if config is None:
if not api_key or not project_id:
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id) logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
return return
try: try:
requests.post( requests.post(
f"https://api.browserbase.com/v1/sessions/{session_id}", f"{config['base_url']}/v1/sessions/{session_id}",
headers={ headers={
"X-BB-API-Key": api_key, "X-BB-API-Key": config["api_key"],
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
json={ json={
"projectId": project_id, "projectId": config["project_id"],
"status": "REQUEST_RELEASE", "status": "REQUEST_RELEASE",
}, },
timeout=5, timeout=5,

View file

@ -78,6 +78,7 @@ except Exception:
from tools.browser_providers.base import CloudBrowserProvider from tools.browser_providers.base import CloudBrowserProvider
from tools.browser_providers.browserbase import BrowserbaseProvider from tools.browser_providers.browserbase import BrowserbaseProvider
from tools.browser_providers.browser_use import BrowserUseProvider from tools.browser_providers.browser_use import BrowserUseProvider
from tools.tool_backend_helpers import normalize_browser_cloud_provider
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -235,7 +236,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
"""Return the configured cloud browser provider, or None for local mode. """Return the configured cloud browser provider, or None for local mode.
Reads ``config["browser"]["cloud_provider"]`` once and caches the result Reads ``config["browser"]["cloud_provider"]`` once and caches the result
for the process lifetime. If unset local mode (None). for the process lifetime. An explicit ``local`` provider disables cloud
fallback. If unset, fall back to Browserbase when direct or managed
Browserbase credentials are available.
""" """
global _cached_cloud_provider, _cloud_provider_resolved global _cached_cloud_provider, _cloud_provider_resolved
if _cloud_provider_resolved: if _cloud_provider_resolved:
@ -249,14 +252,45 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
import yaml import yaml
with open(config_path) as f: with open(config_path) as f:
cfg = yaml.safe_load(f) or {} cfg = yaml.safe_load(f) or {}
provider_key = cfg.get("browser", {}).get("cloud_provider") browser_cfg = cfg.get("browser", {})
provider_key = None
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
provider_key = normalize_browser_cloud_provider(
browser_cfg.get("cloud_provider")
)
if provider_key == "local":
_cached_cloud_provider = None
return None
if provider_key and provider_key in _PROVIDER_REGISTRY: if provider_key and provider_key in _PROVIDER_REGISTRY:
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]() _cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
except Exception as e: except Exception as e:
logger.debug("Could not read cloud_provider from config: %s", e) logger.debug("Could not read cloud_provider from config: %s", e)
if _cached_cloud_provider is None:
fallback_provider = BrowserbaseProvider()
if fallback_provider.is_configured():
_cached_cloud_provider = fallback_provider
return _cached_cloud_provider return _cached_cloud_provider
def _get_browserbase_config_or_none() -> Optional[Dict[str, Any]]:
"""Return Browserbase direct or managed config, or None when unavailable."""
return BrowserbaseProvider()._get_config_or_none()
def _get_browserbase_config() -> Dict[str, Any]:
"""Return Browserbase config or raise when neither direct nor managed mode is available."""
return BrowserbaseProvider()._get_config()
def _is_local_mode() -> bool:
"""Return True when the browser tool will use a local browser backend."""
if _get_cdp_override():
return False
return _get_cloud_provider() is None
def _socket_safe_tmpdir() -> str: def _socket_safe_tmpdir() -> str:
"""Return a short temp directory path suitable for Unix domain sockets. """Return a short temp directory path suitable for Unix domain sockets.
@ -1845,7 +1879,7 @@ if __name__ == "__main__":
print(" Install: npm install -g agent-browser && agent-browser install --with-deps") print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
if _cp is not None and not _cp.is_configured(): if _cp is not None and not _cp.is_configured():
print(f" - {_cp.provider_name()} credentials not configured") print(f" - {_cp.provider_name()} credentials not configured")
print(" Tip: remove cloud_provider from config to use free local mode instead") print(" Tip: set browser.cloud_provider to 'local' to use free local mode instead")
print("\n📋 Available Browser Tools:") print("\n📋 Available Browser Tools:")
for schema in BROWSER_TOOL_SCHEMAS: for schema in BROWSER_TOOL_SCHEMAS:

View file

@ -757,7 +757,8 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None) -> dict:
f"Available via `from hermes_tools import ...`:\n\n" f"Available via `from hermes_tools import ...`:\n\n"
f"{tool_lines}\n\n" f"{tool_lines}\n\n"
"Limits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. " "Limits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. "
"terminal() is foreground-only (no background or pty).\n\n" "terminal() is foreground-only (no background or pty). "
"If the session uses a cloud sandbox backend, treat it as resumable task state rather than a durable always-on machine.\n\n"
"Print your final result to stdout. Use Python stdlib (json, re, math, csv, " "Print your final result to stdout. Use Python stdlib (json, re, math, csv, "
"datetime, collections, etc.) for processing between tool calls.\n\n" "datetime, collections, etc.) for processing between tool calls.\n\n"
"Also available (no import needed — built into hermes_tools):\n" "Also available (no import needed — built into hermes_tools):\n"

View file

@ -0,0 +1,282 @@
"""Managed Modal environment backed by tool-gateway."""
from __future__ import annotations
import json
import logging
import os
import requests
import time
import uuid
from typing import Any, Dict, Optional
from tools.environments.base import BaseEnvironment
from tools.interrupt import is_interrupted
from tools.managed_tool_gateway import resolve_managed_tool_gateway
logger = logging.getLogger(__name__)
def _request_timeout_env(name: str, default: float) -> float:
try:
value = float(os.getenv(name, str(default)))
return value if value > 0 else default
except (TypeError, ValueError):
return default
class ManagedModalEnvironment(BaseEnvironment):
"""Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup."""
_CONNECT_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CONNECT_TIMEOUT_SECONDS", 1.0)
_POLL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_POLL_READ_TIMEOUT_SECONDS", 5.0)
_CANCEL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CANCEL_READ_TIMEOUT_SECONDS", 5.0)
def __init__(
self,
image: str,
cwd: str = "/root",
timeout: int = 60,
modal_sandbox_kwargs: Optional[Dict[str, Any]] = None,
persistent_filesystem: bool = True,
task_id: str = "default",
):
super().__init__(cwd=cwd, timeout=timeout)
gateway = resolve_managed_tool_gateway("modal")
if gateway is None:
raise ValueError("Managed Modal requires a configured tool gateway and Nous user token")
self._gateway_origin = gateway.gateway_origin.rstrip("/")
self._nous_user_token = gateway.nous_user_token
self._task_id = task_id
self._persistent = persistent_filesystem
self._image = image
self._sandbox_kwargs = dict(modal_sandbox_kwargs or {})
self._create_idempotency_key = str(uuid.uuid4())
self._sandbox_id = self._create_sandbox()
def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None,
stdin_data: str | None = None) -> dict:
exec_command, sudo_stdin = self._prepare_command(command)
# When a sudo password is present, inject it via a shell-level pipe
# (same approach as the direct ModalEnvironment) since the gateway
# cannot pipe subprocess stdin directly.
if sudo_stdin is not None:
import shlex
exec_command = (
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
)
exec_cwd = cwd or self.cwd
effective_timeout = timeout or self.timeout
exec_id = str(uuid.uuid4())
payload: Dict[str, Any] = {
"execId": exec_id,
"command": exec_command,
"cwd": exec_cwd,
"timeoutMs": int(effective_timeout * 1000),
}
if stdin_data is not None:
payload["stdinData"] = stdin_data
try:
response = self._request(
"POST",
f"/v1/sandboxes/{self._sandbox_id}/execs",
json=payload,
timeout=10,
)
except Exception as exc:
return {
"output": f"Managed Modal exec failed: {exc}",
"returncode": 1,
}
if response.status_code >= 400:
return {
"output": self._format_error("Managed Modal exec failed", response),
"returncode": 1,
}
body = response.json()
status = body.get("status")
if status in {"completed", "failed", "cancelled", "timeout"}:
return {
"output": body.get("output", ""),
"returncode": body.get("returncode", 1),
}
if body.get("execId") != exec_id:
return {
"output": "Managed Modal exec start did not return the expected exec id",
"returncode": 1,
}
poll_interval = 0.25
deadline = time.monotonic() + effective_timeout + 10
while time.monotonic() < deadline:
if is_interrupted():
self._cancel_exec(exec_id)
return {
"output": "[Command interrupted - Modal sandbox exec cancelled]",
"returncode": 130,
}
try:
status_response = self._request(
"GET",
f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}",
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS),
)
except Exception as exc:
return {
"output": f"Managed Modal exec poll failed: {exc}",
"returncode": 1,
}
if status_response.status_code == 404:
return {
"output": "Managed Modal exec not found",
"returncode": 1,
}
if status_response.status_code >= 400:
return {
"output": self._format_error("Managed Modal exec poll failed", status_response),
"returncode": 1,
}
status_body = status_response.json()
status = status_body.get("status")
if status in {"completed", "failed", "cancelled", "timeout"}:
return {
"output": status_body.get("output", ""),
"returncode": status_body.get("returncode", 1),
}
time.sleep(poll_interval)
self._cancel_exec(exec_id)
return {
"output": f"Managed Modal exec timed out after {effective_timeout}s",
"returncode": 124,
}
def cleanup(self):
if not getattr(self, "_sandbox_id", None):
return
try:
self._request(
"POST",
f"/v1/sandboxes/{self._sandbox_id}/terminate",
json={
"snapshotBeforeTerminate": self._persistent,
},
timeout=60,
)
except Exception as exc:
logger.warning("Managed Modal cleanup failed: %s", exc)
finally:
self._sandbox_id = None
def _create_sandbox(self) -> str:
cpu = self._coerce_number(self._sandbox_kwargs.get("cpu"), 1)
memory = self._coerce_number(
self._sandbox_kwargs.get("memoryMiB", self._sandbox_kwargs.get("memory")),
5120,
)
disk = self._coerce_number(
self._sandbox_kwargs.get("ephemeral_disk", self._sandbox_kwargs.get("diskMiB")),
None,
)
create_payload = {
"image": self._image,
"cwd": self.cwd,
"cpu": cpu,
"memoryMiB": memory,
"timeoutMs": 3_600_000,
"idleTimeoutMs": max(300_000, int(self.timeout * 1000)),
"persistentFilesystem": self._persistent,
"logicalKey": self._task_id,
}
if disk is not None:
create_payload["diskMiB"] = disk
response = self._request(
"POST",
"/v1/sandboxes",
json=create_payload,
timeout=60,
extra_headers={
"x-idempotency-key": self._create_idempotency_key,
},
)
if response.status_code >= 400:
raise RuntimeError(self._format_error("Managed Modal create failed", response))
body = response.json()
sandbox_id = body.get("id")
if not isinstance(sandbox_id, str) or not sandbox_id:
raise RuntimeError("Managed Modal create did not return a sandbox id")
return sandbox_id
def _request(self, method: str, path: str, *,
json: Dict[str, Any] | None = None,
timeout: int = 30,
extra_headers: Dict[str, str] | None = None) -> requests.Response:
headers = {
"Authorization": f"Bearer {self._nous_user_token}",
"Content-Type": "application/json",
}
if extra_headers:
headers.update(extra_headers)
return requests.request(
method,
f"{self._gateway_origin}{path}",
headers=headers,
json=json,
timeout=timeout,
)
def _cancel_exec(self, exec_id: str) -> None:
try:
self._request(
"POST",
f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}/cancel",
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._CANCEL_READ_TIMEOUT_SECONDS),
)
except Exception as exc:
logger.warning("Managed Modal exec cancel failed: %s", exc)
@staticmethod
def _coerce_number(value: Any, default: float) -> float:
try:
if value is None:
return default
return float(value)
except (TypeError, ValueError):
return default
@staticmethod
def _format_error(prefix: str, response: requests.Response) -> str:
try:
payload = response.json()
if isinstance(payload, dict):
message = payload.get("error") or payload.get("message") or payload.get("code")
if isinstance(message, str) and message:
return f"{prefix}: {message}"
return f"{prefix}: {json.dumps(payload, ensure_ascii=False)}"
except Exception:
pass
text = response.text.strip()
if text:
return f"{prefix}: {text}"
return f"{prefix}: HTTP {response.status_code}"

View file

@ -20,6 +20,7 @@ from tools.interrupt import is_interrupted
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json" _SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
_DIRECT_SNAPSHOT_NAMESPACE = "direct"
def _load_snapshots() -> Dict[str, str]: def _load_snapshots() -> Dict[str, str]:
@ -38,12 +39,72 @@ def _save_snapshots(data: Dict[str, str]) -> None:
_SNAPSHOT_STORE.write_text(json.dumps(data, indent=2)) _SNAPSHOT_STORE.write_text(json.dumps(data, indent=2))
class _AsyncWorker: def _direct_snapshot_key(task_id: str) -> str:
"""Background thread with its own event loop for async-safe swe-rex calls. return f"{_DIRECT_SNAPSHOT_NAMESPACE}:{task_id}"
Allows sync code to submit async coroutines and block for results,
even when called from inside another running event loop (e.g. Atropos). def _get_snapshot_restore_candidate(task_id: str) -> tuple[str | None, bool]:
""" """Return a snapshot id for direct Modal restore and whether the key is legacy."""
snapshots = _load_snapshots()
namespaced_key = _direct_snapshot_key(task_id)
snapshot_id = snapshots.get(namespaced_key)
if isinstance(snapshot_id, str) and snapshot_id:
return snapshot_id, False
legacy_snapshot_id = snapshots.get(task_id)
if isinstance(legacy_snapshot_id, str) and legacy_snapshot_id:
return legacy_snapshot_id, True
return None, False
def _store_direct_snapshot(task_id: str, snapshot_id: str) -> None:
"""Persist the direct Modal snapshot id under the direct namespace."""
snapshots = _load_snapshots()
snapshots[_direct_snapshot_key(task_id)] = snapshot_id
snapshots.pop(task_id, None)
_save_snapshots(snapshots)
def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> None:
"""Remove direct Modal snapshot entries for a task, including legacy keys."""
snapshots = _load_snapshots()
updated = False
for key in (_direct_snapshot_key(task_id), task_id):
value = snapshots.get(key)
if value is None:
continue
if snapshot_id is None or value == snapshot_id:
snapshots.pop(key, None)
updated = True
if updated:
_save_snapshots(snapshots)
def _resolve_modal_image(image_spec: Any) -> Any:
"""Convert registry references or snapshot ids into Modal image objects."""
import modal as _modal
if not isinstance(image_spec, str):
return image_spec
if image_spec.startswith("im-"):
return _modal.Image.from_id(image_spec)
return _modal.Image.from_registry(
image_spec,
setup_dockerfile_commands=[
"RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; "
"python -m ensurepip --upgrade --default-pip 2>/dev/null || true",
],
)
class _AsyncWorker:
"""Background thread with its own event loop for async-safe swe-rex calls."""
def __init__(self): def __init__(self):
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
@ -101,42 +162,20 @@ class ModalEnvironment(BaseEnvironment):
sandbox_kwargs = dict(modal_sandbox_kwargs or {}) sandbox_kwargs = dict(modal_sandbox_kwargs or {})
# If persistent, try to restore from a previous snapshot restored_snapshot_id = None
restored_image = None restored_from_legacy_key = False
if self._persistent: if self._persistent:
snapshot_id = _load_snapshots().get(self._task_id) restored_snapshot_id, restored_from_legacy_key = _get_snapshot_restore_candidate(self._task_id)
if snapshot_id: if restored_snapshot_id:
try: logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
import modal
restored_image = modal.Image.from_id(snapshot_id)
logger.info("Modal: restoring from snapshot %s", snapshot_id[:20])
except Exception as e:
logger.warning("Modal: failed to restore snapshot, using base image: %s", e)
restored_image = None
effective_image = restored_image if restored_image else image
# Pre-build a modal.Image with pip fix for Modal's legacy image builder.
# Some task images have broken pip; fix via ensurepip before Modal uses it.
import modal as _modal
if isinstance(effective_image, str):
effective_image = _modal.Image.from_registry(
effective_image,
setup_dockerfile_commands=[
"RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; "
"python -m ensurepip --upgrade --default-pip 2>/dev/null || true",
],
)
# Start the async worker thread and create the deployment on it
# so all gRPC channels are bound to the worker's event loop.
self._worker.start() self._worker.start()
from swerex.deployment.modal import ModalDeployment from swerex.deployment.modal import ModalDeployment
async def _create_and_start(): async def _create_and_start(image_spec: Any):
deployment = ModalDeployment( deployment = ModalDeployment(
image=effective_image, image=image_spec,
startup_timeout=180.0, startup_timeout=180.0,
runtime_timeout=3600.0, runtime_timeout=3600.0,
deployment_timeout=3600.0, deployment_timeout=3600.0,
@ -146,7 +185,30 @@ class ModalEnvironment(BaseEnvironment):
await deployment.start() await deployment.start()
return deployment return deployment
self._deployment = self._worker.run_coroutine(_create_and_start()) try:
target_image_spec = restored_snapshot_id or image
try:
effective_image = _resolve_modal_image(target_image_spec)
self._deployment = self._worker.run_coroutine(_create_and_start(effective_image))
except Exception as exc:
if not restored_snapshot_id:
raise
logger.warning(
"Modal: failed to restore snapshot %s, retrying with base image: %s",
restored_snapshot_id[:20],
exc,
)
_delete_direct_snapshot(self._task_id, restored_snapshot_id)
base_image = _resolve_modal_image(image)
self._deployment = self._worker.run_coroutine(_create_and_start(base_image))
else:
if restored_snapshot_id and restored_from_legacy_key:
_store_direct_snapshot(self._task_id, restored_snapshot_id)
logger.info("Modal: migrated legacy snapshot entry for task %s", self._task_id)
except Exception:
self._worker.stop()
raise
def execute(self, command: str, cwd: str = "", *, def execute(self, command: str, cwd: str = "", *,
timeout: int | None = None, timeout: int | None = None,
@ -160,7 +222,7 @@ class ModalEnvironment(BaseEnvironment):
exec_command, sudo_stdin = self._prepare_command(command) exec_command, sudo_stdin = self._prepare_command(command)
# Modal sandboxes execute commands via the Modal SDK and cannot pipe # Modal sandboxes execute commands via the Modal SDK and cannot pipe
# subprocess stdin directly the way a local Popen can. When a sudo # subprocess stdin directly the way a local Popen can. When a sudo
# password is present, use a shell-level pipe from printf so that the # password is present, use a shell-level pipe from printf so that the
# password feeds sudo -S without appearing as an echo argument embedded # password feeds sudo -S without appearing as an echo argument embedded
# in the shell string. # in the shell string.
@ -175,7 +237,6 @@ class ModalEnvironment(BaseEnvironment):
effective_cwd = cwd or self.cwd effective_cwd = cwd or self.cwd
effective_timeout = timeout or self.timeout effective_timeout = timeout or self.timeout
# Run in a background thread so we can poll for interrupts
result_holder = {"value": None, "error": None} result_holder = {"value": None, "error": None}
def _run(): def _run():
@ -191,6 +252,7 @@ class ModalEnvironment(BaseEnvironment):
merge_output_streams=True, merge_output_streams=True,
) )
) )
output = self._worker.run_coroutine(_do_execute()) output = self._worker.run_coroutine(_do_execute())
result_holder["value"] = { result_holder["value"] = {
"output": output.stdout, "output": output.stdout,
@ -227,7 +289,7 @@ class ModalEnvironment(BaseEnvironment):
if self._persistent: if self._persistent:
try: try:
sandbox = getattr(self._deployment, '_sandbox', None) sandbox = getattr(self._deployment, "_sandbox", None)
if sandbox: if sandbox:
async def _snapshot(): async def _snapshot():
img = await sandbox.snapshot_filesystem.aio() img = await sandbox.snapshot_filesystem.aio()
@ -239,11 +301,12 @@ class ModalEnvironment(BaseEnvironment):
snapshot_id = None snapshot_id = None
if snapshot_id: if snapshot_id:
snapshots = _load_snapshots() _store_direct_snapshot(self._task_id, snapshot_id)
snapshots[self._task_id] = snapshot_id logger.info(
_save_snapshots(snapshots) "Modal: saved filesystem snapshot %s for task %s",
logger.info("Modal: saved filesystem snapshot %s for task %s", snapshot_id[:20],
snapshot_id[:20], self._task_id) self._task_id,
)
except Exception as e: except Exception as e:
logger.warning("Modal: filesystem snapshot failed: %s", e) logger.warning("Modal: filesystem snapshot failed: %s", e)

View file

@ -32,9 +32,13 @@ import json
import logging import logging
import os import os
import datetime import datetime
import threading
import uuid
from typing import Dict, Any, Optional, Union from typing import Dict, Any, Optional, Union
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -77,6 +81,137 @@ VALID_OUTPUT_FORMATS = ["jpeg", "png"]
VALID_ACCELERATION_MODES = ["none", "regular", "high"] VALID_ACCELERATION_MODES = ["none", "regular", "high"]
_debug = DebugSession("image_tools", env_var="IMAGE_TOOLS_DEBUG") _debug = DebugSession("image_tools", env_var="IMAGE_TOOLS_DEBUG")
_managed_fal_client = None
_managed_fal_client_config = None
_managed_fal_client_lock = threading.Lock()
def _resolve_managed_fal_gateway():
"""Return managed fal-queue gateway config when direct FAL credentials are absent."""
if os.getenv("FAL_KEY"):
return None
return resolve_managed_tool_gateway("fal-queue")
def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:
normalized_origin = str(queue_run_origin or "").strip().rstrip("/")
if not normalized_origin:
raise ValueError("Managed FAL queue origin is required")
return f"{normalized_origin}/"
class _ManagedFalSyncClient:
"""Small per-instance wrapper around fal_client.SyncClient for managed queue hosts."""
def __init__(self, *, key: str, queue_run_origin: str):
sync_client_class = getattr(fal_client, "SyncClient", None)
if sync_client_class is None:
raise RuntimeError("fal_client.SyncClient is required for managed FAL gateway mode")
client_module = getattr(fal_client, "client", None)
if client_module is None:
raise RuntimeError("fal_client.client is required for managed FAL gateway mode")
self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)
self._sync_client = sync_client_class(key=key)
self._http_client = getattr(self._sync_client, "_client", None)
self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None)
self._raise_for_status = getattr(client_module, "_raise_for_status", None)
self._request_handle_class = getattr(client_module, "SyncRequestHandle", None)
self._add_hint_header = getattr(client_module, "add_hint_header", None)
self._add_priority_header = getattr(client_module, "add_priority_header", None)
self._add_timeout_header = getattr(client_module, "add_timeout_header", None)
if self._http_client is None:
raise RuntimeError("fal_client.SyncClient._client is required for managed FAL gateway mode")
if self._maybe_retry_request is None or self._raise_for_status is None:
raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode")
if self._request_handle_class is None:
raise RuntimeError("fal_client.client.SyncRequestHandle is required for managed FAL gateway mode")
def submit(
self,
application: str,
arguments: Dict[str, Any],
*,
path: str = "",
hint: Optional[str] = None,
webhook_url: Optional[str] = None,
priority: Any = None,
headers: Optional[Dict[str, str]] = None,
start_timeout: Optional[Union[int, float]] = None,
):
url = self._queue_url_format + application
if path:
url += "/" + path.lstrip("/")
if webhook_url is not None:
url += "?" + urlencode({"fal_webhook": webhook_url})
request_headers = dict(headers or {})
if hint is not None and self._add_hint_header is not None:
self._add_hint_header(hint, request_headers)
if priority is not None:
if self._add_priority_header is None:
raise RuntimeError("fal_client.client.add_priority_header is required for priority requests")
self._add_priority_header(priority, request_headers)
if start_timeout is not None:
if self._add_timeout_header is None:
raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests")
self._add_timeout_header(start_timeout, request_headers)
response = self._maybe_retry_request(
self._http_client,
"POST",
url,
json=arguments,
timeout=getattr(self._sync_client, "default_timeout", 120.0),
headers=request_headers,
)
self._raise_for_status(response)
data = response.json()
return self._request_handle_class(
request_id=data["request_id"],
response_url=data["response_url"],
status_url=data["status_url"],
cancel_url=data["cancel_url"],
client=self._http_client,
)
def _get_managed_fal_client(managed_gateway):
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
global _managed_fal_client, _managed_fal_client_config
client_config = (
managed_gateway.gateway_origin.rstrip("/"),
managed_gateway.nous_user_token,
)
with _managed_fal_client_lock:
if _managed_fal_client is not None and _managed_fal_client_config == client_config:
return _managed_fal_client
_managed_fal_client = _ManagedFalSyncClient(
key=managed_gateway.nous_user_token,
queue_run_origin=managed_gateway.gateway_origin,
)
_managed_fal_client_config = client_config
return _managed_fal_client
def _submit_fal_request(model: str, arguments: Dict[str, Any]):
"""Submit a FAL request using direct credentials or the managed queue gateway."""
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
managed_gateway = _resolve_managed_fal_gateway()
if managed_gateway is None:
return fal_client.submit(model, arguments=arguments, headers=request_headers)
managed_client = _get_managed_fal_client(managed_gateway)
return managed_client.submit(
model,
arguments=arguments,
headers=request_headers,
)
def _validate_parameters( def _validate_parameters(
@ -186,9 +321,9 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]:
# The async API (submit_async) caches a global httpx.AsyncClient via # The async API (submit_async) caches a global httpx.AsyncClient via
# @cached_property, which breaks when asyncio.run() destroys the loop # @cached_property, which breaks when asyncio.run() destroys the loop
# between calls (gateway thread-pool pattern). # between calls (gateway thread-pool pattern).
handler = fal_client.submit( handler = _submit_fal_request(
UPSCALER_MODEL, UPSCALER_MODEL,
arguments=upscaler_arguments arguments=upscaler_arguments,
) )
# Get the upscaled result (sync — blocks until done) # Get the upscaled result (sync — blocks until done)
@ -280,8 +415,10 @@ def image_generate_tool(
raise ValueError("Prompt is required and must be a non-empty string") raise ValueError("Prompt is required and must be a non-empty string")
# Check API key availability # Check API key availability
if not os.getenv("FAL_KEY"): if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
raise ValueError("FAL_KEY environment variable not set") raise ValueError(
"FAL_KEY environment variable not set and managed FAL gateway is unavailable"
)
# Validate other parameters # Validate other parameters
validated_params = _validate_parameters( validated_params = _validate_parameters(
@ -312,9 +449,9 @@ def image_generate_tool(
logger.info(" Guidance: %s", validated_params['guidance_scale']) logger.info(" Guidance: %s", validated_params['guidance_scale'])
# Submit request to FAL.ai using sync API (avoids cached event loop issues) # Submit request to FAL.ai using sync API (avoids cached event loop issues)
handler = fal_client.submit( handler = _submit_fal_request(
DEFAULT_MODEL, DEFAULT_MODEL,
arguments=arguments arguments=arguments,
) )
# Get the result (sync — blocks until done) # Get the result (sync — blocks until done)
@ -379,10 +516,12 @@ def image_generate_tool(
error_msg = f"Error generating image: {str(e)}" error_msg = f"Error generating image: {str(e)}"
logger.error("%s", error_msg, exc_info=True) logger.error("%s", error_msg, exc_info=True)
# Prepare error response - minimal format # Include error details so callers can diagnose failures
response_data = { response_data = {
"success": False, "success": False,
"image": None "image": None,
"error": str(e),
"error_type": type(e).__name__,
} }
debug_call_data["error"] = error_msg debug_call_data["error"] = error_msg
@ -400,7 +539,7 @@ def check_fal_api_key() -> bool:
Returns: Returns:
bool: True if API key is set, False otherwise bool: True if API key is set, False otherwise
""" """
return bool(os.getenv("FAL_KEY")) return bool(os.getenv("FAL_KEY") or _resolve_managed_fal_gateway())
def check_image_generation_requirements() -> bool: def check_image_generation_requirements() -> bool:
@ -556,7 +695,7 @@ registry.register(
schema=IMAGE_GENERATE_SCHEMA, schema=IMAGE_GENERATE_SCHEMA,
handler=_handle_image_generate, handler=_handle_image_generate,
check_fn=check_image_generation_requirements, check_fn=check_image_generation_requirements,
requires_env=["FAL_KEY"], requires_env=[],
is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway
emoji="🎨", emoji="🎨",
) )

View file

@ -0,0 +1,160 @@
"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs."""
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from dataclasses import dataclass
from typing import Callable, Optional
from hermes_cli.config import get_hermes_home
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
@dataclass(frozen=True)
class ManagedToolGatewayConfig:
vendor: str
gateway_origin: str
nous_user_token: str
managed_mode: bool
def auth_json_path():
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""
return get_hermes_home() / "auth.json"
def _read_nous_provider_state() -> Optional[dict]:
try:
path = auth_json_path()
if not path.is_file():
return None
data = json.loads(path.read_text())
providers = data.get("providers", {})
if not isinstance(providers, dict):
return None
nous_provider = providers.get("nous", {})
if isinstance(nous_provider, dict):
return nous_provider
except Exception:
pass
return None
def _parse_timestamp(value: object) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
expires = _parse_timestamp(expires_at)
if expires is None:
return True
remaining = (expires - datetime.now(timezone.utc)).total_seconds()
return remaining <= max(0, int(skew_seconds))
def read_nous_access_token() -> Optional[str]:
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
nous_provider = _read_nous_provider_state() or {}
access_token = nous_provider.get("access_token")
cached_token = access_token.strip() if isinstance(access_token, str) and access_token.strip() else None
if cached_token and not _access_token_is_expiring(
nous_provider.get("expires_at"),
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
):
return cached_token
try:
from hermes_cli.auth import resolve_nous_access_token
refreshed_token = resolve_nous_access_token(
refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
)
if isinstance(refreshed_token, str) and refreshed_token.strip():
return refreshed_token.strip()
except Exception:
pass
return cached_token
def get_tool_gateway_scheme() -> str:
"""Return configured shared gateway URL scheme."""
scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower()
if not scheme:
return _DEFAULT_TOOL_GATEWAY_SCHEME
if scheme in {"http", "https"}:
return scheme
raise ValueError("TOOL_GATEWAY_SCHEME must be 'http' or 'https'")
def build_vendor_gateway_url(vendor: str) -> str:
"""Return the gateway origin for a specific vendor."""
vendor_key = f"{vendor.upper().replace('-', '_')}_GATEWAY_URL"
explicit_vendor_url = os.getenv(vendor_key, "").strip().rstrip("/")
if explicit_vendor_url:
return explicit_vendor_url
shared_scheme = get_tool_gateway_scheme()
shared_domain = os.getenv("TOOL_GATEWAY_DOMAIN", "").strip().strip("/")
if shared_domain:
return f"{shared_scheme}://{vendor}-gateway.{shared_domain}"
return f"{shared_scheme}://{vendor}-gateway.{_DEFAULT_TOOL_GATEWAY_DOMAIN}"
def resolve_managed_tool_gateway(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> Optional[ManagedToolGatewayConfig]:
"""Resolve shared managed-tool gateway config for a vendor."""
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
resolved_token_reader = token_reader or read_nous_access_token
gateway_origin = resolved_gateway_builder(vendor)
nous_user_token = resolved_token_reader()
if not gateway_origin or not nous_user_token:
return None
return ManagedToolGatewayConfig(
vendor=vendor,
gateway_origin=gateway_origin,
nous_user_token=nous_user_token,
managed_mode=True,
)
def is_managed_tool_gateway_ready(
vendor: str,
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> bool:
"""Return True when gateway URL and Nous access token are available."""
return resolve_managed_tool_gateway(
vendor,
gateway_builder=gateway_builder,
token_reader=token_reader,
) is not None

View file

@ -3,12 +3,12 @@
Terminal Tool Module Terminal Tool Module
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments. A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
Supports local execution, Docker containers, and Modal cloud sandboxes. Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
Environment Selection (via TERMINAL_ENV environment variable): Environment Selection (via TERMINAL_ENV environment variable):
- "local": Execute directly on the host machine (default, fastest) - "local": Execute directly on the host machine (default, fastest)
- "docker": Execute in Docker containers (isolated, requires Docker) - "docker": Execute in Docker containers (isolated, requires Docker)
- "modal": Execute in Modal cloud sandboxes (scalable, requires Modal account) - "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
Features: Features:
- Multiple execution backends (local, docker, modal) - Multiple execution backends (local, docker, modal)
@ -16,6 +16,10 @@ Features:
- VM/container lifecycle management - VM/container lifecycle management
- Automatic cleanup after inactivity - Automatic cleanup after inactivity
Cloud sandbox note:
- Persistent filesystems preserve working state across sandbox recreation
- Persistent filesystems do NOT guarantee the same live sandbox or long-running processes survive cleanup, idle reaping, or Hermes exit
Usage: Usage:
from terminal_tool import terminal_tool from terminal_tool import terminal_tool
@ -50,12 +54,18 @@ logger = logging.getLogger(__name__)
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None:
"""Backward-compatible no-op after minisweagent_path.py removal."""
return
# ============================================================================= # =============================================================================
# Custom Singularity Environment with more space # Custom Singularity Environment with more space
# ============================================================================= # =============================================================================
# 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
# Disk usage warning threshold (in GB) # Disk usage warning threshold (in GB)
@ -361,10 +371,12 @@ from tools.environments.singularity import SingularityEnvironment as _Singularit
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
from tools.environments.docker import DockerEnvironment as _DockerEnvironment from tools.environments.docker import DockerEnvironment as _DockerEnvironment
from tools.environments.modal import ModalEnvironment as _ModalEnvironment from tools.environments.modal import ModalEnvironment as _ModalEnvironment
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
# Tool description for LLM # Tool description for LLM
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem persists between calls. TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls.
Do NOT use cat/head/tail to read files use read_file instead. Do NOT use cat/head/tail to read files use read_file instead.
Do NOT use grep/rg/find to search use search_files instead. Do NOT use grep/rg/find to search use search_files instead.
@ -380,6 +392,7 @@ Working directory: Use 'workdir' for per-command cwd.
PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL). PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).
Do NOT use vim/nano/interactive tools without pty=true they hang without a pseudo-terminal. Pipe git output to cat if it might page. Do NOT use vim/nano/interactive tools without pty=true they hang without a pseudo-terminal. Pipe git output to cat if it might page.
Important: cloud sandboxes may be cleaned up, idled out, or recreated between turns. Persistent filesystem means files can resume later; it does NOT guarantee a continuously running machine or surviving background processes. Use terminal sandboxes for task work, not durable hosting.
""" """
# Global state for environment lifecycle management # Global state for environment lifecycle management
@ -493,6 +506,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")),
"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}"),
@ -525,6 +539,27 @@ 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."""
normalized_mode = normalize_modal_mode(modal_mode)
has_direct = has_direct_modal_credentials()
managed_ready = is_managed_tool_gateway_ready("modal")
if normalized_mode == "managed":
selected_backend = "managed" if managed_ready else None
elif normalized_mode == "direct":
selected_backend = "direct" if has_direct else None
else:
selected_backend = "direct" if has_direct else "managed" if managed_ready else None
return {
"mode": normalized_mode,
"has_direct": has_direct,
"managed_ready": managed_ready,
"selected_backend": selected_backend,
}
def _create_environment(env_type: str, image: str, cwd: str, timeout: int, def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
ssh_config: dict = None, container_config: dict = None, ssh_config: dict = None, container_config: dict = None,
local_config: dict = None, local_config: dict = None,
@ -591,6 +626,28 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
except Exception: except Exception:
pass pass
modal_state = _get_modal_backend_state(cc.get("modal_mode"))
if modal_state["selected_backend"] == "managed":
return _ManagedModalEnvironment(
image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs,
persistent_filesystem=persistent, task_id=task_id,
)
if modal_state["selected_backend"] != "direct":
if modal_state["mode"] == "managed":
raise ValueError(
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
)
if modal_state["mode"] == "direct":
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."
)
return _ModalEnvironment( return _ModalEnvironment(
image=image, cwd=cwd, timeout=timeout, image=image, cwd=cwd, timeout=timeout,
modal_sandbox_kwargs=sandbox_kwargs, modal_sandbox_kwargs=sandbox_kwargs,
@ -956,6 +1013,7 @@ def terminal_tool(
"container_memory": config.get("container_memory", 5120), "container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200), "container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True), "container_persistent": config.get("container_persistent", True),
"modal_mode": config.get("modal_mode", "auto"),
"docker_volumes": config.get("docker_volumes", []), "docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False), "docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
} }
@ -1173,10 +1231,14 @@ def terminal_tool(
}, ensure_ascii=False) }, ensure_ascii=False)
except Exception as e: except Exception as e:
import traceback
tb_str = traceback.format_exc()
logger.error("terminal_tool exception:\n%s", tb_str)
return json.dumps({ return json.dumps({
"output": "", "output": "",
"exit_code": -1, "exit_code": -1,
"error": f"Failed to execute command: {str(e)}", "error": f"Failed to execute command: {str(e)}",
"traceback": tb_str,
"status": "error" "status": "error"
}, ensure_ascii=False) }, ensure_ascii=False)
@ -1216,18 +1278,35 @@ def check_terminal_requirements() -> bool:
return True return True
elif env_type == "modal": elif env_type == "modal":
modal_state = _get_modal_backend_state(config.get("modal_mode"))
if modal_state["selected_backend"] == "managed":
return True
if modal_state["selected_backend"] != "direct":
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."
)
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."
)
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 importlib.util.find_spec("swerex") is None: if importlib.util.find_spec("swerex") is None:
logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[modal]'") logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'")
return False
has_token = os.getenv("MODAL_TOKEN_ID") is not None
has_config = Path.home().joinpath(".modal.toml").exists()
if not (has_token or has_config):
logger.error(
"Modal backend selected but no MODAL_TOKEN_ID environment variable "
"or ~/.modal.toml config file was found. Configure Modal or choose "
"a different TERMINAL_ENV."
)
return False return False
return True return True
elif env_type == "daytona": elif env_type == "daytona":

View file

@ -0,0 +1,41 @@
"""Shared helpers for tool backend selection."""
from __future__ import annotations
import os
from pathlib import Path
_DEFAULT_BROWSER_PROVIDER = "local"
_DEFAULT_MODAL_MODE = "auto"
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
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."""
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
if mode in _VALID_MODAL_MODES:
return mode
return _DEFAULT_MODAL_MODE
def has_direct_modal_credentials() -> bool:
"""Return True when direct Modal credentials/config are available."""
return bool(
(os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"))
or (Path.home() / ".modal.toml").exists()
)
def resolve_openai_audio_api_key() -> str:
"""Prefer the voice-tools key, but fall back to the normal OpenAI key."""
return (
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
or os.getenv("OPENAI_API_KEY", "")
).strip()

View file

@ -31,6 +31,10 @@ import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from urllib.parse import urljoin
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import resolve_openai_audio_api_key
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
@ -41,8 +45,17 @@ logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
import importlib.util as _ilu import importlib.util as _ilu
_HAS_FASTER_WHISPER = _ilu.find_spec("faster_whisper") is not None
_HAS_OPENAI = _ilu.find_spec("openai") is not None
def _safe_find_spec(module_name: str) -> bool:
try:
return _ilu.find_spec(module_name) is not None
except (ImportError, ValueError):
return module_name in globals() or module_name in os.sys.modules
_HAS_FASTER_WHISPER = _safe_find_spec("faster_whisper")
_HAS_OPENAI = _safe_find_spec("openai")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Constants # Constants
@ -116,9 +129,9 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
return bool(enabled) return bool(enabled)
def _resolve_openai_api_key() -> str: def _has_openai_audio_backend() -> bool:
"""Prefer the voice-tools key, but fall back to the normal OpenAI key.""" """Return True when OpenAI audio can use direct credentials or the managed gateway."""
return os.getenv("VOICE_TOOLS_OPENAI_KEY", "") or os.getenv("OPENAI_API_KEY", "") return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio"))
def _find_binary(binary_name: str) -> Optional[str]: def _find_binary(binary_name: str) -> Optional[str]:
@ -210,7 +223,7 @@ def _get_provider(stt_config: dict) -> str:
return "none" return "none"
if provider == "openai": if provider == "openai":
if _HAS_OPENAI and _resolve_openai_api_key(): if _HAS_OPENAI and _has_openai_audio_backend():
return "openai" return "openai"
logger.warning( logger.warning(
"STT provider 'openai' configured but no API key available" "STT provider 'openai' configured but no API key available"
@ -228,7 +241,7 @@ def _get_provider(stt_config: dict) -> str:
if _HAS_OPENAI and os.getenv("GROQ_API_KEY"): if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
logger.info("No local STT available, using Groq Whisper API") logger.info("No local STT available, using Groq Whisper API")
return "groq" return "groq"
if _HAS_OPENAI and _resolve_openai_api_key(): if _HAS_OPENAI and _has_openai_audio_backend():
logger.info("No local STT available, using OpenAI Whisper API") logger.info("No local STT available, using OpenAI Whisper API")
return "openai" return "openai"
return "none" return "none"
@ -404,19 +417,23 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
try: try:
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0) client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0)
try:
with open(file_path, "rb") as audio_file:
transcription = client.audio.transcriptions.create(
model=model_name,
file=audio_file,
response_format="text",
)
with open(file_path, "rb") as audio_file: transcript_text = str(transcription).strip()
transcription = client.audio.transcriptions.create( logger.info("Transcribed %s via Groq API (%s, %d chars)",
model=model_name, Path(file_path).name, model_name, len(transcript_text))
file=audio_file,
response_format="text",
)
transcript_text = str(transcription).strip() return {"success": True, "transcript": transcript_text, "provider": "groq"}
logger.info("Transcribed %s via Groq API (%s, %d chars)", finally:
Path(file_path).name, model_name, len(transcript_text)) close = getattr(client, "close", None)
if callable(close):
return {"success": True, "transcript": transcript_text, "provider": "groq"} close()
except PermissionError: except PermissionError:
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
@ -437,12 +454,13 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]: def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
"""Transcribe using OpenAI Whisper API (paid).""" """Transcribe using OpenAI Whisper API (paid)."""
api_key = _resolve_openai_api_key() try:
if not api_key: api_key, base_url = _resolve_openai_audio_client_config()
except ValueError as exc:
return { return {
"success": False, "success": False,
"transcript": "", "transcript": "",
"error": "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set", "error": str(exc),
} }
if not _HAS_OPENAI: if not _HAS_OPENAI:
@ -455,20 +473,24 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
try: try:
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
client = OpenAI(api_key=api_key, base_url=OPENAI_BASE_URL, timeout=30, max_retries=0) client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0)
try:
with open(file_path, "rb") as audio_file:
transcription = client.audio.transcriptions.create(
model=model_name,
file=audio_file,
response_format="text" if model_name == "whisper-1" else "json",
)
with open(file_path, "rb") as audio_file: transcript_text = _extract_transcript_text(transcription)
transcription = client.audio.transcriptions.create( logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
model=model_name, Path(file_path).name, model_name, len(transcript_text))
file=audio_file,
response_format="text",
)
transcript_text = str(transcription).strip() return {"success": True, "transcript": transcript_text, "provider": "openai"}
logger.info("Transcribed %s via OpenAI API (%s, %d chars)", finally:
Path(file_path).name, model_name, len(transcript_text)) close = getattr(client, "close", None)
if callable(close):
return {"success": True, "transcript": transcript_text, "provider": "openai"} close()
except PermissionError: except PermissionError:
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"} return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
@ -554,3 +576,38 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
"or OPENAI_API_KEY for the OpenAI Whisper API." "or OPENAI_API_KEY for the OpenAI Whisper API."
), ),
} }
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return direct OpenAI audio config or a managed gateway fallback."""
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, OPENAI_BASE_URL
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"
)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
)
def _extract_transcript_text(transcription: Any) -> str:
"""Normalize text and JSON transcription responses to a plain string."""
if isinstance(transcription, str):
return transcription.strip()
if hasattr(transcription, "text"):
value = getattr(transcription, "text")
if isinstance(value, str):
return value.strip()
if isinstance(transcription, dict):
value = transcription.get("text")
if isinstance(value, str):
return value.strip()
return str(transcription).strip()

View file

@ -32,11 +32,15 @@ import shutil
import subprocess import subprocess
import tempfile import tempfile
import threading import threading
import uuid
from pathlib import Path from pathlib import Path
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
from typing import Callable, Dict, Any, Optional from typing import Callable, Dict, Any, Optional
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.tool_backend_helpers import 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
@ -74,6 +78,7 @@ DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"
DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5" DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5"
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts" DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
DEFAULT_OPENAI_VOICE = "alloy" DEFAULT_OPENAI_VOICE = "alloy"
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
DEFAULT_OUTPUT_DIR = str(get_hermes_home() / "audio_cache") DEFAULT_OUTPUT_DIR = str(get_hermes_home() / "audio_cache")
MAX_TEXT_LENGTH = 4000 MAX_TEXT_LENGTH = 4000
@ -233,14 +238,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
Returns: Returns:
Path to the saved audio file. Path to the saved audio file.
""" """
api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "") api_key, base_url = _resolve_openai_audio_client_config()
if not api_key:
raise ValueError("VOICE_TOOLS_OPENAI_KEY not set. Get one at https://platform.openai.com/api-keys")
oai_config = tts_config.get("openai", {}) oai_config = tts_config.get("openai", {})
model = oai_config.get("model", DEFAULT_OPENAI_MODEL) model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE) voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE)
base_url = oai_config.get("base_url", "https://api.openai.com/v1") base_url = oai_config.get("base_url", base_url)
# Determine response format from extension # Determine response format from extension
if output_path.endswith(".ogg"): if output_path.endswith(".ogg"):
@ -250,15 +253,21 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
OpenAIClient = _import_openai_client() OpenAIClient = _import_openai_client()
client = OpenAIClient(api_key=api_key, base_url=base_url) client = OpenAIClient(api_key=api_key, base_url=base_url)
response = client.audio.speech.create( try:
model=model, response = client.audio.speech.create(
voice=voice, model=model,
input=text, voice=voice,
response_format=response_format, input=text,
) response_format=response_format,
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
)
response.stream_to_file(output_path) response.stream_to_file(output_path)
return output_path return output_path
finally:
close = getattr(client, "close", None)
if callable(close):
close()
# =========================================================================== # ===========================================================================
@ -539,7 +548,7 @@ def check_tts_requirements() -> bool:
pass pass
try: try:
_import_openai_client() _import_openai_client()
if os.getenv("VOICE_TOOLS_OPENAI_KEY"): if _has_openai_audio_backend():
return True return True
except ImportError: except ImportError:
pass pass
@ -548,6 +557,28 @@ def check_tts_requirements() -> bool:
return False return False
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return direct OpenAI audio config or a managed gateway fallback."""
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
return direct_api_key, DEFAULT_OPENAI_BASE_URL
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"
)
return managed_gateway.nous_user_token, urljoin(
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
)
def _has_openai_audio_backend() -> bool:
"""Return True when OpenAI audio can use direct credentials or the managed gateway."""
return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio"))
# =========================================================================== # ===========================================================================
# Streaming TTS: sentence-by-sentence pipeline for ElevenLabs # Streaming TTS: sentence-by-sentence pipeline for ElevenLabs
# =========================================================================== # ===========================================================================
@ -802,7 +833,10 @@ if __name__ == "__main__":
print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}") print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}")
print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}") print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}")
print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}") print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}")
print(f" API Key: {'set' if os.getenv('VOICE_TOOLS_OPENAI_KEY') else 'not set (VOICE_TOOLS_OPENAI_KEY)'}") print(
" API Key: "
f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}"
)
print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}") print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}") print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}")

View file

@ -4,15 +4,18 @@ Standalone Web Tools Module
This module provides generic web tools that work with multiple backend providers. This module provides generic web tools that work with multiple backend providers.
Backend is selected during ``hermes tools`` setup (web.backend in config.yaml). Backend is selected during ``hermes tools`` setup (web.backend in config.yaml).
When available, Hermes can route Firecrawl calls through a Nous-hosted tool-gateway
for Nous Subscribers only.
Available tools: Available tools:
- web_search_tool: Search the web for information - web_search_tool: Search the web for information
- web_extract_tool: Extract content from specific web pages - web_extract_tool: Extract content from specific web pages
- web_crawl_tool: Crawl websites with specific instructions (Firecrawl only) - web_crawl_tool: Crawl websites with specific instructions
Backend compatibility: Backend compatibility:
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl) - Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl; direct or derived firecrawl-gateway.<domain> for Nous Subscribers)
- Parallel: https://docs.parallel.ai (search, extract) - Parallel: https://docs.parallel.ai (search, extract)
- Tavily: https://tavily.com (search, extract, crawl)
LLM Processing: LLM Processing:
- Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction - Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction
@ -44,8 +47,13 @@ import asyncio
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
import httpx import httpx
from firecrawl import Firecrawl from firecrawl import Firecrawl
from agent.auxiliary_client import async_call_llm from agent.auxiliary_client import get_async_text_auxiliary_client
from tools.debug_helpers import DebugSession from tools.debug_helpers import DebugSession
from tools.managed_tool_gateway import (
build_vendor_gateway_url,
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)
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
@ -78,10 +86,13 @@ def _get_backend() -> str:
return configured return configured
# Fallback for manual / legacy config — use whichever key is present. # Fallback for manual / legacy config — use whichever key is present.
has_firecrawl = _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") has_firecrawl = (
_has_env("FIRECRAWL_API_KEY")
or _has_env("FIRECRAWL_API_URL")
or _is_tool_gateway_ready()
)
has_parallel = _has_env("PARALLEL_API_KEY") has_parallel = _has_env("PARALLEL_API_KEY")
has_tavily = _has_env("TAVILY_API_KEY") has_tavily = _has_env("TAVILY_API_KEY")
if has_tavily and not has_firecrawl and not has_parallel: if has_tavily and not has_firecrawl and not has_parallel:
return "tavily" return "tavily"
if has_parallel and not has_firecrawl: if has_parallel and not has_firecrawl:
@ -90,35 +101,100 @@ def _get_backend() -> str:
# Default to firecrawl (backward compat, or when both are set) # Default to firecrawl (backward compat, or when both are set)
return "firecrawl" return "firecrawl"
def _is_backend_available(backend: str) -> bool:
"""Return True when the selected backend is currently usable."""
if backend == "parallel":
return _has_env("PARALLEL_API_KEY")
if backend == "firecrawl":
return check_firecrawl_api_key()
if backend == "tavily":
return _has_env("TAVILY_API_KEY")
return False
# ─── Firecrawl Client ──────────────────────────────────────────────────────── # ─── Firecrawl Client ────────────────────────────────────────────────────────
_firecrawl_client = None _firecrawl_client = None
_firecrawl_client_config = None
def _get_direct_firecrawl_config() -> Optional[tuple[Dict[str, str], tuple[str, Optional[str], Optional[str]]]]:
"""Return explicit direct Firecrawl kwargs + cache key, or None when unset."""
api_key = os.getenv("FIRECRAWL_API_KEY", "").strip()
api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/")
if not api_key and not api_url:
return None
kwargs: Dict[str, str] = {}
if api_key:
kwargs["api_key"] = api_key
if api_url:
kwargs["api_url"] = api_url
return kwargs, ("direct", api_url or None, api_key or None)
def _get_firecrawl_gateway_url() -> str:
"""Return configured Firecrawl gateway URL."""
return build_vendor_gateway_url("firecrawl")
def _is_tool_gateway_ready() -> bool:
"""Return True when gateway URL and a Nous Subscriber token are available."""
return resolve_managed_tool_gateway("firecrawl", token_reader=_read_nous_access_token) is not None
def _has_direct_firecrawl_config() -> bool:
"""Return True when direct Firecrawl config is explicitly configured."""
return _get_direct_firecrawl_config() is not None
def _raise_web_backend_configuration_error() -> None:
"""Raise a clear error for unsupported web backend configuration."""
raise ValueError(
"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."
)
def _get_firecrawl_client(): def _get_firecrawl_client():
"""Get or create the Firecrawl client (lazy initialization). """Get or create Firecrawl client.
Uses the cloud API by default (requires FIRECRAWL_API_KEY). Direct Firecrawl takes precedence when explicitly configured. Otherwise
Set FIRECRAWL_API_URL to point at a self-hosted instance instead Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers.
in that case the API key is optional (set USE_DB_AUTHENTICATION=false
on your Firecrawl server to disable auth entirely).
""" """
global _firecrawl_client global _firecrawl_client, _firecrawl_client_config
if _firecrawl_client is None:
api_key = os.getenv("FIRECRAWL_API_KEY") direct_config = _get_direct_firecrawl_config()
api_url = os.getenv("FIRECRAWL_API_URL") if direct_config is not None:
if not api_key and not api_url: kwargs, client_config = direct_config
logger.error("Firecrawl client initialization failed: missing configuration.") else:
raise ValueError( managed_gateway = resolve_managed_tool_gateway(
"Firecrawl client not configured. " "firecrawl",
"Set FIRECRAWL_API_KEY (cloud) or FIRECRAWL_API_URL (self-hosted). " token_reader=_read_nous_access_token,
"This tool requires Firecrawl to be available." )
) if managed_gateway is None:
kwargs = {} logger.error("Firecrawl client initialization failed: missing direct config and tool-gateway auth.")
if api_key: _raise_web_backend_configuration_error()
kwargs["api_key"] = api_key
if api_url: kwargs = {
kwargs["api_url"] = api_url "api_key": managed_gateway.nous_user_token,
_firecrawl_client = Firecrawl(**kwargs) "api_url": managed_gateway.gateway_origin,
}
client_config = (
"tool-gateway",
kwargs["api_url"],
managed_gateway.nous_user_token,
)
if _firecrawl_client is not None and _firecrawl_client_config == client_config:
return _firecrawl_client
_firecrawl_client = Firecrawl(**kwargs)
_firecrawl_client_config = client_config
return _firecrawl_client return _firecrawl_client
# ─── Parallel Client ───────────────────────────────────────────────────────── # ─── Parallel Client ─────────────────────────────────────────────────────────
@ -243,10 +319,112 @@ def _normalize_tavily_documents(response: dict, fallback_url: str = "") -> List[
return documents return documents
def _to_plain_object(value: Any) -> Any:
"""Convert SDK objects to plain python data structures when possible."""
if value is None:
return None
if isinstance(value, (dict, list, str, int, float, bool)):
return value
if hasattr(value, "model_dump"):
try:
return value.model_dump()
except Exception:
pass
if hasattr(value, "__dict__"):
try:
return {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
except Exception:
pass
return value
def _normalize_result_list(values: Any) -> List[Dict[str, Any]]:
"""Normalize mixed SDK/list payloads into a list of dicts."""
if not isinstance(values, list):
return []
normalized: List[Dict[str, Any]] = []
for item in values:
plain = _to_plain_object(item)
if isinstance(plain, dict):
normalized.append(plain)
return normalized
def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]:
"""Extract Firecrawl search results across SDK/direct/gateway response shapes."""
response_plain = _to_plain_object(response)
if isinstance(response_plain, dict):
data = response_plain.get("data")
if isinstance(data, list):
return _normalize_result_list(data)
if isinstance(data, dict):
data_web = _normalize_result_list(data.get("web"))
if data_web:
return data_web
data_results = _normalize_result_list(data.get("results"))
if data_results:
return data_results
top_web = _normalize_result_list(response_plain.get("web"))
if top_web:
return top_web
top_results = _normalize_result_list(response_plain.get("results"))
if top_results:
return top_results
if hasattr(response, "web"):
return _normalize_result_list(getattr(response, "web", []))
return []
def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]:
"""Normalize Firecrawl scrape payload shape across SDK and gateway variants."""
result_plain = _to_plain_object(scrape_result)
if not isinstance(result_plain, dict):
return {}
nested = result_plain.get("data")
if isinstance(nested, dict):
return nested
return result_plain
DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000 DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000
# Allow per-task override via env var def _is_nous_auxiliary_client(client: Any) -> bool:
DEFAULT_SUMMARIZER_MODEL = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None """Return True when the resolved auxiliary backend is Nous Portal."""
base_url = str(getattr(client, "base_url", "") or "").lower()
return "nousresearch.com" in base_url
def _resolve_web_extract_auxiliary(model: Optional[str] = None) -> tuple[Optional[Any], Optional[str], Dict[str, Any]]:
"""Resolve the current web-extract auxiliary client, model, and extra body."""
client, default_model = get_async_text_auxiliary_client("web_extract")
configured_model = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip()
effective_model = model or configured_model or default_model
extra_body: Dict[str, Any] = {}
if client is not None and _is_nous_auxiliary_client(client):
from agent.auxiliary_client import get_auxiliary_extra_body
extra_body = get_auxiliary_extra_body() or {"tags": ["product=hermes-agent"]}
return client, effective_model, extra_body
def _get_default_summarizer_model() -> Optional[str]:
"""Return the current default model for web extraction summarization."""
_, model, _ = _resolve_web_extract_auxiliary()
return model
_debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG") _debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG")
@ -255,7 +433,7 @@ async def process_content_with_llm(
content: str, content: str,
url: str = "", url: str = "",
title: str = "", title: str = "",
model: str = DEFAULT_SUMMARIZER_MODEL, model: Optional[str] = None,
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
) -> Optional[str]: ) -> Optional[str]:
""" """
@ -338,7 +516,7 @@ async def process_content_with_llm(
async def _call_summarizer_llm( async def _call_summarizer_llm(
content: str, content: str,
context_str: str, context_str: str,
model: str, model: Optional[str],
max_tokens: int = 20000, max_tokens: int = 20000,
is_chunk: bool = False, is_chunk: bool = False,
chunk_info: str = "" chunk_info: str = ""
@ -404,22 +582,22 @@ Create a markdown summary that captures all key information in a well-organized,
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
call_kwargs = { aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
"task": "web_extract", if aux_client is None or not effective_model:
"messages": [ logger.warning("No auxiliary model available for web content processing")
return None
from agent.auxiliary_client import auxiliary_max_tokens_param
response = await aux_client.chat.completions.create(
model=effective_model,
messages=[
{"role": "system", "content": system_prompt}, {"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt} {"role": "user", "content": user_prompt}
], ],
"temperature": 0.1, temperature=0.1,
"max_tokens": max_tokens, **auxiliary_max_tokens_param(max_tokens),
} **({} if not extra_body else {"extra_body": extra_body}),
if model: )
call_kwargs["model"] = model
response = await async_call_llm(**call_kwargs)
return response.choices[0].message.content.strip() return response.choices[0].message.content.strip()
except RuntimeError:
logger.warning("No auxiliary model available for web content processing")
return None
except Exception as api_error: except Exception as api_error:
last_error = api_error last_error = api_error
if attempt < max_retries - 1: if attempt < max_retries - 1:
@ -436,7 +614,7 @@ Create a markdown summary that captures all key information in a well-organized,
async def _process_large_content_chunked( async def _process_large_content_chunked(
content: str, content: str,
context_str: str, context_str: str,
model: str, model: Optional[str],
chunk_size: int, chunk_size: int,
max_output_size: int max_output_size: int
) -> Optional[str]: ) -> Optional[str]:
@ -523,18 +701,25 @@ Synthesize these into ONE cohesive, comprehensive summary that:
Create a single, unified markdown summary.""" Create a single, unified markdown summary."""
try: try:
call_kwargs = { aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
"task": "web_extract", if aux_client is None or not effective_model:
"messages": [ logger.warning("No auxiliary model for synthesis, concatenating summaries")
fallback = "\n\n".join(summaries)
if len(fallback) > max_output_size:
fallback = fallback[:max_output_size] + "\n\n[... truncated ...]"
return fallback
from agent.auxiliary_client import auxiliary_max_tokens_param
response = await aux_client.chat.completions.create(
model=effective_model,
messages=[
{"role": "system", "content": "You synthesize multiple summaries into one cohesive, comprehensive summary. Be thorough but concise."}, {"role": "system", "content": "You synthesize multiple summaries into one cohesive, comprehensive summary. Be thorough but concise."},
{"role": "user", "content": synthesis_prompt} {"role": "user", "content": synthesis_prompt}
], ],
"temperature": 0.1, temperature=0.1,
"max_tokens": 20000, **auxiliary_max_tokens_param(20000),
} **({} if not extra_body else {"extra_body": extra_body}),
if model: )
call_kwargs["model"] = model
response = await async_call_llm(**call_kwargs)
final_summary = response.choices[0].message.content.strip() final_summary = response.choices[0].message.content.strip()
# Enforce hard cap # Enforce hard cap
@ -750,35 +935,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
limit=limit limit=limit
) )
# The response is a SearchData object with web, news, and images attributes web_results = _extract_web_search_results(response)
# When not scraping, the results are directly in these attributes
web_results = []
# Check if response has web attribute (SearchData object)
if hasattr(response, 'web'):
# Response is a SearchData object with web attribute
if response.web:
# Convert each SearchResultWeb object to dict
for result in response.web:
if hasattr(result, 'model_dump'):
# Pydantic model - use model_dump
web_results.append(result.model_dump())
elif hasattr(result, '__dict__'):
# Regular object - use __dict__
web_results.append(result.__dict__)
elif isinstance(result, dict):
# Already a dict
web_results.append(result)
elif hasattr(response, 'model_dump'):
# Response has model_dump method - use it to get dict
response_dict = response.model_dump()
if 'web' in response_dict and response_dict['web']:
web_results = response_dict['web']
elif isinstance(response, dict):
# Response is already a dictionary
if 'web' in response and response['web']:
web_results = response['web']
results_count = len(web_results) results_count = len(web_results)
logger.info("Found %d search results", results_count) logger.info("Found %d search results", results_count)
@ -819,7 +976,7 @@ async def web_extract_tool(
urls: List[str], urls: List[str],
format: str = None, format: str = None,
use_llm_processing: bool = True, use_llm_processing: bool = True,
model: str = DEFAULT_SUMMARIZER_MODEL, model: Optional[str] = None,
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
) -> str: ) -> str:
""" """
@ -832,7 +989,7 @@ async def web_extract_tool(
urls (List[str]): List of URLs to extract content from urls (List[str]): List of URLs to extract content from
format (str): Desired output format ("markdown" or "html", optional) format (str): Desired output format ("markdown" or "html", optional)
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True) use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview) model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model)
min_length (int): Minimum content length to trigger LLM processing (default: 5000) min_length (int): Minimum content length to trigger LLM processing (default: 5000)
Returns: Returns:
@ -929,39 +1086,11 @@ async def web_extract_tool(
formats=formats formats=formats
) )
# Process the result - properly handle object serialization scrape_payload = _extract_scrape_payload(scrape_result)
metadata = {} metadata = scrape_payload.get("metadata", {})
title = "" title = ""
content_markdown = None content_markdown = scrape_payload.get("markdown")
content_html = None content_html = scrape_payload.get("html")
# Extract data from the scrape result
if hasattr(scrape_result, 'model_dump'):
# Pydantic model - use model_dump to get dict
result_dict = scrape_result.model_dump()
content_markdown = result_dict.get('markdown')
content_html = result_dict.get('html')
metadata = result_dict.get('metadata', {})
elif hasattr(scrape_result, '__dict__'):
# Regular object with attributes
content_markdown = getattr(scrape_result, 'markdown', None)
content_html = getattr(scrape_result, 'html', None)
# Handle metadata - convert to dict if it's an object
metadata_obj = getattr(scrape_result, 'metadata', {})
if hasattr(metadata_obj, 'model_dump'):
metadata = metadata_obj.model_dump()
elif hasattr(metadata_obj, '__dict__'):
metadata = metadata_obj.__dict__
elif isinstance(metadata_obj, dict):
metadata = metadata_obj
else:
metadata = {}
elif isinstance(scrape_result, dict):
# Already a dictionary
content_markdown = scrape_result.get('markdown')
content_html = scrape_result.get('html')
metadata = scrape_result.get('metadata', {})
# Ensure metadata is a dict (not an object) # Ensure metadata is a dict (not an object)
if not isinstance(metadata, dict): if not isinstance(metadata, dict):
@ -1019,9 +1148,11 @@ async def web_extract_tool(
debug_call_data["pages_extracted"] = pages_extracted debug_call_data["pages_extracted"] = pages_extracted
debug_call_data["original_response_size"] = len(json.dumps(response)) debug_call_data["original_response_size"] = len(json.dumps(response))
effective_model = model or _get_default_summarizer_model()
auxiliary_available = check_auxiliary_model()
# Process each result with LLM if enabled # Process each result with LLM if enabled
if use_llm_processing: if use_llm_processing and auxiliary_available:
logger.info("Processing extracted content with LLM (parallel)...") logger.info("Processing extracted content with LLM (parallel)...")
debug_call_data["processing_applied"].append("llm_processing") debug_call_data["processing_applied"].append("llm_processing")
@ -1039,7 +1170,7 @@ async def web_extract_tool(
# Process content with LLM # Process content with LLM
processed = await process_content_with_llm( processed = await process_content_with_llm(
raw_content, url, title, model, min_length raw_content, url, title, effective_model, min_length
) )
if processed: if processed:
@ -1055,7 +1186,7 @@ async def web_extract_tool(
"original_size": original_size, "original_size": original_size,
"processed_size": processed_size, "processed_size": processed_size,
"compression_ratio": compression_ratio, "compression_ratio": compression_ratio,
"model_used": model "model_used": effective_model
} }
return result, metrics, "processed" return result, metrics, "processed"
else: else:
@ -1087,6 +1218,9 @@ async def web_extract_tool(
else: else:
logger.warning("%s (no content to process)", url) logger.warning("%s (no content to process)", url)
else: else:
if use_llm_processing and not auxiliary_available:
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
debug_call_data["processing_applied"].append("llm_processing_unavailable")
# Print summary of extracted pages for debugging (original behavior) # Print summary of extracted pages for debugging (original behavior)
for result in response.get('results', []): for result in response.get('results', []):
url = result.get('url', 'Unknown URL') url = result.get('url', 'Unknown URL')
@ -1141,7 +1275,7 @@ async def web_crawl_tool(
instructions: str = None, instructions: str = None,
depth: str = "basic", depth: str = "basic",
use_llm_processing: bool = True, use_llm_processing: bool = True,
model: str = DEFAULT_SUMMARIZER_MODEL, model: Optional[str] = None,
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
) -> str: ) -> str:
""" """
@ -1155,7 +1289,7 @@ async def web_crawl_tool(
instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional) instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional)
depth (str): Depth of extraction ("basic" or "advanced", default: "basic") depth (str): Depth of extraction ("basic" or "advanced", default: "basic")
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True) use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview) model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model)
min_length (int): Minimum content length to trigger LLM processing (default: 5000) min_length (int): Minimum content length to trigger LLM processing (default: 5000)
Returns: Returns:
@ -1185,6 +1319,8 @@ async def web_crawl_tool(
} }
try: try:
effective_model = model or _get_default_summarizer_model()
auxiliary_available = check_auxiliary_model()
backend = _get_backend() backend = _get_backend()
# Tavily supports crawl via its /crawl endpoint # Tavily supports crawl via its /crawl endpoint
@ -1229,7 +1365,7 @@ async def web_crawl_tool(
debug_call_data["original_response_size"] = len(json.dumps(response)) debug_call_data["original_response_size"] = len(json.dumps(response))
# Process each result with LLM if enabled # Process each result with LLM if enabled
if use_llm_processing: if use_llm_processing and auxiliary_available:
logger.info("Processing crawled content with LLM (parallel)...") logger.info("Processing crawled content with LLM (parallel)...")
debug_call_data["processing_applied"].append("llm_processing") debug_call_data["processing_applied"].append("llm_processing")
@ -1240,12 +1376,12 @@ async def web_crawl_tool(
if not content: if not content:
return result, None, "no_content" return result, None, "no_content"
original_size = len(content) original_size = len(content)
processed = await process_content_with_llm(content, page_url, title, model, min_length) processed = await process_content_with_llm(content, page_url, title, effective_model, min_length)
if processed: if processed:
result['raw_content'] = content result['raw_content'] = content
result['content'] = processed result['content'] = processed
metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed), metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed),
"compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": model} "compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": effective_model}
return result, metrics, "processed" return result, metrics, "processed"
metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size, metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size,
"compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"} "compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"}
@ -1258,6 +1394,10 @@ async def web_crawl_tool(
debug_call_data["compression_metrics"].append(metrics) debug_call_data["compression_metrics"].append(metrics)
debug_call_data["pages_processed_with_llm"] += 1 debug_call_data["pages_processed_with_llm"] += 1
if use_llm_processing and not auxiliary_available:
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
debug_call_data["processing_applied"].append("llm_processing_unavailable")
trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"), trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"),
**({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])] **({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])]
result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False) result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False)
@ -1267,10 +1407,12 @@ async def web_crawl_tool(
_debug.save() _debug.save()
return cleaned_result return cleaned_result
# web_crawl requires Firecrawl — Parallel has no crawl API # web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
if not (os.getenv("FIRECRAWL_API_KEY") or os.getenv("FIRECRAWL_API_URL")): if not check_firecrawl_api_key():
return json.dumps({ return json.dumps({
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, " "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.", "or use web_search + web_extract instead.",
"success": False, "success": False,
}, ensure_ascii=False) }, ensure_ascii=False)
@ -1431,7 +1573,7 @@ async def web_crawl_tool(
debug_call_data["original_response_size"] = len(json.dumps(response)) debug_call_data["original_response_size"] = len(json.dumps(response))
# Process each result with LLM if enabled # Process each result with LLM if enabled
if use_llm_processing: if use_llm_processing and auxiliary_available:
logger.info("Processing crawled content with LLM (parallel)...") logger.info("Processing crawled content with LLM (parallel)...")
debug_call_data["processing_applied"].append("llm_processing") debug_call_data["processing_applied"].append("llm_processing")
@ -1449,7 +1591,7 @@ async def web_crawl_tool(
# Process content with LLM # Process content with LLM
processed = await process_content_with_llm( processed = await process_content_with_llm(
content, page_url, title, model, min_length content, page_url, title, effective_model, min_length
) )
if processed: if processed:
@ -1465,7 +1607,7 @@ async def web_crawl_tool(
"original_size": original_size, "original_size": original_size,
"processed_size": processed_size, "processed_size": processed_size,
"compression_ratio": compression_ratio, "compression_ratio": compression_ratio,
"model_used": model "model_used": effective_model
} }
return result, metrics, "processed" return result, metrics, "processed"
else: else:
@ -1497,6 +1639,9 @@ async def web_crawl_tool(
else: else:
logger.warning("%s (no content to process)", page_url) logger.warning("%s (no content to process)", page_url)
else: else:
if use_llm_processing and not auxiliary_available:
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
debug_call_data["processing_applied"].append("llm_processing_unavailable")
# Print summary of crawled pages for debugging (original behavior) # Print summary of crawled pages for debugging (original behavior)
for result in response.get('results', []): for result in response.get('results', []):
page_url = result.get('url', 'Unknown URL') page_url = result.get('url', 'Unknown URL')
@ -1540,38 +1685,34 @@ async def web_crawl_tool(
return json.dumps({"error": error_msg}, ensure_ascii=False) return json.dumps({"error": error_msg}, ensure_ascii=False)
# Convenience function to check if API key is available # Convenience function to check Firecrawl credentials
def check_firecrawl_api_key() -> bool: def check_firecrawl_api_key() -> bool:
""" """
Check if the Firecrawl API key is available in environment variables. Check whether the Firecrawl backend is available.
Availability is true when either:
1) direct Firecrawl config (`FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL`), or
2) Firecrawl gateway origin + Nous Subscriber access token
(fallback when direct Firecrawl is not configured).
Returns: Returns:
bool: True if API key is set, False otherwise bool: True if direct Firecrawl or the tool-gateway can be used.
""" """
return bool(os.getenv("FIRECRAWL_API_KEY")) return _has_direct_firecrawl_config() or _is_tool_gateway_ready()
def check_web_api_key() -> bool: def check_web_api_key() -> bool:
"""Check if any web backend API key is available (Parallel, Firecrawl, or Tavily).""" """Check whether the configured web backend is available."""
return bool( configured = _load_web_config().get("backend", "").lower().strip()
os.getenv("PARALLEL_API_KEY") if configured in ("parallel", "firecrawl", "tavily"):
or os.getenv("FIRECRAWL_API_KEY") return _is_backend_available(configured)
or os.getenv("FIRECRAWL_API_URL") return any(_is_backend_available(backend) for backend in ("parallel", "firecrawl", "tavily"))
or os.getenv("TAVILY_API_KEY")
)
def check_auxiliary_model() -> bool: def check_auxiliary_model() -> bool:
"""Check if an auxiliary text model is available for LLM content processing.""" """Check if an auxiliary text model is available for LLM content processing."""
try: client, _, _ = _resolve_web_extract_auxiliary()
from agent.auxiliary_client import resolve_provider_client return client is not None
for p in ("openrouter", "nous", "custom", "codex"):
client, _ = resolve_provider_client(p)
if client is not None:
return True
return False
except Exception:
return False
def get_debug_session_info() -> Dict[str, Any]: def get_debug_session_info() -> Dict[str, Any]:
@ -1588,7 +1729,11 @@ if __name__ == "__main__":
# Check if API keys are available # Check if API keys are available
web_available = check_web_api_key() web_available = check_web_api_key()
tool_gateway_available = _is_tool_gateway_ready()
firecrawl_key_available = bool(os.getenv("FIRECRAWL_API_KEY", "").strip())
firecrawl_url_available = bool(os.getenv("FIRECRAWL_API_URL", "").strip())
nous_available = check_auxiliary_model() nous_available = check_auxiliary_model()
default_summarizer_model = _get_default_summarizer_model()
if web_available: if web_available:
backend = _get_backend() backend = _get_backend()
@ -1598,17 +1743,28 @@ if __name__ == "__main__":
elif backend == "tavily": elif backend == "tavily":
print(" Using Tavily API (https://tavily.com)") print(" Using Tavily API (https://tavily.com)")
else: else:
print(" Using Firecrawl API (https://firecrawl.dev)") if firecrawl_url_available:
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
elif firecrawl_key_available:
print(" Using direct Firecrawl cloud API")
elif tool_gateway_available:
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
else:
print(" Firecrawl backend selected but not configured")
else: else:
print("❌ No web search backend configured") print("❌ No web search backend configured")
print("Set PARALLEL_API_KEY, TAVILY_API_KEY, or FIRECRAWL_API_KEY") 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"
)
if not nous_available: if not nous_available:
print("❌ No auxiliary model available for LLM content processing") print("❌ No auxiliary model available for LLM content processing")
print("Set OPENROUTER_API_KEY, configure Nous Portal, or set OPENAI_BASE_URL + OPENAI_API_KEY") print("Set OPENROUTER_API_KEY, configure Nous Portal, or set OPENAI_BASE_URL + OPENAI_API_KEY")
print("⚠️ Without an auxiliary model, LLM content processing will be disabled") print("⚠️ Without an auxiliary model, LLM content processing will be disabled")
else: else:
print(f"✅ Auxiliary model available: {DEFAULT_SUMMARIZER_MODEL}") print(f"✅ Auxiliary model available: {default_summarizer_model}")
if not web_available: if not web_available:
exit(1) exit(1)
@ -1616,7 +1772,7 @@ if __name__ == "__main__":
print("🛠️ Web tools ready for use!") print("🛠️ Web tools ready for use!")
if nous_available: if nous_available:
print(f"🧠 LLM content processing available with {DEFAULT_SUMMARIZER_MODEL}") print(f"🧠 LLM content processing available with {default_summarizer_model}")
print(f" Default min length for processing: {DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION} chars") print(f" Default min length for processing: {DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION} chars")
# Show debug mode status # Show debug mode status
@ -1711,7 +1867,16 @@ 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=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_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",
],
emoji="🔍", emoji="🔍",
) )
registry.register( registry.register(
@ -1721,7 +1886,16 @@ 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=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_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",
],
is_async=True, is_async=True,
emoji="📄", emoji="📄",
) )

View file

@ -78,6 +78,9 @@ 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/)) |
@ -114,6 +117,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `TERMINAL_CWD` | Working directory for all terminal sessions | | `TERMINAL_CWD` | Working directory for all terminal sessions |
| `SUDO_PASSWORD` | Enable sudo without interactive prompt | | `SUDO_PASSWORD` | Enable sudo without interactive prompt |
For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETIME_SECONDS` controls when Hermes cleans up an idle terminal session, and later resumes may recreate the sandbox rather than keep the same live processes running.
## SSH Backend ## SSH Backend
| Variable | Description | | Variable | Description |

View file

@ -695,6 +695,8 @@ terminal:
persistent_shell: true # Enabled by default for SSH backend persistent_shell: true # Enabled by default for SSH backend
``` ```
For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
### Common Terminal Backend Issues ### Common Terminal Backend Issues
If terminal commands fail immediately or the terminal tool is reported as disabled, check the following: If terminal commands fail immediately or the terminal tool is reported as disabled, check the following:
@ -723,8 +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**
- You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file. - 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.
- If neither is present, the backend check fails and Hermes will report that the Modal backend is not available. - 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.
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.

View file

@ -109,6 +109,13 @@ 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: