Merge pull request #3287 from NousResearch/rewbs/tool-use-charge-to-subscription
This commit is contained in:
@@ -652,6 +652,73 @@ def build_skills_system_prompt(
|
||||
return result
|
||||
|
||||
|
||||
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
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
||||
return ""
|
||||
|
||||
if not managed_nous_tools_enabled():
|
||||
return ""
|
||||
|
||||
valid_names = set(valid_tool_names or set())
|
||||
relevant_tool_names = {
|
||||
"web_search",
|
||||
"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)
|
||||
# =========================================================================
|
||||
|
||||
@@ -6,6 +6,8 @@ import os
|
||||
import re
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
_COMPLEX_KEYWORDS = {
|
||||
"debug",
|
||||
"debugging",
|
||||
@@ -47,13 +49,7 @@ _URL_RE = re.compile(r"https?://|www\.", re.IGNORECASE)
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool = False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return bool(value)
|
||||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
def _coerce_int(value: Any, default: int) -> int:
|
||||
|
||||
@@ -11,11 +11,11 @@ Solution:
|
||||
_AsyncWorker thread internally, making it safe for both CLI and Atropos use.
|
||||
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:
|
||||
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
|
||||
@@ -26,17 +26,10 @@ _patches_applied = False
|
||||
|
||||
|
||||
def apply_patches():
|
||||
"""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.
|
||||
"""
|
||||
"""Apply all monkey patches needed for Atropos compatibility."""
|
||||
global _patches_applied
|
||||
if _patches_applied:
|
||||
return
|
||||
|
||||
# Modal async-safety is now built into tools/environments/modal.py
|
||||
# via the _AsyncWorker class. No monkey-patching needed.
|
||||
logger.debug("apply_patches() called — no patches needed (async safety is built-in)")
|
||||
|
||||
logger.debug("apply_patches() called; no patches needed (async safety is built-in)")
|
||||
_patches_applied = True
|
||||
|
||||
@@ -17,6 +17,7 @@ from typing import Dict, List, Optional, Any
|
||||
from enum import Enum
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from utils import is_truthy_value
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,10 +26,6 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
"""Coerce bool-ish config values, preserving a caller-provided default."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, int):
|
||||
return value != 0
|
||||
if isinstance(value, str):
|
||||
lowered = value.strip().lower()
|
||||
if lowered in ("true", "1", "yes", "on"):
|
||||
@@ -36,7 +33,7 @@ def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||
if lowered in ("false", "0", "no", "off"):
|
||||
return False
|
||||
return default
|
||||
return default
|
||||
return is_truthy_value(value, default=default)
|
||||
|
||||
|
||||
def _normalize_unauthorized_dm_behavior(value: Any, default: str = "pair") -> str:
|
||||
@@ -908,5 +905,3 @@ def _apply_env_overrides(config: GatewayConfig) -> None:
|
||||
config.default_reset_policy.at_hour = int(reset_hour)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
@@ -1377,6 +1377,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)
|
||||
|
||||
|
||||
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 refresh_nous_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
|
||||
@@ -22,6 +22,8 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
|
||||
|
||||
_IS_WINDOWS = platform.system() == "Windows"
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# Env var names written to .env that aren't in OPTIONAL_ENV_VARS
|
||||
@@ -41,7 +43,6 @@ _EXTRA_ENV_KEYS = frozenset({
|
||||
"MATTERMOST_HOME_CHANNEL", "MATTERMOST_REPLY_MODE",
|
||||
"MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_HOME_ROOM",
|
||||
})
|
||||
|
||||
import yaml
|
||||
|
||||
from hermes_cli.colors import Colors, color
|
||||
@@ -212,6 +213,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
"terminal": {
|
||||
"backend": "local",
|
||||
"modal_mode": "auto",
|
||||
"cwd": ".", # Use current directory
|
||||
"timeout": 180,
|
||||
# Environment variables to pass through to sandboxed execution
|
||||
@@ -532,6 +534,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||||
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
|
||||
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
|
||||
10: ["TAVILY_API_KEY"],
|
||||
11: ["TERMINAL_MODAL_MODE"],
|
||||
}
|
||||
|
||||
# Required environment variables with metadata for migration prompts.
|
||||
@@ -750,6 +753,38 @@ OPTIONAL_ENV_VARS = {
|
||||
"category": "tool",
|
||||
"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": {
|
||||
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
||||
"prompt": "Tavily API key",
|
||||
@@ -1079,6 +1114,15 @@ OPTIONAL_ENV_VARS = {
|
||||
},
|
||||
}
|
||||
|
||||
if not _managed_nous_tools_enabled():
|
||||
for _hidden_var in (
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
|
||||
|
||||
|
||||
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -1994,7 +2038,9 @@ def set_config_value(key: str, value: str):
|
||||
# Check if it's an API key (goes to .env)
|
||||
api_keys = [
|
||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||
'EXA_API_KEY', 'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
|
||||
'EXA_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',
|
||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||
@@ -2050,6 +2096,7 @@ def set_config_value(key: str, value: str):
|
||||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||
_config_to_env_sync = {
|
||||
"terminal.backend": "TERMINAL_ENV",
|
||||
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||
|
||||
@@ -858,10 +858,10 @@ def cmd_setup(args):
|
||||
def cmd_model(args):
|
||||
"""Select default model — starts with provider selection, then model picker."""
|
||||
_require_tty("model")
|
||||
select_provider_and_model()
|
||||
select_provider_and_model(args=args)
|
||||
|
||||
|
||||
def select_provider_and_model():
|
||||
def select_provider_and_model(args=None):
|
||||
"""Core provider selection + model picking logic.
|
||||
|
||||
Shared by ``cmd_model`` (``hermes model``) and the setup wizard
|
||||
@@ -1006,7 +1006,7 @@ def select_provider_and_model():
|
||||
if selected_provider == "openrouter":
|
||||
_model_flow_openrouter(config, current_model)
|
||||
elif selected_provider == "nous":
|
||||
_model_flow_nous(config, current_model)
|
||||
_model_flow_nous(config, current_model, args=args)
|
||||
elif selected_provider == "openai-codex":
|
||||
_model_flow_openai_codex(config, current_model)
|
||||
elif selected_provider == "copilot-acp":
|
||||
@@ -1112,7 +1112,7 @@ def _model_flow_openrouter(config, current_model=""):
|
||||
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."""
|
||||
from hermes_cli.auth import (
|
||||
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
|
||||
@@ -1120,7 +1120,11 @@ def _model_flow_nous(config, current_model=""):
|
||||
fetch_nous_models, AuthError, format_auth_error,
|
||||
_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
|
||||
|
||||
state = get_provider_auth_state("nous")
|
||||
@@ -1129,11 +1133,19 @@ def _model_flow_nous(config, current_model=""):
|
||||
print()
|
||||
try:
|
||||
mock_args = argparse.Namespace(
|
||||
portal_url=None, inference_url=None, client_id=None,
|
||||
scope=None, no_browser=False, timeout=15.0,
|
||||
ca_bundle=None, insecure=False,
|
||||
portal_url=getattr(args, "portal_url", None),
|
||||
inference_url=getattr(args, "inference_url", None),
|
||||
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"])
|
||||
print()
|
||||
for line in get_nous_subscription_explainer_lines():
|
||||
print(line)
|
||||
except SystemExit:
|
||||
print("Login cancelled or failed.")
|
||||
return
|
||||
@@ -1182,7 +1194,36 @@ def _model_flow_nous(config, current_model=""):
|
||||
# Reactivate Nous as the provider and update config
|
||||
inference_url = creds.get("base_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
|
||||
if get_env_value("OPENAI_BASE_URL"):
|
||||
save_env_value("OPENAI_BASE_URL", "")
|
||||
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)")
|
||||
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:
|
||||
print("No change.")
|
||||
|
||||
@@ -3843,6 +3884,44 @@ For more help on a command:
|
||||
help="Select default model and provider",
|
||||
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)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
517
hermes_cli/nous_subscription.py
Normal file
517
hermes_cli/nous_subscription.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""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,
|
||||
managed_nous_tools_enabled,
|
||||
normalize_browser_cloud_provider,
|
||||
normalize_modal_mode,
|
||||
resolve_modal_backend_state,
|
||||
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",
|
||||
"camofox": "Camofox",
|
||||
"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 _resolve_browser_feature_state(
|
||||
*,
|
||||
browser_tool_enabled: bool,
|
||||
browser_provider: str,
|
||||
browser_provider_explicit: bool,
|
||||
browser_local_available: bool,
|
||||
direct_camofox: bool,
|
||||
direct_browserbase: bool,
|
||||
direct_browser_use: bool,
|
||||
managed_browser_available: bool,
|
||||
) -> tuple[str, bool, bool, bool]:
|
||||
"""Resolve browser availability using the same precedence as runtime."""
|
||||
if direct_camofox:
|
||||
return "camofox", True, bool(browser_tool_enabled), False
|
||||
|
||||
if browser_provider_explicit:
|
||||
current_provider = browser_provider or "local"
|
||||
if current_provider == "browserbase":
|
||||
provider_available = managed_browser_available or direct_browserbase
|
||||
available = bool(browser_local_available and provider_available)
|
||||
managed = bool(
|
||||
browser_tool_enabled
|
||||
and browser_local_available
|
||||
and managed_browser_available
|
||||
and not direct_browserbase
|
||||
)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, managed
|
||||
if current_provider == "browser-use":
|
||||
available = bool(browser_local_available and direct_browser_use)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
if current_provider == "camofox":
|
||||
return current_provider, False, False, False
|
||||
|
||||
current_provider = "local"
|
||||
available = bool(browser_local_available)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return current_provider, available, active, False
|
||||
|
||||
if managed_browser_available or direct_browserbase:
|
||||
available = bool(browser_local_available)
|
||||
managed = bool(
|
||||
browser_tool_enabled
|
||||
and browser_local_available
|
||||
and managed_browser_available
|
||||
and not direct_browserbase
|
||||
)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "browserbase", available, active, managed
|
||||
|
||||
available = bool(browser_local_available)
|
||||
active = bool(browser_tool_enabled and available)
|
||||
return "local", available, active, False
|
||||
|
||||
|
||||
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 = {}
|
||||
|
||||
managed_tools_flag = managed_nous_tools_enabled()
|
||||
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_cfg = config.get("web") if isinstance(config.get("web"), dict) else {}
|
||||
tts_cfg = config.get("tts") if isinstance(config.get("tts"), dict) else {}
|
||||
browser_cfg = config.get("browser") if isinstance(config.get("browser"), dict) else {}
|
||||
terminal_cfg = config.get("terminal") if isinstance(config.get("terminal"), dict) else {}
|
||||
|
||||
web_backend = str(web_cfg.get("backend") or "").strip().lower()
|
||||
tts_provider = str(tts_cfg.get("provider") or "edge").strip().lower()
|
||||
browser_provider_explicit = "cloud_provider" in browser_cfg
|
||||
browser_provider = normalize_browser_cloud_provider(
|
||||
browser_cfg.get("cloud_provider") if browser_provider_explicit else None
|
||||
)
|
||||
terminal_backend = (
|
||||
str(terminal_cfg.get("backend") or "local").strip().lower()
|
||||
)
|
||||
modal_mode = normalize_modal_mode(
|
||||
terminal_cfg.get("modal_mode")
|
||||
)
|
||||
|
||||
direct_exa = bool(get_env_value("EXA_API_KEY"))
|
||||
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_camofox = bool(get_env_value("CAMOFOX_URL"))
|
||||
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 = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||
managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browserbase")
|
||||
managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||
modal_state = resolve_modal_backend_state(
|
||||
modal_mode,
|
||||
has_direct=direct_modal,
|
||||
managed_ready=managed_modal_available,
|
||||
)
|
||||
|
||||
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 == "exa" and direct_exa)
|
||||
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_exa 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_local_available = _has_agent_browser()
|
||||
(
|
||||
browser_current_provider,
|
||||
browser_available,
|
||||
browser_active,
|
||||
browser_managed,
|
||||
) = _resolve_browser_feature_state(
|
||||
browser_tool_enabled=browser_tool_enabled,
|
||||
browser_provider=browser_provider,
|
||||
browser_provider_explicit=browser_provider_explicit,
|
||||
browser_local_available=browser_local_available,
|
||||
direct_camofox=direct_camofox,
|
||||
direct_browserbase=direct_browserbase,
|
||||
direct_browser_use=direct_browser_use,
|
||||
managed_browser_available=managed_browser_available,
|
||||
)
|
||||
|
||||
if terminal_backend != "modal":
|
||||
modal_managed = False
|
||||
modal_available = True
|
||||
modal_active = bool(modal_tool_enabled)
|
||||
modal_direct_override = False
|
||||
elif modal_state["selected_backend"] == "managed":
|
||||
modal_managed = bool(modal_tool_enabled)
|
||||
modal_available = True
|
||||
modal_active = bool(modal_tool_enabled)
|
||||
modal_direct_override = False
|
||||
elif modal_state["selected_backend"] == "direct":
|
||||
modal_managed = False
|
||||
modal_available = True
|
||||
modal_active = bool(modal_tool_enabled)
|
||||
modal_direct_override = bool(modal_tool_enabled)
|
||||
elif modal_mode == "managed":
|
||||
modal_managed = False
|
||||
modal_available = bool(managed_modal_available)
|
||||
modal_active = False
|
||||
modal_direct_override = False
|
||||
elif modal_mode == "direct":
|
||||
modal_managed = False
|
||||
modal_available = bool(direct_modal)
|
||||
modal_active = False
|
||||
modal_direct_override = False
|
||||
else:
|
||||
modal_managed = False
|
||||
modal_available = bool(managed_modal_available or direct_modal)
|
||||
modal_active = False
|
||||
modal_direct_override = False
|
||||
|
||||
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=browser_provider_explicit,
|
||||
),
|
||||
"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]:
|
||||
if not managed_nous_tools_enabled():
|
||||
return []
|
||||
|
||||
return [
|
||||
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
|
||||
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
|
||||
"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`."""
|
||||
if not managed_nous_tools_enabled():
|
||||
return set()
|
||||
|
||||
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]:
|
||||
if not managed_nous_tools_enabled():
|
||||
return set()
|
||||
|
||||
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
|
||||
@@ -38,6 +38,8 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
|
||||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover – yaml is optional at import time
|
||||
@@ -65,7 +67,7 @@ _NS_PARENT = "hermes_plugins"
|
||||
|
||||
def _env_enabled(name: str) -> bool:
|
||||
"""Return True when an env var is set to a truthy opt-in value."""
|
||||
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
return env_var_enabled(name)
|
||||
|
||||
|
||||
def _get_disabled_plugins() -> set:
|
||||
|
||||
@@ -18,6 +18,12 @@ import sys
|
||||
from pathlib import Path
|
||||
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,
|
||||
)
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
from hermes_constants import get_optional_skills_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -594,6 +600,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
print_header("Tool Availability Summary")
|
||||
|
||||
tool_status = []
|
||||
subscription_features = get_nous_subscription_features(config)
|
||||
|
||||
# Vision — use the same runtime resolver as the actual vision tools
|
||||
try:
|
||||
@@ -615,42 +622,61 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
|
||||
|
||||
# Web tools (Exa, Parallel, Firecrawl, or Tavily)
|
||||
if get_env_value("EXA_API_KEY") or 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"):
|
||||
tool_status.append(("Web Search & Extract", True, None))
|
||||
if subscription_features.web.managed_by_nous:
|
||||
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:
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
|
||||
tool_status.append(("Web Search & Extract", False, "EXA_API_KEY, PARALLEL_API_KEY, FIRECRAWL_API_KEY/FIRECRAWL_API_URL, or TAVILY_API_KEY"))
|
||||
|
||||
# Browser tools (local Chromium or Browserbase cloud)
|
||||
import shutil
|
||||
|
||||
_ab_found = (
|
||||
shutil.which("agent-browser")
|
||||
or (
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
).exists()
|
||||
)
|
||||
if get_env_value("CAMOFOX_URL"):
|
||||
tool_status.append(("Browser Automation (Camofox)", True, None))
|
||||
elif get_env_value("BROWSERBASE_API_KEY"):
|
||||
tool_status.append(("Browser Automation (Browserbase)", True, None))
|
||||
elif _ab_found:
|
||||
tool_status.append(("Browser Automation (local)", True, None))
|
||||
# Browser tools (local Chromium, Camofox, Browserbase, or Browser Use)
|
||||
browser_provider = subscription_features.browser.current_provider
|
||||
if subscription_features.browser.managed_by_nous:
|
||||
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
|
||||
elif subscription_features.browser.available:
|
||||
label = "Browser Automation"
|
||||
if browser_provider:
|
||||
label = f"Browser Automation ({browser_provider})"
|
||||
tool_status.append((label, True, None))
|
||||
else:
|
||||
missing_browser_hint = "npm install -g agent-browser, set CAMOFOX_URL, or configure Browserbase"
|
||||
if browser_provider == "Browserbase":
|
||||
missing_browser_hint = (
|
||||
"npm install -g agent-browser and set "
|
||||
"BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID"
|
||||
)
|
||||
elif browser_provider == "Browser Use":
|
||||
missing_browser_hint = (
|
||||
"npm install -g agent-browser and set BROWSER_USE_API_KEY"
|
||||
)
|
||||
elif browser_provider == "Camofox":
|
||||
missing_browser_hint = "CAMOFOX_URL"
|
||||
elif browser_provider == "Local browser":
|
||||
missing_browser_hint = "npm install -g agent-browser"
|
||||
tool_status.append(
|
||||
("Browser Automation", False, "npm install -g agent-browser or set CAMOFOX_URL")
|
||||
("Browser Automation", False, missing_browser_hint)
|
||||
)
|
||||
|
||||
# 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))
|
||||
else:
|
||||
tool_status.append(("Image Generation", False, "FAL_KEY"))
|
||||
|
||||
# TTS — show configured provider
|
||||
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))
|
||||
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))
|
||||
elif tts_provider == "neutts":
|
||||
try:
|
||||
@@ -665,6 +691,16 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||
else:
|
||||
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 managed_nous_tools_enabled() and subscription_features.nous_auth_present:
|
||||
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
|
||||
|
||||
# Tinker + WandB (RL training)
|
||||
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
|
||||
tool_status.append(("RL Training (Tinker)", True, None))
|
||||
@@ -874,6 +910,7 @@ def setup_model_provider(config: dict):
|
||||
if isinstance(_m, dict):
|
||||
selected_provider = _m.get("provider")
|
||||
|
||||
nous_subscription_selected = selected_provider == "nous"
|
||||
|
||||
# ── Same-provider fallback & rotation setup ──
|
||||
if _supports_same_provider_pool_setup(selected_provider):
|
||||
@@ -1039,10 +1076,20 @@ def setup_model_provider(config: dict):
|
||||
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Offer TTS provider selection at the end of model setup
|
||||
_setup_tts_provider(config)
|
||||
# Offer TTS provider selection at the end of model setup, except when
|
||||
# Nous subscription defaults are already being applied.
|
||||
if selected_provider != "nous":
|
||||
_setup_tts_provider(config)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1110,6 +1157,7 @@ def _setup_tts_provider(config: dict):
|
||||
"""Interactive TTS provider selection with install flow for NeuTTS."""
|
||||
tts_config = config.get("tts", {})
|
||||
current_provider = tts_config.get("provider", "edge")
|
||||
subscription_features = get_nous_subscription_features(config)
|
||||
|
||||
provider_labels = {
|
||||
"edge": "Edge TTS",
|
||||
@@ -1124,20 +1172,36 @@ def _setup_tts_provider(config: dict):
|
||||
print_info(f"Current: {current_label}")
|
||||
print()
|
||||
|
||||
choices = [
|
||||
"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)",
|
||||
f"Keep current ({current_label})",
|
||||
]
|
||||
idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1)
|
||||
choices = []
|
||||
providers = []
|
||||
if managed_nous_tools_enabled() and subscription_features.nous_auth_present:
|
||||
choices.append("Nous Subscription (managed OpenAI TTS, billed to your subscription)")
|
||||
providers.append("nous-openai")
|
||||
choices.extend(
|
||||
[
|
||||
"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
|
||||
|
||||
providers = ["edge", "elevenlabs", "openai", "neutts"]
|
||||
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":
|
||||
# Check if already installed
|
||||
@@ -1175,8 +1239,8 @@ def _setup_tts_provider(config: dict):
|
||||
print_warning("No API key provided. Falling back to Edge TTS.")
|
||||
selected = "edge"
|
||||
|
||||
elif selected == "openai":
|
||||
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY")
|
||||
elif selected == "openai" and not selected_via_nous:
|
||||
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
|
||||
if not existing:
|
||||
print()
|
||||
api_key = prompt("OpenAI API key for TTS", password=True)
|
||||
@@ -1331,63 +1395,99 @@ def setup_terminal_backend(config: dict):
|
||||
elif selected_backend == "modal":
|
||||
print_success("Terminal backend: Modal")
|
||||
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 modal SDK is installed
|
||||
try:
|
||||
__import__("modal")
|
||||
except ImportError:
|
||||
print_info("Installing modal SDK...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[
|
||||
uv_bin,
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"modal",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
managed_modal_available = bool(
|
||||
managed_nous_tools_enabled()
|
||||
and
|
||||
get_nous_subscription_features(config).nous_auth_present
|
||||
and is_managed_tool_gateway_ready("modal")
|
||||
)
|
||||
modal_mode = normalize_modal_mode(config.get("terminal", {}).get("modal_mode"))
|
||||
use_managed_modal = False
|
||||
if managed_modal_available:
|
||||
modal_choices = [
|
||||
"Use my Nous subscription",
|
||||
"Use my own Modal account",
|
||||
]
|
||||
if modal_mode == "managed":
|
||||
default_modal_idx = 0
|
||||
elif modal_mode == "direct":
|
||||
default_modal_idx = 1
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
else:
|
||||
print_warning(
|
||||
"Install failed — run manually: pip install modal"
|
||||
)
|
||||
default_modal_idx = 1 if get_env_value("MODAL_TOKEN_ID") else 0
|
||||
modal_mode_idx = prompt_choice(
|
||||
"Select how Modal execution should be billed:",
|
||||
modal_choices,
|
||||
default_modal_idx,
|
||||
)
|
||||
use_managed_modal = modal_mode_idx == 0
|
||||
|
||||
# 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):
|
||||
if use_managed_modal:
|
||||
config["terminal"]["modal_mode"] = "managed"
|
||||
print_info("Modal execution will use the managed Nous gateway and bill to your subscription.")
|
||||
if get_env_value("MODAL_TOKEN_ID") or get_env_value("MODAL_TOKEN_SECRET"):
|
||||
print_info(
|
||||
"Direct Modal credentials are still configured, but this backend is pinned to managed mode."
|
||||
)
|
||||
else:
|
||||
config["terminal"]["modal_mode"] = "direct"
|
||||
print_info("Requires a Modal account: https://modal.com")
|
||||
|
||||
# Check if modal SDK is installed
|
||||
try:
|
||||
__import__("modal")
|
||||
except ImportError:
|
||||
print_info("Installing modal SDK...")
|
||||
import subprocess
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
result = subprocess.run(
|
||||
[
|
||||
uv_bin,
|
||||
"pip",
|
||||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"modal",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
else:
|
||||
print_warning("Install failed — run manually: pip install 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_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_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)
|
||||
|
||||
@@ -1501,6 +1601,8 @@ def setup_terminal_backend(config: dict):
|
||||
# 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.
|
||||
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)
|
||||
print()
|
||||
print_success(f"Terminal backend set to: {selected_backend}")
|
||||
@@ -2472,6 +2574,17 @@ SETUP_SECTIONS = [
|
||||
("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):
|
||||
"""Run the interactive setup wizard.
|
||||
@@ -2622,8 +2735,7 @@ def run_setup_wizard(args):
|
||||
# Individual section — map by key, not by position.
|
||||
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
|
||||
# so positional indexing (choice - 3) would dispatch the wrong section.
|
||||
_RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"]
|
||||
section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
|
||||
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3]
|
||||
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
|
||||
if section:
|
||||
_, label, func = section
|
||||
|
||||
@@ -15,8 +15,10 @@ from hermes_cli.auth import AuthError, resolve_provider
|
||||
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.models import provider_label
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
def check_mark(ok: bool) -> str:
|
||||
if ok:
|
||||
@@ -186,6 +188,31 @@ def show_status(args):
|
||||
if codex_status.get("error") and not codex_logged_in:
|
||||
print(f" Error: {codex_status.get('error')}")
|
||||
|
||||
# =========================================================================
|
||||
# Nous Subscription Features
|
||||
# =========================================================================
|
||||
if managed_nous_tools_enabled():
|
||||
features = get_nous_subscription_features(config)
|
||||
print()
|
||||
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
|
||||
if not features.nous_auth_present:
|
||||
print(" Nous Portal ✗ not logged in")
|
||||
else:
|
||||
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
|
||||
# =========================================================================
|
||||
|
||||
@@ -20,6 +20,11 @@ from hermes_cli.config import (
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.nous_subscription import (
|
||||
apply_nous_managed_defaults,
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -158,6 +163,15 @@ TOOL_CATEGORIES = {
|
||||
"name": "Text-to-Speech",
|
||||
"icon": "🔊",
|
||||
"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",
|
||||
"tag": "Free - no API key needed",
|
||||
@@ -188,6 +202,15 @@ TOOL_CATEGORIES = {
|
||||
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
|
||||
"icon": "🔍",
|
||||
"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",
|
||||
"tag": "Hosted service - search, extract, and crawl",
|
||||
@@ -234,6 +257,14 @@ TOOL_CATEGORIES = {
|
||||
"name": "Image Generation",
|
||||
"icon": "🎨",
|
||||
"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",
|
||||
"tag": "FLUX 2 Pro with auto-upscaling",
|
||||
@@ -247,11 +278,21 @@ TOOL_CATEGORIES = {
|
||||
"name": "Browser Automation",
|
||||
"icon": "🌐",
|
||||
"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",
|
||||
"tag": "Free headless Chromium (no API key needed)",
|
||||
"env_vars": [],
|
||||
"browser_provider": None,
|
||||
"browser_provider": "local",
|
||||
"post_setup": "browserbase", # Same npm install for agent-browser
|
||||
},
|
||||
{
|
||||
@@ -581,8 +622,11 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
|
||||
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."""
|
||||
if config is None:
|
||||
config = load_config()
|
||||
|
||||
if ts_key == "vision":
|
||||
try:
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
@@ -592,10 +636,16 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
||||
except Exception:
|
||||
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)
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
if cat:
|
||||
for provider in cat.get("providers", []):
|
||||
for provider in _visible_providers(cat, config):
|
||||
env_vars = provider.get("env_vars", [])
|
||||
if not env_vars:
|
||||
return True # No-key provider (e.g. Local Browser, Edge TTS)
|
||||
@@ -805,11 +855,45 @@ def _configure_toolset(ts_key: str, config: dict):
|
||||
_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("managed_nous_feature") and not managed_nous_tools_enabled():
|
||||
continue
|
||||
if provider.get("requires_nous_auth") and not features.nous_auth_present:
|
||||
continue
|
||||
visible.append(provider)
|
||||
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):
|
||||
"""Configure a tool category with provider selection."""
|
||||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = cat["providers"]
|
||||
providers = _visible_providers(cat, config)
|
||||
|
||||
# Check Python version requirement
|
||||
if cat.get("requires_python"):
|
||||
@@ -874,6 +958,27 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||
|
||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||
"""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"):
|
||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||
if "browser_provider" in provider:
|
||||
@@ -900,6 +1005,13 @@ def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||
def _configure_provider(provider: dict, config: dict):
|
||||
"""Configure a single provider - prompt for API keys and set config."""
|
||||
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
|
||||
if provider.get("tts_provider"):
|
||||
@@ -908,11 +1020,12 @@ def _configure_provider(provider: dict, config: dict):
|
||||
# Set browser cloud provider in config if applicable
|
||||
if "browser_provider" in 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
|
||||
_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
|
||||
if provider.get("web_backend"):
|
||||
@@ -920,7 +1033,16 @@ def _configure_provider(provider: dict, config: dict):
|
||||
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||
|
||||
if not env_vars:
|
||||
if provider.get("post_setup"):
|
||||
_run_post_setup(provider["post_setup"])
|
||||
_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
|
||||
|
||||
# Prompt for each required env var
|
||||
@@ -1028,7 +1150,7 @@ def _reconfigure_tool(config: dict):
|
||||
cat = TOOL_CATEGORIES.get(ts_key)
|
||||
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
|
||||
if cat or reqs:
|
||||
if _toolset_has_keys(ts_key):
|
||||
if _toolset_has_keys(ts_key, config):
|
||||
configurable.append((ts_key, ts_label))
|
||||
|
||||
if not configurable:
|
||||
@@ -1058,7 +1180,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
"""Reconfigure a tool category - provider selection + API key update."""
|
||||
icon = cat.get("icon", "")
|
||||
name = cat["name"]
|
||||
providers = cat["providers"]
|
||||
providers = _visible_providers(cat, config)
|
||||
|
||||
if len(providers) == 1:
|
||||
provider = providers[0]
|
||||
@@ -1093,6 +1215,13 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||
def _reconfigure_provider(provider: dict, config: dict):
|
||||
"""Reconfigure a provider - update API keys."""
|
||||
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"):
|
||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||
@@ -1100,12 +1229,12 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
|
||||
if "browser_provider" in 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
|
||||
_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
|
||||
if provider.get("web_backend"):
|
||||
@@ -1113,7 +1242,16 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||
|
||||
if not env_vars:
|
||||
if provider.get("post_setup"):
|
||||
_run_post_setup(provider["post_setup"])
|
||||
_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
|
||||
|
||||
for var in env_vars:
|
||||
@@ -1222,13 +1360,23 @@ 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)
|
||||
print(color(f" - {label}", Colors.RED))
|
||||
|
||||
auto_configured = apply_nous_managed_defaults(
|
||||
config,
|
||||
enabled_toolsets=new_enabled,
|
||||
)
|
||||
if managed_nous_tools_enabled():
|
||||
for ts_key in sorted(auto_configured):
|
||||
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
|
||||
print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
|
||||
|
||||
# Walk through ALL selected tools that have provider options or
|
||||
# need API keys. This ensures browser (Local vs Browserbase),
|
||||
# TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
|
||||
# a free provider exists.
|
||||
to_configure = [
|
||||
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:
|
||||
@@ -1321,7 +1469,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
# Configure API keys for newly enabled tools
|
||||
for ts_key in sorted(added):
|
||||
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)
|
||||
_save_platform_tools(config, pk, new_enabled)
|
||||
save_config(config)
|
||||
@@ -1361,7 +1509,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||
# Configure newly enabled toolsets that need API keys
|
||||
for ts_key in sorted(added):
|
||||
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)
|
||||
|
||||
_save_platform_tools(config, pkey, new_enabled)
|
||||
|
||||
@@ -39,7 +39,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
modal = ["modal>=1.0.0,<2"]
|
||||
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"]
|
||||
cron = ["croniter>=6.0.0,<7"]
|
||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||
|
||||
@@ -15,6 +15,7 @@ requests
|
||||
jinja2
|
||||
pydantic>=2.0
|
||||
PyJWT[crypto]
|
||||
debugpy
|
||||
|
||||
# Web tools
|
||||
firecrawl-py
|
||||
|
||||
10
run_agent.py
10
run_agent.py
@@ -79,6 +79,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||
from agent.prompt_builder import (
|
||||
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
|
||||
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
|
||||
build_nous_subscription_prompt,
|
||||
)
|
||||
from agent.model_metadata import (
|
||||
fetch_model_metadata,
|
||||
@@ -100,7 +101,7 @@ from agent.trajectory import (
|
||||
convert_scratchpad_to_think, has_incomplete_scratchpad,
|
||||
save_trajectory as _save_trajectory_to_file,
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
from utils import atomic_json_write, env_var_enabled
|
||||
|
||||
HONCHO_TOOL_NAMES = {
|
||||
"honcho_context",
|
||||
@@ -2151,7 +2152,7 @@ class AIAgent:
|
||||
|
||||
self._vprint(f"{self.log_prefix}🧾 Request debug dump written to: {dump_file}")
|
||||
|
||||
if os.getenv("HERMES_DUMP_REQUEST_STDOUT", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
if env_var_enabled("HERMES_DUMP_REQUEST_STDOUT"):
|
||||
print(json.dumps(dump_payload, ensure_ascii=False, indent=2, default=str))
|
||||
|
||||
return dump_file
|
||||
@@ -2594,6 +2595,9 @@ class AIAgent:
|
||||
if 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)
|
||||
# Tool-use enforcement: tells the model to actually call tools instead
|
||||
# of describing intended actions. Controlled by config.yaml
|
||||
# agent.tool_use_enforcement:
|
||||
@@ -6834,7 +6838,7 @@ class AIAgent:
|
||||
if self.api_mode == "codex_responses":
|
||||
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
||||
|
||||
if os.getenv("HERMES_DUMP_REQUESTS", "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
if env_var_enabled("HERMES_DUMP_REQUESTS"):
|
||||
self._dump_api_request_debug(api_kwargs, reason="preflight")
|
||||
|
||||
# Always prefer the streaming path — even without stream
|
||||
|
||||
@@ -17,6 +17,7 @@ from agent.prompt_builder import (
|
||||
_find_git_root,
|
||||
_strip_yaml_frontmatter,
|
||||
build_skills_system_prompt,
|
||||
build_nous_subscription_prompt,
|
||||
build_context_files_prompt,
|
||||
CONTEXT_FILE_MAX_CHARS,
|
||||
DEFAULT_AGENT_IDENTITY,
|
||||
@@ -26,6 +27,7 @@ from agent.prompt_builder import (
|
||||
SESSION_SEARCH_GUIDANCE,
|
||||
PLATFORM_HINTS,
|
||||
)
|
||||
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -407,6 +409,62 @@ class TestBuildSkillsSystemPrompt:
|
||||
assert "backend-skill" in result
|
||||
|
||||
|
||||
class TestBuildNousSubscriptionPrompt:
|
||||
def test_includes_active_subscription_features(self, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||
lambda config=None: NousSubscriptionFeatures(
|
||||
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.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
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
|
||||
|
||||
def test_feature_flag_off_returns_empty_prompt(self, monkeypatch):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
|
||||
prompt = build_nous_subscription_prompt({"web_search"})
|
||||
|
||||
assert prompt == ""
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Context files prompt builder
|
||||
# =========================================================================
|
||||
@@ -578,8 +636,12 @@ class TestBuildContextFilesPrompt:
|
||||
reason="APFS default volume is case-insensitive; CLAUDE.md and claude.md alias the same path",
|
||||
)
|
||||
def test_claude_md_uppercase_takes_priority(self, tmp_path):
|
||||
(tmp_path / "CLAUDE.md").write_text("From uppercase.")
|
||||
(tmp_path / "claude.md").write_text("From lowercase.")
|
||||
uppercase = tmp_path / "CLAUDE.md"
|
||||
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))
|
||||
assert "From uppercase" in result
|
||||
assert "From lowercase" not in result
|
||||
|
||||
96
tests/hermes_cli/test_nous_subscription.py
Normal file
96
tests/hermes_cli/test_nous_subscription.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Tests for Nous subscription feature detection."""
|
||||
|
||||
from hermes_cli import nous_subscription as ns
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
|
||||
env = {"EXA_API_KEY": "exa-test"}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
|
||||
features = ns.get_nous_subscription_features({"web": {"backend": "exa"}})
|
||||
|
||||
assert features.web.available is True
|
||||
assert features.web.active is True
|
||||
assert features.web.managed_by_nous is False
|
||||
assert features.web.direct_override is True
|
||||
assert features.web.current_provider == "exa"
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "terminal")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: True)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: vendor == "modal")
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"terminal": {"backend": "modal", "modal_mode": "auto"}}
|
||||
)
|
||||
|
||||
assert features.modal.available is True
|
||||
assert features.modal.active is True
|
||||
assert features.modal.managed_by_nous is True
|
||||
assert features.modal.direct_override is False
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_prefers_camofox_over_managed_browserbase(monkeypatch):
|
||||
env = {"CAMOFOX_URL": "http://localhost:9377"}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
ns,
|
||||
"is_managed_tool_gateway_ready",
|
||||
lambda vendor: vendor == "browserbase",
|
||||
)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "browserbase"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is True
|
||||
assert features.browser.active is True
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.direct_override is True
|
||||
assert features.browser.current_provider == "Camofox"
|
||||
|
||||
|
||||
def test_get_nous_subscription_features_requires_agent_browser_for_browserbase(monkeypatch):
|
||||
env = {
|
||||
"BROWSERBASE_API_KEY": "bb-key",
|
||||
"BROWSERBASE_PROJECT_ID": "bb-project",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
||||
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {})
|
||||
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: False)
|
||||
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
||||
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
||||
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
||||
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
||||
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
||||
|
||||
features = ns.get_nous_subscription_features(
|
||||
{"browser": {"cloud_provider": "browserbase"}}
|
||||
)
|
||||
|
||||
assert features.browser.available is False
|
||||
assert features.browser.active is False
|
||||
assert features.browser.managed_by_nous is False
|
||||
assert features.browser.current_provider == "Browserbase"
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for setup_model_provider — verifies the delegation to
|
||||
select_provider_and_model() and config dict sync."""
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
|
||||
from hermes_cli.auth import get_active_provider
|
||||
from hermes_cli.config import load_config, save_config
|
||||
@@ -220,3 +222,86 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
|
||||
reloaded = load_config()
|
||||
assert isinstance(reloaded["model"], dict)
|
||||
assert reloaded["model"]["provider"] == "openai-codex"
|
||||
|
||||
|
||||
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
config = load_config()
|
||||
|
||||
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_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||
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"
|
||||
|
||||
@@ -8,7 +8,8 @@ that the setup wizard correctly syncs config from disk after the call.
|
||||
from __future__ import annotations
|
||||
|
||||
from hermes_cli.config import load_config, save_config, save_env_value
|
||||
from hermes_cli.setup import setup_model_provider
|
||||
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
||||
from hermes_cli.setup import _print_setup_summary, setup_model_provider
|
||||
|
||||
|
||||
def _maybe_keep_current_tts(question, choices):
|
||||
@@ -405,3 +406,72 @@ def test_setup_switch_preserves_non_model_config(tmp_path, monkeypatch):
|
||||
reloaded = load_config()
|
||||
assert reloaded["terminal"]["timeout"] == 999
|
||||
assert reloaded["model"]["provider"] == "openrouter"
|
||||
|
||||
|
||||
def test_setup_summary_marks_anthropic_auth_as_vision_available(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-api03-key")
|
||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: ["anthropic"])
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Vision (image analysis)" in output
|
||||
assert "missing run 'hermes setup' to configure" not in output
|
||||
|
||||
|
||||
def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.get_nous_subscription_features",
|
||||
lambda config: 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, True, True, False, True, True, "Camofox"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Camofox)" in output
|
||||
|
||||
|
||||
def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_path, monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_clear_provider_env(monkeypatch)
|
||||
monkeypatch.setenv("BROWSERBASE_API_KEY", "bb-key")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.setup.get_nous_subscription_features",
|
||||
lambda config: 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, "Browserbase"),
|
||||
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"),
|
||||
},
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("agent.auxiliary_client.get_available_vision_backends", lambda: [])
|
||||
|
||||
_print_setup_summary(load_config(), tmp_path)
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "Browser Automation (Browserbase)" not in output
|
||||
assert "Browser Automation" in output
|
||||
assert "BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID" in output
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for non-interactive setup and first-run headless behavior."""
|
||||
|
||||
from argparse import Namespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -92,3 +92,48 @@ class TestNonInteractiveSetup:
|
||||
mock_setup.assert_not_called()
|
||||
out = capsys.readouterr().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()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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=""):
|
||||
import hermes_cli.auth as auth_mod
|
||||
@@ -59,3 +61,64 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
|
||||
out = capsys.readouterr().out
|
||||
assert "Model: qwen3:latest" in out
|
||||
assert "Provider: Custom endpoint" in out
|
||||
|
||||
|
||||
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
from hermes_cli import status as status_mod
|
||||
|
||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||
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
|
||||
|
||||
|
||||
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
from hermes_cli import status as status_mod
|
||||
|
||||
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
status_mod,
|
||||
"load_config",
|
||||
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
|
||||
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Nous Subscription Features" not in out
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.tools_config import (
|
||||
_configure_provider,
|
||||
_get_platform_tools,
|
||||
_platform_toolset_summary,
|
||||
_save_platform_tools,
|
||||
_toolset_has_keys,
|
||||
TOOL_CATEGORIES,
|
||||
_visible_providers,
|
||||
tools_command,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +82,10 @@ def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("AUXILIARY_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
|
||||
|
||||
@@ -239,6 +247,92 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
|
||||
assert "terminal" not in saved
|
||||
|
||||
|
||||
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
config = {"model": {"provider": "nous"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"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_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||
config = {"model": {"provider": "nous"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||
lambda: {"logged_in": True},
|
||||
)
|
||||
|
||||
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
|
||||
|
||||
assert all(not provider["name"].startswith("Nous Subscription") for provider in providers)
|
||||
|
||||
|
||||
def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||
config = {}
|
||||
local_provider = next(
|
||||
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):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
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 == []
|
||||
|
||||
# ── Platform / toolset consistency ────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -78,6 +78,13 @@ def _install_prompt_toolkit_stubs():
|
||||
|
||||
|
||||
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:
|
||||
importlib.import_module("prompt_toolkit")
|
||||
except ModuleNotFoundError:
|
||||
@@ -269,6 +276,83 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
|
||||
assert shell.model == "gpt-5.2-codex"
|
||||
|
||||
|
||||
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
config = {
|
||||
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||
"tts": {"provider": "elevenlabs"},
|
||||
"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):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
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):
|
||||
"""Model comes from config.yaml, not LLM_MODEL env var.
|
||||
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
|
||||
@@ -472,4 +556,56 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||
assert "Detected model: llm" in output
|
||||
# OPENAI_BASE_URL is no longer saved to .env — config.yaml is authoritative
|
||||
assert "OPENAI_BASE_URL" not in saved_env
|
||||
assert saved_env["MODEL"] == "llm"
|
||||
assert saved_env["MODEL"] == "llm"
|
||||
|
||||
|
||||
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||
monkeypatch.setattr(hermes_main, "_require_tty", lambda *a: None)
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -605,6 +605,11 @@ class TestBuildSystemPrompt:
|
||||
# Should contain current date info like "Conversation started:"
|
||||
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
|
||||
|
||||
def test_skills_prompt_derives_available_toolsets_from_loaded_tools(self):
|
||||
tools = _make_tool_defs("web_search", "skills_list", "skill_view", "skill_manage")
|
||||
toolset_map = {
|
||||
|
||||
29
tests/test_utils_truthy_values.py
Normal file
29
tests/test_utils_truthy_values.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for shared truthy-value helpers."""
|
||||
|
||||
from utils import env_var_enabled, is_truthy_value
|
||||
|
||||
|
||||
def test_is_truthy_value_accepts_common_truthy_strings():
|
||||
assert is_truthy_value("true") is True
|
||||
assert is_truthy_value(" YES ") is True
|
||||
assert is_truthy_value("on") is True
|
||||
assert is_truthy_value("1") is True
|
||||
|
||||
|
||||
def test_is_truthy_value_respects_default_for_none():
|
||||
assert is_truthy_value(None, default=True) is True
|
||||
assert is_truthy_value(None, default=False) is False
|
||||
|
||||
|
||||
def test_is_truthy_value_rejects_falsey_strings():
|
||||
assert is_truthy_value("false") is False
|
||||
assert is_truthy_value("0") is False
|
||||
assert is_truthy_value("off") is False
|
||||
|
||||
|
||||
def test_env_var_enabled_uses_shared_truthy_rules(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_TEST_BOOL", "YeS")
|
||||
assert env_var_enabled("HERMES_TEST_BOOL") is True
|
||||
|
||||
monkeypatch.setenv("HERMES_TEST_BOOL", "no")
|
||||
assert env_var_enabled("HERMES_TEST_BOOL") is False
|
||||
459
tests/tools/test_managed_browserbase_and_modal.py
Normal file
459
tests/tools/test_managed_browserbase_and_modal.py
Normal file
@@ -0,0 +1,459 @@
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_managed_nous_tools(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
|
||||
|
||||
def _install_fake_tools_package():
|
||||
_reset_modules(("tools", "agent"))
|
||||
|
||||
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_auto_mode_prefers_managed_modal_when_available():
|
||||
_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-auto",
|
||||
)
|
||||
|
||||
assert result == "managed-modal-env"
|
||||
assert managed_ctor.called
|
||||
assert not direct_ctor.called
|
||||
|
||||
|
||||
def test_terminal_tool_auto_mode_falls_back_to_direct_modal_when_managed_unavailable():
|
||||
_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=False),
|
||||
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-fallback",
|
||||
)
|
||||
|
||||
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",
|
||||
)
|
||||
293
tests/tools/test_managed_media_gateways.py
Normal file
293
tests/tools/test_managed_media_gateways.py
Normal file
@@ -0,0 +1,293 @@
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_managed_nous_tools(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
|
||||
|
||||
def _install_fake_tools_package():
|
||||
tools_package = types.ModuleType("tools")
|
||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||
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
|
||||
309
tests/tools/test_managed_modal_environment.py
Normal file
309
tests/tools/test_managed_modal_environment.py
Normal file
@@ -0,0 +1,309 @@
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _install_fake_tools_package(*, credential_mounts=None):
|
||||
_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,
|
||||
)
|
||||
)
|
||||
sys.modules["tools.credential_files"] = types.SimpleNamespace(
|
||||
get_credential_file_mounts=lambda: list(credential_mounts or []),
|
||||
)
|
||||
|
||||
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")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
|
||||
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(modal_common.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")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
|
||||
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(modal_common.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")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
|
||||
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(modal_common.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()
|
||||
|
||||
|
||||
def test_managed_modal_create_and_cleanup_preserve_gateway_persistence_fields(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
create_payloads = []
|
||||
terminate_payloads = []
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
create_payloads.append(json)
|
||||
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||
if method == "POST" and url.endswith("/terminate"):
|
||||
terminate_payloads.append(json)
|
||||
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",
|
||||
task_id="task-managed-persist",
|
||||
persistent_filesystem=False,
|
||||
)
|
||||
env.cleanup()
|
||||
|
||||
assert create_payloads == [{
|
||||
"image": "python:3.11",
|
||||
"cwd": "/root",
|
||||
"cpu": 1.0,
|
||||
"memoryMiB": 5120.0,
|
||||
"timeoutMs": 3_600_000,
|
||||
"idleTimeoutMs": 300_000,
|
||||
"persistentFilesystem": False,
|
||||
"logicalKey": "task-managed-persist",
|
||||
}]
|
||||
assert terminate_payloads == [{"snapshotBeforeTerminate": False}]
|
||||
|
||||
|
||||
def test_managed_modal_rejects_host_credential_passthrough():
|
||||
_install_fake_tools_package(
|
||||
credential_mounts=[{
|
||||
"host_path": "/tmp/token.json",
|
||||
"container_path": "/root/.hermes/token.json",
|
||||
}]
|
||||
)
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
with pytest.raises(ValueError, match="credential-file passthrough"):
|
||||
managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
|
||||
|
||||
def test_managed_modal_execute_times_out_and_cancels(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
modal_common = sys.modules["tools.environments.modal_common"]
|
||||
|
||||
calls = []
|
||||
monotonic_values = iter([0.0, 12.5])
|
||||
|
||||
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}")
|
||||
|
||||
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||
monkeypatch.setattr(modal_common.time, "monotonic", lambda: next(monotonic_values))
|
||||
monkeypatch.setattr(modal_common.time, "sleep", lambda _: None)
|
||||
|
||||
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
result = env.execute("sleep 30", timeout=2)
|
||||
env.cleanup()
|
||||
|
||||
assert result == {
|
||||
"output": "Managed Modal exec timed out after 2s",
|
||||
"returncode": 124,
|
||||
}
|
||||
assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls)
|
||||
101
tests/tools/test_managed_tool_gateway.py
Normal file
101
tests/tools/test_managed_tool_gateway.py
Normal file
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
{
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
||||
"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,
|
||||
{
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"browserbase",
|
||||
token_reader=lambda: "nous-token",
|
||||
)
|
||||
|
||||
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,
|
||||
{
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"firecrawl",
|
||||
token_reader=lambda: None,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag():
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"firecrawl",
|
||||
token_reader=lambda: "nous-token",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
||||
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"
|
||||
222
tests/tools/test_modal_snapshot_isolation.py
Normal file
222
tests/tools/test_modal_snapshot_isolation.py
Normal file
@@ -0,0 +1,222 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_tool_modules():
|
||||
original_hermes_home = os.environ.get("HERMES_HOME")
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
if name == "tools"
|
||||
or name.startswith("tools.")
|
||||
or name == "hermes_cli"
|
||||
or name.startswith("hermes_cli.")
|
||||
or name == "modal"
|
||||
or name.startswith("modal.")
|
||||
}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if original_hermes_home is None:
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
else:
|
||||
os.environ["HERMES_HOME"] = original_hermes_home
|
||||
_reset_modules(("tools", "hermes_cli", "modal"))
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
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", "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"
|
||||
os.environ["HERMES_HOME"] = str(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)
|
||||
sys.modules["tools.credential_files"] = types.SimpleNamespace(
|
||||
get_credential_file_mounts=lambda: [],
|
||||
iter_skills_files=lambda: [],
|
||||
)
|
||||
|
||||
from_id_calls: list[str] = []
|
||||
registry_calls: list[tuple[str, list[str] | None]] = []
|
||||
create_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}
|
||||
|
||||
async def _lookup_aio(_name: str, create_if_missing: bool = False):
|
||||
return types.SimpleNamespace(name="hermes-agent", create_if_missing=create_if_missing)
|
||||
|
||||
class _FakeSandboxInstance:
|
||||
def __init__(self, image):
|
||||
self.image = image
|
||||
|
||||
async def _snapshot_aio():
|
||||
return types.SimpleNamespace(object_id=snapshot_id)
|
||||
|
||||
async def _terminate_aio():
|
||||
return None
|
||||
|
||||
self.snapshot_filesystem = types.SimpleNamespace(aio=_snapshot_aio)
|
||||
self.terminate = types.SimpleNamespace(aio=_terminate_aio)
|
||||
|
||||
async def _create_aio(*_args, image=None, app=None, timeout=None, **kwargs):
|
||||
create_calls.append({
|
||||
"image": image,
|
||||
"app": app,
|
||||
"timeout": timeout,
|
||||
**kwargs,
|
||||
})
|
||||
image_id = image.get("image_id") if isinstance(image, dict) else None
|
||||
if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids:
|
||||
raise RuntimeError(f"cannot restore {image_id}")
|
||||
return _FakeSandboxInstance(image)
|
||||
|
||||
class _FakeMount:
|
||||
@staticmethod
|
||||
def from_local_file(host_path: str, remote_path: str):
|
||||
return {"host_path": host_path, "remote_path": remote_path}
|
||||
|
||||
class _FakeApp:
|
||||
lookup = types.SimpleNamespace(aio=_lookup_aio)
|
||||
|
||||
class _FakeSandbox:
|
||||
create = types.SimpleNamespace(aio=_create_aio)
|
||||
|
||||
sys.modules["modal"] = types.SimpleNamespace(
|
||||
Image=_FakeImage,
|
||||
App=_FakeApp,
|
||||
Sandbox=_FakeSandbox,
|
||||
Mount=_FakeMount,
|
||||
)
|
||||
|
||||
return {
|
||||
"snapshot_store": hermes_home / "modal_snapshots.json",
|
||||
"create_calls": create_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["create_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["create_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]
|
||||
@@ -7,10 +7,13 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool")
|
||||
def _clear_terminal_env(monkeypatch):
|
||||
"""Remove terminal env vars that could affect requirements checks."""
|
||||
keys = [
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||
"TERMINAL_ENV",
|
||||
"TERMINAL_MODAL_MODE",
|
||||
"TERMINAL_SSH_HOST",
|
||||
"TERMINAL_SSH_USER",
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
]
|
||||
@@ -63,7 +66,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
# Pretend modal 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())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
@@ -71,6 +74,102 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"Modal backend selected but no MODAL_TOKEN_ID environment variable" in record.getMessage()
|
||||
"Modal backend selected but no direct Modal credentials/config was found" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
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_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
|
||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
|
||||
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)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
def test_modal_backend_managed_mode_does_not_fall_back_to_direct(monkeypatch, caplog, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
|
||||
monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
|
||||
monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkeypatch, caplog, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
@@ -26,3 +26,31 @@ class TestTerminalRequirements:
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
assert "terminal" in 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("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
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
|
||||
|
||||
@@ -236,6 +236,7 @@ class TestTranscribeGroq:
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "hello world"
|
||||
assert result["provider"] == "groq"
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_whitespace_stripped(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
@@ -277,6 +278,7 @@ class TestTranscribeGroq:
|
||||
|
||||
assert result["success"] is False
|
||||
assert "API error" in result["error"]
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_permission_error(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
@@ -332,6 +334,7 @@ class TestTranscribeOpenAIExtended:
|
||||
result = _transcribe_openai(sample_wav, "whisper-1")
|
||||
|
||||
assert result["transcript"] == "hello"
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_permission_error(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
@@ -346,6 +349,7 @@ class TestTranscribeOpenAIExtended:
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Permission denied" in result["error"]
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
|
||||
class TestTranscribeLocalCommand:
|
||||
|
||||
@@ -5,12 +5,16 @@ Coverage:
|
||||
constructor failure recovery, return value verification, edge cases.
|
||||
_get_backend() — backend selection logic with env var combinations.
|
||||
_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 sys
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
|
||||
class TestFirecrawlClientConfig:
|
||||
@@ -20,14 +24,33 @@ class TestFirecrawlClientConfig:
|
||||
"""Reset client and env vars before each test."""
|
||||
import tools.web_tools
|
||||
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 (
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||
"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["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset client after each test."""
|
||||
import tools.web_tools
|
||||
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 (
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
# ── Configuration matrix ─────────────────────────────────────────
|
||||
@@ -67,9 +90,152 @@ class TestFirecrawlClientConfig:
|
||||
def test_no_config_raises_with_helpful_message(self):
|
||||
"""Neither key nor URL → ValueError with guidance."""
|
||||
with patch("tools.web_tools.Firecrawl"):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||
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()
|
||||
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"))]
|
||||
|
||||
with patch(
|
||||
"tools.web_tools._resolve_web_extract_auxiliary",
|
||||
side_effect=[(None, None, {}), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model", {})],
|
||||
), patch(
|
||||
"tools.web_tools.async_call_llm",
|
||||
new=AsyncMock(return_value=response),
|
||||
) as mock_async_call:
|
||||
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"
|
||||
mock_async_call.assert_awaited_once()
|
||||
|
||||
# ── Singleton caching ────────────────────────────────────────────
|
||||
|
||||
@@ -117,9 +283,10 @@ class TestFirecrawlClientConfig:
|
||||
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
|
||||
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
|
||||
with patch("tools.web_tools.Firecrawl"):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError):
|
||||
_get_firecrawl_client()
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError):
|
||||
_get_firecrawl_client()
|
||||
|
||||
|
||||
class TestBackendSelection:
|
||||
@@ -130,11 +297,24 @@ class TestBackendSelection:
|
||||
setups.
|
||||
"""
|
||||
|
||||
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
|
||||
_ENV_KEYS = (
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||
"EXA_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",
|
||||
)
|
||||
|
||||
def setup_method(self):
|
||||
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||
for key in self._ENV_KEYS:
|
||||
os.environ.pop(key, None)
|
||||
if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def teardown_method(self):
|
||||
for key in self._ENV_KEYS:
|
||||
@@ -148,6 +328,13 @@ class TestBackendSelection:
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
|
||||
assert _get_backend() == "parallel"
|
||||
|
||||
def test_config_exa(self):
|
||||
"""web.backend=exa in config → 'exa' regardless of other keys."""
|
||||
from tools.web_tools import _get_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "exa"}), \
|
||||
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
|
||||
assert _get_backend() == "exa"
|
||||
|
||||
def test_config_firecrawl(self):
|
||||
"""web.backend=firecrawl in config → 'firecrawl' even if Parallel key set."""
|
||||
from tools.web_tools import _get_backend
|
||||
@@ -189,6 +376,20 @@ class TestBackendSelection:
|
||||
patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):
|
||||
assert _get_backend() == "parallel"
|
||||
|
||||
def test_fallback_exa_only_key(self):
|
||||
"""Only EXA_API_KEY set → 'exa'."""
|
||||
from tools.web_tools import _get_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
|
||||
assert _get_backend() == "exa"
|
||||
|
||||
def test_fallback_parallel_takes_priority_over_exa(self):
|
||||
"""Exa should only win the fallback path when it is the only configured backend."""
|
||||
from tools.web_tools import _get_backend
|
||||
with patch("tools.web_tools._load_web_config", return_value={}), \
|
||||
patch.dict(os.environ, {"EXA_API_KEY": "exa-test", "PARALLEL_API_KEY": "par-test"}):
|
||||
assert _get_backend() == "parallel"
|
||||
|
||||
def test_fallback_tavily_only_key(self):
|
||||
"""Only TAVILY_API_KEY set → 'tavily'."""
|
||||
from tools.web_tools import _get_backend
|
||||
@@ -246,11 +447,25 @@ class TestParallelClientConfig:
|
||||
import tools.web_tools
|
||||
tools.web_tools._parallel_client = None
|
||||
os.environ.pop("PARALLEL_API_KEY", None)
|
||||
fake_parallel = types.ModuleType("parallel")
|
||||
|
||||
class Parallel:
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
|
||||
class AsyncParallel:
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
|
||||
fake_parallel.Parallel = Parallel
|
||||
fake_parallel.AsyncParallel = AsyncParallel
|
||||
sys.modules["parallel"] = fake_parallel
|
||||
|
||||
def teardown_method(self):
|
||||
import tools.web_tools
|
||||
tools.web_tools._parallel_client = None
|
||||
os.environ.pop("PARALLEL_API_KEY", None)
|
||||
sys.modules.pop("parallel", None)
|
||||
|
||||
def test_creates_client_with_key(self):
|
||||
"""PARALLEL_API_KEY set → creates Parallel client."""
|
||||
@@ -276,14 +491,55 @@ class TestParallelClientConfig:
|
||||
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:
|
||||
"""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 = (
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
|
||||
"EXA_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",
|
||||
)
|
||||
|
||||
def setup_method(self):
|
||||
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
|
||||
for key in self._ENV_KEYS:
|
||||
os.environ.pop(key, None)
|
||||
if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def teardown_method(self):
|
||||
for key in self._ENV_KEYS:
|
||||
@@ -294,6 +550,11 @@ class TestCheckWebApiKey:
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_exa_key_only(self):
|
||||
with patch.dict(os.environ, {"EXA_API_KEY": "exa-test"}):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_firecrawl_key_only(self):
|
||||
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": "fc-test"}):
|
||||
from tools.web_tools import check_web_api_key
|
||||
@@ -329,3 +590,28 @@ class TestCheckWebApiKey:
|
||||
}):
|
||||
from tools.web_tools import check_web_api_key
|
||||
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
|
||||
|
||||
|
||||
def test_web_requires_env_includes_exa_key():
|
||||
from tools.web_tools import _web_requires_env
|
||||
|
||||
assert "EXA_API_KEY" in _web_requires_env()
|
||||
|
||||
@@ -1,262 +1,25 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tools Package
|
||||
"""Tools package namespace.
|
||||
|
||||
This package contains all the specific tool implementations for the Hermes Agent.
|
||||
Each module provides specialized functionality for different capabilities:
|
||||
Keep package import side effects minimal. Importing ``tools`` should not
|
||||
eagerly import the full tool stack, because several subsystems load tools while
|
||||
``hermes_cli.config`` is still initializing.
|
||||
|
||||
- web_tools: Web search, content extraction, and crawling
|
||||
- terminal_tool: Command execution (local/docker/modal/daytona/ssh/singularity backends)
|
||||
- vision_tools: Image analysis and understanding
|
||||
- mixture_of_agents_tool: Multi-model collaborative reasoning
|
||||
- image_generation_tool: Text-to-image generation with upscaling
|
||||
Callers should import concrete submodules directly, for example:
|
||||
|
||||
The tools are imported into model_tools.py which provides a unified interface
|
||||
for the AI agent to access all capabilities.
|
||||
import tools.web_tools
|
||||
from tools import browser_tool
|
||||
|
||||
Python will resolve those submodules via the package path without needing them
|
||||
to be re-exported here.
|
||||
"""
|
||||
|
||||
# Export all tools for easy importing
|
||||
from .web_tools import (
|
||||
web_search_tool,
|
||||
web_extract_tool,
|
||||
web_crawl_tool,
|
||||
check_firecrawl_api_key
|
||||
)
|
||||
|
||||
# Primary terminal tool (local/docker/singularity/modal/daytona/ssh)
|
||||
from .terminal_tool import (
|
||||
terminal_tool,
|
||||
check_terminal_requirements,
|
||||
cleanup_vm,
|
||||
cleanup_all_environments,
|
||||
get_active_environments_info,
|
||||
register_task_env_overrides,
|
||||
clear_task_env_overrides,
|
||||
TERMINAL_TOOL_DESCRIPTION
|
||||
)
|
||||
|
||||
from .vision_tools import (
|
||||
vision_analyze_tool,
|
||||
check_vision_requirements
|
||||
)
|
||||
|
||||
from .mixture_of_agents_tool import (
|
||||
mixture_of_agents_tool,
|
||||
check_moa_requirements
|
||||
)
|
||||
|
||||
from .image_generation_tool import (
|
||||
image_generate_tool,
|
||||
check_image_generation_requirements
|
||||
)
|
||||
|
||||
from .skills_tool import (
|
||||
skills_list,
|
||||
skill_view,
|
||||
check_skills_requirements,
|
||||
SKILLS_TOOL_DESCRIPTION
|
||||
)
|
||||
|
||||
from .skill_manager_tool import (
|
||||
skill_manage,
|
||||
check_skill_manage_requirements,
|
||||
SKILL_MANAGE_SCHEMA
|
||||
)
|
||||
|
||||
# Browser automation tools (agent-browser + Browserbase)
|
||||
from .browser_tool import (
|
||||
browser_navigate,
|
||||
browser_snapshot,
|
||||
browser_click,
|
||||
browser_type,
|
||||
browser_scroll,
|
||||
browser_back,
|
||||
browser_press,
|
||||
browser_close,
|
||||
browser_get_images,
|
||||
browser_vision,
|
||||
cleanup_browser,
|
||||
cleanup_all_browsers,
|
||||
get_active_browser_sessions,
|
||||
check_browser_requirements,
|
||||
BROWSER_TOOL_SCHEMAS
|
||||
)
|
||||
|
||||
# Cronjob management tools (CLI-only, hermes-cli toolset)
|
||||
from .cronjob_tools import (
|
||||
cronjob,
|
||||
schedule_cronjob,
|
||||
list_cronjobs,
|
||||
remove_cronjob,
|
||||
check_cronjob_requirements,
|
||||
get_cronjob_tool_definitions,
|
||||
CRONJOB_SCHEMA,
|
||||
)
|
||||
|
||||
# RL Training tools (Tinker-Atropos)
|
||||
from .rl_training_tool import (
|
||||
rl_list_environments,
|
||||
rl_select_environment,
|
||||
rl_get_current_config,
|
||||
rl_edit_config,
|
||||
rl_start_training,
|
||||
rl_check_status,
|
||||
rl_stop_training,
|
||||
rl_get_results,
|
||||
rl_list_runs,
|
||||
rl_test_inference,
|
||||
check_rl_api_keys,
|
||||
get_missing_keys,
|
||||
)
|
||||
|
||||
# File manipulation tools (read, write, patch, search)
|
||||
from .file_tools import (
|
||||
read_file_tool,
|
||||
write_file_tool,
|
||||
patch_tool,
|
||||
search_tool,
|
||||
get_file_tools,
|
||||
clear_file_ops_cache,
|
||||
)
|
||||
|
||||
# Text-to-speech tools (Edge TTS / ElevenLabs / OpenAI)
|
||||
from .tts_tool import (
|
||||
text_to_speech_tool,
|
||||
check_tts_requirements,
|
||||
)
|
||||
|
||||
# Planning & task management tool
|
||||
from .todo_tool import (
|
||||
todo_tool,
|
||||
check_todo_requirements,
|
||||
TODO_SCHEMA,
|
||||
TodoStore,
|
||||
)
|
||||
|
||||
# Clarifying questions tool (interactive Q&A with the user)
|
||||
from .clarify_tool import (
|
||||
clarify_tool,
|
||||
check_clarify_requirements,
|
||||
CLARIFY_SCHEMA,
|
||||
)
|
||||
|
||||
# Code execution sandbox (programmatic tool calling)
|
||||
from .code_execution_tool import (
|
||||
execute_code,
|
||||
check_sandbox_requirements,
|
||||
EXECUTE_CODE_SCHEMA,
|
||||
)
|
||||
|
||||
# Subagent delegation (spawn child agents with isolated context)
|
||||
from .delegate_tool import (
|
||||
delegate_task,
|
||||
check_delegate_requirements,
|
||||
DELEGATE_TASK_SCHEMA,
|
||||
)
|
||||
|
||||
# File tools have no external requirements - they use the terminal backend
|
||||
def check_file_requirements():
|
||||
"""File tools only require terminal backend to be available."""
|
||||
"""File tools only require terminal backend availability."""
|
||||
from .terminal_tool import check_terminal_requirements
|
||||
|
||||
return check_terminal_requirements()
|
||||
|
||||
__all__ = [
|
||||
# Web tools
|
||||
'web_search_tool',
|
||||
'web_extract_tool',
|
||||
'web_crawl_tool',
|
||||
'check_firecrawl_api_key',
|
||||
# Terminal tools
|
||||
'terminal_tool',
|
||||
'check_terminal_requirements',
|
||||
'cleanup_vm',
|
||||
'cleanup_all_environments',
|
||||
'get_active_environments_info',
|
||||
'register_task_env_overrides',
|
||||
'clear_task_env_overrides',
|
||||
'TERMINAL_TOOL_DESCRIPTION',
|
||||
# Vision tools
|
||||
'vision_analyze_tool',
|
||||
'check_vision_requirements',
|
||||
# MoA tools
|
||||
'mixture_of_agents_tool',
|
||||
'check_moa_requirements',
|
||||
# Image generation tools
|
||||
'image_generate_tool',
|
||||
'check_image_generation_requirements',
|
||||
# Skills tools
|
||||
'skills_list',
|
||||
'skill_view',
|
||||
'check_skills_requirements',
|
||||
'SKILLS_TOOL_DESCRIPTION',
|
||||
# Skill management
|
||||
'skill_manage',
|
||||
'check_skill_manage_requirements',
|
||||
'SKILL_MANAGE_SCHEMA',
|
||||
# Browser automation tools
|
||||
'browser_navigate',
|
||||
'browser_snapshot',
|
||||
'browser_click',
|
||||
'browser_type',
|
||||
'browser_scroll',
|
||||
'browser_back',
|
||||
'browser_press',
|
||||
'browser_close',
|
||||
'browser_get_images',
|
||||
'browser_vision',
|
||||
'cleanup_browser',
|
||||
'cleanup_all_browsers',
|
||||
'get_active_browser_sessions',
|
||||
'check_browser_requirements',
|
||||
'BROWSER_TOOL_SCHEMAS',
|
||||
# Cronjob management tools (CLI-only)
|
||||
'cronjob',
|
||||
'schedule_cronjob',
|
||||
'list_cronjobs',
|
||||
'remove_cronjob',
|
||||
'check_cronjob_requirements',
|
||||
'get_cronjob_tool_definitions',
|
||||
'CRONJOB_SCHEMA',
|
||||
# RL Training tools
|
||||
'rl_list_environments',
|
||||
'rl_select_environment',
|
||||
'rl_get_current_config',
|
||||
'rl_edit_config',
|
||||
'rl_start_training',
|
||||
'rl_check_status',
|
||||
'rl_stop_training',
|
||||
'rl_get_results',
|
||||
'rl_list_runs',
|
||||
'rl_test_inference',
|
||||
'check_rl_api_keys',
|
||||
'get_missing_keys',
|
||||
# File manipulation tools
|
||||
'read_file_tool',
|
||||
'write_file_tool',
|
||||
'patch_tool',
|
||||
'search_tool',
|
||||
'get_file_tools',
|
||||
'clear_file_ops_cache',
|
||||
'check_file_requirements',
|
||||
# Text-to-speech tools
|
||||
'text_to_speech_tool',
|
||||
'check_tts_requirements',
|
||||
# Planning & task management tool
|
||||
'todo_tool',
|
||||
'check_todo_requirements',
|
||||
'TODO_SCHEMA',
|
||||
'TodoStore',
|
||||
# Clarifying questions tool
|
||||
'clarify_tool',
|
||||
'check_clarify_requirements',
|
||||
'CLARIFY_SCHEMA',
|
||||
# Code execution sandbox
|
||||
'execute_code',
|
||||
'check_sandbox_requirements',
|
||||
'EXECUTE_CODE_SCHEMA',
|
||||
# Subagent delegation
|
||||
'delegate_task',
|
||||
'check_delegate_requirements',
|
||||
'DELEGATE_TASK_SCHEMA',
|
||||
]
|
||||
|
||||
__all__ = ["check_file_requirements"]
|
||||
|
||||
@@ -2,14 +2,58 @@
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_pending_create_keys: Dict[str, str] = {}
|
||||
_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):
|
||||
@@ -19,28 +63,51 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
return "Browserbase"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return bool(
|
||||
os.environ.get("BROWSERBASE_API_KEY")
|
||||
and os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
)
|
||||
return self._get_config_or_none() is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 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")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
raise ValueError(
|
||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment "
|
||||
"variables are required. Get your credentials at "
|
||||
"https://browserbase.com"
|
||||
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:
|
||||
message = (
|
||||
"Browserbase requires direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials."
|
||||
)
|
||||
return {"api_key": api_key, "project_id": project_id}
|
||||
if managed_nous_tools_enabled():
|
||||
message = (
|
||||
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID "
|
||||
"credentials or a managed Browserbase gateway configuration."
|
||||
)
|
||||
raise ValueError(message)
|
||||
return config
|
||||
|
||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||
config = self._get_config()
|
||||
managed_mode = bool(config.get("managed_mode"))
|
||||
|
||||
# Optional env-var knobs
|
||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||
@@ -80,8 +147,11 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
"Content-Type": "application/json",
|
||||
"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(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
@@ -91,7 +161,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
keepalive_fallback = False
|
||||
|
||||
# Handle 402 — paid features unavailable
|
||||
if response.status_code == 402:
|
||||
if response.status_code == 402 and not managed_mode:
|
||||
if enable_keep_alive:
|
||||
keepalive_fallback = True
|
||||
logger.warning(
|
||||
@@ -100,7 +170,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
)
|
||||
session_config.pop("keepAlive", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
@@ -114,20 +184,25 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
)
|
||||
session_config.pop("proxies", None)
|
||||
response = requests.post(
|
||||
"https://api.browserbase.com/v1/sessions",
|
||||
f"{config['base_url']}/v1/sessions",
|
||||
headers=headers,
|
||||
json=session_config,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if not response.ok:
|
||||
if managed_mode and not _should_preserve_pending_create_key(response):
|
||||
_clear_pending_create_key(task_id)
|
||||
raise RuntimeError(
|
||||
f"Failed to create Browserbase session: "
|
||||
f"{response.status_code} {response.text}"
|
||||
)
|
||||
|
||||
session_data = response.json()
|
||||
if managed_mode:
|
||||
_clear_pending_create_key(task_id)
|
||||
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:
|
||||
features_enabled["proxies"] = True
|
||||
@@ -146,6 +221,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
"bb_session_id": session_data["id"],
|
||||
"cdp_url": session_data["connectUrl"],
|
||||
"features": features_enabled,
|
||||
"external_call_id": external_call_id,
|
||||
}
|
||||
|
||||
def close_session(self, session_id: str) -> bool:
|
||||
@@ -157,7 +233,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
@@ -184,20 +260,19 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||
return False
|
||||
|
||||
def emergency_cleanup(self, session_id: str) -> None:
|
||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||
if not api_key or not project_id:
|
||||
config = self._get_config_or_none()
|
||||
if config is None:
|
||||
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
|
||||
return
|
||||
try:
|
||||
requests.post(
|
||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
||||
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||
headers={
|
||||
"X-BB-API-Key": api_key,
|
||||
"X-BB-API-Key": config["api_key"],
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"projectId": project_id,
|
||||
"projectId": config["project_id"],
|
||||
"status": "REQUEST_RELEASE",
|
||||
},
|
||||
timeout=5,
|
||||
|
||||
@@ -78,6 +78,7 @@ except Exception:
|
||||
from tools.browser_providers.base import CloudBrowserProvider
|
||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||
|
||||
# Camofox local anti-detection browser backend (optional).
|
||||
# When CAMOFOX_URL is set, all browser operations route through the
|
||||
@@ -245,7 +246,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
"""Return the configured cloud browser provider, or None for local mode.
|
||||
|
||||
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
|
||||
if _cloud_provider_resolved:
|
||||
@@ -259,14 +262,45 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
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:
|
||||
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
|
||||
except Exception as 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
|
||||
|
||||
|
||||
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 _is_local_backend() -> bool:
|
||||
"""Return True when the browser runs locally (no cloud provider).
|
||||
|
||||
@@ -1970,7 +2004,7 @@ if __name__ == "__main__":
|
||||
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
|
||||
if _cp is not None and not _cp.is_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:")
|
||||
for schema in BROWSER_TOOL_SCHEMAS:
|
||||
|
||||
@@ -765,7 +765,8 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None) -> dict:
|
||||
f"Available via `from hermes_tools import ...`:\n\n"
|
||||
f"{tool_lines}\n\n"
|
||||
"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, "
|
||||
"datetime, collections, etc.) for processing between tool calls.\n\n"
|
||||
"Also available (no import needed — built into hermes_tools):\n"
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
|
||||
def get_sandbox_dir() -> Path:
|
||||
|
||||
282
tools/environments/managed_modal.py
Normal file
282
tools/environments/managed_modal.py
Normal 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 uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from tools.environments.modal_common import (
|
||||
BaseModalExecutionEnvironment,
|
||||
ModalExecStart,
|
||||
PreparedModalExec,
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ManagedModalExecHandle:
|
||||
exec_id: str
|
||||
|
||||
|
||||
class ManagedModalEnvironment(BaseModalExecutionEnvironment):
|
||||
"""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)
|
||||
_client_timeout_grace_seconds = 10.0
|
||||
_interrupt_output = "[Command interrupted - Modal sandbox exec cancelled]"
|
||||
_unexpected_error_prefix = "Managed Modal exec failed"
|
||||
|
||||
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)
|
||||
|
||||
self._guard_unsupported_credential_passthrough()
|
||||
|
||||
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 _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart:
|
||||
exec_id = str(uuid.uuid4())
|
||||
payload: Dict[str, Any] = {
|
||||
"execId": exec_id,
|
||||
"command": prepared.command,
|
||||
"cwd": prepared.cwd,
|
||||
"timeoutMs": int(prepared.timeout * 1000),
|
||||
}
|
||||
if prepared.stdin_data is not None:
|
||||
payload["stdinData"] = prepared.stdin_data
|
||||
|
||||
try:
|
||||
response = self._request(
|
||||
"POST",
|
||||
f"/v1/sandboxes/{self._sandbox_id}/execs",
|
||||
json=payload,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception as exc:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(f"Managed Modal exec failed: {exc}")
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(
|
||||
self._format_error("Managed Modal exec failed", response)
|
||||
)
|
||||
)
|
||||
|
||||
body = response.json()
|
||||
status = body.get("status")
|
||||
if status in {"completed", "failed", "cancelled", "timeout"}:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._result(
|
||||
body.get("output", ""),
|
||||
body.get("returncode", 1),
|
||||
)
|
||||
)
|
||||
|
||||
if body.get("execId") != exec_id:
|
||||
return ModalExecStart(
|
||||
immediate_result=self._error_result(
|
||||
"Managed Modal exec start did not return the expected exec id"
|
||||
)
|
||||
)
|
||||
|
||||
return ModalExecStart(handle=_ManagedModalExecHandle(exec_id=exec_id))
|
||||
|
||||
def _poll_modal_exec(self, handle: _ManagedModalExecHandle) -> dict | None:
|
||||
try:
|
||||
status_response = self._request(
|
||||
"GET",
|
||||
f"/v1/sandboxes/{self._sandbox_id}/execs/{handle.exec_id}",
|
||||
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS),
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._error_result(f"Managed Modal exec poll failed: {exc}")
|
||||
|
||||
if status_response.status_code == 404:
|
||||
return self._error_result("Managed Modal exec not found")
|
||||
|
||||
if status_response.status_code >= 400:
|
||||
return self._error_result(
|
||||
self._format_error("Managed Modal exec poll failed", status_response)
|
||||
)
|
||||
|
||||
status_body = status_response.json()
|
||||
status = status_body.get("status")
|
||||
if status in {"completed", "failed", "cancelled", "timeout"}:
|
||||
return self._result(
|
||||
status_body.get("output", ""),
|
||||
status_body.get("returncode", 1),
|
||||
)
|
||||
return None
|
||||
|
||||
def _cancel_modal_exec(self, handle: _ManagedModalExecHandle) -> None:
|
||||
self._cancel_exec(handle.exec_id)
|
||||
|
||||
def _timeout_result_for_modal(self, timeout: int) -> dict:
|
||||
return self._result(f"Managed Modal exec timed out after {timeout}s", 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 _guard_unsupported_credential_passthrough(self) -> None:
|
||||
"""Managed Modal does not sync or mount host credential files."""
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts
|
||||
except Exception:
|
||||
return
|
||||
|
||||
mounts = get_credential_file_mounts()
|
||||
if mounts:
|
||||
raise ValueError(
|
||||
"Managed Modal does not support host credential-file passthrough. "
|
||||
"Use TERMINAL_MODAL_MODE=direct when skills or config require "
|
||||
"credential files inside the sandbox."
|
||||
)
|
||||
|
||||
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}"
|
||||
@@ -1,14 +1,7 @@
|
||||
"""Modal cloud execution environment using the Modal SDK directly.
|
||||
"""Modal cloud execution environment using the native Modal SDK directly.
|
||||
|
||||
Replaces the previous swe-rex ModalDeployment wrapper with native Modal
|
||||
Sandbox.create() + Sandbox.exec() calls. This eliminates the need for
|
||||
swe-rex's HTTP runtime server and unencrypted tunnel, fixing:
|
||||
- AsyncUsageWarning from synchronous App.lookup in async context
|
||||
- DeprecationError from unencrypted_ports / .url on unencrypted tunnels
|
||||
|
||||
Supports persistent filesystem snapshots: when enabled, the sandbox's
|
||||
filesystem is snapshotted on cleanup and restored on next creation, so
|
||||
installed packages, project files, and config changes survive across sessions.
|
||||
Uses ``Sandbox.create()`` + ``Sandbox.exec()`` instead of the older runtime
|
||||
wrapper, while preserving Hermes' persistent snapshot behavior across sessions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -16,17 +9,21 @@ import json
|
||||
import logging
|
||||
import shlex
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.modal_common import (
|
||||
BaseModalExecutionEnvironment,
|
||||
ModalExecStart,
|
||||
PreparedModalExec,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
|
||||
_DIRECT_SNAPSHOT_NAMESPACE = "direct"
|
||||
|
||||
|
||||
def _load_snapshots() -> Dict[str, str]:
|
||||
@@ -45,12 +42,72 @@ def _save_snapshots(data: Dict[str, str]) -> None:
|
||||
_SNAPSHOT_STORE.write_text(json.dumps(data, indent=2))
|
||||
|
||||
|
||||
class _AsyncWorker:
|
||||
"""Background thread with its own event loop for async-safe Modal calls.
|
||||
def _direct_snapshot_key(task_id: str) -> str:
|
||||
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 and whether it came from the legacy key format."""
|
||||
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 Modal calls."""
|
||||
|
||||
def __init__(self):
|
||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
@@ -81,14 +138,19 @@ class _AsyncWorker:
|
||||
self._thread.join(timeout=10)
|
||||
|
||||
|
||||
class ModalEnvironment(BaseEnvironment):
|
||||
"""Modal cloud execution via native Modal SDK.
|
||||
@dataclass
|
||||
class _DirectModalExecHandle:
|
||||
thread: threading.Thread
|
||||
result_holder: Dict[str, Any]
|
||||
|
||||
Uses Modal's Sandbox.create() for container lifecycle and Sandbox.exec()
|
||||
for command execution — no intermediate HTTP server or tunnel required.
|
||||
Adds sudo -S support, configurable resources (CPU, memory, disk),
|
||||
and optional filesystem persistence via Modal's snapshot API.
|
||||
"""
|
||||
|
||||
class ModalEnvironment(BaseModalExecutionEnvironment):
|
||||
"""Modal cloud execution via native Modal sandboxes."""
|
||||
|
||||
_stdin_mode = "heredoc"
|
||||
_poll_interval_seconds = 0.2
|
||||
_interrupt_output = "[Command interrupted - Modal sandbox terminated]"
|
||||
_unexpected_error_prefix = "Modal execution error"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -107,39 +169,21 @@ class ModalEnvironment(BaseEnvironment):
|
||||
self._sandbox = None
|
||||
self._app = None
|
||||
self._worker = _AsyncWorker()
|
||||
self._synced_files: Dict[str, tuple] = {}
|
||||
|
||||
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
||||
|
||||
# If persistent, try to restore from a previous snapshot
|
||||
restored_image = None
|
||||
restored_snapshot_id = None
|
||||
restored_from_legacy_key = False
|
||||
if self._persistent:
|
||||
snapshot_id = _load_snapshots().get(self._task_id)
|
||||
if snapshot_id:
|
||||
try:
|
||||
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",
|
||||
],
|
||||
restored_snapshot_id, restored_from_legacy_key = _get_snapshot_restore_candidate(
|
||||
self._task_id
|
||||
)
|
||||
if restored_snapshot_id:
|
||||
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
||||
|
||||
import modal as _modal
|
||||
|
||||
# Mount credential files (OAuth tokens, etc.) declared by skills.
|
||||
# These are read-only copies so the sandbox can authenticate with
|
||||
# external services but can't modify the host's credentials.
|
||||
cred_mounts = []
|
||||
try:
|
||||
from tools.credential_files import get_credential_file_mounts, iter_skills_files
|
||||
@@ -171,34 +215,63 @@ class ModalEnvironment(BaseEnvironment):
|
||||
except Exception as e:
|
||||
logger.debug("Modal: could not load credential file mounts: %s", e)
|
||||
|
||||
# Start the async worker thread and create sandbox on it
|
||||
# so all gRPC channels are bound to the worker's event loop.
|
||||
self._worker.start()
|
||||
|
||||
async def _create_sandbox():
|
||||
app = await _modal.App.lookup.aio(
|
||||
"hermes-agent", create_if_missing=True
|
||||
)
|
||||
async def _create_sandbox(image_spec: Any):
|
||||
app = await _modal.App.lookup.aio("hermes-agent", create_if_missing=True)
|
||||
create_kwargs = dict(sandbox_kwargs)
|
||||
if cred_mounts:
|
||||
existing_mounts = list(create_kwargs.pop("mounts", []))
|
||||
existing_mounts.extend(cred_mounts)
|
||||
create_kwargs["mounts"] = existing_mounts
|
||||
sandbox = await _modal.Sandbox.create.aio(
|
||||
"sleep", "infinity",
|
||||
image=effective_image,
|
||||
"sleep",
|
||||
"infinity",
|
||||
image=image_spec,
|
||||
app=app,
|
||||
timeout=int(create_kwargs.pop("timeout", 3600)),
|
||||
**create_kwargs,
|
||||
)
|
||||
return app, sandbox
|
||||
|
||||
self._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(), timeout=300
|
||||
)
|
||||
# Track synced files to avoid redundant pushes.
|
||||
# Key: container_path, Value: (mtime, size) of last synced version.
|
||||
self._synced_files: Dict[str, tuple] = {}
|
||||
try:
|
||||
target_image_spec = restored_snapshot_id or image
|
||||
try:
|
||||
# _resolve_modal_image keeps the Modal bootstrap fix together:
|
||||
# it applies setup_dockerfile_commands with ensurepip before
|
||||
# Modal builds registry images, while snapshot ids restore via
|
||||
# modal.Image.from_id() without rebuilding.
|
||||
effective_image = _resolve_modal_image(target_image_spec)
|
||||
self._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(effective_image),
|
||||
timeout=300,
|
||||
)
|
||||
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._app, self._sandbox = self._worker.run_coroutine(
|
||||
_create_sandbox(base_image),
|
||||
timeout=300,
|
||||
)
|
||||
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
|
||||
|
||||
logger.info("Modal: sandbox created (task=%s)", self._task_id)
|
||||
|
||||
def _push_file_to_sandbox(self, host_path: str, container_path: str) -> bool:
|
||||
@@ -253,86 +326,57 @@ class ModalEnvironment(BaseEnvironment):
|
||||
except Exception as e:
|
||||
logger.debug("Modal: file sync failed: %s", e)
|
||||
|
||||
def execute(self, command: str, cwd: str = "", *,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None) -> dict:
|
||||
# Sync credential files before each command so mid-session
|
||||
# OAuth setups are picked up without requiring a restart.
|
||||
def _before_execute(self) -> None:
|
||||
self._sync_files()
|
||||
|
||||
if stdin_data is not None:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
while marker in stdin_data:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
command = f"{command} << '{marker}'\n{stdin_data}\n{marker}"
|
||||
|
||||
exec_command, sudo_stdin = self._prepare_command(command)
|
||||
|
||||
# Modal sandboxes execute commands via exec() and cannot pipe
|
||||
# subprocess stdin directly. When a sudo password is present,
|
||||
# use a shell-level pipe from printf.
|
||||
if sudo_stdin is not None:
|
||||
exec_command = (
|
||||
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
|
||||
)
|
||||
|
||||
effective_cwd = cwd or self.cwd
|
||||
effective_timeout = timeout or self.timeout
|
||||
|
||||
# Wrap command with cd + stderr merge
|
||||
full_command = f"cd {shlex.quote(effective_cwd)} && {exec_command}"
|
||||
|
||||
# Run in a background thread so we can poll for interrupts
|
||||
def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart:
|
||||
full_command = f"cd {shlex.quote(prepared.cwd)} && {prepared.command}"
|
||||
result_holder = {"value": None, "error": None}
|
||||
|
||||
def _run():
|
||||
try:
|
||||
async def _do_execute():
|
||||
process = await self._sandbox.exec.aio(
|
||||
"bash", "-c", full_command,
|
||||
timeout=effective_timeout,
|
||||
"bash",
|
||||
"-c",
|
||||
full_command,
|
||||
timeout=prepared.timeout,
|
||||
)
|
||||
# Read stdout; redirect stderr to stdout in the shell
|
||||
# command so we get merged output
|
||||
stdout = await process.stdout.read.aio()
|
||||
stderr = await process.stderr.read.aio()
|
||||
exit_code = await process.wait.aio()
|
||||
# Merge stdout + stderr (stderr after stdout)
|
||||
if isinstance(stdout, bytes):
|
||||
stdout = stdout.decode("utf-8", errors="replace")
|
||||
if isinstance(stderr, bytes):
|
||||
stderr = stderr.decode("utf-8", errors="replace")
|
||||
output = stdout
|
||||
if stderr:
|
||||
output = f"{stdout}\n{stderr}" if stdout else stderr
|
||||
return output, exit_code
|
||||
return self._result(output, exit_code)
|
||||
|
||||
output, exit_code = self._worker.run_coroutine(
|
||||
_do_execute(), timeout=effective_timeout + 30
|
||||
result_holder["value"] = self._worker.run_coroutine(
|
||||
_do_execute(),
|
||||
timeout=prepared.timeout + 30,
|
||||
)
|
||||
result_holder["value"] = {
|
||||
"output": output,
|
||||
"returncode": exit_code,
|
||||
}
|
||||
except Exception as e:
|
||||
result_holder["error"] = e
|
||||
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
while t.is_alive():
|
||||
t.join(timeout=0.2)
|
||||
if is_interrupted():
|
||||
try:
|
||||
self._worker.run_coroutine(
|
||||
self._sandbox.terminate.aio(),
|
||||
timeout=15,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"output": "[Command interrupted - Modal sandbox terminated]",
|
||||
"returncode": 130,
|
||||
}
|
||||
return ModalExecStart(handle=_DirectModalExecHandle(thread=t, result_holder=result_holder))
|
||||
|
||||
if result_holder["error"]:
|
||||
return {"output": f"Modal execution error: {result_holder['error']}", "returncode": 1}
|
||||
return result_holder["value"]
|
||||
def _poll_modal_exec(self, handle: _DirectModalExecHandle) -> dict | None:
|
||||
if handle.thread.is_alive():
|
||||
return None
|
||||
if handle.result_holder["error"]:
|
||||
return self._error_result(f"Modal execution error: {handle.result_holder['error']}")
|
||||
return handle.result_holder["value"]
|
||||
|
||||
def _cancel_modal_exec(self, handle: _DirectModalExecHandle) -> None:
|
||||
self._worker.run_coroutine(
|
||||
self._sandbox.terminate.aio(),
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
def cleanup(self):
|
||||
"""Snapshot the filesystem (if persistent) then stop the sandbox."""
|
||||
@@ -351,11 +395,12 @@ class ModalEnvironment(BaseEnvironment):
|
||||
snapshot_id = None
|
||||
|
||||
if snapshot_id:
|
||||
snapshots = _load_snapshots()
|
||||
snapshots[self._task_id] = snapshot_id
|
||||
_save_snapshots(snapshots)
|
||||
logger.info("Modal: saved filesystem snapshot %s for task %s",
|
||||
snapshot_id[:20], self._task_id)
|
||||
_store_direct_snapshot(self._task_id, snapshot_id)
|
||||
logger.info(
|
||||
"Modal: saved filesystem snapshot %s for task %s",
|
||||
snapshot_id[:20],
|
||||
self._task_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
||||
|
||||
|
||||
178
tools/environments/modal_common.py
Normal file
178
tools/environments/modal_common.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Shared Hermes-side execution flow for Modal transports.
|
||||
|
||||
This module deliberately stops at the Hermes boundary:
|
||||
- command preparation
|
||||
- cwd/timeout normalization
|
||||
- stdin/sudo shell wrapping
|
||||
- common result shape
|
||||
- interrupt/cancel polling
|
||||
|
||||
Direct Modal and managed Modal keep separate transport logic, persistence, and
|
||||
trust-boundary decisions in their own modules.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shlex
|
||||
import time
|
||||
import uuid
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedModalExec:
|
||||
"""Normalized command data passed to a transport-specific exec runner."""
|
||||
|
||||
command: str
|
||||
cwd: str
|
||||
timeout: int
|
||||
stdin_data: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModalExecStart:
|
||||
"""Transport response after starting an exec."""
|
||||
|
||||
handle: Any | None = None
|
||||
immediate_result: dict | None = None
|
||||
|
||||
|
||||
def wrap_modal_stdin_heredoc(command: str, stdin_data: str) -> str:
|
||||
"""Append stdin as a shell heredoc for transports without stdin piping."""
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
while marker in stdin_data:
|
||||
marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}"
|
||||
return f"{command} << '{marker}'\n{stdin_data}\n{marker}"
|
||||
|
||||
|
||||
def wrap_modal_sudo_pipe(command: str, sudo_stdin: str) -> str:
|
||||
"""Feed sudo via a shell pipe for transports without direct stdin piping."""
|
||||
return f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {command}"
|
||||
|
||||
|
||||
class BaseModalExecutionEnvironment(BaseEnvironment):
|
||||
"""Common execute() flow for direct and managed Modal transports."""
|
||||
|
||||
_stdin_mode = "payload"
|
||||
_poll_interval_seconds = 0.25
|
||||
_client_timeout_grace_seconds: float | None = None
|
||||
_interrupt_output = "[Command interrupted]"
|
||||
_unexpected_error_prefix = "Modal execution error"
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str = "",
|
||||
*,
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None,
|
||||
) -> dict:
|
||||
self._before_execute()
|
||||
prepared = self._prepare_modal_exec(
|
||||
command,
|
||||
cwd=cwd,
|
||||
timeout=timeout,
|
||||
stdin_data=stdin_data,
|
||||
)
|
||||
|
||||
try:
|
||||
start = self._start_modal_exec(prepared)
|
||||
except Exception as exc:
|
||||
return self._error_result(f"{self._unexpected_error_prefix}: {exc}")
|
||||
|
||||
if start.immediate_result is not None:
|
||||
return start.immediate_result
|
||||
|
||||
if start.handle is None:
|
||||
return self._error_result(
|
||||
f"{self._unexpected_error_prefix}: transport did not return an exec handle"
|
||||
)
|
||||
|
||||
deadline = None
|
||||
if self._client_timeout_grace_seconds is not None:
|
||||
deadline = time.monotonic() + prepared.timeout + self._client_timeout_grace_seconds
|
||||
|
||||
while True:
|
||||
if is_interrupted():
|
||||
try:
|
||||
self._cancel_modal_exec(start.handle)
|
||||
except Exception:
|
||||
pass
|
||||
return self._result(self._interrupt_output, 130)
|
||||
|
||||
try:
|
||||
result = self._poll_modal_exec(start.handle)
|
||||
except Exception as exc:
|
||||
return self._error_result(f"{self._unexpected_error_prefix}: {exc}")
|
||||
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
try:
|
||||
self._cancel_modal_exec(start.handle)
|
||||
except Exception:
|
||||
pass
|
||||
return self._timeout_result_for_modal(prepared.timeout)
|
||||
|
||||
time.sleep(self._poll_interval_seconds)
|
||||
|
||||
def _before_execute(self) -> None:
|
||||
"""Hook for backends that need pre-exec sync or validation."""
|
||||
return None
|
||||
|
||||
def _prepare_modal_exec(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str = "",
|
||||
timeout: int | None = None,
|
||||
stdin_data: str | None = None,
|
||||
) -> PreparedModalExec:
|
||||
effective_cwd = cwd or self.cwd
|
||||
effective_timeout = timeout or self.timeout
|
||||
|
||||
exec_command = command
|
||||
exec_stdin = stdin_data if self._stdin_mode == "payload" else None
|
||||
if stdin_data is not None and self._stdin_mode == "heredoc":
|
||||
exec_command = wrap_modal_stdin_heredoc(exec_command, stdin_data)
|
||||
|
||||
exec_command, sudo_stdin = self._prepare_command(exec_command)
|
||||
if sudo_stdin is not None:
|
||||
exec_command = wrap_modal_sudo_pipe(exec_command, sudo_stdin)
|
||||
|
||||
return PreparedModalExec(
|
||||
command=exec_command,
|
||||
cwd=effective_cwd,
|
||||
timeout=effective_timeout,
|
||||
stdin_data=exec_stdin,
|
||||
)
|
||||
|
||||
def _result(self, output: str, returncode: int) -> dict:
|
||||
return {
|
||||
"output": output,
|
||||
"returncode": returncode,
|
||||
}
|
||||
|
||||
def _error_result(self, output: str) -> dict:
|
||||
return self._result(output, 1)
|
||||
|
||||
def _timeout_result_for_modal(self, timeout: int) -> dict:
|
||||
return self._result(f"Command timed out after {timeout}s", 124)
|
||||
|
||||
@abstractmethod
|
||||
def _start_modal_exec(self, prepared: PreparedModalExec) -> ModalExecStart:
|
||||
"""Begin a transport-specific exec."""
|
||||
|
||||
@abstractmethod
|
||||
def _poll_modal_exec(self, handle: Any) -> dict | None:
|
||||
"""Return a final result dict when complete, else ``None``."""
|
||||
|
||||
@abstractmethod
|
||||
def _cancel_modal_exec(self, handle: Any) -> None:
|
||||
"""Cancel or terminate the active transport exec."""
|
||||
@@ -16,7 +16,7 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.environments.base import BaseEnvironment
|
||||
from tools.interrupt import is_interrupted
|
||||
|
||||
|
||||
@@ -32,9 +32,14 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import datetime
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Dict, Any, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
import fal_client
|
||||
from tools.debug_helpers import DebugSession
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,6 +82,137 @@ VALID_OUTPUT_FORMATS = ["jpeg", "png"]
|
||||
VALID_ACCELERATION_MODES = ["none", "regular", "high"]
|
||||
|
||||
_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(
|
||||
@@ -186,9 +322,9 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]:
|
||||
# The async API (submit_async) caches a global httpx.AsyncClient via
|
||||
# @cached_property, which breaks when asyncio.run() destroys the loop
|
||||
# between calls (gateway thread-pool pattern).
|
||||
handler = fal_client.submit(
|
||||
handler = _submit_fal_request(
|
||||
UPSCALER_MODEL,
|
||||
arguments=upscaler_arguments
|
||||
arguments=upscaler_arguments,
|
||||
)
|
||||
|
||||
# Get the upscaled result (sync — blocks until done)
|
||||
@@ -280,8 +416,11 @@ def image_generate_tool(
|
||||
raise ValueError("Prompt is required and must be a non-empty string")
|
||||
|
||||
# Check API key availability
|
||||
if not os.getenv("FAL_KEY"):
|
||||
raise ValueError("FAL_KEY environment variable not set")
|
||||
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
|
||||
message = "FAL_KEY environment variable not set"
|
||||
if managed_nous_tools_enabled():
|
||||
message += " and managed FAL gateway is unavailable"
|
||||
raise ValueError(message)
|
||||
|
||||
# Validate other parameters
|
||||
validated_params = _validate_parameters(
|
||||
@@ -312,9 +451,9 @@ def image_generate_tool(
|
||||
logger.info(" Guidance: %s", validated_params['guidance_scale'])
|
||||
|
||||
# Submit request to FAL.ai using sync API (avoids cached event loop issues)
|
||||
handler = fal_client.submit(
|
||||
handler = _submit_fal_request(
|
||||
DEFAULT_MODEL,
|
||||
arguments=arguments
|
||||
arguments=arguments,
|
||||
)
|
||||
|
||||
# Get the result (sync — blocks until done)
|
||||
@@ -379,10 +518,12 @@ def image_generate_tool(
|
||||
error_msg = f"Error generating image: {str(e)}"
|
||||
logger.error("%s", error_msg, exc_info=True)
|
||||
|
||||
# Prepare error response - minimal format
|
||||
# Include error details so callers can diagnose failures
|
||||
response_data = {
|
||||
"success": False,
|
||||
"image": None
|
||||
"image": None,
|
||||
"error": str(e),
|
||||
"error_type": type(e).__name__,
|
||||
}
|
||||
|
||||
debug_call_data["error"] = error_msg
|
||||
@@ -400,7 +541,7 @@ def check_fal_api_key() -> bool:
|
||||
Returns:
|
||||
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:
|
||||
@@ -556,7 +697,7 @@ registry.register(
|
||||
schema=IMAGE_GENERATE_SCHEMA,
|
||||
handler=_handle_image_generate,
|
||||
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
|
||||
emoji="🎨",
|
||||
)
|
||||
|
||||
167
tools/managed_tool_gateway.py
Normal file
167
tools/managed_tool_gateway.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled
|
||||
|
||||
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
|
||||
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
|
||||
_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 as exc:
|
||||
logger.debug("Nous access token refresh failed: %s", exc)
|
||||
|
||||
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."""
|
||||
if not managed_nous_tools_enabled():
|
||||
return None
|
||||
|
||||
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
|
||||
resolved_token_reader = token_reader or read_nous_access_token
|
||||
|
||||
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
|
||||
@@ -78,7 +78,6 @@ from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional, Set, Tuple
|
||||
|
||||
import yaml
|
||||
from hermes_cli.config import load_env, _ENV_VAR_NAME_RE
|
||||
from tools.registry import registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -101,11 +100,28 @@ _PLATFORM_MAP = {
|
||||
"linux": "linux",
|
||||
"windows": "win32",
|
||||
}
|
||||
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
_EXCLUDED_SKILL_DIRS = frozenset((".git", ".github", ".hub"))
|
||||
_REMOTE_ENV_BACKENDS = frozenset({"docker", "singularity", "modal", "ssh", "daytona"})
|
||||
_secret_capture_callback = None
|
||||
|
||||
|
||||
def load_env() -> Dict[str, str]:
|
||||
"""Load profile-scoped environment variables from HERMES_HOME/.env."""
|
||||
env_path = get_hermes_home() / ".env"
|
||||
env_vars: Dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
return env_vars
|
||||
|
||||
with env_path.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
env_vars[key.strip()] = value.strip().strip("\"'")
|
||||
return env_vars
|
||||
|
||||
|
||||
class SkillReadinessStatus(str, Enum):
|
||||
AVAILABLE = "available"
|
||||
SETUP_NEEDED = "setup_needed"
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
Terminal Tool Module
|
||||
|
||||
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):
|
||||
- "local": Execute directly on the host machine (default, fastest)
|
||||
- "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:
|
||||
- Multiple execution backends (local, docker, modal)
|
||||
@@ -16,6 +16,10 @@ Features:
|
||||
- VM/container lifecycle management
|
||||
- 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:
|
||||
from terminal_tool import terminal_tool
|
||||
|
||||
@@ -51,12 +55,23 @@ from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — r
|
||||
# display_hermes_home imported lazily at call site (stale-module safety during hermes update)
|
||||
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
|
||||
from tools.environments.singularity import _get_scratch_dir
|
||||
from tools.tool_backend_helpers import (
|
||||
coerce_modal_mode,
|
||||
has_direct_modal_credentials,
|
||||
managed_nous_tools_enabled,
|
||||
resolve_modal_backend_state,
|
||||
)
|
||||
|
||||
|
||||
# Disk usage warning threshold (in GB)
|
||||
@@ -363,10 +378,12 @@ from tools.environments.singularity import SingularityEnvironment as _Singularit
|
||||
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
|
||||
from tools.environments.docker import DockerEnvironment as _DockerEnvironment
|
||||
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
|
||||
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 grep/rg/find to search — use search_files instead.
|
||||
@@ -382,6 +399,7 @@ Working directory: Use 'workdir' for per-command cwd.
|
||||
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.
|
||||
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
|
||||
@@ -495,6 +513,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||
|
||||
return {
|
||||
"env_type": env_type,
|
||||
"modal_mode": coerce_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
|
||||
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
|
||||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||
@@ -527,6 +546,15 @@ 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."""
|
||||
return resolve_modal_backend_state(
|
||||
modal_mode,
|
||||
has_direct=has_direct_modal_credentials(),
|
||||
managed_ready=is_managed_tool_gateway_ready("modal"),
|
||||
)
|
||||
|
||||
|
||||
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
ssh_config: dict = None, container_config: dict = None,
|
||||
local_config: dict = None,
|
||||
@@ -592,7 +620,39 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||
sandbox_kwargs["ephemeral_disk"] = disk
|
||||
except Exception:
|
||||
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["managed_mode_blocked"]:
|
||||
raise ValueError(
|
||||
"Modal backend is configured for managed mode, but "
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
|
||||
"Modal credentials/config were found. Enable the feature flag or "
|
||||
"choose TERMINAL_MODAL_MODE=direct/auto."
|
||||
)
|
||||
if modal_state["mode"] == "managed":
|
||||
raise ValueError(
|
||||
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
|
||||
)
|
||||
if modal_state["mode"] == "direct":
|
||||
raise ValueError(
|
||||
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
|
||||
)
|
||||
message = "Modal backend selected but no direct Modal credentials/config was found."
|
||||
if managed_nous_tools_enabled():
|
||||
message = (
|
||||
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
|
||||
)
|
||||
raise ValueError(message)
|
||||
|
||||
return _ModalEnvironment(
|
||||
image=image, cwd=cwd, timeout=timeout,
|
||||
modal_sandbox_kwargs=sandbox_kwargs,
|
||||
@@ -958,6 +1018,7 @@ def terminal_tool(
|
||||
"container_memory": config.get("container_memory", 5120),
|
||||
"container_disk": config.get("container_disk", 51200),
|
||||
"container_persistent": config.get("container_persistent", True),
|
||||
"modal_mode": config.get("modal_mode", "auto"),
|
||||
"docker_volumes": config.get("docker_volumes", []),
|
||||
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
|
||||
}
|
||||
@@ -1175,10 +1236,14 @@ def terminal_tool(
|
||||
}, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb_str = traceback.format_exc()
|
||||
logger.error("terminal_tool exception:\n%s", tb_str)
|
||||
return json.dumps({
|
||||
"output": "",
|
||||
"exit_code": -1,
|
||||
"error": f"Failed to execute command: {str(e)}",
|
||||
"traceback": tb_str,
|
||||
"status": "error"
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -1218,18 +1283,58 @@ def check_terminal_requirements() -> bool:
|
||||
return True
|
||||
|
||||
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["managed_mode_blocked"]:
|
||||
logger.error(
|
||||
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
|
||||
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
|
||||
"Modal credentials/config were found. Enable the feature flag "
|
||||
"or choose TERMINAL_MODAL_MODE=direct/auto."
|
||||
)
|
||||
return False
|
||||
if modal_state["mode"] == "managed":
|
||||
logger.error(
|
||||
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
|
||||
"tool gateway is unavailable. Configure the managed gateway or choose "
|
||||
"TERMINAL_MODAL_MODE=direct/auto."
|
||||
)
|
||||
return False
|
||||
elif modal_state["mode"] == "direct":
|
||||
if managed_nous_tools_enabled():
|
||||
logger.error(
|
||||
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
|
||||
"Modal credentials/config were found. Configure Modal or choose "
|
||||
"TERMINAL_MODAL_MODE=managed/auto."
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
|
||||
"Modal credentials/config were found. Configure Modal or choose "
|
||||
"TERMINAL_MODAL_MODE=auto."
|
||||
)
|
||||
return False
|
||||
else:
|
||||
if managed_nous_tools_enabled():
|
||||
logger.error(
|
||||
"Modal backend selected but no direct Modal credentials/config or managed "
|
||||
"tool gateway was found. Configure Modal, set up the managed gateway, "
|
||||
"or choose a different TERMINAL_ENV."
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Modal backend selected but no direct Modal credentials/config was found. "
|
||||
"Configure Modal or choose a different TERMINAL_ENV."
|
||||
)
|
||||
return False
|
||||
|
||||
if importlib.util.find_spec("modal") is None:
|
||||
logger.error("modal is required for modal terminal backend: pip install 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."
|
||||
)
|
||||
logger.error("modal is required for direct modal terminal backend: pip install modal")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
elif env_type == "daytona":
|
||||
|
||||
89
tools/tool_backend_helpers.py
Normal file
89
tools/tool_backend_helpers.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Shared helpers for tool backend selection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from utils import env_var_enabled
|
||||
|
||||
_DEFAULT_BROWSER_PROVIDER = "local"
|
||||
_DEFAULT_MODAL_MODE = "auto"
|
||||
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
|
||||
|
||||
|
||||
def managed_nous_tools_enabled() -> bool:
|
||||
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
|
||||
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
|
||||
|
||||
|
||||
def normalize_browser_cloud_provider(value: object | None) -> str:
|
||||
"""Return a normalized browser provider key."""
|
||||
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
|
||||
return provider or _DEFAULT_BROWSER_PROVIDER
|
||||
|
||||
|
||||
def coerce_modal_mode(value: object | None) -> str:
|
||||
"""Return the requested modal mode when valid, else the default."""
|
||||
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
|
||||
if mode in _VALID_MODAL_MODES:
|
||||
return mode
|
||||
return _DEFAULT_MODAL_MODE
|
||||
|
||||
|
||||
def normalize_modal_mode(value: object | None) -> str:
|
||||
"""Return a normalized modal execution mode."""
|
||||
return coerce_modal_mode(value)
|
||||
|
||||
|
||||
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_modal_backend_state(
|
||||
modal_mode: object | None,
|
||||
*,
|
||||
has_direct: bool,
|
||||
managed_ready: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve direct vs managed Modal backend selection.
|
||||
|
||||
Semantics:
|
||||
- ``direct`` means direct-only
|
||||
- ``managed`` means managed-only
|
||||
- ``auto`` prefers managed when available, then falls back to direct
|
||||
"""
|
||||
requested_mode = coerce_modal_mode(modal_mode)
|
||||
normalized_mode = normalize_modal_mode(modal_mode)
|
||||
managed_mode_blocked = (
|
||||
requested_mode == "managed" and not managed_nous_tools_enabled()
|
||||
)
|
||||
|
||||
if normalized_mode == "managed":
|
||||
selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else None
|
||||
elif normalized_mode == "direct":
|
||||
selected_backend = "direct" if has_direct else None
|
||||
else:
|
||||
selected_backend = "managed" if managed_nous_tools_enabled() and managed_ready else "direct" if has_direct else None
|
||||
|
||||
return {
|
||||
"requested_mode": requested_mode,
|
||||
"mode": normalized_mode,
|
||||
"has_direct": has_direct,
|
||||
"managed_ready": managed_ready,
|
||||
"managed_mode_blocked": managed_mode_blocked,
|
||||
"selected_backend": selected_backend,
|
||||
}
|
||||
|
||||
|
||||
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()
|
||||
@@ -31,6 +31,11 @@ import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from utils import is_truthy_value
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
@@ -41,8 +46,17 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
@@ -109,16 +123,12 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
|
||||
if stt_config is None:
|
||||
stt_config = _load_stt_config()
|
||||
enabled = stt_config.get("enabled", True)
|
||||
if isinstance(enabled, str):
|
||||
return enabled.strip().lower() in ("true", "1", "yes", "on")
|
||||
if enabled is None:
|
||||
return True
|
||||
return bool(enabled)
|
||||
return is_truthy_value(enabled, default=True)
|
||||
|
||||
|
||||
def _resolve_openai_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", "")
|
||||
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"))
|
||||
|
||||
|
||||
def _find_binary(binary_name: str) -> Optional[str]:
|
||||
@@ -210,7 +220,7 @@ def _get_provider(stt_config: dict) -> str:
|
||||
return "none"
|
||||
|
||||
if provider == "openai":
|
||||
if _HAS_OPENAI and _resolve_openai_api_key():
|
||||
if _HAS_OPENAI and _has_openai_audio_backend():
|
||||
return "openai"
|
||||
logger.warning(
|
||||
"STT provider 'openai' configured but no API key available"
|
||||
@@ -228,7 +238,7 @@ def _get_provider(stt_config: dict) -> str:
|
||||
if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
|
||||
logger.info("No local STT available, using Groq Whisper API")
|
||||
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")
|
||||
return "openai"
|
||||
return "none"
|
||||
@@ -404,19 +414,23 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||
try:
|
||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||
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:
|
||||
transcription = client.audio.transcriptions.create(
|
||||
model=model_name,
|
||||
file=audio_file,
|
||||
response_format="text",
|
||||
)
|
||||
transcript_text = str(transcription).strip()
|
||||
logger.info("Transcribed %s via Groq API (%s, %d chars)",
|
||||
Path(file_path).name, model_name, len(transcript_text))
|
||||
|
||||
transcript_text = str(transcription).strip()
|
||||
logger.info("Transcribed %s via Groq API (%s, %d chars)",
|
||||
Path(file_path).name, model_name, len(transcript_text))
|
||||
|
||||
return {"success": True, "transcript": transcript_text, "provider": "groq"}
|
||||
return {"success": True, "transcript": transcript_text, "provider": "groq"}
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
except PermissionError:
|
||||
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
||||
@@ -437,12 +451,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]:
|
||||
"""Transcribe using OpenAI Whisper API (paid)."""
|
||||
api_key = _resolve_openai_api_key()
|
||||
if not api_key:
|
||||
try:
|
||||
api_key, base_url = _resolve_openai_audio_client_config()
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"transcript": "",
|
||||
"error": "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set",
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
if not _HAS_OPENAI:
|
||||
@@ -455,20 +470,24 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||
|
||||
try:
|
||||
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:
|
||||
transcription = client.audio.transcriptions.create(
|
||||
model=model_name,
|
||||
file=audio_file,
|
||||
response_format="text",
|
||||
)
|
||||
transcript_text = _extract_transcript_text(transcription)
|
||||
logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
|
||||
Path(file_path).name, model_name, len(transcript_text))
|
||||
|
||||
transcript_text = str(transcription).strip()
|
||||
logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
|
||||
Path(file_path).name, model_name, len(transcript_text))
|
||||
|
||||
return {"success": True, "transcript": transcript_text, "provider": "openai"}
|
||||
return {"success": True, "transcript": transcript_text, "provider": "openai"}
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
except PermissionError:
|
||||
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
||||
@@ -554,3 +573,39 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
|
||||
"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:
|
||||
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
|
||||
if managed_nous_tools_enabled():
|
||||
message += ", and the managed OpenAI audio gateway is unavailable"
|
||||
raise ValueError(message)
|
||||
|
||||
return managed_gateway.nous_user_token, urljoin(
|
||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
@@ -32,11 +32,14 @@ import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from hermes_constants import get_hermes_home
|
||||
from typing import Callable, Dict, Any, Optional
|
||||
from urllib.parse import urljoin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy imports -- providers are imported only when actually used to avoid
|
||||
@@ -74,6 +77,8 @@ DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"
|
||||
DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5"
|
||||
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
|
||||
DEFAULT_OPENAI_VOICE = "alloy"
|
||||
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
|
||||
def _get_default_output_dir() -> str:
|
||||
from hermes_constants import get_hermes_dir
|
||||
return str(get_hermes_dir("cache/audio", "audio_cache"))
|
||||
@@ -237,14 +242,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
Returns:
|
||||
Path to the saved audio file.
|
||||
"""
|
||||
api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
|
||||
if not api_key:
|
||||
raise ValueError("VOICE_TOOLS_OPENAI_KEY not set. Get one at https://platform.openai.com/api-keys")
|
||||
api_key, base_url = _resolve_openai_audio_client_config()
|
||||
|
||||
oai_config = tts_config.get("openai", {})
|
||||
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
|
||||
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
|
||||
if output_path.endswith(".ogg"):
|
||||
@@ -254,15 +257,21 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||
|
||||
OpenAIClient = _import_openai_client()
|
||||
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||
response = client.audio.speech.create(
|
||||
model=model,
|
||||
voice=voice,
|
||||
input=text,
|
||||
response_format=response_format,
|
||||
)
|
||||
try:
|
||||
response = client.audio.speech.create(
|
||||
model=model,
|
||||
voice=voice,
|
||||
input=text,
|
||||
response_format=response_format,
|
||||
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
|
||||
)
|
||||
|
||||
response.stream_to_file(output_path)
|
||||
return output_path
|
||||
response.stream_to_file(output_path)
|
||||
return output_path
|
||||
finally:
|
||||
close = getattr(client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
@@ -543,7 +552,7 @@ def check_tts_requirements() -> bool:
|
||||
pass
|
||||
try:
|
||||
_import_openai_client()
|
||||
if os.getenv("VOICE_TOOLS_OPENAI_KEY"):
|
||||
if _has_openai_audio_backend():
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -552,6 +561,29 @@ def check_tts_requirements() -> bool:
|
||||
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:
|
||||
message = "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set"
|
||||
if managed_nous_tools_enabled():
|
||||
message += ", and the managed OpenAI audio gateway is unavailable"
|
||||
raise ValueError(message)
|
||||
|
||||
return managed_gateway.nous_user_token, urljoin(
|
||||
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
# ===========================================================================
|
||||
@@ -806,7 +838,10 @@ if __name__ == "__main__":
|
||||
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" 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"\n Output dir: {DEFAULT_OUTPUT_DIR}")
|
||||
|
||||
|
||||
@@ -4,15 +4,19 @@ Standalone Web Tools Module
|
||||
|
||||
This module provides generic web tools that work with multiple backend providers.
|
||||
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:
|
||||
- web_search_tool: Search the web for information
|
||||
- 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:
|
||||
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl)
|
||||
- Exa: https://exa.ai (search, extract)
|
||||
- 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)
|
||||
- Tavily: https://tavily.com (search, extract, crawl)
|
||||
|
||||
LLM Processing:
|
||||
- Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction
|
||||
@@ -44,8 +48,18 @@ import asyncio
|
||||
from typing import List, Dict, Any, Optional
|
||||
import httpx
|
||||
from firecrawl import Firecrawl
|
||||
from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
|
||||
from agent.auxiliary_client import (
|
||||
async_call_llm,
|
||||
extract_content_or_reasoning,
|
||||
get_async_text_auxiliary_client,
|
||||
)
|
||||
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.tool_backend_helpers import managed_nous_tools_enabled
|
||||
from tools.url_safety import is_safe_url
|
||||
from tools.website_policy import check_website_access
|
||||
|
||||
@@ -77,48 +91,152 @@ def _get_backend() -> str:
|
||||
if configured in ("parallel", "firecrawl", "tavily", "exa"):
|
||||
return configured
|
||||
|
||||
# Fallback for manual / legacy config — pick highest-priority backend
|
||||
# that has a key configured. Order: firecrawl > parallel > tavily > exa.
|
||||
for backend, keys in [
|
||||
("firecrawl", ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL")),
|
||||
("parallel", ("PARALLEL_API_KEY",)),
|
||||
("tavily", ("TAVILY_API_KEY",)),
|
||||
("exa", ("EXA_API_KEY",)),
|
||||
]:
|
||||
if any(_has_env(k) for k in keys):
|
||||
# Fallback for manual / legacy config — pick the highest-priority
|
||||
# available backend. Firecrawl also counts as available when the managed
|
||||
# tool gateway is configured for Nous subscribers.
|
||||
backend_candidates = (
|
||||
("firecrawl", _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL") or _is_tool_gateway_ready()),
|
||||
("parallel", _has_env("PARALLEL_API_KEY")),
|
||||
("tavily", _has_env("TAVILY_API_KEY")),
|
||||
("exa", _has_env("EXA_API_KEY")),
|
||||
)
|
||||
for backend, available in backend_candidates:
|
||||
if available:
|
||||
return backend
|
||||
|
||||
return "firecrawl" # default (backward compat)
|
||||
|
||||
|
||||
def _is_backend_available(backend: str) -> bool:
|
||||
"""Return True when the selected backend is currently usable."""
|
||||
if backend == "exa":
|
||||
return _has_env("EXA_API_KEY")
|
||||
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 = 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."""
|
||||
message = (
|
||||
"Web tools are not configured. "
|
||||
"Set FIRECRAWL_API_KEY for cloud Firecrawl or set FIRECRAWL_API_URL for a self-hosted Firecrawl instance."
|
||||
)
|
||||
if managed_nous_tools_enabled():
|
||||
message += (
|
||||
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
|
||||
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
|
||||
)
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def _firecrawl_backend_help_suffix() -> str:
|
||||
"""Return optional managed-gateway guidance for Firecrawl help text."""
|
||||
if not managed_nous_tools_enabled():
|
||||
return ""
|
||||
return (
|
||||
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
|
||||
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
|
||||
)
|
||||
|
||||
|
||||
def _web_requires_env() -> list[str]:
|
||||
"""Return tool metadata env vars for the currently enabled web backends."""
|
||||
requires = [
|
||||
"EXA_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"TAVILY_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
]
|
||||
if managed_nous_tools_enabled():
|
||||
requires.extend(
|
||||
[
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
]
|
||||
)
|
||||
return requires
|
||||
|
||||
|
||||
def _get_firecrawl_client():
|
||||
"""Get or create the Firecrawl client (lazy initialization).
|
||||
"""Get or create Firecrawl client.
|
||||
|
||||
Uses the cloud API by default (requires FIRECRAWL_API_KEY).
|
||||
Set FIRECRAWL_API_URL to point at a self-hosted instance instead —
|
||||
in that case the API key is optional (set USE_DB_AUTHENTICATION=false
|
||||
on your Firecrawl server to disable auth entirely).
|
||||
Direct Firecrawl takes precedence when explicitly configured. Otherwise
|
||||
Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers.
|
||||
"""
|
||||
global _firecrawl_client
|
||||
if _firecrawl_client is None:
|
||||
api_key = os.getenv("FIRECRAWL_API_KEY")
|
||||
api_url = os.getenv("FIRECRAWL_API_URL")
|
||||
if not api_key and not api_url:
|
||||
logger.error("Firecrawl client initialization failed: missing configuration.")
|
||||
raise ValueError(
|
||||
"Firecrawl client not configured. "
|
||||
"Set FIRECRAWL_API_KEY (cloud) or FIRECRAWL_API_URL (self-hosted). "
|
||||
"This tool requires Firecrawl to be available."
|
||||
)
|
||||
kwargs = {}
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
if api_url:
|
||||
kwargs["api_url"] = api_url
|
||||
_firecrawl_client = Firecrawl(**kwargs)
|
||||
global _firecrawl_client, _firecrawl_client_config
|
||||
|
||||
direct_config = _get_direct_firecrawl_config()
|
||||
if direct_config is not None:
|
||||
kwargs, client_config = direct_config
|
||||
else:
|
||||
managed_gateway = resolve_managed_tool_gateway(
|
||||
"firecrawl",
|
||||
token_reader=_read_nous_access_token,
|
||||
)
|
||||
if managed_gateway is None:
|
||||
logger.error("Firecrawl client initialization failed: missing direct config and tool-gateway auth.")
|
||||
_raise_web_backend_configuration_error()
|
||||
|
||||
kwargs = {
|
||||
"api_key": managed_gateway.nous_user_token,
|
||||
"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
|
||||
|
||||
# ─── Parallel Client ─────────────────────────────────────────────────────────
|
||||
@@ -243,10 +361,115 @@ def _normalize_tavily_documents(response: dict, fallback_url: str = "") -> List[
|
||||
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
|
||||
|
||||
# Allow per-task override via env var
|
||||
DEFAULT_SUMMARIZER_MODEL = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None
|
||||
def _is_nous_auxiliary_client(client: Any) -> bool:
|
||||
"""Return True when the resolved auxiliary backend is Nous Portal."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
base_url = str(getattr(client, "base_url", "") or "")
|
||||
host = (urlparse(base_url).hostname or "").lower()
|
||||
return host == "nousresearch.com" or host.endswith(".nousresearch.com")
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -255,7 +478,7 @@ async def process_content_with_llm(
|
||||
content: str,
|
||||
url: str = "",
|
||||
title: str = "",
|
||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
||||
model: Optional[str] = None,
|
||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
@@ -338,7 +561,7 @@ async def process_content_with_llm(
|
||||
async def _call_summarizer_llm(
|
||||
content: str,
|
||||
context_str: str,
|
||||
model: str,
|
||||
model: Optional[str],
|
||||
max_tokens: int = 20000,
|
||||
is_chunk: bool = False,
|
||||
chunk_info: str = ""
|
||||
@@ -404,17 +627,22 @@ Create a markdown summary that captures all key information in a well-organized,
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
|
||||
if aux_client is None or not effective_model:
|
||||
logger.warning("No auxiliary model available for web content processing")
|
||||
return None
|
||||
call_kwargs = {
|
||||
"task": "web_extract",
|
||||
"model": effective_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt}
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
if model:
|
||||
call_kwargs["model"] = model
|
||||
if extra_body:
|
||||
call_kwargs["extra_body"] = extra_body
|
||||
response = await async_call_llm(**call_kwargs)
|
||||
content = extract_content_or_reasoning(response)
|
||||
if content:
|
||||
@@ -445,7 +673,7 @@ Create a markdown summary that captures all key information in a well-organized,
|
||||
async def _process_large_content_chunked(
|
||||
content: str,
|
||||
context_str: str,
|
||||
model: str,
|
||||
model: Optional[str],
|
||||
chunk_size: int,
|
||||
max_output_size: int
|
||||
) -> Optional[str]:
|
||||
@@ -532,17 +760,26 @@ Synthesize these into ONE cohesive, comprehensive summary that:
|
||||
Create a single, unified markdown summary."""
|
||||
|
||||
try:
|
||||
aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
|
||||
if aux_client is None or not effective_model:
|
||||
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
|
||||
|
||||
call_kwargs = {
|
||||
"task": "web_extract",
|
||||
"model": effective_model,
|
||||
"messages": [
|
||||
{"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,
|
||||
"max_tokens": 20000,
|
||||
}
|
||||
if model:
|
||||
call_kwargs["model"] = model
|
||||
if extra_body:
|
||||
call_kwargs["extra_body"] = extra_body
|
||||
response = await async_call_llm(**call_kwargs)
|
||||
final_summary = extract_content_or_reasoning(response)
|
||||
|
||||
@@ -551,7 +788,6 @@ Create a single, unified markdown summary."""
|
||||
logger.warning("Synthesis LLM returned empty content, retrying once")
|
||||
response = await async_call_llm(**call_kwargs)
|
||||
final_summary = extract_content_or_reasoning(response)
|
||||
|
||||
# Enforce hard cap
|
||||
if len(final_summary) > max_output_size:
|
||||
final_summary = final_summary[:max_output_size] + "\n\n[... summary truncated for context management ...]"
|
||||
@@ -859,35 +1095,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# The response is a SearchData object with web, news, and images attributes
|
||||
# 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']
|
||||
|
||||
web_results = _extract_web_search_results(response)
|
||||
results_count = len(web_results)
|
||||
logger.info("Found %d search results", results_count)
|
||||
|
||||
@@ -916,11 +1124,11 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||
except Exception as e:
|
||||
error_msg = f"Error searching web: {str(e)}"
|
||||
logger.debug("%s", error_msg)
|
||||
|
||||
|
||||
debug_call_data["error"] = error_msg
|
||||
_debug.log_call("web_search_tool", debug_call_data)
|
||||
_debug.save()
|
||||
|
||||
|
||||
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
||||
|
||||
|
||||
@@ -928,7 +1136,7 @@ async def web_extract_tool(
|
||||
urls: List[str],
|
||||
format: str = None,
|
||||
use_llm_processing: bool = True,
|
||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
||||
model: Optional[str] = None,
|
||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||
) -> str:
|
||||
"""
|
||||
@@ -941,7 +1149,7 @@ async def web_extract_tool(
|
||||
urls (List[str]): List of URLs to extract content from
|
||||
format (str): Desired output format ("markdown" or "html", optional)
|
||||
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)
|
||||
|
||||
Security: URLs are checked for embedded secrets before fetching.
|
||||
@@ -1052,39 +1260,11 @@ async def web_extract_tool(
|
||||
formats=formats
|
||||
)
|
||||
|
||||
# Process the result - properly handle object serialization
|
||||
metadata = {}
|
||||
scrape_payload = _extract_scrape_payload(scrape_result)
|
||||
metadata = scrape_payload.get("metadata", {})
|
||||
title = ""
|
||||
content_markdown = None
|
||||
content_html = None
|
||||
|
||||
# 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', {})
|
||||
content_markdown = scrape_payload.get("markdown")
|
||||
content_html = scrape_payload.get("html")
|
||||
|
||||
# Ensure metadata is a dict (not an object)
|
||||
if not isinstance(metadata, dict):
|
||||
@@ -1142,9 +1322,11 @@ async def web_extract_tool(
|
||||
|
||||
debug_call_data["pages_extracted"] = pages_extracted
|
||||
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
|
||||
if use_llm_processing:
|
||||
if use_llm_processing and auxiliary_available:
|
||||
logger.info("Processing extracted content with LLM (parallel)...")
|
||||
debug_call_data["processing_applied"].append("llm_processing")
|
||||
|
||||
@@ -1162,7 +1344,7 @@ async def web_extract_tool(
|
||||
|
||||
# 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:
|
||||
@@ -1178,7 +1360,7 @@ async def web_extract_tool(
|
||||
"original_size": original_size,
|
||||
"processed_size": processed_size,
|
||||
"compression_ratio": compression_ratio,
|
||||
"model_used": model
|
||||
"model_used": effective_model
|
||||
}
|
||||
return result, metrics, "processed"
|
||||
else:
|
||||
@@ -1210,6 +1392,9 @@ async def web_extract_tool(
|
||||
else:
|
||||
logger.warning("%s (no content to process)", url)
|
||||
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)
|
||||
for result in response.get('results', []):
|
||||
url = result.get('url', 'Unknown URL')
|
||||
@@ -1264,7 +1449,7 @@ async def web_crawl_tool(
|
||||
instructions: str = None,
|
||||
depth: str = "basic",
|
||||
use_llm_processing: bool = True,
|
||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
||||
model: Optional[str] = None,
|
||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||
) -> str:
|
||||
"""
|
||||
@@ -1278,7 +1463,7 @@ async def web_crawl_tool(
|
||||
instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional)
|
||||
depth (str): Depth of extraction ("basic" or "advanced", default: "basic")
|
||||
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)
|
||||
|
||||
Returns:
|
||||
@@ -1308,6 +1493,8 @@ async def web_crawl_tool(
|
||||
}
|
||||
|
||||
try:
|
||||
effective_model = model or _get_default_summarizer_model()
|
||||
auxiliary_available = check_auxiliary_model()
|
||||
backend = _get_backend()
|
||||
|
||||
# Tavily supports crawl via its /crawl endpoint
|
||||
@@ -1352,7 +1539,7 @@ async def web_crawl_tool(
|
||||
debug_call_data["original_response_size"] = len(json.dumps(response))
|
||||
|
||||
# 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)...")
|
||||
debug_call_data["processing_applied"].append("llm_processing")
|
||||
|
||||
@@ -1363,12 +1550,12 @@ async def web_crawl_tool(
|
||||
if not content:
|
||||
return result, None, "no_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:
|
||||
result['raw_content'] = content
|
||||
result['content'] = 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"
|
||||
metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size,
|
||||
"compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"}
|
||||
@@ -1381,6 +1568,10 @@ async def web_crawl_tool(
|
||||
debug_call_data["compression_metrics"].append(metrics)
|
||||
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"),
|
||||
**({ "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)
|
||||
@@ -1390,11 +1581,11 @@ async def web_crawl_tool(
|
||||
_debug.save()
|
||||
return cleaned_result
|
||||
|
||||
# web_crawl requires Firecrawl — Parallel has no crawl API
|
||||
if not (os.getenv("FIRECRAWL_API_KEY") or os.getenv("FIRECRAWL_API_URL")):
|
||||
# web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
|
||||
if not check_firecrawl_api_key():
|
||||
return json.dumps({
|
||||
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, "
|
||||
"or use web_search + web_extract instead.",
|
||||
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
|
||||
f"{_firecrawl_backend_help_suffix()}, or use web_search + web_extract instead.",
|
||||
"success": False,
|
||||
}, ensure_ascii=False)
|
||||
|
||||
@@ -1554,7 +1745,7 @@ async def web_crawl_tool(
|
||||
debug_call_data["original_response_size"] = len(json.dumps(response))
|
||||
|
||||
# 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)...")
|
||||
debug_call_data["processing_applied"].append("llm_processing")
|
||||
|
||||
@@ -1572,7 +1763,7 @@ async def web_crawl_tool(
|
||||
|
||||
# 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:
|
||||
@@ -1588,7 +1779,7 @@ async def web_crawl_tool(
|
||||
"original_size": original_size,
|
||||
"processed_size": processed_size,
|
||||
"compression_ratio": compression_ratio,
|
||||
"model_used": model
|
||||
"model_used": effective_model
|
||||
}
|
||||
return result, metrics, "processed"
|
||||
else:
|
||||
@@ -1620,6 +1811,9 @@ async def web_crawl_tool(
|
||||
else:
|
||||
logger.warning("%s (no content to process)", page_url)
|
||||
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)
|
||||
for result in response.get('results', []):
|
||||
page_url = result.get('url', 'Unknown URL')
|
||||
@@ -1663,39 +1857,34 @@ async def web_crawl_tool(
|
||||
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:
|
||||
"""
|
||||
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:
|
||||
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:
|
||||
"""Check if any web backend API key is available (Exa, Parallel, Firecrawl, or Tavily)."""
|
||||
return bool(
|
||||
os.getenv("EXA_API_KEY")
|
||||
or os.getenv("PARALLEL_API_KEY")
|
||||
or os.getenv("FIRECRAWL_API_KEY")
|
||||
or os.getenv("FIRECRAWL_API_URL")
|
||||
or os.getenv("TAVILY_API_KEY")
|
||||
)
|
||||
"""Check whether the configured web backend is available."""
|
||||
configured = _load_web_config().get("backend", "").lower().strip()
|
||||
if configured in ("exa", "parallel", "firecrawl", "tavily"):
|
||||
return _is_backend_available(configured)
|
||||
return any(_is_backend_available(backend) for backend in ("exa", "parallel", "firecrawl", "tavily"))
|
||||
|
||||
|
||||
def check_auxiliary_model() -> bool:
|
||||
"""Check if an auxiliary text model is available for LLM content processing."""
|
||||
try:
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
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
|
||||
client, _, _ = _resolve_web_extract_auxiliary()
|
||||
return client is not None
|
||||
|
||||
|
||||
def get_debug_session_info() -> Dict[str, Any]:
|
||||
@@ -1712,7 +1901,11 @@ if __name__ == "__main__":
|
||||
|
||||
# Check if API keys are available
|
||||
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()
|
||||
default_summarizer_model = _get_default_summarizer_model()
|
||||
|
||||
if web_available:
|
||||
backend = _get_backend()
|
||||
@@ -1724,17 +1917,27 @@ if __name__ == "__main__":
|
||||
elif backend == "tavily":
|
||||
print(" Using Tavily API (https://tavily.com)")
|
||||
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:
|
||||
print("❌ No web search backend configured")
|
||||
print("Set EXA_API_KEY, PARALLEL_API_KEY, TAVILY_API_KEY, or FIRECRAWL_API_KEY")
|
||||
print(
|
||||
"Set EXA_API_KEY, PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL"
|
||||
f"{_firecrawl_backend_help_suffix()}"
|
||||
)
|
||||
|
||||
if not nous_available:
|
||||
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("⚠️ Without an auxiliary model, LLM content processing will be disabled")
|
||||
else:
|
||||
print(f"✅ Auxiliary model available: {DEFAULT_SUMMARIZER_MODEL}")
|
||||
print(f"✅ Auxiliary model available: {default_summarizer_model}")
|
||||
|
||||
if not web_available:
|
||||
exit(1)
|
||||
@@ -1742,7 +1945,7 @@ if __name__ == "__main__":
|
||||
print("🛠️ Web tools ready for use!")
|
||||
|
||||
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")
|
||||
|
||||
# Show debug mode status
|
||||
@@ -1837,7 +2040,7 @@ registry.register(
|
||||
schema=WEB_SEARCH_SCHEMA,
|
||||
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=["EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"],
|
||||
requires_env=_web_requires_env(),
|
||||
emoji="🔍",
|
||||
)
|
||||
registry.register(
|
||||
@@ -1847,7 +2050,7 @@ registry.register(
|
||||
handler=lambda args, **kw: web_extract_tool(
|
||||
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
||||
check_fn=check_web_api_key,
|
||||
requires_env=["EXA_API_KEY", "PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"],
|
||||
requires_env=_web_requires_env(),
|
||||
is_async=True,
|
||||
emoji="📄",
|
||||
)
|
||||
|
||||
19
utils.py
19
utils.py
@@ -9,6 +9,25 @@ from typing import Any, Union
|
||||
import yaml
|
||||
|
||||
|
||||
TRUTHY_STRINGS = frozenset({"1", "true", "yes", "on"})
|
||||
|
||||
|
||||
def is_truthy_value(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce bool-ish values using the project's shared truthy string set."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in TRUTHY_STRINGS
|
||||
return bool(value)
|
||||
|
||||
|
||||
def env_var_enabled(name: str, default: str = "") -> bool:
|
||||
"""Return True when an environment variable is set to a truthy value."""
|
||||
return is_truthy_value(os.getenv(name, default), default=False)
|
||||
|
||||
|
||||
def atomic_json_write(
|
||||
path: Union[str, Path],
|
||||
data: Any,
|
||||
|
||||
@@ -118,6 +118,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||
| `TERMINAL_CWD` | Working directory for all terminal sessions |
|
||||
| `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
|
||||
|
||||
| Variable | Description |
|
||||
|
||||
@@ -88,6 +88,8 @@ terminal:
|
||||
daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona 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.
|
||||
|
||||
### Backend Overview
|
||||
|
||||
| Backend | Where commands run | Isolation | Best for |
|
||||
@@ -188,7 +190,7 @@ terminal:
|
||||
|
||||
**Required:** Either `MODAL_TOKEN_ID` + `MODAL_TOKEN_SECRET` environment variables, or a `~/.modal.toml` config file.
|
||||
|
||||
**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`.
|
||||
**Persistence:** When enabled, the sandbox filesystem is snapshotted on cleanup and restored on next session. Snapshots are tracked in `~/.hermes/modal_snapshots.json`. This preserves filesystem state, not live processes, PID space, or background jobs.
|
||||
|
||||
**Credential files:** Automatically mounted from `~/.hermes/` (OAuth tokens, etc.) and synced before each command.
|
||||
|
||||
@@ -243,7 +245,7 @@ If terminal commands fail immediately or the terminal tool is reported as disabl
|
||||
- **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration.
|
||||
- **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters.
|
||||
|
||||
When in doubt, set `terminal.backend` back to `local` and verify commands run there first.
|
||||
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
|
||||
|
||||
### Docker Volume Mounts
|
||||
|
||||
|
||||
Reference in New Issue
Block a user