mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: add managed tool gateway and Nous subscription support
- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
This commit is contained in:
parent
cbf195e806
commit
95dc9aaa75
44 changed files with 4567 additions and 423 deletions
11
.env.example
11
.env.example
|
|
@ -69,6 +69,17 @@ OPENCODE_GO_API_KEY=
|
||||||
# Get at: https://parallel.ai
|
# Get at: https://parallel.ai
|
||||||
PARALLEL_API_KEY=
|
PARALLEL_API_KEY=
|
||||||
|
|
||||||
|
# Tool-gateway config (Nous Subscribers only; preferred when available)
|
||||||
|
# Uses your Nous Subscriber OAuth access token from the Hermes auth store by default.
|
||||||
|
# Defaults to the Nous production gateway. Override for local dev.
|
||||||
|
#
|
||||||
|
# Derive vendor gateway URLs from a shared domain suffix:
|
||||||
|
# TOOL_GATEWAY_DOMAIN=nousresearch.com
|
||||||
|
# TOOL_GATEWAY_SCHEME=https
|
||||||
|
#
|
||||||
|
# Override the subscriber token (defaults to ~/.hermes/auth.json):
|
||||||
|
# TOOL_GATEWAY_USER_TOKEN=
|
||||||
|
|
||||||
# Firecrawl API Key - Web search, extract, and crawl
|
# Firecrawl API Key - Web search, extract, and crawl
|
||||||
# Get at: https://firecrawl.dev/
|
# Get at: https://firecrawl.dev/
|
||||||
FIRECRAWL_API_KEY=
|
FIRECRAWL_API_KEY=
|
||||||
|
|
|
||||||
|
|
@ -422,6 +422,69 @@ def build_skills_system_prompt(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_nous_subscription_prompt(valid_tool_names: "set[str] | None" = None) -> str:
|
||||||
|
"""Build a compact Nous subscription capability block for the system prompt."""
|
||||||
|
try:
|
||||||
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Failed to import Nous subscription helper: %s", exc)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
valid_names = set(valid_tool_names or set())
|
||||||
|
relevant_tool_names = {
|
||||||
|
"web_search",
|
||||||
|
"web_extract",
|
||||||
|
"browser_navigate",
|
||||||
|
"browser_snapshot",
|
||||||
|
"browser_click",
|
||||||
|
"browser_type",
|
||||||
|
"browser_scroll",
|
||||||
|
"browser_console",
|
||||||
|
"browser_close",
|
||||||
|
"browser_press",
|
||||||
|
"browser_get_images",
|
||||||
|
"browser_vision",
|
||||||
|
"image_generate",
|
||||||
|
"text_to_speech",
|
||||||
|
"terminal",
|
||||||
|
"process",
|
||||||
|
"execute_code",
|
||||||
|
}
|
||||||
|
|
||||||
|
if valid_names and not (valid_names & relevant_tool_names):
|
||||||
|
return ""
|
||||||
|
|
||||||
|
features = get_nous_subscription_features()
|
||||||
|
|
||||||
|
def _status_line(feature) -> str:
|
||||||
|
if feature.managed_by_nous:
|
||||||
|
return f"- {feature.label}: active via Nous subscription"
|
||||||
|
if feature.active:
|
||||||
|
current = feature.current_provider or "configured provider"
|
||||||
|
return f"- {feature.label}: currently using {current}"
|
||||||
|
if feature.included_by_default and features.nous_auth_present:
|
||||||
|
return f"- {feature.label}: included with Nous subscription, not currently selected"
|
||||||
|
if feature.key == "modal" and features.nous_auth_present:
|
||||||
|
return f"- {feature.label}: optional via Nous subscription"
|
||||||
|
return f"- {feature.label}: not currently available"
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"# Nous Subscription",
|
||||||
|
"Nous subscription includes managed web tools (Firecrawl), image generation (FAL), OpenAI TTS, and browser automation (Browserbase) by default. Modal execution is optional.",
|
||||||
|
"Current capability status:",
|
||||||
|
]
|
||||||
|
lines.extend(_status_line(feature) for feature in features.items())
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"When a Nous-managed feature is active, do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys.",
|
||||||
|
"If the user is not subscribed and asks for a capability that Nous subscription would unlock or simplify, suggest Nous subscription as one option alongside direct setup or local alternatives.",
|
||||||
|
"Do not mention subscription unless the user asks about it or it directly solves the current missing capability.",
|
||||||
|
"Useful commands: hermes setup, hermes setup tools, hermes setup terminal, hermes status.",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
# Context files (SOUL.md, AGENTS.md, .cursorrules)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,11 @@ Solution:
|
||||||
_AsyncWorker thread internally, making it safe for both CLI and Atropos use.
|
_AsyncWorker thread internally, making it safe for both CLI and Atropos use.
|
||||||
No monkey-patching is required.
|
No monkey-patching is required.
|
||||||
|
|
||||||
This module is kept for backward compatibility — apply_patches() is now a no-op.
|
This module is kept for backward compatibility. apply_patches() is a no-op.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
Call apply_patches() once at import time (done automatically by hermes_base_env.py).
|
Call apply_patches() once at import time (done automatically by hermes_base_env.py).
|
||||||
This is idempotent — calling it multiple times is safe.
|
This is idempotent and safe to call multiple times.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -26,17 +26,10 @@ _patches_applied = False
|
||||||
|
|
||||||
|
|
||||||
def apply_patches():
|
def apply_patches():
|
||||||
"""Apply all monkey patches needed for Atropos compatibility.
|
"""Apply all monkey patches needed for Atropos compatibility."""
|
||||||
|
|
||||||
Now a no-op — Modal async safety is built directly into ModalEnvironment.
|
|
||||||
Safe to call multiple times.
|
|
||||||
"""
|
|
||||||
global _patches_applied
|
global _patches_applied
|
||||||
if _patches_applied:
|
if _patches_applied:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Modal async-safety is now built into tools/environments/modal.py
|
logger.debug("apply_patches() called; no patches needed (async safety is built-in)")
|
||||||
# via the _AsyncWorker class. No monkey-patching needed.
|
|
||||||
logger.debug("apply_patches() called — no patches needed (async safety is built-in)")
|
|
||||||
|
|
||||||
_patches_applied = True
|
_patches_applied = True
|
||||||
|
|
|
||||||
|
|
@ -1295,6 +1295,89 @@ def _agent_key_is_usable(state: Dict[str, Any], min_ttl_seconds: int) -> bool:
|
||||||
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
|
return not _is_expiring(state.get("agent_key_expires_at"), min_ttl_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_nous_access_token(
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 15.0,
|
||||||
|
insecure: Optional[bool] = None,
|
||||||
|
ca_bundle: Optional[str] = None,
|
||||||
|
refresh_skew_seconds: int = ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||||
|
) -> str:
|
||||||
|
"""Resolve a refresh-aware Nous Portal access token for managed tool gateways."""
|
||||||
|
with _auth_store_lock():
|
||||||
|
auth_store = _load_auth_store()
|
||||||
|
state = _load_provider_state(auth_store, "nous")
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
raise AuthError(
|
||||||
|
"Hermes is not logged into Nous Portal.",
|
||||||
|
provider="nous",
|
||||||
|
relogin_required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
portal_base_url = (
|
||||||
|
_optional_base_url(state.get("portal_base_url"))
|
||||||
|
or os.getenv("HERMES_PORTAL_BASE_URL")
|
||||||
|
or os.getenv("NOUS_PORTAL_BASE_URL")
|
||||||
|
or DEFAULT_NOUS_PORTAL_URL
|
||||||
|
).rstrip("/")
|
||||||
|
client_id = str(state.get("client_id") or DEFAULT_NOUS_CLIENT_ID)
|
||||||
|
verify = _resolve_verify(insecure=insecure, ca_bundle=ca_bundle, auth_state=state)
|
||||||
|
|
||||||
|
access_token = state.get("access_token")
|
||||||
|
refresh_token = state.get("refresh_token")
|
||||||
|
if not isinstance(access_token, str) or not access_token:
|
||||||
|
raise AuthError(
|
||||||
|
"No access token found for Nous Portal login.",
|
||||||
|
provider="nous",
|
||||||
|
relogin_required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _is_expiring(state.get("expires_at"), refresh_skew_seconds):
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
if not isinstance(refresh_token, str) or not refresh_token:
|
||||||
|
raise AuthError(
|
||||||
|
"Session expired and no refresh token is available.",
|
||||||
|
provider="nous",
|
||||||
|
relogin_required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(timeout_seconds if timeout_seconds else 15.0)
|
||||||
|
with httpx.Client(
|
||||||
|
timeout=timeout,
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
verify=verify,
|
||||||
|
) as client:
|
||||||
|
refreshed = _refresh_access_token(
|
||||||
|
client=client,
|
||||||
|
portal_base_url=portal_base_url,
|
||||||
|
client_id=client_id,
|
||||||
|
refresh_token=refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
access_ttl = _coerce_ttl_seconds(refreshed.get("expires_in"))
|
||||||
|
state["access_token"] = refreshed["access_token"]
|
||||||
|
state["refresh_token"] = refreshed.get("refresh_token") or refresh_token
|
||||||
|
state["token_type"] = refreshed.get("token_type") or state.get("token_type") or "Bearer"
|
||||||
|
state["scope"] = refreshed.get("scope") or state.get("scope")
|
||||||
|
state["obtained_at"] = now.isoformat()
|
||||||
|
state["expires_in"] = access_ttl
|
||||||
|
state["expires_at"] = datetime.fromtimestamp(
|
||||||
|
now.timestamp() + access_ttl,
|
||||||
|
tz=timezone.utc,
|
||||||
|
).isoformat()
|
||||||
|
state["portal_base_url"] = portal_base_url
|
||||||
|
state["client_id"] = client_id
|
||||||
|
state["tls"] = {
|
||||||
|
"insecure": verify is False,
|
||||||
|
"ca_bundle": verify if isinstance(verify, str) else None,
|
||||||
|
}
|
||||||
|
_save_provider_state(auth_store, "nous", state)
|
||||||
|
_save_auth_store(auth_store)
|
||||||
|
return state["access_token"]
|
||||||
|
|
||||||
|
|
||||||
def resolve_nous_runtime_credentials(
|
def resolve_nous_runtime_credentials(
|
||||||
*,
|
*,
|
||||||
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
min_key_ttl_seconds: int = DEFAULT_AGENT_KEY_MIN_TTL_SECONDS,
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ DEFAULT_CONFIG = {
|
||||||
|
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"backend": "local",
|
"backend": "local",
|
||||||
|
"modal_mode": "auto",
|
||||||
"cwd": ".", # Use current directory
|
"cwd": ".", # Use current directory
|
||||||
"timeout": 180,
|
"timeout": 180,
|
||||||
# Environment variables to pass through to sandboxed execution
|
# Environment variables to pass through to sandboxed execution
|
||||||
|
|
@ -407,7 +408,7 @@ DEFAULT_CONFIG = {
|
||||||
},
|
},
|
||||||
|
|
||||||
# Config schema version - bump this when adding new required fields
|
# Config schema version - bump this when adding new required fields
|
||||||
"_config_version": 10,
|
"_config_version": 11,
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -422,6 +423,7 @@ ENV_VARS_BY_VERSION: Dict[int, List[str]] = {
|
||||||
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
|
5: ["WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS",
|
||||||
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
|
"SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"],
|
||||||
10: ["TAVILY_API_KEY"],
|
10: ["TAVILY_API_KEY"],
|
||||||
|
11: ["TERMINAL_MODAL_MODE"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Required environment variables with metadata for migration prompts.
|
# Required environment variables with metadata for migration prompts.
|
||||||
|
|
@ -617,6 +619,38 @@ OPTIONAL_ENV_VARS = {
|
||||||
"category": "tool",
|
"category": "tool",
|
||||||
"advanced": True,
|
"advanced": True,
|
||||||
},
|
},
|
||||||
|
"FIRECRAWL_GATEWAY_URL": {
|
||||||
|
"description": "Exact Firecrawl tool-gateway origin override for Nous Subscribers only (optional)",
|
||||||
|
"prompt": "Firecrawl gateway URL (leave empty to derive from domain)",
|
||||||
|
"url": None,
|
||||||
|
"password": False,
|
||||||
|
"category": "tool",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
|
"TOOL_GATEWAY_DOMAIN": {
|
||||||
|
"description": "Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, e.g. nousresearch.com -> firecrawl-gateway.nousresearch.com",
|
||||||
|
"prompt": "Tool-gateway domain suffix",
|
||||||
|
"url": None,
|
||||||
|
"password": False,
|
||||||
|
"category": "tool",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
|
"TOOL_GATEWAY_SCHEME": {
|
||||||
|
"description": "Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts (`https` by default, set `http` for local gateway testing)",
|
||||||
|
"prompt": "Tool-gateway URL scheme",
|
||||||
|
"url": None,
|
||||||
|
"password": False,
|
||||||
|
"category": "tool",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": {
|
||||||
|
"description": "Explicit Nous Subscriber access token for tool-gateway requests (optional; otherwise read from the Hermes auth store)",
|
||||||
|
"prompt": "Tool-gateway user token",
|
||||||
|
"url": None,
|
||||||
|
"password": True,
|
||||||
|
"category": "tool",
|
||||||
|
"advanced": True,
|
||||||
|
},
|
||||||
"TAVILY_API_KEY": {
|
"TAVILY_API_KEY": {
|
||||||
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
"description": "Tavily API key for AI-native web search, extract, and crawl",
|
||||||
"prompt": "Tavily API key",
|
"prompt": "Tavily API key",
|
||||||
|
|
@ -1808,7 +1842,9 @@ def set_config_value(key: str, value: str):
|
||||||
# Check if it's an API key (goes to .env)
|
# Check if it's an API key (goes to .env)
|
||||||
api_keys = [
|
api_keys = [
|
||||||
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
'OPENROUTER_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'VOICE_TOOLS_OPENAI_KEY',
|
||||||
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL', 'TAVILY_API_KEY',
|
'PARALLEL_API_KEY', 'FIRECRAWL_API_KEY', 'FIRECRAWL_API_URL',
|
||||||
|
'FIRECRAWL_GATEWAY_URL', 'TOOL_GATEWAY_DOMAIN', 'TOOL_GATEWAY_SCHEME',
|
||||||
|
'TOOL_GATEWAY_USER_TOKEN', 'TAVILY_API_KEY',
|
||||||
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID', 'BROWSER_USE_API_KEY',
|
||||||
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
'FAL_KEY', 'TELEGRAM_BOT_TOKEN', 'DISCORD_BOT_TOKEN',
|
||||||
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
'TERMINAL_SSH_HOST', 'TERMINAL_SSH_USER', 'TERMINAL_SSH_KEY',
|
||||||
|
|
@ -1864,6 +1900,7 @@ def set_config_value(key: str, value: str):
|
||||||
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
|
||||||
_config_to_env_sync = {
|
_config_to_env_sync = {
|
||||||
"terminal.backend": "TERMINAL_ENV",
|
"terminal.backend": "TERMINAL_ENV",
|
||||||
|
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
|
||||||
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
|
||||||
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
|
||||||
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
|
||||||
|
|
|
||||||
|
|
@ -872,7 +872,7 @@ def cmd_model(args):
|
||||||
if selected_provider == "openrouter":
|
if selected_provider == "openrouter":
|
||||||
_model_flow_openrouter(config, current_model)
|
_model_flow_openrouter(config, current_model)
|
||||||
elif selected_provider == "nous":
|
elif selected_provider == "nous":
|
||||||
_model_flow_nous(config, current_model)
|
_model_flow_nous(config, current_model, args=args)
|
||||||
elif selected_provider == "openai-codex":
|
elif selected_provider == "openai-codex":
|
||||||
_model_flow_openai_codex(config, current_model)
|
_model_flow_openai_codex(config, current_model)
|
||||||
elif selected_provider == "copilot-acp":
|
elif selected_provider == "copilot-acp":
|
||||||
|
|
@ -981,7 +981,7 @@ def _model_flow_openrouter(config, current_model=""):
|
||||||
print("No change.")
|
print("No change.")
|
||||||
|
|
||||||
|
|
||||||
def _model_flow_nous(config, current_model=""):
|
def _model_flow_nous(config, current_model="", args=None):
|
||||||
"""Nous Portal provider: ensure logged in, then pick model."""
|
"""Nous Portal provider: ensure logged in, then pick model."""
|
||||||
from hermes_cli.auth import (
|
from hermes_cli.auth import (
|
||||||
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
|
get_provider_auth_state, _prompt_model_selection, _save_model_choice,
|
||||||
|
|
@ -989,7 +989,11 @@ def _model_flow_nous(config, current_model=""):
|
||||||
fetch_nous_models, AuthError, format_auth_error,
|
fetch_nous_models, AuthError, format_auth_error,
|
||||||
_login_nous, PROVIDER_REGISTRY,
|
_login_nous, PROVIDER_REGISTRY,
|
||||||
)
|
)
|
||||||
from hermes_cli.config import get_env_value, save_env_value
|
from hermes_cli.config import get_env_value, save_config, save_env_value
|
||||||
|
from hermes_cli.nous_subscription import (
|
||||||
|
apply_nous_provider_defaults,
|
||||||
|
get_nous_subscription_explainer_lines,
|
||||||
|
)
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
state = get_provider_auth_state("nous")
|
state = get_provider_auth_state("nous")
|
||||||
|
|
@ -998,11 +1002,19 @@ def _model_flow_nous(config, current_model=""):
|
||||||
print()
|
print()
|
||||||
try:
|
try:
|
||||||
mock_args = argparse.Namespace(
|
mock_args = argparse.Namespace(
|
||||||
portal_url=None, inference_url=None, client_id=None,
|
portal_url=getattr(args, "portal_url", None),
|
||||||
scope=None, no_browser=False, timeout=15.0,
|
inference_url=getattr(args, "inference_url", None),
|
||||||
ca_bundle=None, insecure=False,
|
client_id=getattr(args, "client_id", None),
|
||||||
|
scope=getattr(args, "scope", None),
|
||||||
|
no_browser=bool(getattr(args, "no_browser", False)),
|
||||||
|
timeout=getattr(args, "timeout", None) or 15.0,
|
||||||
|
ca_bundle=getattr(args, "ca_bundle", None),
|
||||||
|
insecure=bool(getattr(args, "insecure", False)),
|
||||||
)
|
)
|
||||||
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
|
||||||
|
print()
|
||||||
|
for line in get_nous_subscription_explainer_lines():
|
||||||
|
print(line)
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
print("Login cancelled or failed.")
|
print("Login cancelled or failed.")
|
||||||
return
|
return
|
||||||
|
|
@ -1049,11 +1061,36 @@ def _model_flow_nous(config, current_model=""):
|
||||||
# Reactivate Nous as the provider and update config
|
# Reactivate Nous as the provider and update config
|
||||||
inference_url = creds.get("base_url", "")
|
inference_url = creds.get("base_url", "")
|
||||||
_update_config_for_provider("nous", inference_url)
|
_update_config_for_provider("nous", inference_url)
|
||||||
|
current_model_cfg = config.get("model")
|
||||||
|
if isinstance(current_model_cfg, dict):
|
||||||
|
model_cfg = dict(current_model_cfg)
|
||||||
|
elif isinstance(current_model_cfg, str) and current_model_cfg.strip():
|
||||||
|
model_cfg = {"default": current_model_cfg.strip()}
|
||||||
|
else:
|
||||||
|
model_cfg = {}
|
||||||
|
model_cfg["provider"] = "nous"
|
||||||
|
model_cfg["default"] = selected
|
||||||
|
if inference_url and inference_url.strip():
|
||||||
|
model_cfg["base_url"] = inference_url.rstrip("/")
|
||||||
|
else:
|
||||||
|
model_cfg.pop("base_url", None)
|
||||||
|
config["model"] = model_cfg
|
||||||
# Clear any custom endpoint that might conflict
|
# Clear any custom endpoint that might conflict
|
||||||
if get_env_value("OPENAI_BASE_URL"):
|
if get_env_value("OPENAI_BASE_URL"):
|
||||||
save_env_value("OPENAI_BASE_URL", "")
|
save_env_value("OPENAI_BASE_URL", "")
|
||||||
save_env_value("OPENAI_API_KEY", "")
|
save_env_value("OPENAI_API_KEY", "")
|
||||||
|
changed_defaults = apply_nous_provider_defaults(config)
|
||||||
|
save_config(config)
|
||||||
print(f"Default model set to: {selected} (via Nous Portal)")
|
print(f"Default model set to: {selected} (via Nous Portal)")
|
||||||
|
if "tts" in changed_defaults:
|
||||||
|
print("TTS provider set to: OpenAI TTS via your Nous subscription")
|
||||||
|
else:
|
||||||
|
current_tts = str(config.get("tts", {}).get("provider") or "edge")
|
||||||
|
if current_tts.lower() not in {"", "edge"}:
|
||||||
|
print(f"Keeping your existing TTS provider: {current_tts}")
|
||||||
|
print()
|
||||||
|
for line in get_nous_subscription_explainer_lines():
|
||||||
|
print(line)
|
||||||
else:
|
else:
|
||||||
print("No change.")
|
print("No change.")
|
||||||
|
|
||||||
|
|
@ -3174,6 +3211,44 @@ For more help on a command:
|
||||||
help="Select default model and provider",
|
help="Select default model and provider",
|
||||||
description="Interactively select your inference provider and default model"
|
description="Interactively select your inference provider and default model"
|
||||||
)
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--portal-url",
|
||||||
|
help="Portal base URL for Nous login (default: production portal)"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--inference-url",
|
||||||
|
help="Inference API base URL for Nous login (default: production inference API)"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--client-id",
|
||||||
|
default=None,
|
||||||
|
help="OAuth client id to use for Nous login (default: hermes-cli)"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--scope",
|
||||||
|
default=None,
|
||||||
|
help="OAuth scope to request for Nous login"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--no-browser",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not attempt to open the browser automatically during Nous login"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--timeout",
|
||||||
|
type=float,
|
||||||
|
default=15.0,
|
||||||
|
help="HTTP request timeout in seconds for Nous login (default: 15)"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--ca-bundle",
|
||||||
|
help="Path to CA bundle PEM file for Nous TLS verification"
|
||||||
|
)
|
||||||
|
model_parser.add_argument(
|
||||||
|
"--insecure",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable TLS verification for Nous login (testing only)"
|
||||||
|
)
|
||||||
model_parser.set_defaults(func=cmd_model)
|
model_parser.set_defaults(func=cmd_model)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
437
hermes_cli/nous_subscription.py
Normal file
437
hermes_cli/nous_subscription.py
Normal file
|
|
@ -0,0 +1,437 @@
|
||||||
|
"""Helpers for Nous subscription managed-tool capabilities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, Optional, Set
|
||||||
|
|
||||||
|
from hermes_cli.auth import get_nous_auth_status
|
||||||
|
from hermes_cli.config import get_env_value, load_config
|
||||||
|
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||||
|
from tools.tool_backend_helpers import (
|
||||||
|
has_direct_modal_credentials,
|
||||||
|
normalize_browser_cloud_provider,
|
||||||
|
normalize_modal_mode,
|
||||||
|
resolve_openai_audio_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_PLATFORM_TOOLSETS = {
|
||||||
|
"cli": "hermes-cli",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NousFeatureState:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
included_by_default: bool
|
||||||
|
available: bool
|
||||||
|
active: bool
|
||||||
|
managed_by_nous: bool
|
||||||
|
direct_override: bool
|
||||||
|
toolset_enabled: bool
|
||||||
|
current_provider: str = ""
|
||||||
|
explicit_configured: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NousSubscriptionFeatures:
|
||||||
|
subscribed: bool
|
||||||
|
nous_auth_present: bool
|
||||||
|
provider_is_nous: bool
|
||||||
|
features: Dict[str, NousFeatureState]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def web(self) -> NousFeatureState:
|
||||||
|
return self.features["web"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_gen(self) -> NousFeatureState:
|
||||||
|
return self.features["image_gen"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tts(self) -> NousFeatureState:
|
||||||
|
return self.features["tts"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def browser(self) -> NousFeatureState:
|
||||||
|
return self.features["browser"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modal(self) -> NousFeatureState:
|
||||||
|
return self.features["modal"]
|
||||||
|
|
||||||
|
def items(self) -> Iterable[NousFeatureState]:
|
||||||
|
ordered = ("web", "image_gen", "tts", "browser", "modal")
|
||||||
|
for key in ordered:
|
||||||
|
yield self.features[key]
|
||||||
|
|
||||||
|
|
||||||
|
def _model_config_dict(config: Dict[str, object]) -> Dict[str, object]:
|
||||||
|
model_cfg = config.get("model")
|
||||||
|
if isinstance(model_cfg, dict):
|
||||||
|
return dict(model_cfg)
|
||||||
|
if isinstance(model_cfg, str) and model_cfg.strip():
|
||||||
|
return {"default": model_cfg.strip()}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
|
||||||
|
from toolsets import resolve_toolset
|
||||||
|
|
||||||
|
platform_toolsets = config.get("platform_toolsets")
|
||||||
|
if not isinstance(platform_toolsets, dict) or not platform_toolsets:
|
||||||
|
platform_toolsets = {"cli": [_DEFAULT_PLATFORM_TOOLSETS["cli"]]}
|
||||||
|
|
||||||
|
target_tools = set(resolve_toolset(toolset_key))
|
||||||
|
if not target_tools:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for platform, raw_toolsets in platform_toolsets.items():
|
||||||
|
if isinstance(raw_toolsets, list):
|
||||||
|
toolset_names = list(raw_toolsets)
|
||||||
|
else:
|
||||||
|
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
|
||||||
|
toolset_names = [default_toolset] if default_toolset else []
|
||||||
|
if not toolset_names:
|
||||||
|
default_toolset = _DEFAULT_PLATFORM_TOOLSETS.get(platform)
|
||||||
|
if default_toolset:
|
||||||
|
toolset_names = [default_toolset]
|
||||||
|
|
||||||
|
available_tools: Set[str] = set()
|
||||||
|
for toolset_name in toolset_names:
|
||||||
|
if not isinstance(toolset_name, str) or not toolset_name:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
available_tools.update(resolve_toolset(toolset_name))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_tools and target_tools.issubset(available_tools):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_agent_browser() -> bool:
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
agent_browser_bin = shutil.which("agent-browser")
|
||||||
|
local_bin = (
|
||||||
|
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||||
|
)
|
||||||
|
return bool(agent_browser_bin or local_bin.exists())
|
||||||
|
|
||||||
|
|
||||||
|
def _browser_label(current_provider: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"browserbase": "Browserbase",
|
||||||
|
"browser-use": "Browser Use",
|
||||||
|
"local": "Local browser",
|
||||||
|
}
|
||||||
|
return mapping.get(current_provider or "local", current_provider or "Local browser")
|
||||||
|
|
||||||
|
|
||||||
|
def _tts_label(current_provider: str) -> str:
|
||||||
|
mapping = {
|
||||||
|
"openai": "OpenAI TTS",
|
||||||
|
"elevenlabs": "ElevenLabs",
|
||||||
|
"edge": "Edge TTS",
|
||||||
|
"neutts": "NeuTTS",
|
||||||
|
}
|
||||||
|
return mapping.get(current_provider or "edge", current_provider or "Edge TTS")
|
||||||
|
def get_nous_subscription_features(
|
||||||
|
config: Optional[Dict[str, object]] = None,
|
||||||
|
) -> NousSubscriptionFeatures:
|
||||||
|
if config is None:
|
||||||
|
config = load_config() or {}
|
||||||
|
config = dict(config)
|
||||||
|
model_cfg = _model_config_dict(config)
|
||||||
|
provider_is_nous = str(model_cfg.get("provider") or "").strip().lower() == "nous"
|
||||||
|
|
||||||
|
try:
|
||||||
|
nous_status = get_nous_auth_status()
|
||||||
|
except Exception:
|
||||||
|
nous_status = {}
|
||||||
|
|
||||||
|
nous_auth_present = bool(nous_status.get("logged_in"))
|
||||||
|
subscribed = provider_is_nous or nous_auth_present
|
||||||
|
|
||||||
|
web_tool_enabled = _toolset_enabled(config, "web")
|
||||||
|
image_tool_enabled = _toolset_enabled(config, "image_gen")
|
||||||
|
tts_tool_enabled = _toolset_enabled(config, "tts")
|
||||||
|
browser_tool_enabled = _toolset_enabled(config, "browser")
|
||||||
|
modal_tool_enabled = _toolset_enabled(config, "terminal")
|
||||||
|
|
||||||
|
web_backend = str(config.get("web", {}).get("backend") or "").strip().lower() if isinstance(config.get("web"), dict) else ""
|
||||||
|
tts_provider = str(config.get("tts", {}).get("provider") or "edge").strip().lower() if isinstance(config.get("tts"), dict) else "edge"
|
||||||
|
browser_provider = normalize_browser_cloud_provider(
|
||||||
|
config.get("browser", {}).get("cloud_provider")
|
||||||
|
if isinstance(config.get("browser"), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
terminal_backend = (
|
||||||
|
str(config.get("terminal", {}).get("backend") or "local").strip().lower()
|
||||||
|
if isinstance(config.get("terminal"), dict)
|
||||||
|
else "local"
|
||||||
|
)
|
||||||
|
modal_mode = normalize_modal_mode(
|
||||||
|
config.get("terminal", {}).get("modal_mode")
|
||||||
|
if isinstance(config.get("terminal"), dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
|
||||||
|
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
|
||||||
|
direct_tavily = bool(get_env_value("TAVILY_API_KEY"))
|
||||||
|
direct_fal = bool(get_env_value("FAL_KEY"))
|
||||||
|
direct_openai_tts = bool(resolve_openai_audio_api_key())
|
||||||
|
direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY"))
|
||||||
|
direct_browserbase = bool(get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
|
||||||
|
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
|
||||||
|
direct_modal = has_direct_modal_credentials()
|
||||||
|
|
||||||
|
managed_web_available = nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
|
||||||
|
managed_image_available = nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
|
||||||
|
managed_tts_available = nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
|
||||||
|
managed_browser_available = nous_auth_present and is_managed_tool_gateway_ready("browserbase")
|
||||||
|
managed_modal_available = nous_auth_present and is_managed_tool_gateway_ready("modal")
|
||||||
|
|
||||||
|
web_managed = web_backend == "firecrawl" and managed_web_available and not direct_firecrawl
|
||||||
|
web_active = bool(
|
||||||
|
web_tool_enabled
|
||||||
|
and (
|
||||||
|
web_managed
|
||||||
|
or (web_backend == "firecrawl" and direct_firecrawl)
|
||||||
|
or (web_backend == "parallel" and direct_parallel)
|
||||||
|
or (web_backend == "tavily" and direct_tavily)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
web_available = bool(
|
||||||
|
managed_web_available or direct_firecrawl or direct_parallel or direct_tavily
|
||||||
|
)
|
||||||
|
|
||||||
|
image_managed = image_tool_enabled and managed_image_available and not direct_fal
|
||||||
|
image_active = bool(image_tool_enabled and (image_managed or direct_fal))
|
||||||
|
image_available = bool(managed_image_available or direct_fal)
|
||||||
|
|
||||||
|
tts_current_provider = tts_provider or "edge"
|
||||||
|
tts_managed = (
|
||||||
|
tts_tool_enabled
|
||||||
|
and tts_current_provider == "openai"
|
||||||
|
and managed_tts_available
|
||||||
|
and not direct_openai_tts
|
||||||
|
)
|
||||||
|
tts_available = bool(
|
||||||
|
tts_current_provider in {"edge", "neutts"}
|
||||||
|
or (tts_current_provider == "openai" and (managed_tts_available or direct_openai_tts))
|
||||||
|
or (tts_current_provider == "elevenlabs" and direct_elevenlabs)
|
||||||
|
)
|
||||||
|
tts_active = bool(tts_tool_enabled and tts_available)
|
||||||
|
|
||||||
|
browser_current_provider = browser_provider or "local"
|
||||||
|
browser_local_available = _has_agent_browser()
|
||||||
|
browser_managed = (
|
||||||
|
browser_tool_enabled
|
||||||
|
and browser_current_provider == "browserbase"
|
||||||
|
and managed_browser_available
|
||||||
|
and not direct_browserbase
|
||||||
|
)
|
||||||
|
browser_available = bool(
|
||||||
|
browser_local_available
|
||||||
|
or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase))
|
||||||
|
or (browser_current_provider == "browser-use" and direct_browser_use)
|
||||||
|
)
|
||||||
|
browser_active = bool(
|
||||||
|
browser_tool_enabled
|
||||||
|
and (
|
||||||
|
(browser_current_provider == "local" and browser_local_available)
|
||||||
|
or (browser_current_provider == "browserbase" and (managed_browser_available or direct_browserbase))
|
||||||
|
or (browser_current_provider == "browser-use" and direct_browser_use)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if terminal_backend != "modal":
|
||||||
|
modal_managed = False
|
||||||
|
modal_available = True
|
||||||
|
modal_active = bool(modal_tool_enabled)
|
||||||
|
modal_direct_override = False
|
||||||
|
elif modal_mode == "managed":
|
||||||
|
modal_managed = bool(modal_tool_enabled and managed_modal_available)
|
||||||
|
modal_available = bool(managed_modal_available)
|
||||||
|
modal_active = bool(modal_tool_enabled and managed_modal_available)
|
||||||
|
modal_direct_override = False
|
||||||
|
elif modal_mode == "direct":
|
||||||
|
modal_managed = False
|
||||||
|
modal_available = bool(direct_modal)
|
||||||
|
modal_active = bool(modal_tool_enabled and direct_modal)
|
||||||
|
modal_direct_override = bool(direct_modal)
|
||||||
|
else:
|
||||||
|
modal_managed = bool(
|
||||||
|
modal_tool_enabled
|
||||||
|
and managed_modal_available
|
||||||
|
and not direct_modal
|
||||||
|
)
|
||||||
|
modal_available = bool(managed_modal_available or direct_modal)
|
||||||
|
modal_active = bool(modal_tool_enabled and (direct_modal or managed_modal_available))
|
||||||
|
modal_direct_override = bool(direct_modal)
|
||||||
|
|
||||||
|
tts_explicit_configured = False
|
||||||
|
raw_tts_cfg = config.get("tts")
|
||||||
|
if isinstance(raw_tts_cfg, dict) and "provider" in raw_tts_cfg:
|
||||||
|
tts_explicit_configured = tts_provider not in {"", "edge"}
|
||||||
|
|
||||||
|
features = {
|
||||||
|
"web": NousFeatureState(
|
||||||
|
key="web",
|
||||||
|
label="Web tools",
|
||||||
|
included_by_default=True,
|
||||||
|
available=web_available,
|
||||||
|
active=web_active,
|
||||||
|
managed_by_nous=web_managed,
|
||||||
|
direct_override=web_active and not web_managed,
|
||||||
|
toolset_enabled=web_tool_enabled,
|
||||||
|
current_provider=web_backend or "",
|
||||||
|
explicit_configured=bool(web_backend),
|
||||||
|
),
|
||||||
|
"image_gen": NousFeatureState(
|
||||||
|
key="image_gen",
|
||||||
|
label="Image generation",
|
||||||
|
included_by_default=True,
|
||||||
|
available=image_available,
|
||||||
|
active=image_active,
|
||||||
|
managed_by_nous=image_managed,
|
||||||
|
direct_override=image_active and not image_managed,
|
||||||
|
toolset_enabled=image_tool_enabled,
|
||||||
|
current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""),
|
||||||
|
explicit_configured=direct_fal,
|
||||||
|
),
|
||||||
|
"tts": NousFeatureState(
|
||||||
|
key="tts",
|
||||||
|
label="OpenAI TTS",
|
||||||
|
included_by_default=True,
|
||||||
|
available=tts_available,
|
||||||
|
active=tts_active,
|
||||||
|
managed_by_nous=tts_managed,
|
||||||
|
direct_override=tts_active and not tts_managed,
|
||||||
|
toolset_enabled=tts_tool_enabled,
|
||||||
|
current_provider=_tts_label(tts_current_provider),
|
||||||
|
explicit_configured=tts_explicit_configured,
|
||||||
|
),
|
||||||
|
"browser": NousFeatureState(
|
||||||
|
key="browser",
|
||||||
|
label="Browser automation",
|
||||||
|
included_by_default=True,
|
||||||
|
available=browser_available,
|
||||||
|
active=browser_active,
|
||||||
|
managed_by_nous=browser_managed,
|
||||||
|
direct_override=browser_active and not browser_managed,
|
||||||
|
toolset_enabled=browser_tool_enabled,
|
||||||
|
current_provider=_browser_label(browser_current_provider),
|
||||||
|
explicit_configured=isinstance(config.get("browser"), dict) and "cloud_provider" in config.get("browser", {}),
|
||||||
|
),
|
||||||
|
"modal": NousFeatureState(
|
||||||
|
key="modal",
|
||||||
|
label="Modal execution",
|
||||||
|
included_by_default=False,
|
||||||
|
available=modal_available,
|
||||||
|
active=modal_active,
|
||||||
|
managed_by_nous=modal_managed,
|
||||||
|
direct_override=terminal_backend == "modal" and modal_direct_override,
|
||||||
|
toolset_enabled=modal_tool_enabled,
|
||||||
|
current_provider="Modal" if terminal_backend == "modal" else terminal_backend or "local",
|
||||||
|
explicit_configured=terminal_backend == "modal",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return NousSubscriptionFeatures(
|
||||||
|
subscribed=subscribed,
|
||||||
|
nous_auth_present=nous_auth_present,
|
||||||
|
provider_is_nous=provider_is_nous,
|
||||||
|
features=features,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nous_subscription_explainer_lines() -> list[str]:
|
||||||
|
return [
|
||||||
|
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
|
||||||
|
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
|
||||||
|
"Change these later with: hermes setup tools, hermes setup terminal, or hermes status.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
|
||||||
|
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
if not features.provider_is_nous:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
tts_cfg = config.get("tts")
|
||||||
|
if not isinstance(tts_cfg, dict):
|
||||||
|
tts_cfg = {}
|
||||||
|
config["tts"] = tts_cfg
|
||||||
|
|
||||||
|
current_tts = str(tts_cfg.get("provider") or "edge").strip().lower()
|
||||||
|
if current_tts not in {"", "edge"}:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
tts_cfg["provider"] = "openai"
|
||||||
|
return {"tts"}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_nous_managed_defaults(
|
||||||
|
config: Dict[str, object],
|
||||||
|
*,
|
||||||
|
enabled_toolsets: Optional[Iterable[str]] = None,
|
||||||
|
) -> set[str]:
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
if not features.provider_is_nous:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
selected_toolsets = set(enabled_toolsets or ())
|
||||||
|
changed: set[str] = set()
|
||||||
|
|
||||||
|
web_cfg = config.get("web")
|
||||||
|
if not isinstance(web_cfg, dict):
|
||||||
|
web_cfg = {}
|
||||||
|
config["web"] = web_cfg
|
||||||
|
|
||||||
|
tts_cfg = config.get("tts")
|
||||||
|
if not isinstance(tts_cfg, dict):
|
||||||
|
tts_cfg = {}
|
||||||
|
config["tts"] = tts_cfg
|
||||||
|
|
||||||
|
browser_cfg = config.get("browser")
|
||||||
|
if not isinstance(browser_cfg, dict):
|
||||||
|
browser_cfg = {}
|
||||||
|
config["browser"] = browser_cfg
|
||||||
|
|
||||||
|
if "web" in selected_toolsets and not features.web.explicit_configured and not (
|
||||||
|
get_env_value("PARALLEL_API_KEY")
|
||||||
|
or get_env_value("TAVILY_API_KEY")
|
||||||
|
or get_env_value("FIRECRAWL_API_KEY")
|
||||||
|
or get_env_value("FIRECRAWL_API_URL")
|
||||||
|
):
|
||||||
|
web_cfg["backend"] = "firecrawl"
|
||||||
|
changed.add("web")
|
||||||
|
|
||||||
|
if "tts" in selected_toolsets and not features.tts.explicit_configured and not (
|
||||||
|
resolve_openai_audio_api_key()
|
||||||
|
or get_env_value("ELEVENLABS_API_KEY")
|
||||||
|
):
|
||||||
|
tts_cfg["provider"] = "openai"
|
||||||
|
changed.add("tts")
|
||||||
|
|
||||||
|
if "browser" in selected_toolsets and not features.browser.explicit_configured and not (
|
||||||
|
get_env_value("BROWSERBASE_API_KEY")
|
||||||
|
or get_env_value("BROWSER_USE_API_KEY")
|
||||||
|
):
|
||||||
|
browser_cfg["cloud_provider"] = "browserbase"
|
||||||
|
changed.add("browser")
|
||||||
|
|
||||||
|
if "image_gen" in selected_toolsets and not get_env_value("FAL_KEY"):
|
||||||
|
changed.add("image_gen")
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
@ -18,6 +18,12 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
from hermes_cli.nous_subscription import (
|
||||||
|
apply_nous_provider_defaults,
|
||||||
|
get_nous_subscription_explainer_lines,
|
||||||
|
get_nous_subscription_features,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
|
@ -52,6 +58,13 @@ def _set_default_model(config: Dict[str, Any], model_name: str) -> None:
|
||||||
config["model"] = model_cfg
|
config["model"] = model_cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _print_nous_subscription_guidance() -> None:
|
||||||
|
print()
|
||||||
|
print_header("Nous Subscription Tools")
|
||||||
|
for line in get_nous_subscription_explainer_lines():
|
||||||
|
print_info(line)
|
||||||
|
|
||||||
|
|
||||||
# Default model lists per provider — used as fallback when the live
|
# Default model lists per provider — used as fallback when the live
|
||||||
# /models endpoint can't be reached.
|
# /models endpoint can't be reached.
|
||||||
_DEFAULT_PROVIDER_MODELS = {
|
_DEFAULT_PROVIDER_MODELS = {
|
||||||
|
|
@ -560,6 +573,7 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
print_header("Tool Availability Summary")
|
print_header("Tool Availability Summary")
|
||||||
|
|
||||||
tool_status = []
|
tool_status = []
|
||||||
|
subscription_features = get_nous_subscription_features(config)
|
||||||
|
|
||||||
# Vision — use the same runtime resolver as the actual vision tools
|
# Vision — use the same runtime resolver as the actual vision tools
|
||||||
try:
|
try:
|
||||||
|
|
@ -581,8 +595,13 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
|
tool_status.append(("Mixture of Agents", False, "OPENROUTER_API_KEY"))
|
||||||
|
|
||||||
# Web tools (Parallel, Firecrawl, or Tavily)
|
# Web tools (Parallel, Firecrawl, or Tavily)
|
||||||
if get_env_value("PARALLEL_API_KEY") or get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL") or get_env_value("TAVILY_API_KEY"):
|
if subscription_features.web.managed_by_nous:
|
||||||
tool_status.append(("Web Search & Extract", True, None))
|
tool_status.append(("Web Search & Extract (Nous subscription)", True, None))
|
||||||
|
elif subscription_features.web.available:
|
||||||
|
label = "Web Search & Extract"
|
||||||
|
if subscription_features.web.current_provider:
|
||||||
|
label = f"Web Search & Extract ({subscription_features.web.current_provider})"
|
||||||
|
tool_status.append((label, True, None))
|
||||||
else:
|
else:
|
||||||
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
|
tool_status.append(("Web Search & Extract", False, "PARALLEL_API_KEY, FIRECRAWL_API_KEY, or TAVILY_API_KEY"))
|
||||||
|
|
||||||
|
|
@ -595,7 +614,9 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||||
).exists()
|
).exists()
|
||||||
)
|
)
|
||||||
if get_env_value("BROWSERBASE_API_KEY"):
|
if subscription_features.browser.managed_by_nous:
|
||||||
|
tool_status.append(("Browser Automation (Nous Browserbase)", True, None))
|
||||||
|
elif subscription_features.browser.current_provider == "Browserbase" and subscription_features.browser.available:
|
||||||
tool_status.append(("Browser Automation (Browserbase)", True, None))
|
tool_status.append(("Browser Automation (Browserbase)", True, None))
|
||||||
elif _ab_found:
|
elif _ab_found:
|
||||||
tool_status.append(("Browser Automation (local)", True, None))
|
tool_status.append(("Browser Automation (local)", True, None))
|
||||||
|
|
@ -605,16 +626,22 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
)
|
)
|
||||||
|
|
||||||
# FAL (image generation)
|
# FAL (image generation)
|
||||||
if get_env_value("FAL_KEY"):
|
if subscription_features.image_gen.managed_by_nous:
|
||||||
|
tool_status.append(("Image Generation (Nous subscription)", True, None))
|
||||||
|
elif subscription_features.image_gen.available:
|
||||||
tool_status.append(("Image Generation", True, None))
|
tool_status.append(("Image Generation", True, None))
|
||||||
else:
|
else:
|
||||||
tool_status.append(("Image Generation", False, "FAL_KEY"))
|
tool_status.append(("Image Generation", False, "FAL_KEY"))
|
||||||
|
|
||||||
# TTS — show configured provider
|
# TTS — show configured provider
|
||||||
tts_provider = config.get("tts", {}).get("provider", "edge")
|
tts_provider = config.get("tts", {}).get("provider", "edge")
|
||||||
if tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
|
if subscription_features.tts.managed_by_nous:
|
||||||
|
tool_status.append(("Text-to-Speech (OpenAI via Nous subscription)", True, None))
|
||||||
|
elif tts_provider == "elevenlabs" and get_env_value("ELEVENLABS_API_KEY"):
|
||||||
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
|
tool_status.append(("Text-to-Speech (ElevenLabs)", True, None))
|
||||||
elif tts_provider == "openai" and get_env_value("VOICE_TOOLS_OPENAI_KEY"):
|
elif tts_provider == "openai" and (
|
||||||
|
get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
|
||||||
|
):
|
||||||
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
|
tool_status.append(("Text-to-Speech (OpenAI)", True, None))
|
||||||
elif tts_provider == "neutts":
|
elif tts_provider == "neutts":
|
||||||
try:
|
try:
|
||||||
|
|
@ -629,6 +656,16 @@ def _print_setup_summary(config: dict, hermes_home):
|
||||||
else:
|
else:
|
||||||
tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
|
tool_status.append(("Text-to-Speech (Edge TTS)", True, None))
|
||||||
|
|
||||||
|
if subscription_features.modal.managed_by_nous:
|
||||||
|
tool_status.append(("Modal Execution (Nous subscription)", True, None))
|
||||||
|
elif config.get("terminal", {}).get("backend") == "modal":
|
||||||
|
if subscription_features.modal.direct_override:
|
||||||
|
tool_status.append(("Modal Execution (direct Modal)", True, None))
|
||||||
|
else:
|
||||||
|
tool_status.append(("Modal Execution", False, "run 'hermes setup terminal'"))
|
||||||
|
elif subscription_features.nous_auth_present:
|
||||||
|
tool_status.append(("Modal Execution (optional via Nous subscription)", True, None))
|
||||||
|
|
||||||
# Tinker + WandB (RL training)
|
# Tinker + WandB (RL training)
|
||||||
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
|
if get_env_value("TINKER_API_KEY") and get_env_value("WANDB_API_KEY"):
|
||||||
tool_status.append(("RL Training (Tinker)", True, None))
|
tool_status.append(("RL Training (Tinker)", True, None))
|
||||||
|
|
@ -905,6 +942,7 @@ def setup_model_provider(config: dict):
|
||||||
)
|
)
|
||||||
selected_base_url = None # deferred until after model selection
|
selected_base_url = None # deferred until after model selection
|
||||||
nous_models = [] # populated if Nous login succeeds
|
nous_models = [] # populated if Nous login succeeds
|
||||||
|
nous_subscription_selected = False
|
||||||
|
|
||||||
if provider_idx == 0: # OpenRouter
|
if provider_idx == 0: # OpenRouter
|
||||||
selected_provider = "openrouter"
|
selected_provider = "openrouter"
|
||||||
|
|
@ -1000,6 +1038,9 @@ def setup_model_provider(config: dict):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not fetch Nous models after login: %s", e)
|
logger.debug("Could not fetch Nous models after login: %s", e)
|
||||||
|
|
||||||
|
nous_subscription_selected = True
|
||||||
|
_print_nous_subscription_guidance()
|
||||||
|
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
print_warning("Nous Portal login was cancelled or failed.")
|
print_warning("Nous Portal login was cancelled or failed.")
|
||||||
print_info("You can try again later with: hermes model")
|
print_info("You can try again later with: hermes model")
|
||||||
|
|
@ -1773,9 +1814,19 @@ def setup_model_provider(config: dict):
|
||||||
if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None:
|
if selected_provider in ("copilot-acp", "copilot", "zai", "kimi-coding", "minimax", "minimax-cn", "kilocode", "anthropic") and selected_base_url is not None:
|
||||||
_update_config_for_provider(selected_provider, selected_base_url)
|
_update_config_for_provider(selected_provider, selected_base_url)
|
||||||
|
|
||||||
|
if selected_provider == "nous" and nous_subscription_selected:
|
||||||
|
changed_defaults = apply_nous_provider_defaults(config)
|
||||||
|
current_tts = str(config.get("tts", {}).get("provider") or "edge")
|
||||||
|
if "tts" in changed_defaults:
|
||||||
|
print_success("TTS provider set to: OpenAI TTS via your Nous subscription")
|
||||||
|
else:
|
||||||
|
print_info(f"Keeping your existing TTS provider: {current_tts}")
|
||||||
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
# Offer TTS provider selection at the end of model setup
|
# Offer TTS provider selection at the end of model setup, except when
|
||||||
|
# Nous subscription defaults are already being applied.
|
||||||
|
if selected_provider != "nous":
|
||||||
_setup_tts_provider(config)
|
_setup_tts_provider(config)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1844,6 +1895,7 @@ def _setup_tts_provider(config: dict):
|
||||||
"""Interactive TTS provider selection with install flow for NeuTTS."""
|
"""Interactive TTS provider selection with install flow for NeuTTS."""
|
||||||
tts_config = config.get("tts", {})
|
tts_config = config.get("tts", {})
|
||||||
current_provider = tts_config.get("provider", "edge")
|
current_provider = tts_config.get("provider", "edge")
|
||||||
|
subscription_features = get_nous_subscription_features(config)
|
||||||
|
|
||||||
provider_labels = {
|
provider_labels = {
|
||||||
"edge": "Edge TTS",
|
"edge": "Edge TTS",
|
||||||
|
|
@ -1858,20 +1910,36 @@ def _setup_tts_provider(config: dict):
|
||||||
print_info(f"Current: {current_label}")
|
print_info(f"Current: {current_label}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
choices = [
|
choices = []
|
||||||
|
providers = []
|
||||||
|
if 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)",
|
"Edge TTS (free, cloud-based, no setup needed)",
|
||||||
"ElevenLabs (premium quality, needs API key)",
|
"ElevenLabs (premium quality, needs API key)",
|
||||||
"OpenAI TTS (good quality, needs API key)",
|
"OpenAI TTS (good quality, needs API key)",
|
||||||
"NeuTTS (local on-device, free, ~300MB model download)",
|
"NeuTTS (local on-device, free, ~300MB model download)",
|
||||||
f"Keep current ({current_label})",
|
|
||||||
]
|
]
|
||||||
idx = prompt_choice("Select TTS provider:", choices, len(choices) - 1)
|
)
|
||||||
|
providers.extend(["edge", "elevenlabs", "openai", "neutts"])
|
||||||
|
choices.append(f"Keep current ({current_label})")
|
||||||
|
keep_current_idx = len(choices) - 1
|
||||||
|
idx = prompt_choice("Select TTS provider:", choices, keep_current_idx)
|
||||||
|
|
||||||
if idx == 4: # Keep current
|
if idx == keep_current_idx:
|
||||||
return
|
return
|
||||||
|
|
||||||
providers = ["edge", "elevenlabs", "openai", "neutts"]
|
|
||||||
selected = providers[idx]
|
selected = providers[idx]
|
||||||
|
selected_via_nous = selected == "nous-openai"
|
||||||
|
if selected == "nous-openai":
|
||||||
|
selected = "openai"
|
||||||
|
print_info("OpenAI TTS will use the managed Nous gateway and bill to your subscription.")
|
||||||
|
if get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY"):
|
||||||
|
print_warning(
|
||||||
|
"Direct OpenAI credentials are still configured and may take precedence until removed from ~/.hermes/.env."
|
||||||
|
)
|
||||||
|
|
||||||
if selected == "neutts":
|
if selected == "neutts":
|
||||||
# Check if already installed
|
# Check if already installed
|
||||||
|
|
@ -1909,8 +1977,8 @@ def _setup_tts_provider(config: dict):
|
||||||
print_warning("No API key provided. Falling back to Edge TTS.")
|
print_warning("No API key provided. Falling back to Edge TTS.")
|
||||||
selected = "edge"
|
selected = "edge"
|
||||||
|
|
||||||
elif selected == "openai":
|
elif selected == "openai" and not selected_via_nous:
|
||||||
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY")
|
existing = get_env_value("VOICE_TOOLS_OPENAI_KEY") or get_env_value("OPENAI_API_KEY")
|
||||||
if not existing:
|
if not existing:
|
||||||
print()
|
print()
|
||||||
api_key = prompt("OpenAI API key for TTS", password=True)
|
api_key = prompt("OpenAI API key for TTS", password=True)
|
||||||
|
|
@ -2065,6 +2133,42 @@ def setup_terminal_backend(config: dict):
|
||||||
elif selected_backend == "modal":
|
elif selected_backend == "modal":
|
||||||
print_success("Terminal backend: Modal")
|
print_success("Terminal backend: Modal")
|
||||||
print_info("Serverless cloud sandboxes. Each session gets its own container.")
|
print_info("Serverless cloud sandboxes. Each session gets its own container.")
|
||||||
|
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||||
|
from tools.tool_backend_helpers import normalize_modal_mode
|
||||||
|
|
||||||
|
managed_modal_available = bool(
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
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")
|
print_info("Requires a Modal account: https://modal.com")
|
||||||
|
|
||||||
# Check if swe-rex[modal] is installed
|
# Check if swe-rex[modal] is installed
|
||||||
|
|
@ -2235,6 +2339,8 @@ def setup_terminal_backend(config: dict):
|
||||||
# Sync terminal backend to .env so terminal_tool picks it up directly.
|
# Sync terminal backend to .env so terminal_tool picks it up directly.
|
||||||
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
|
# config.yaml is the source of truth, but terminal_tool reads TERMINAL_ENV.
|
||||||
save_env_value("TERMINAL_ENV", selected_backend)
|
save_env_value("TERMINAL_ENV", selected_backend)
|
||||||
|
if selected_backend == "modal":
|
||||||
|
save_env_value("TERMINAL_MODAL_MODE", config["terminal"].get("modal_mode", "auto"))
|
||||||
save_config(config)
|
save_config(config)
|
||||||
print()
|
print()
|
||||||
print_success(f"Terminal backend set to: {selected_backend}")
|
print_success(f"Terminal backend set to: {selected_backend}")
|
||||||
|
|
@ -3089,6 +3195,17 @@ SETUP_SECTIONS = [
|
||||||
("agent", "Agent Settings", setup_agent_settings),
|
("agent", "Agent Settings", setup_agent_settings),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# The returning-user menu intentionally omits standalone TTS because model setup
|
||||||
|
# already includes TTS selection and tools setup covers the rest of the provider
|
||||||
|
# configuration. Keep this list in the same order as the visible menu entries.
|
||||||
|
RETURNING_USER_MENU_SECTION_KEYS = [
|
||||||
|
"model",
|
||||||
|
"terminal",
|
||||||
|
"gateway",
|
||||||
|
"tools",
|
||||||
|
"agent",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def run_setup_wizard(args):
|
def run_setup_wizard(args):
|
||||||
"""Run the interactive setup wizard.
|
"""Run the interactive setup wizard.
|
||||||
|
|
@ -3237,8 +3354,7 @@ def run_setup_wizard(args):
|
||||||
# Individual section — map by key, not by position.
|
# Individual section — map by key, not by position.
|
||||||
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
|
# SETUP_SECTIONS includes TTS but the returning-user menu skips it,
|
||||||
# so positional indexing (choice - 3) would dispatch the wrong section.
|
# so positional indexing (choice - 3) would dispatch the wrong section.
|
||||||
_RETURNING_USER_SECTION_KEYS = ["model", "terminal", "gateway", "tools", "agent"]
|
section_key = RETURNING_USER_MENU_SECTION_KEYS[choice - 3]
|
||||||
section_key = _RETURNING_USER_SECTION_KEYS[choice - 3]
|
|
||||||
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
|
section = next((s for s in SETUP_SECTIONS if s[0] == section_key), None)
|
||||||
if section:
|
if section:
|
||||||
_, label, func = section
|
_, label, func = section
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from hermes_cli.auth import AuthError, resolve_provider
|
||||||
from hermes_cli.colors import Colors, color
|
from hermes_cli.colors import Colors, color
|
||||||
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config
|
||||||
from hermes_cli.models import provider_label
|
from hermes_cli.models import provider_label
|
||||||
|
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||||
from hermes_constants import OPENROUTER_MODELS_URL
|
from hermes_constants import OPENROUTER_MODELS_URL
|
||||||
|
|
||||||
|
|
@ -186,6 +187,30 @@ def show_status(args):
|
||||||
if codex_status.get("error") and not codex_logged_in:
|
if codex_status.get("error") and not codex_logged_in:
|
||||||
print(f" Error: {codex_status.get('error')}")
|
print(f" Error: {codex_status.get('error')}")
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Nous Subscription Features
|
||||||
|
# =========================================================================
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
print()
|
||||||
|
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
|
||||||
|
if not features.nous_auth_present:
|
||||||
|
print(" Nous Portal ✗ not logged in")
|
||||||
|
else:
|
||||||
|
print(" Nous Portal ✓ managed tools available")
|
||||||
|
for feature in features.items():
|
||||||
|
if feature.managed_by_nous:
|
||||||
|
state = "active via Nous subscription"
|
||||||
|
elif feature.active:
|
||||||
|
current = feature.current_provider or "configured provider"
|
||||||
|
state = f"active via {current}"
|
||||||
|
elif feature.included_by_default and features.nous_auth_present:
|
||||||
|
state = "included by subscription, not currently selected"
|
||||||
|
elif feature.key == "modal" and features.nous_auth_present:
|
||||||
|
state = "available via subscription (optional)"
|
||||||
|
else:
|
||||||
|
state = "not configured"
|
||||||
|
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# API-Key Providers
|
# API-Key Providers
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,10 @@ from hermes_cli.config import (
|
||||||
load_config, save_config, get_env_value, save_env_value,
|
load_config, save_config, get_env_value, save_env_value,
|
||||||
)
|
)
|
||||||
from hermes_cli.colors import Colors, color
|
from hermes_cli.colors import Colors, color
|
||||||
|
from hermes_cli.nous_subscription import (
|
||||||
|
apply_nous_managed_defaults,
|
||||||
|
get_nous_subscription_features,
|
||||||
|
)
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
|
@ -146,6 +150,15 @@ TOOL_CATEGORIES = {
|
||||||
"name": "Text-to-Speech",
|
"name": "Text-to-Speech",
|
||||||
"icon": "🔊",
|
"icon": "🔊",
|
||||||
"providers": [
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "Nous Subscription",
|
||||||
|
"tag": "Managed OpenAI TTS billed to your subscription",
|
||||||
|
"env_vars": [],
|
||||||
|
"tts_provider": "openai",
|
||||||
|
"requires_nous_auth": True,
|
||||||
|
"managed_nous_feature": "tts",
|
||||||
|
"override_env_vars": ["VOICE_TOOLS_OPENAI_KEY", "OPENAI_API_KEY"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Microsoft Edge TTS",
|
"name": "Microsoft Edge TTS",
|
||||||
"tag": "Free - no API key needed",
|
"tag": "Free - no API key needed",
|
||||||
|
|
@ -176,6 +189,15 @@ TOOL_CATEGORIES = {
|
||||||
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
|
"setup_note": "A free DuckDuckGo search skill is also included — skip this if you don't need a premium provider.",
|
||||||
"icon": "🔍",
|
"icon": "🔍",
|
||||||
"providers": [
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "Nous Subscription",
|
||||||
|
"tag": "Managed Firecrawl billed to your subscription",
|
||||||
|
"web_backend": "firecrawl",
|
||||||
|
"env_vars": [],
|
||||||
|
"requires_nous_auth": True,
|
||||||
|
"managed_nous_feature": "web",
|
||||||
|
"override_env_vars": ["FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Firecrawl Cloud",
|
"name": "Firecrawl Cloud",
|
||||||
"tag": "Hosted service - search, extract, and crawl",
|
"tag": "Hosted service - search, extract, and crawl",
|
||||||
|
|
@ -214,6 +236,14 @@ TOOL_CATEGORIES = {
|
||||||
"name": "Image Generation",
|
"name": "Image Generation",
|
||||||
"icon": "🎨",
|
"icon": "🎨",
|
||||||
"providers": [
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "Nous Subscription",
|
||||||
|
"tag": "Managed FAL image generation billed to your subscription",
|
||||||
|
"env_vars": [],
|
||||||
|
"requires_nous_auth": True,
|
||||||
|
"managed_nous_feature": "image_gen",
|
||||||
|
"override_env_vars": ["FAL_KEY"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "FAL.ai",
|
"name": "FAL.ai",
|
||||||
"tag": "FLUX 2 Pro with auto-upscaling",
|
"tag": "FLUX 2 Pro with auto-upscaling",
|
||||||
|
|
@ -227,11 +257,21 @@ TOOL_CATEGORIES = {
|
||||||
"name": "Browser Automation",
|
"name": "Browser Automation",
|
||||||
"icon": "🌐",
|
"icon": "🌐",
|
||||||
"providers": [
|
"providers": [
|
||||||
|
{
|
||||||
|
"name": "Nous Subscription (Browserbase cloud)",
|
||||||
|
"tag": "Managed Browserbase billed to your subscription",
|
||||||
|
"env_vars": [],
|
||||||
|
"browser_provider": "browserbase",
|
||||||
|
"requires_nous_auth": True,
|
||||||
|
"managed_nous_feature": "browser",
|
||||||
|
"override_env_vars": ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
|
||||||
|
"post_setup": "browserbase",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Local Browser",
|
"name": "Local Browser",
|
||||||
"tag": "Free headless Chromium (no API key needed)",
|
"tag": "Free headless Chromium (no API key needed)",
|
||||||
"env_vars": [],
|
"env_vars": [],
|
||||||
"browser_provider": None,
|
"browser_provider": "local",
|
||||||
"post_setup": "browserbase", # Same npm install for agent-browser
|
"post_setup": "browserbase", # Same npm install for agent-browser
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -475,8 +515,11 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
|
|
||||||
def _toolset_has_keys(ts_key: str) -> bool:
|
def _toolset_has_keys(ts_key: str, config: dict = None) -> bool:
|
||||||
"""Check if a toolset's required API keys are configured."""
|
"""Check if a toolset's required API keys are configured."""
|
||||||
|
if config is None:
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
if ts_key == "vision":
|
if ts_key == "vision":
|
||||||
try:
|
try:
|
||||||
from agent.auxiliary_client import resolve_vision_provider_client
|
from agent.auxiliary_client import resolve_vision_provider_client
|
||||||
|
|
@ -486,10 +529,16 @@ def _toolset_has_keys(ts_key: str) -> bool:
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if ts_key in {"web", "image_gen", "tts", "browser"}:
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
feature = features.features.get(ts_key)
|
||||||
|
if feature and (feature.available or feature.managed_by_nous):
|
||||||
|
return True
|
||||||
|
|
||||||
# Check TOOL_CATEGORIES first (provider-aware)
|
# Check TOOL_CATEGORIES first (provider-aware)
|
||||||
cat = TOOL_CATEGORIES.get(ts_key)
|
cat = TOOL_CATEGORIES.get(ts_key)
|
||||||
if cat:
|
if cat:
|
||||||
for provider in cat.get("providers", []):
|
for provider in _visible_providers(cat, config):
|
||||||
env_vars = provider.get("env_vars", [])
|
env_vars = provider.get("env_vars", [])
|
||||||
if env_vars and all(get_env_value(e["key"]) for e in env_vars):
|
if env_vars and all(get_env_value(e["key"]) for e in env_vars):
|
||||||
return True
|
return True
|
||||||
|
|
@ -629,11 +678,43 @@ def _configure_toolset(ts_key: str, config: dict):
|
||||||
_configure_simple_requirements(ts_key)
|
_configure_simple_requirements(ts_key)
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_providers(cat: dict, config: dict) -> list[dict]:
|
||||||
|
"""Return provider entries visible for the current auth/config state."""
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
visible = []
|
||||||
|
for provider in cat.get("providers", []):
|
||||||
|
if provider.get("requires_nous_auth") and not features.nous_auth_present:
|
||||||
|
continue
|
||||||
|
visible.append(provider)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
def _toolset_needs_configuration_prompt(ts_key: str, config: dict) -> bool:
|
||||||
|
"""Return True when enabling this toolset should open provider setup."""
|
||||||
|
cat = TOOL_CATEGORIES.get(ts_key)
|
||||||
|
if not cat:
|
||||||
|
return not _toolset_has_keys(ts_key, config)
|
||||||
|
|
||||||
|
if ts_key == "tts":
|
||||||
|
tts_cfg = config.get("tts", {})
|
||||||
|
return not isinstance(tts_cfg, dict) or "provider" not in tts_cfg
|
||||||
|
if ts_key == "web":
|
||||||
|
web_cfg = config.get("web", {})
|
||||||
|
return not isinstance(web_cfg, dict) or "backend" not in web_cfg
|
||||||
|
if ts_key == "browser":
|
||||||
|
browser_cfg = config.get("browser", {})
|
||||||
|
return not isinstance(browser_cfg, dict) or "cloud_provider" not in browser_cfg
|
||||||
|
if ts_key == "image_gen":
|
||||||
|
return not get_env_value("FAL_KEY")
|
||||||
|
|
||||||
|
return not _toolset_has_keys(ts_key, config)
|
||||||
|
|
||||||
|
|
||||||
def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||||
"""Configure a tool category with provider selection."""
|
"""Configure a tool category with provider selection."""
|
||||||
icon = cat.get("icon", "")
|
icon = cat.get("icon", "")
|
||||||
name = cat["name"]
|
name = cat["name"]
|
||||||
providers = cat["providers"]
|
providers = _visible_providers(cat, config)
|
||||||
|
|
||||||
# Check Python version requirement
|
# Check Python version requirement
|
||||||
if cat.get("requires_python"):
|
if cat.get("requires_python"):
|
||||||
|
|
@ -698,6 +779,27 @@ def _configure_tool_category(ts_key: str, cat: dict, config: dict):
|
||||||
|
|
||||||
def _is_provider_active(provider: dict, config: dict) -> bool:
|
def _is_provider_active(provider: dict, config: dict) -> bool:
|
||||||
"""Check if a provider entry matches the currently active config."""
|
"""Check if a provider entry matches the currently active config."""
|
||||||
|
managed_feature = provider.get("managed_nous_feature")
|
||||||
|
if managed_feature:
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
feature = features.features.get(managed_feature)
|
||||||
|
if feature is None:
|
||||||
|
return False
|
||||||
|
if managed_feature == "image_gen":
|
||||||
|
return feature.managed_by_nous
|
||||||
|
if provider.get("tts_provider"):
|
||||||
|
return (
|
||||||
|
feature.managed_by_nous
|
||||||
|
and config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||||
|
)
|
||||||
|
if "browser_provider" in provider:
|
||||||
|
current = config.get("browser", {}).get("cloud_provider")
|
||||||
|
return feature.managed_by_nous and provider["browser_provider"] == current
|
||||||
|
if provider.get("web_backend"):
|
||||||
|
current = config.get("web", {}).get("backend")
|
||||||
|
return feature.managed_by_nous and current == provider["web_backend"]
|
||||||
|
return feature.managed_by_nous
|
||||||
|
|
||||||
if provider.get("tts_provider"):
|
if provider.get("tts_provider"):
|
||||||
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
return config.get("tts", {}).get("provider") == provider["tts_provider"]
|
||||||
if "browser_provider" in provider:
|
if "browser_provider" in provider:
|
||||||
|
|
@ -724,6 +826,13 @@ def _detect_active_provider_index(providers: list, config: dict) -> int:
|
||||||
def _configure_provider(provider: dict, config: dict):
|
def _configure_provider(provider: dict, config: dict):
|
||||||
"""Configure a single provider - prompt for API keys and set config."""
|
"""Configure a single provider - prompt for API keys and set config."""
|
||||||
env_vars = provider.get("env_vars", [])
|
env_vars = provider.get("env_vars", [])
|
||||||
|
managed_feature = provider.get("managed_nous_feature")
|
||||||
|
|
||||||
|
if provider.get("requires_nous_auth"):
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
if not features.nous_auth_present:
|
||||||
|
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||||
|
return
|
||||||
|
|
||||||
# Set TTS provider in config if applicable
|
# Set TTS provider in config if applicable
|
||||||
if provider.get("tts_provider"):
|
if provider.get("tts_provider"):
|
||||||
|
|
@ -732,11 +841,12 @@ def _configure_provider(provider: dict, config: dict):
|
||||||
# Set browser cloud provider in config if applicable
|
# Set browser cloud provider in config if applicable
|
||||||
if "browser_provider" in provider:
|
if "browser_provider" in provider:
|
||||||
bp = provider["browser_provider"]
|
bp = provider["browser_provider"]
|
||||||
if bp:
|
if bp == "local":
|
||||||
|
config.setdefault("browser", {})["cloud_provider"] = "local"
|
||||||
|
_print_success(" Browser set to local mode")
|
||||||
|
elif bp:
|
||||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||||
_print_success(f" Browser cloud provider set to: {bp}")
|
_print_success(f" Browser cloud provider set to: {bp}")
|
||||||
else:
|
|
||||||
config.get("browser", {}).pop("cloud_provider", None)
|
|
||||||
|
|
||||||
# Set web search backend in config if applicable
|
# Set web search backend in config if applicable
|
||||||
if provider.get("web_backend"):
|
if provider.get("web_backend"):
|
||||||
|
|
@ -744,7 +854,16 @@ def _configure_provider(provider: dict, config: dict):
|
||||||
_print_success(f" Web backend set to: {provider['web_backend']}")
|
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||||
|
|
||||||
if not env_vars:
|
if not env_vars:
|
||||||
|
if provider.get("post_setup"):
|
||||||
|
_run_post_setup(provider["post_setup"])
|
||||||
_print_success(f" {provider['name']} - no configuration needed!")
|
_print_success(f" {provider['name']} - no configuration needed!")
|
||||||
|
if managed_feature:
|
||||||
|
_print_info(" Requests for this tool will be billed to your Nous subscription.")
|
||||||
|
override_envs = provider.get("override_env_vars", [])
|
||||||
|
if any(get_env_value(env_var) for env_var in override_envs):
|
||||||
|
_print_warning(
|
||||||
|
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Prompt for each required env var
|
# Prompt for each required env var
|
||||||
|
|
@ -847,7 +966,7 @@ def _reconfigure_tool(config: dict):
|
||||||
cat = TOOL_CATEGORIES.get(ts_key)
|
cat = TOOL_CATEGORIES.get(ts_key)
|
||||||
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
|
reqs = TOOLSET_ENV_REQUIREMENTS.get(ts_key)
|
||||||
if cat or reqs:
|
if cat or reqs:
|
||||||
if _toolset_has_keys(ts_key):
|
if _toolset_has_keys(ts_key, config):
|
||||||
configurable.append((ts_key, ts_label))
|
configurable.append((ts_key, ts_label))
|
||||||
|
|
||||||
if not configurable:
|
if not configurable:
|
||||||
|
|
@ -877,7 +996,7 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||||
"""Reconfigure a tool category - provider selection + API key update."""
|
"""Reconfigure a tool category - provider selection + API key update."""
|
||||||
icon = cat.get("icon", "")
|
icon = cat.get("icon", "")
|
||||||
name = cat["name"]
|
name = cat["name"]
|
||||||
providers = cat["providers"]
|
providers = _visible_providers(cat, config)
|
||||||
|
|
||||||
if len(providers) == 1:
|
if len(providers) == 1:
|
||||||
provider = providers[0]
|
provider = providers[0]
|
||||||
|
|
@ -912,6 +1031,13 @@ def _configure_tool_category_for_reconfig(ts_key: str, cat: dict, config: dict):
|
||||||
def _reconfigure_provider(provider: dict, config: dict):
|
def _reconfigure_provider(provider: dict, config: dict):
|
||||||
"""Reconfigure a provider - update API keys."""
|
"""Reconfigure a provider - update API keys."""
|
||||||
env_vars = provider.get("env_vars", [])
|
env_vars = provider.get("env_vars", [])
|
||||||
|
managed_feature = provider.get("managed_nous_feature")
|
||||||
|
|
||||||
|
if provider.get("requires_nous_auth"):
|
||||||
|
features = get_nous_subscription_features(config)
|
||||||
|
if not features.nous_auth_present:
|
||||||
|
_print_warning(" Nous Subscription is only available after logging into Nous Portal.")
|
||||||
|
return
|
||||||
|
|
||||||
if provider.get("tts_provider"):
|
if provider.get("tts_provider"):
|
||||||
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
|
||||||
|
|
@ -919,12 +1045,12 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||||
|
|
||||||
if "browser_provider" in provider:
|
if "browser_provider" in provider:
|
||||||
bp = provider["browser_provider"]
|
bp = provider["browser_provider"]
|
||||||
if bp:
|
if bp == "local":
|
||||||
|
config.setdefault("browser", {})["cloud_provider"] = "local"
|
||||||
|
_print_success(" Browser set to local mode")
|
||||||
|
elif bp:
|
||||||
config.setdefault("browser", {})["cloud_provider"] = bp
|
config.setdefault("browser", {})["cloud_provider"] = bp
|
||||||
_print_success(f" Browser cloud provider set to: {bp}")
|
_print_success(f" Browser cloud provider set to: {bp}")
|
||||||
else:
|
|
||||||
config.get("browser", {}).pop("cloud_provider", None)
|
|
||||||
_print_success(" Browser set to local mode")
|
|
||||||
|
|
||||||
# Set web search backend in config if applicable
|
# Set web search backend in config if applicable
|
||||||
if provider.get("web_backend"):
|
if provider.get("web_backend"):
|
||||||
|
|
@ -932,7 +1058,16 @@ def _reconfigure_provider(provider: dict, config: dict):
|
||||||
_print_success(f" Web backend set to: {provider['web_backend']}")
|
_print_success(f" Web backend set to: {provider['web_backend']}")
|
||||||
|
|
||||||
if not env_vars:
|
if not env_vars:
|
||||||
|
if provider.get("post_setup"):
|
||||||
|
_run_post_setup(provider["post_setup"])
|
||||||
_print_success(f" {provider['name']} - no configuration needed!")
|
_print_success(f" {provider['name']} - no configuration needed!")
|
||||||
|
if managed_feature:
|
||||||
|
_print_info(" Requests for this tool will be billed to your Nous subscription.")
|
||||||
|
override_envs = provider.get("override_env_vars", [])
|
||||||
|
if any(get_env_value(env_var) for env_var in override_envs):
|
||||||
|
_print_warning(
|
||||||
|
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
for var in env_vars:
|
for var in env_vars:
|
||||||
|
|
@ -1041,13 +1176,22 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
|
label = next((l for k, l, _ in _get_effective_configurable_toolsets() if k == ts), ts)
|
||||||
print(color(f" - {label}", Colors.RED))
|
print(color(f" - {label}", Colors.RED))
|
||||||
|
|
||||||
|
auto_configured = apply_nous_managed_defaults(
|
||||||
|
config,
|
||||||
|
enabled_toolsets=new_enabled,
|
||||||
|
)
|
||||||
|
for ts_key in sorted(auto_configured):
|
||||||
|
label = next((l for k, l, _ in CONFIGURABLE_TOOLSETS if k == ts_key), ts_key)
|
||||||
|
print(color(f" ✓ {label}: using your Nous subscription defaults", Colors.GREEN))
|
||||||
|
|
||||||
# Walk through ALL selected tools that have provider options or
|
# Walk through ALL selected tools that have provider options or
|
||||||
# need API keys. This ensures browser (Local vs Browserbase),
|
# need API keys. This ensures browser (Local vs Browserbase),
|
||||||
# TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
|
# TTS (Edge vs OpenAI vs ElevenLabs), etc. are shown even when
|
||||||
# a free provider exists.
|
# a free provider exists.
|
||||||
to_configure = [
|
to_configure = [
|
||||||
ts_key for ts_key in sorted(new_enabled)
|
ts_key for ts_key in sorted(new_enabled)
|
||||||
if TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)
|
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key))
|
||||||
|
and ts_key not in auto_configured
|
||||||
]
|
]
|
||||||
|
|
||||||
if to_configure:
|
if to_configure:
|
||||||
|
|
@ -1140,7 +1284,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
# Configure API keys for newly enabled tools
|
# Configure API keys for newly enabled tools
|
||||||
for ts_key in sorted(added):
|
for ts_key in sorted(added):
|
||||||
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
||||||
if not _toolset_has_keys(ts_key):
|
if _toolset_needs_configuration_prompt(ts_key, config):
|
||||||
_configure_toolset(ts_key, config)
|
_configure_toolset(ts_key, config)
|
||||||
_save_platform_tools(config, pk, new_enabled)
|
_save_platform_tools(config, pk, new_enabled)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
@ -1180,7 +1324,7 @@ def tools_command(args=None, first_install: bool = False, config: dict = None):
|
||||||
# Configure newly enabled toolsets that need API keys
|
# Configure newly enabled toolsets that need API keys
|
||||||
for ts_key in sorted(added):
|
for ts_key in sorted(added):
|
||||||
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
if (TOOL_CATEGORIES.get(ts_key) or TOOLSET_ENV_REQUIREMENTS.get(ts_key)):
|
||||||
if not _toolset_has_keys(ts_key):
|
if _toolset_needs_configuration_prompt(ts_key, config):
|
||||||
_configure_toolset(ts_key, config)
|
_configure_toolset(ts_key, config)
|
||||||
|
|
||||||
_save_platform_tools(config, pkey, new_enabled)
|
_save_platform_tools(config, pkey, new_enabled)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ dependencies = [
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
modal = ["swe-rex[modal]>=1.4.0,<2"]
|
modal = ["swe-rex[modal]>=1.4.0,<2"]
|
||||||
daytona = ["daytona>=0.148.0,<1"]
|
daytona = ["daytona>=0.148.0,<1"]
|
||||||
dev = ["pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"]
|
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2"]
|
||||||
messaging = ["python-telegram-bot>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
messaging = ["python-telegram-bot>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||||
cron = ["croniter>=6.0.0,<7"]
|
cron = ["croniter>=6.0.0,<7"]
|
||||||
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
slack = ["slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4"]
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ requests
|
||||||
jinja2
|
jinja2
|
||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
PyJWT[crypto]
|
PyJWT[crypto]
|
||||||
|
debugpy
|
||||||
|
|
||||||
# Web tools
|
# Web tools
|
||||||
firecrawl-py
|
firecrawl-py
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ from hermes_constants import OPENROUTER_BASE_URL
|
||||||
from agent.prompt_builder import (
|
from agent.prompt_builder import (
|
||||||
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
|
DEFAULT_AGENT_IDENTITY, PLATFORM_HINTS,
|
||||||
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
|
MEMORY_GUIDANCE, SESSION_SEARCH_GUIDANCE, SKILLS_GUIDANCE,
|
||||||
|
build_nous_subscription_prompt,
|
||||||
)
|
)
|
||||||
from agent.model_metadata import (
|
from agent.model_metadata import (
|
||||||
fetch_model_metadata,
|
fetch_model_metadata,
|
||||||
|
|
@ -2388,6 +2389,10 @@ class AIAgent:
|
||||||
if tool_guidance:
|
if tool_guidance:
|
||||||
prompt_parts.append(" ".join(tool_guidance))
|
prompt_parts.append(" ".join(tool_guidance))
|
||||||
|
|
||||||
|
nous_subscription_prompt = build_nous_subscription_prompt(self.valid_tool_names)
|
||||||
|
if nous_subscription_prompt:
|
||||||
|
prompt_parts.append(nous_subscription_prompt)
|
||||||
|
|
||||||
# Honcho CLI awareness: tell Hermes about its own management commands
|
# Honcho CLI awareness: tell Hermes about its own management commands
|
||||||
# so it can refer the user to them rather than reinventing answers.
|
# so it can refer the user to them rather than reinventing answers.
|
||||||
if self._honcho and self._honcho_session_key:
|
if self._honcho and self._honcho_session_key:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import importlib
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from agent.prompt_builder import (
|
from agent.prompt_builder import (
|
||||||
_scan_context_content,
|
_scan_context_content,
|
||||||
_truncate_content,
|
_truncate_content,
|
||||||
|
|
@ -15,6 +17,7 @@ from agent.prompt_builder import (
|
||||||
_find_git_root,
|
_find_git_root,
|
||||||
_strip_yaml_frontmatter,
|
_strip_yaml_frontmatter,
|
||||||
build_skills_system_prompt,
|
build_skills_system_prompt,
|
||||||
|
build_nous_subscription_prompt,
|
||||||
build_context_files_prompt,
|
build_context_files_prompt,
|
||||||
CONTEXT_FILE_MAX_CHARS,
|
CONTEXT_FILE_MAX_CHARS,
|
||||||
DEFAULT_AGENT_IDENTITY,
|
DEFAULT_AGENT_IDENTITY,
|
||||||
|
|
@ -22,6 +25,7 @@ from agent.prompt_builder import (
|
||||||
SESSION_SEARCH_GUIDANCE,
|
SESSION_SEARCH_GUIDANCE,
|
||||||
PLATFORM_HINTS,
|
PLATFORM_HINTS,
|
||||||
)
|
)
|
||||||
|
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -395,6 +399,53 @@ class TestBuildSkillsSystemPrompt:
|
||||||
assert "backend-skill" in result
|
assert "backend-skill" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildNousSubscriptionPrompt:
|
||||||
|
def test_includes_active_subscription_features(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
|
lambda config=None: NousSubscriptionFeatures(
|
||||||
|
subscribed=True,
|
||||||
|
nous_auth_present=True,
|
||||||
|
provider_is_nous=True,
|
||||||
|
features={
|
||||||
|
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||||
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||||
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||||
|
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
|
||||||
|
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = build_nous_subscription_prompt({"web_search", "browser_navigate"})
|
||||||
|
|
||||||
|
assert "Browserbase" in prompt
|
||||||
|
assert "Modal execution is optional" in prompt
|
||||||
|
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browserbase API keys" in prompt
|
||||||
|
|
||||||
|
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_subscription_features",
|
||||||
|
lambda config=None: NousSubscriptionFeatures(
|
||||||
|
subscribed=False,
|
||||||
|
nous_auth_present=False,
|
||||||
|
provider_is_nous=False,
|
||||||
|
features={
|
||||||
|
"web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""),
|
||||||
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""),
|
||||||
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""),
|
||||||
|
"browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""),
|
||||||
|
"modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = build_nous_subscription_prompt({"image_generate"})
|
||||||
|
|
||||||
|
assert "suggest Nous subscription as one option" in prompt
|
||||||
|
assert "Do not mention subscription unless" in prompt
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Context files prompt builder
|
# Context files prompt builder
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -562,8 +613,12 @@ class TestBuildContextFilesPrompt:
|
||||||
assert "Lowercase claude rules" in result
|
assert "Lowercase claude rules" in result
|
||||||
|
|
||||||
def test_claude_md_uppercase_takes_priority(self, tmp_path):
|
def test_claude_md_uppercase_takes_priority(self, tmp_path):
|
||||||
(tmp_path / "CLAUDE.md").write_text("From uppercase.")
|
uppercase = tmp_path / "CLAUDE.md"
|
||||||
(tmp_path / "claude.md").write_text("From lowercase.")
|
lowercase = tmp_path / "claude.md"
|
||||||
|
uppercase.write_text("From uppercase.")
|
||||||
|
lowercase.write_text("From lowercase.")
|
||||||
|
if uppercase.samefile(lowercase):
|
||||||
|
pytest.skip("filesystem is case-insensitive")
|
||||||
result = build_context_files_prompt(cwd=str(tmp_path))
|
result = build_context_files_prompt(cwd=str(tmp_path))
|
||||||
assert "From uppercase" in result
|
assert "From uppercase" in result
|
||||||
assert "From lowercase" not in result
|
assert "From lowercase" not in result
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
from hermes_cli.auth import _update_config_for_provider, get_active_provider
|
from hermes_cli.auth import _update_config_for_provider, get_active_provider
|
||||||
from hermes_cli.config import load_config, save_config
|
from hermes_cli.config import load_config, save_config
|
||||||
|
|
@ -136,6 +138,8 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
|
||||||
def fake_prompt_choice(question, choices, default=0):
|
def fake_prompt_choice(question, choices, default=0):
|
||||||
if question == "Select your inference provider:":
|
if question == "Select your inference provider:":
|
||||||
return 2 # OpenAI Codex
|
return 2 # OpenAI Codex
|
||||||
|
if question == "Configure vision:":
|
||||||
|
return len(choices) - 1
|
||||||
if question == "Select default model:":
|
if question == "Select default model:":
|
||||||
return 0
|
return 0
|
||||||
tts_idx = _maybe_keep_current_tts(question, choices)
|
tts_idx = _maybe_keep_current_tts(question, choices)
|
||||||
|
|
@ -176,3 +180,171 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
|
||||||
assert reloaded["model"]["provider"] == "openai-codex"
|
assert reloaded["model"]["provider"] == "openai-codex"
|
||||||
assert reloaded["model"]["default"] == "gpt-5.2-codex"
|
assert reloaded["model"]["default"] == "gpt-5.2-codex"
|
||||||
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nous_setup_sets_managed_openai_tts_when_unconfigured(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_prompt_choice(question, choices, default=0):
|
||||||
|
if question == "Select your inference provider:":
|
||||||
|
return 1
|
||||||
|
if question == "Configure vision:":
|
||||||
|
return len(choices) - 1
|
||||||
|
if question == "Select default model:":
|
||||||
|
return len(choices) - 1
|
||||||
|
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||||
|
|
||||||
|
def _fake_login_nous(*args, **kwargs):
|
||||||
|
auth_path = tmp_path / "auth.json"
|
||||||
|
auth_path.write_text(json.dumps({"active_provider": "nous", "providers": {"nous": {"access_token": "nous-token"}}}))
|
||||||
|
_update_config_for_provider("nous", "https://inference.example.com/v1")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login_nous)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||||
|
lambda *args, **kwargs: {
|
||||||
|
"base_url": "https://inference.example.com/v1",
|
||||||
|
"api_key": "nous-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.fetch_nous_models",
|
||||||
|
lambda *args, **kwargs: ["gemini-3-flash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert config["tts"]["provider"] == "openai"
|
||||||
|
assert "Nous subscription enables managed web tools" in out
|
||||||
|
assert "OpenAI TTS via your Nous subscription" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_nous_setup_preserves_existing_tts_provider(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
_clear_provider_env(monkeypatch)
|
||||||
|
|
||||||
|
config = load_config()
|
||||||
|
config["tts"] = {"provider": "elevenlabs"}
|
||||||
|
|
||||||
|
def fake_prompt_choice(question, choices, default=0):
|
||||||
|
if question == "Select your inference provider:":
|
||||||
|
return 1
|
||||||
|
if question == "Configure vision:":
|
||||||
|
return len(choices) - 1
|
||||||
|
if question == "Select default model:":
|
||||||
|
return len(choices) - 1
|
||||||
|
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth._login_nous",
|
||||||
|
lambda *args, **kwargs: (tmp_path / "auth.json").write_text(
|
||||||
|
json.dumps({"active_provider": "nous", "providers": {"nous": {"access_token": "nous-token"}}})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||||
|
lambda *args, **kwargs: {
|
||||||
|
"base_url": "https://inference.example.com/v1",
|
||||||
|
"api_key": "nous-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.fetch_nous_models",
|
||||||
|
lambda *args, **kwargs: ["gemini-3-flash"],
|
||||||
|
)
|
||||||
|
|
||||||
|
setup_model_provider(config)
|
||||||
|
|
||||||
|
assert config["tts"]["provider"] == "elevenlabs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_prompt_choice(question, choices, default=0):
|
||||||
|
if question == "Select terminal backend:":
|
||||||
|
return 2
|
||||||
|
if question == "Select how Modal execution should be billed:":
|
||||||
|
return 0
|
||||||
|
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||||
|
|
||||||
|
def fake_prompt(message, *args, **kwargs):
|
||||||
|
assert "Modal Token" not in message
|
||||||
|
raise AssertionError(f"Unexpected prompt call: {message}")
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt", fake_prompt)
|
||||||
|
monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.setup.get_nous_subscription_features",
|
||||||
|
lambda config: type("Features", (), {"nous_auth_present": True})(),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
"tools.managed_tool_gateway",
|
||||||
|
types.SimpleNamespace(
|
||||||
|
is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
|
||||||
|
resolve_managed_tool_gateway=lambda vendor: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
from hermes_cli.setup import setup_terminal_backend
|
||||||
|
|
||||||
|
setup_terminal_backend(config)
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert config["terminal"]["backend"] == "modal"
|
||||||
|
assert config["terminal"]["modal_mode"] == "managed"
|
||||||
|
assert "bill to your subscription" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
config = load_config()
|
||||||
|
|
||||||
|
def fake_prompt_choice(question, choices, default=0):
|
||||||
|
if question == "Select terminal backend:":
|
||||||
|
return 2
|
||||||
|
if question == "Select how Modal execution should be billed:":
|
||||||
|
return 1
|
||||||
|
raise AssertionError(f"Unexpected prompt_choice call: {question}")
|
||||||
|
|
||||||
|
prompt_values = iter(["token-id", "token-secret", ""])
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
|
||||||
|
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
|
||||||
|
monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.setup.get_nous_subscription_features",
|
||||||
|
lambda config: type("Features", (), {"nous_auth_present": True})(),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
"tools.managed_tool_gateway",
|
||||||
|
types.SimpleNamespace(
|
||||||
|
is_managed_tool_gateway_ready=lambda vendor: vendor == "modal",
|
||||||
|
resolve_managed_tool_gateway=lambda vendor: None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setitem(sys.modules, "swe_rex", object())
|
||||||
|
|
||||||
|
from hermes_cli.setup import setup_terminal_backend
|
||||||
|
|
||||||
|
setup_terminal_backend(config)
|
||||||
|
|
||||||
|
assert config["terminal"]["backend"] == "modal"
|
||||||
|
assert config["terminal"]["modal_mode"] == "direct"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Tests for non-interactive setup and first-run headless behavior."""
|
"""Tests for non-interactive setup and first-run headless behavior."""
|
||||||
|
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -92,3 +92,48 @@ class TestNonInteractiveSetup:
|
||||||
mock_setup.assert_not_called()
|
mock_setup.assert_not_called()
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "hermes config set model.provider custom" in out
|
assert "hermes config set model.provider custom" in out
|
||||||
|
|
||||||
|
def test_returning_user_terminal_menu_choice_dispatches_terminal_section(self, tmp_path):
|
||||||
|
"""Returning-user menu should map Terminal Backend to the terminal setup, not TTS."""
|
||||||
|
from hermes_cli import setup as setup_mod
|
||||||
|
|
||||||
|
args = _make_setup_args()
|
||||||
|
config = {}
|
||||||
|
model_section = MagicMock()
|
||||||
|
tts_section = MagicMock()
|
||||||
|
terminal_section = MagicMock()
|
||||||
|
gateway_section = MagicMock()
|
||||||
|
tools_section = MagicMock()
|
||||||
|
agent_section = MagicMock()
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(setup_mod, "ensure_hermes_home"),
|
||||||
|
patch.object(setup_mod, "load_config", return_value=config),
|
||||||
|
patch.object(setup_mod, "get_hermes_home", return_value=tmp_path),
|
||||||
|
patch.object(setup_mod, "is_interactive_stdin", return_value=True),
|
||||||
|
patch.object(
|
||||||
|
setup_mod,
|
||||||
|
"get_env_value",
|
||||||
|
side_effect=lambda key: "sk-test" if key == "OPENROUTER_API_KEY" else "",
|
||||||
|
),
|
||||||
|
patch("hermes_cli.auth.get_active_provider", return_value=None),
|
||||||
|
patch.object(setup_mod, "prompt_choice", return_value=4),
|
||||||
|
patch.object(
|
||||||
|
setup_mod,
|
||||||
|
"SETUP_SECTIONS",
|
||||||
|
[
|
||||||
|
("model", "Model & Provider", model_section),
|
||||||
|
("tts", "Text-to-Speech", tts_section),
|
||||||
|
("terminal", "Terminal Backend", terminal_section),
|
||||||
|
("gateway", "Messaging Platforms (Gateway)", gateway_section),
|
||||||
|
("tools", "Tools", tools_section),
|
||||||
|
("agent", "Agent Settings", agent_section),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
patch.object(setup_mod, "save_config"),
|
||||||
|
patch.object(setup_mod, "_print_setup_summary"),
|
||||||
|
):
|
||||||
|
setup_mod.run_setup_wizard(args)
|
||||||
|
|
||||||
|
terminal_section.assert_called_once_with(config)
|
||||||
|
tts_section.assert_not_called()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures
|
||||||
|
|
||||||
|
|
||||||
def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""):
|
def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""):
|
||||||
import hermes_cli.auth as auth_mod
|
import hermes_cli.auth as auth_mod
|
||||||
|
|
@ -59,3 +61,42 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
|
||||||
out = capsys.readouterr().out
|
out = capsys.readouterr().out
|
||||||
assert "Model: qwen3:latest" in out
|
assert "Model: qwen3:latest" in out
|
||||||
assert "Provider: Custom endpoint" in out
|
assert "Provider: Custom endpoint" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
|
||||||
|
from hermes_cli import status as status_mod
|
||||||
|
|
||||||
|
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
status_mod,
|
||||||
|
"load_config",
|
||||||
|
lambda: {"model": {"default": "claude-opus-4-6", "provider": "nous"}},
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "nous", raising=False)
|
||||||
|
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "nous", raising=False)
|
||||||
|
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Nous Portal", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
status_mod,
|
||||||
|
"get_nous_subscription_features",
|
||||||
|
lambda config: NousSubscriptionFeatures(
|
||||||
|
subscribed=True,
|
||||||
|
nous_auth_present=True,
|
||||||
|
provider_is_nous=True,
|
||||||
|
features={
|
||||||
|
"web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"),
|
||||||
|
"image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"),
|
||||||
|
"tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"),
|
||||||
|
"browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browserbase"),
|
||||||
|
"modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
raising=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Nous Subscription Features" in out
|
||||||
|
assert "Browser automation" in out
|
||||||
|
assert "active via Nous subscription" in out
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from hermes_cli.tools_config import (
|
from hermes_cli.tools_config import (
|
||||||
|
_configure_provider,
|
||||||
_get_platform_tools,
|
_get_platform_tools,
|
||||||
_platform_toolset_summary,
|
_platform_toolset_summary,
|
||||||
_save_platform_tools,
|
_save_platform_tools,
|
||||||
_toolset_has_keys,
|
_toolset_has_keys,
|
||||||
|
TOOL_CATEGORIES,
|
||||||
|
_visible_providers,
|
||||||
|
tools_command,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -45,6 +49,10 @@ def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
|
||||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False)
|
monkeypatch.delenv("AUXILIARY_VISION_PROVIDER", raising=False)
|
||||||
monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False)
|
monkeypatch.delenv("CONTEXT_VISION_PROVIDER", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"agent.auxiliary_client.resolve_vision_provider_client",
|
||||||
|
lambda: ("openai-codex", object(), "gpt-4.1"),
|
||||||
|
)
|
||||||
|
|
||||||
assert _toolset_has_keys("vision") is True
|
assert _toolset_has_keys("vision") is True
|
||||||
|
|
||||||
|
|
@ -204,3 +212,74 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
|
||||||
|
|
||||||
# Deselected configurable toolset removed
|
# Deselected configurable toolset removed
|
||||||
assert "terminal" not in saved
|
assert "terminal" not in saved
|
||||||
|
|
||||||
|
|
||||||
|
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
|
||||||
|
config = {"model": {"provider": "nous"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||||
|
lambda: {"logged_in": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
|
||||||
|
|
||||||
|
assert providers[0]["name"].startswith("Nous Subscription")
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
||||||
|
config = {}
|
||||||
|
local_provider = next(
|
||||||
|
provider
|
||||||
|
for provider in TOOL_CATEGORIES["browser"]["providers"]
|
||||||
|
if provider.get("browser_provider") == "local"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None)
|
||||||
|
|
||||||
|
_configure_provider(local_provider, config)
|
||||||
|
|
||||||
|
assert config["browser"]["cloud_provider"] == "local"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
||||||
|
config = {
|
||||||
|
"model": {"provider": "nous"},
|
||||||
|
"platform_toolsets": {"cli": []},
|
||||||
|
}
|
||||||
|
for env_var in (
|
||||||
|
"VOICE_TOOLS_OPENAI_KEY",
|
||||||
|
"OPENAI_API_KEY",
|
||||||
|
"ELEVENLABS_API_KEY",
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
"PARALLEL_API_KEY",
|
||||||
|
"BROWSERBASE_API_KEY",
|
||||||
|
"BROWSERBASE_PROJECT_ID",
|
||||||
|
"BROWSER_USE_API_KEY",
|
||||||
|
"FAL_KEY",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(env_var, raising=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._prompt_toolset_checklist",
|
||||||
|
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
||||||
|
lambda: {"logged_in": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
configured = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.tools_config._configure_toolset",
|
||||||
|
lambda ts_key, config: configured.append(ts_key),
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_command(first_install=True, config=config)
|
||||||
|
|
||||||
|
assert config["web"]["backend"] == "firecrawl"
|
||||||
|
assert config["tts"]["provider"] == "openai"
|
||||||
|
assert config["browser"]["cloud_provider"] == "browserbase"
|
||||||
|
assert configured == []
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,13 @@ def _install_prompt_toolkit_stubs():
|
||||||
|
|
||||||
|
|
||||||
def _import_cli():
|
def _import_cli():
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name == "cli" or name == "run_agent" or name == "tools" or name.startswith("tools."):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
|
||||||
|
if "firecrawl" not in sys.modules:
|
||||||
|
sys.modules["firecrawl"] = types.SimpleNamespace(Firecrawl=object)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
importlib.import_module("prompt_toolkit")
|
importlib.import_module("prompt_toolkit")
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
|
|
@ -269,6 +276,81 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
|
||||||
assert shell.model == "gpt-5.2-codex"
|
assert shell.model == "gpt-5.2-codex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
|
||||||
|
config = {
|
||||||
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
|
"tts": {"provider": "elevenlabs"},
|
||||||
|
"browser": {"cloud_provider": "browser-use"},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.get_provider_auth_state",
|
||||||
|
lambda provider: {"access_token": "nous-token"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||||
|
lambda *args, **kwargs: {
|
||||||
|
"base_url": "https://inference.example.com/v1",
|
||||||
|
"api_key": "nous-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.fetch_nous_models",
|
||||||
|
lambda *args, **kwargs: ["claude-opus-4-6"],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
|
||||||
|
lambda: ["Nous subscription enables managed web tools."],
|
||||||
|
)
|
||||||
|
|
||||||
|
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Nous subscription enables managed web tools." in out
|
||||||
|
assert config["tts"]["provider"] == "elevenlabs"
|
||||||
|
assert config["browser"]["cloud_provider"] == "browser-use"
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
|
||||||
|
config = {
|
||||||
|
"model": {"provider": "nous", "default": "claude-opus-4-6"},
|
||||||
|
"tts": {"provider": "edge"},
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.get_provider_auth_state",
|
||||||
|
lambda provider: {"access_token": "nous-token"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.resolve_nous_runtime_credentials",
|
||||||
|
lambda *args, **kwargs: {
|
||||||
|
"base_url": "https://inference.example.com/v1",
|
||||||
|
"api_key": "nous-key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.fetch_nous_models",
|
||||||
|
lambda *args, **kwargs: ["claude-opus-4-6"],
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="": "claude-opus-4-6")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._save_model_choice", lambda model: None)
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._update_config_for_provider", lambda provider, url: None)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.nous_subscription.get_nous_subscription_explainer_lines",
|
||||||
|
lambda: ["Nous subscription enables managed web tools."],
|
||||||
|
)
|
||||||
|
|
||||||
|
hermes_main._model_flow_nous(config, current_model="claude-opus-4-6")
|
||||||
|
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "Nous subscription enables managed web tools." in out
|
||||||
|
assert "OpenAI TTS via your Nous subscription" in out
|
||||||
|
assert config["tts"]["provider"] == "openai"
|
||||||
|
|
||||||
|
|
||||||
def test_codex_provider_uses_config_model(monkeypatch):
|
def test_codex_provider_uses_config_model(monkeypatch):
|
||||||
"""Model comes from config.yaml, not LLM_MODEL env var.
|
"""Model comes from config.yaml, not LLM_MODEL env var.
|
||||||
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
|
Config.yaml is the single source of truth to avoid multi-agent conflicts."""
|
||||||
|
|
@ -469,3 +551,54 @@ def test_model_flow_custom_saves_verified_v1_base_url(monkeypatch, capsys):
|
||||||
assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1"
|
assert saved_env["OPENAI_BASE_URL"] == "http://localhost:8000/v1"
|
||||||
assert saved_env["OPENAI_API_KEY"] == "local-key"
|
assert saved_env["OPENAI_API_KEY"] == "local-key"
|
||||||
assert saved_env["MODEL"] == "llm"
|
assert saved_env["MODEL"] == "llm"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cmd_model_forwards_nous_login_tls_options(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.config.load_config",
|
||||||
|
lambda: {"model": {"default": "gpt-5", "provider": "nous"}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: None)
|
||||||
|
monkeypatch.setattr("hermes_cli.config.get_env_value", lambda key: "")
|
||||||
|
monkeypatch.setattr("hermes_cli.config.save_env_value", lambda key, value: None)
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.resolve_provider", lambda requested, **kwargs: "nous")
|
||||||
|
monkeypatch.setattr("hermes_cli.auth.get_provider_auth_state", lambda provider_id: None)
|
||||||
|
monkeypatch.setattr(hermes_main, "_prompt_provider_choice", lambda choices: 0)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def _fake_login(login_args, provider_config):
|
||||||
|
captured["portal_url"] = login_args.portal_url
|
||||||
|
captured["inference_url"] = login_args.inference_url
|
||||||
|
captured["client_id"] = login_args.client_id
|
||||||
|
captured["scope"] = login_args.scope
|
||||||
|
captured["no_browser"] = login_args.no_browser
|
||||||
|
captured["timeout"] = login_args.timeout
|
||||||
|
captured["ca_bundle"] = login_args.ca_bundle
|
||||||
|
captured["insecure"] = login_args.insecure
|
||||||
|
|
||||||
|
monkeypatch.setattr("hermes_cli.auth._login_nous", _fake_login)
|
||||||
|
|
||||||
|
hermes_main.cmd_model(
|
||||||
|
SimpleNamespace(
|
||||||
|
portal_url="https://portal.nousresearch.com",
|
||||||
|
inference_url="https://inference.nousresearch.com/v1",
|
||||||
|
client_id="hermes-local",
|
||||||
|
scope="openid profile",
|
||||||
|
no_browser=True,
|
||||||
|
timeout=7.5,
|
||||||
|
ca_bundle="/tmp/local-ca.pem",
|
||||||
|
insecure=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured == {
|
||||||
|
"portal_url": "https://portal.nousresearch.com",
|
||||||
|
"inference_url": "https://inference.nousresearch.com/v1",
|
||||||
|
"client_id": "hermes-local",
|
||||||
|
"scope": "openid profile",
|
||||||
|
"no_browser": True,
|
||||||
|
"timeout": 7.5,
|
||||||
|
"ca_bundle": "/tmp/local-ca.pem",
|
||||||
|
"insecure": True,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -584,6 +584,11 @@ class TestBuildSystemPrompt:
|
||||||
# Should contain current date info like "Conversation started:"
|
# Should contain current date info like "Conversation started:"
|
||||||
assert "Conversation started:" in prompt
|
assert "Conversation started:" in prompt
|
||||||
|
|
||||||
|
def test_includes_nous_subscription_prompt(self, agent, monkeypatch):
|
||||||
|
monkeypatch.setattr(run_agent, "build_nous_subscription_prompt", lambda tool_names: "NOUS SUBSCRIPTION BLOCK")
|
||||||
|
prompt = agent._build_system_prompt()
|
||||||
|
assert "NOUS SUBSCRIPTION BLOCK" in prompt
|
||||||
|
|
||||||
|
|
||||||
class TestInvalidateSystemPrompt:
|
class TestInvalidateSystemPrompt:
|
||||||
def test_clears_cache(self, agent):
|
def test_clears_cache(self, agent):
|
||||||
|
|
|
||||||
418
tests/tools/test_managed_browserbase_and_modal.py
Normal file
418
tests/tools/test_managed_browserbase_and_modal.py
Normal file
|
|
@ -0,0 +1,418 @@
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tool_module(module_name: str, filename: str):
|
||||||
|
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_modules(prefixes: tuple[str, ...]):
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name.startswith(prefixes):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_tool_and_agent_modules():
|
||||||
|
original_modules = {
|
||||||
|
name: module
|
||||||
|
for name, module in sys.modules.items()
|
||||||
|
if name == "tools"
|
||||||
|
or name.startswith("tools.")
|
||||||
|
or name == "agent"
|
||||||
|
or name.startswith("agent.")
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_reset_modules(("tools", "agent"))
|
||||||
|
sys.modules.update(original_modules)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_tools_package():
|
||||||
|
_reset_modules(("tools", "agent"))
|
||||||
|
|
||||||
|
tools_package = types.ModuleType("tools")
|
||||||
|
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools"] = tools_package
|
||||||
|
|
||||||
|
env_package = types.ModuleType("tools.environments")
|
||||||
|
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools.environments"] = env_package
|
||||||
|
|
||||||
|
agent_package = types.ModuleType("agent")
|
||||||
|
agent_package.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["agent"] = agent_package
|
||||||
|
sys.modules["agent.auxiliary_client"] = types.SimpleNamespace(
|
||||||
|
call_llm=lambda *args, **kwargs: "",
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
|
||||||
|
"tools.managed_tool_gateway",
|
||||||
|
"managed_tool_gateway.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
interrupt_event = threading.Event()
|
||||||
|
sys.modules["tools.interrupt"] = types.SimpleNamespace(
|
||||||
|
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
|
||||||
|
is_interrupted=lambda: interrupt_event.is_set(),
|
||||||
|
_interrupt_event=interrupt_event,
|
||||||
|
)
|
||||||
|
sys.modules["tools.approval"] = types.SimpleNamespace(
|
||||||
|
detect_dangerous_command=lambda *args, **kwargs: None,
|
||||||
|
check_dangerous_command=lambda *args, **kwargs: {"approved": True},
|
||||||
|
check_all_command_guards=lambda *args, **kwargs: {"approved": True},
|
||||||
|
load_permanent_allowlist=lambda *args, **kwargs: [],
|
||||||
|
DANGEROUS_PATTERNS=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def register(self, **kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sys.modules["tools.registry"] = types.SimpleNamespace(registry=_Registry())
|
||||||
|
|
||||||
|
class _DummyEnvironment:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyEnvironment)
|
||||||
|
sys.modules["tools.environments.local"] = types.SimpleNamespace(LocalEnvironment=_DummyEnvironment)
|
||||||
|
sys.modules["tools.environments.singularity"] = types.SimpleNamespace(
|
||||||
|
_get_scratch_dir=lambda: Path(tempfile.gettempdir()),
|
||||||
|
SingularityEnvironment=_DummyEnvironment,
|
||||||
|
)
|
||||||
|
sys.modules["tools.environments.ssh"] = types.SimpleNamespace(SSHEnvironment=_DummyEnvironment)
|
||||||
|
sys.modules["tools.environments.docker"] = types.SimpleNamespace(DockerEnvironment=_DummyEnvironment)
|
||||||
|
sys.modules["tools.environments.modal"] = types.SimpleNamespace(ModalEnvironment=_DummyEnvironment)
|
||||||
|
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
|
||||||
|
|
||||||
|
|
||||||
|
def test_browserbase_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
|
||||||
|
_install_fake_tools_package()
|
||||||
|
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||||
|
env.update({
|
||||||
|
"HERMES_HOME": str(tmp_path),
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browser_tool = _load_tool_module("tools.browser_tool", "browser_tool.py")
|
||||||
|
|
||||||
|
local_mode = browser_tool._is_local_mode()
|
||||||
|
provider = browser_tool._get_cloud_provider()
|
||||||
|
|
||||||
|
assert local_mode is True
|
||||||
|
assert provider is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browserbase-1"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bb_local_session_1",
|
||||||
|
"connectUrl": "wss://connect.browserbase.example/session",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browserbase_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browserbase",
|
||||||
|
"browser_providers/browserbase.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.object(browserbase_module.requests, "post", return_value=_Response()) as post:
|
||||||
|
provider = browserbase_module.BrowserbaseProvider()
|
||||||
|
session = provider.create_session("task-browserbase-managed")
|
||||||
|
|
||||||
|
sent_headers = post.call_args.kwargs["headers"]
|
||||||
|
assert sent_headers["X-BB-API-Key"] == "nous-token"
|
||||||
|
assert sent_headers["X-Idempotency-Key"].startswith("browserbase-session-create:")
|
||||||
|
assert session["external_call_id"] == "call-browserbase-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_browserbase_managed_gateway_reuses_pending_idempotency_key_after_timeout():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browserbase-2"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bb_local_session_2",
|
||||||
|
"connectUrl": "wss://connect.browserbase.example/session2",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browserbase_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browserbase",
|
||||||
|
"browser_providers/browserbase.py",
|
||||||
|
)
|
||||||
|
provider = browserbase_module.BrowserbaseProvider()
|
||||||
|
timeout = browserbase_module.requests.Timeout("timed out")
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
browserbase_module.requests,
|
||||||
|
"post",
|
||||||
|
side_effect=[timeout, _Response()],
|
||||||
|
) as post:
|
||||||
|
try:
|
||||||
|
provider.create_session("task-browserbase-timeout")
|
||||||
|
except browserbase_module.requests.Timeout:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected Browserbase create_session to propagate timeout")
|
||||||
|
|
||||||
|
provider.create_session("task-browserbase-timeout")
|
||||||
|
|
||||||
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
|
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _ConflictResponse:
|
||||||
|
status_code = 409
|
||||||
|
ok = False
|
||||||
|
text = '{"error":{"code":"CONFLICT","message":"Managed Browserbase session creation is already in progress for this idempotency key"}}'
|
||||||
|
headers = {}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"error": {
|
||||||
|
"code": "CONFLICT",
|
||||||
|
"message": "Managed Browserbase session creation is already in progress for this idempotency key",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SuccessResponse:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browserbase-4"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bb_local_session_4",
|
||||||
|
"connectUrl": "wss://connect.browserbase.example/session4",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browserbase_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browserbase",
|
||||||
|
"browser_providers/browserbase.py",
|
||||||
|
)
|
||||||
|
provider = browserbase_module.BrowserbaseProvider()
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
browserbase_module.requests,
|
||||||
|
"post",
|
||||||
|
side_effect=[_ConflictResponse(), _SuccessResponse()],
|
||||||
|
) as post:
|
||||||
|
try:
|
||||||
|
provider.create_session("task-browserbase-conflict")
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("Expected Browserbase create_session to propagate the in-progress conflict")
|
||||||
|
|
||||||
|
provider.create_session("task-browserbase-conflict")
|
||||||
|
|
||||||
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
|
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_browserbase_managed_gateway_uses_new_idempotency_key_for_a_new_session_after_success():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("BROWSERBASE_API_KEY", None)
|
||||||
|
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||||
|
env.update({
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||||
|
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||||
|
})
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
status_code = 200
|
||||||
|
ok = True
|
||||||
|
text = ""
|
||||||
|
headers = {"x-external-call-id": "call-browserbase-3"}
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"id": "bb_local_session_3",
|
||||||
|
"connectUrl": "wss://connect.browserbase.example/session3",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
browserbase_module = _load_tool_module(
|
||||||
|
"tools.browser_providers.browserbase",
|
||||||
|
"browser_providers/browserbase.py",
|
||||||
|
)
|
||||||
|
provider = browserbase_module.BrowserbaseProvider()
|
||||||
|
|
||||||
|
with patch.object(browserbase_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
|
||||||
|
provider.create_session("task-browserbase-new")
|
||||||
|
provider.create_session("task-browserbase-new")
|
||||||
|
|
||||||
|
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||||
|
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||||
|
assert first_headers["X-Idempotency-Key"] != second_headers["X-Idempotency-Key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_tool_prefers_managed_modal_when_gateway_ready_and_no_direct_creds():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("MODAL_TOKEN_ID", None)
|
||||||
|
env.pop("MODAL_TOKEN_SECRET", None)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||||
|
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
|
||||||
|
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
|
||||||
|
patch.object(Path, "exists", return_value=False),
|
||||||
|
):
|
||||||
|
result = terminal_tool._create_environment(
|
||||||
|
env_type="modal",
|
||||||
|
image="python:3.11",
|
||||||
|
cwd="/root",
|
||||||
|
timeout=60,
|
||||||
|
container_config={
|
||||||
|
"container_cpu": 1,
|
||||||
|
"container_memory": 2048,
|
||||||
|
"container_disk": 1024,
|
||||||
|
"container_persistent": True,
|
||||||
|
"modal_mode": "auto",
|
||||||
|
},
|
||||||
|
task_id="task-modal-managed",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "managed-modal-env"
|
||||||
|
assert managed_ctor.called
|
||||||
|
assert not direct_ctor.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_tool_keeps_direct_modal_when_direct_credentials_exist():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.update({
|
||||||
|
"MODAL_TOKEN_ID": "tok-id",
|
||||||
|
"MODAL_TOKEN_SECRET": "tok-secret",
|
||||||
|
})
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||||
|
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
|
||||||
|
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
|
||||||
|
):
|
||||||
|
result = terminal_tool._create_environment(
|
||||||
|
env_type="modal",
|
||||||
|
image="python:3.11",
|
||||||
|
cwd="/root",
|
||||||
|
timeout=60,
|
||||||
|
container_config={
|
||||||
|
"container_cpu": 1,
|
||||||
|
"container_memory": 2048,
|
||||||
|
"container_disk": 1024,
|
||||||
|
"container_persistent": True,
|
||||||
|
"modal_mode": "auto",
|
||||||
|
},
|
||||||
|
task_id="task-modal-direct",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "direct-modal-env"
|
||||||
|
assert direct_ctor.called
|
||||||
|
assert not managed_ctor.called
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_tool_respects_direct_modal_mode_without_falling_back_to_managed():
|
||||||
|
_install_fake_tools_package()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("MODAL_TOKEN_ID", None)
|
||||||
|
env.pop("MODAL_TOKEN_SECRET", None)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||||
|
patch.object(Path, "exists", return_value=False),
|
||||||
|
):
|
||||||
|
with pytest.raises(ValueError, match="direct Modal credentials"):
|
||||||
|
terminal_tool._create_environment(
|
||||||
|
env_type="modal",
|
||||||
|
image="python:3.11",
|
||||||
|
cwd="/root",
|
||||||
|
timeout=60,
|
||||||
|
container_config={
|
||||||
|
"container_cpu": 1,
|
||||||
|
"container_memory": 2048,
|
||||||
|
"container_disk": 1024,
|
||||||
|
"container_persistent": True,
|
||||||
|
"modal_mode": "direct",
|
||||||
|
},
|
||||||
|
task_id="task-modal-direct-only",
|
||||||
|
)
|
||||||
288
tests/tools/test_managed_media_gateways.py
Normal file
288
tests/tools/test_managed_media_gateways.py
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tool_module(module_name: str, filename: str):
|
||||||
|
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _restore_tool_and_agent_modules():
|
||||||
|
original_modules = {
|
||||||
|
name: module
|
||||||
|
for name, module in sys.modules.items()
|
||||||
|
if name == "tools"
|
||||||
|
or name.startswith("tools.")
|
||||||
|
or name == "agent"
|
||||||
|
or name.startswith("agent.")
|
||||||
|
or name in {"fal_client", "openai"}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if (
|
||||||
|
name == "tools"
|
||||||
|
or name.startswith("tools.")
|
||||||
|
or name == "agent"
|
||||||
|
or name.startswith("agent.")
|
||||||
|
or name in {"fal_client", "openai"}
|
||||||
|
):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
sys.modules.update(original_modules)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_tools_package():
|
||||||
|
tools_package = types.ModuleType("tools")
|
||||||
|
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools"] = tools_package
|
||||||
|
sys.modules["tools.debug_helpers"] = types.SimpleNamespace(
|
||||||
|
DebugSession=lambda *args, **kwargs: types.SimpleNamespace(
|
||||||
|
active=False,
|
||||||
|
session_id="debug-session",
|
||||||
|
log_call=lambda *a, **k: None,
|
||||||
|
save=lambda: None,
|
||||||
|
get_session_info=lambda: {},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
|
||||||
|
"tools.managed_tool_gateway",
|
||||||
|
"managed_tool_gateway.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_fal_client(captured):
|
||||||
|
def submit(model, arguments=None, headers=None):
|
||||||
|
raise AssertionError("managed FAL gateway mode should use fal_client.SyncClient")
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"request_id": "req-123",
|
||||||
|
"response_url": "http://127.0.0.1:3009/requests/req-123",
|
||||||
|
"status_url": "http://127.0.0.1:3009/requests/req-123/status",
|
||||||
|
"cancel_url": "http://127.0.0.1:3009/requests/req-123/cancel",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _maybe_retry_request(client, method, url, json=None, timeout=None, headers=None):
|
||||||
|
captured["submit_via"] = "managed_client"
|
||||||
|
captured["http_client"] = client
|
||||||
|
captured["method"] = method
|
||||||
|
captured["submit_url"] = url
|
||||||
|
captured["arguments"] = json
|
||||||
|
captured["timeout"] = timeout
|
||||||
|
captured["headers"] = headers
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
class SyncRequestHandle:
|
||||||
|
def __init__(self, request_id, response_url, status_url, cancel_url, client):
|
||||||
|
captured["request_id"] = request_id
|
||||||
|
captured["response_url"] = response_url
|
||||||
|
captured["status_url"] = status_url
|
||||||
|
captured["cancel_url"] = cancel_url
|
||||||
|
captured["handle_client"] = client
|
||||||
|
|
||||||
|
class SyncClient:
|
||||||
|
def __init__(self, key=None, default_timeout=120.0):
|
||||||
|
captured["sync_client_inits"] = captured.get("sync_client_inits", 0) + 1
|
||||||
|
captured["client_key"] = key
|
||||||
|
captured["client_timeout"] = default_timeout
|
||||||
|
self.default_timeout = default_timeout
|
||||||
|
self._client = object()
|
||||||
|
|
||||||
|
fal_client_module = types.SimpleNamespace(
|
||||||
|
submit=submit,
|
||||||
|
SyncClient=SyncClient,
|
||||||
|
client=types.SimpleNamespace(
|
||||||
|
_maybe_retry_request=_maybe_retry_request,
|
||||||
|
_raise_for_status=lambda response: None,
|
||||||
|
SyncRequestHandle=SyncRequestHandle,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sys.modules["fal_client"] = fal_client_module
|
||||||
|
return fal_client_module
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_openai_module(captured, transcription_response=None):
|
||||||
|
class FakeSpeechResponse:
|
||||||
|
def stream_to_file(self, output_path):
|
||||||
|
captured["stream_to_file"] = output_path
|
||||||
|
|
||||||
|
class FakeOpenAI:
|
||||||
|
def __init__(self, api_key, base_url, **kwargs):
|
||||||
|
captured["api_key"] = api_key
|
||||||
|
captured["base_url"] = base_url
|
||||||
|
captured["client_kwargs"] = kwargs
|
||||||
|
captured["close_calls"] = captured.get("close_calls", 0)
|
||||||
|
|
||||||
|
def create_speech(**kwargs):
|
||||||
|
captured["speech_kwargs"] = kwargs
|
||||||
|
return FakeSpeechResponse()
|
||||||
|
|
||||||
|
def create_transcription(**kwargs):
|
||||||
|
captured["transcription_kwargs"] = kwargs
|
||||||
|
return transcription_response
|
||||||
|
|
||||||
|
self.audio = types.SimpleNamespace(
|
||||||
|
speech=types.SimpleNamespace(
|
||||||
|
create=create_speech
|
||||||
|
),
|
||||||
|
transcriptions=types.SimpleNamespace(
|
||||||
|
create=create_transcription
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
captured["close_calls"] += 1
|
||||||
|
|
||||||
|
fake_module = types.SimpleNamespace(
|
||||||
|
OpenAI=FakeOpenAI,
|
||||||
|
APIError=Exception,
|
||||||
|
APIConnectionError=Exception,
|
||||||
|
APITimeoutError=Exception,
|
||||||
|
)
|
||||||
|
sys.modules["openai"] = fake_module
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_fal_submit_uses_gateway_origin_and_nous_token(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
_install_fake_tools_package()
|
||||||
|
_install_fake_fal_client(captured)
|
||||||
|
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||||
|
|
||||||
|
image_generation_tool = _load_tool_module(
|
||||||
|
"tools.image_generation_tool",
|
||||||
|
"image_generation_tool.py",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(image_generation_tool.uuid, "uuid4", lambda: "fal-submit-123")
|
||||||
|
|
||||||
|
image_generation_tool._submit_fal_request(
|
||||||
|
"fal-ai/flux-2-pro",
|
||||||
|
{"prompt": "test prompt", "num_images": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert captured["submit_via"] == "managed_client"
|
||||||
|
assert captured["client_key"] == "nous-token"
|
||||||
|
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/flux-2-pro"
|
||||||
|
assert captured["method"] == "POST"
|
||||||
|
assert captured["arguments"] == {"prompt": "test prompt", "num_images": 1}
|
||||||
|
assert captured["headers"] == {"x-idempotency-key": "fal-submit-123"}
|
||||||
|
assert captured["sync_client_inits"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_fal_submit_reuses_cached_sync_client(monkeypatch):
|
||||||
|
captured = {}
|
||||||
|
_install_fake_tools_package()
|
||||||
|
_install_fake_fal_client(captured)
|
||||||
|
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||||
|
|
||||||
|
image_generation_tool = _load_tool_module(
|
||||||
|
"tools.image_generation_tool",
|
||||||
|
"image_generation_tool.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "first"})
|
||||||
|
first_client = captured["http_client"]
|
||||||
|
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "second"})
|
||||||
|
|
||||||
|
assert captured["sync_client_inits"] == 1
|
||||||
|
assert captured["http_client"] is first_client
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatch, tmp_path):
|
||||||
|
captured = {}
|
||||||
|
_install_fake_tools_package()
|
||||||
|
_install_fake_openai_module(captured)
|
||||||
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||||
|
|
||||||
|
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||||
|
monkeypatch.setattr(tts_tool.uuid, "uuid4", lambda: "tts-call-123")
|
||||||
|
output_path = tmp_path / "speech.mp3"
|
||||||
|
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
|
||||||
|
|
||||||
|
assert captured["api_key"] == "nous-token"
|
||||||
|
assert captured["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
|
||||||
|
assert captured["speech_kwargs"]["model"] == "gpt-4o-mini-tts"
|
||||||
|
assert captured["speech_kwargs"]["extra_headers"] == {"x-idempotency-key": "tts-call-123"}
|
||||||
|
assert captured["stream_to_file"] == str(output_path)
|
||||||
|
assert captured["close_calls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_openai_tts_accepts_openai_api_key_as_direct_fallback(monkeypatch, tmp_path):
|
||||||
|
captured = {}
|
||||||
|
_install_fake_tools_package()
|
||||||
|
_install_fake_openai_module(captured)
|
||||||
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "openai-direct-key")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||||
|
|
||||||
|
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||||
|
output_path = tmp_path / "speech.mp3"
|
||||||
|
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
|
||||||
|
|
||||||
|
assert captured["api_key"] == "openai-direct-key"
|
||||||
|
assert captured["base_url"] == "https://api.openai.com/v1"
|
||||||
|
assert captured["close_calls"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_path):
|
||||||
|
whisper_capture = {}
|
||||||
|
_install_fake_tools_package()
|
||||||
|
_install_fake_openai_module(whisper_capture, transcription_response="hello from whisper")
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
|
||||||
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||||
|
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||||
|
|
||||||
|
transcription_tools = _load_tool_module(
|
||||||
|
"tools.transcription_tools",
|
||||||
|
"transcription_tools.py",
|
||||||
|
)
|
||||||
|
transcription_tools._load_stt_config = lambda: {"provider": "openai"}
|
||||||
|
audio_path = tmp_path / "audio.wav"
|
||||||
|
audio_path.write_bytes(b"RIFF0000WAVEfmt ")
|
||||||
|
|
||||||
|
whisper_result = transcription_tools.transcribe_audio(str(audio_path), model="whisper-1")
|
||||||
|
assert whisper_result["success"] is True
|
||||||
|
assert whisper_capture["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
|
||||||
|
assert whisper_capture["transcription_kwargs"]["response_format"] == "text"
|
||||||
|
assert whisper_capture["close_calls"] == 1
|
||||||
|
|
||||||
|
json_capture = {}
|
||||||
|
_install_fake_openai_module(
|
||||||
|
json_capture,
|
||||||
|
transcription_response=types.SimpleNamespace(text="hello from gpt-4o"),
|
||||||
|
)
|
||||||
|
transcription_tools = _load_tool_module(
|
||||||
|
"tools.transcription_tools",
|
||||||
|
"transcription_tools.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
json_result = transcription_tools.transcribe_audio(
|
||||||
|
str(audio_path),
|
||||||
|
model="gpt-4o-mini-transcribe",
|
||||||
|
)
|
||||||
|
assert json_result["success"] is True
|
||||||
|
assert json_result["transcript"] == "hello from gpt-4o"
|
||||||
|
assert json_capture["transcription_kwargs"]["response_format"] == "json"
|
||||||
|
assert json_capture["close_calls"] == 1
|
||||||
213
tests/tools/test_managed_modal_environment.py
Normal file
213
tests/tools/test_managed_modal_environment.py
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tool_module(module_name: str, filename: str):
|
||||||
|
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_modules(prefixes: tuple[str, ...]):
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name.startswith(prefixes):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_tools_package():
|
||||||
|
_reset_modules(("tools", "agent", "hermes_cli"))
|
||||||
|
|
||||||
|
hermes_cli = types.ModuleType("hermes_cli")
|
||||||
|
hermes_cli.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["hermes_cli"] = hermes_cli
|
||||||
|
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
|
||||||
|
get_hermes_home=lambda: Path(tempfile.gettempdir()) / "hermes-home",
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_package = types.ModuleType("tools")
|
||||||
|
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools"] = tools_package
|
||||||
|
|
||||||
|
env_package = types.ModuleType("tools.environments")
|
||||||
|
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools.environments"] = env_package
|
||||||
|
|
||||||
|
interrupt_event = threading.Event()
|
||||||
|
sys.modules["tools.interrupt"] = types.SimpleNamespace(
|
||||||
|
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
|
||||||
|
is_interrupted=lambda: interrupt_event.is_set(),
|
||||||
|
_interrupt_event=interrupt_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
class _DummyBaseEnvironment:
|
||||||
|
def __init__(self, cwd: str, timeout: int, env=None):
|
||||||
|
self.cwd = cwd
|
||||||
|
self.timeout = timeout
|
||||||
|
self.env = env or {}
|
||||||
|
|
||||||
|
def _prepare_command(self, command: str):
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
|
||||||
|
sys.modules["tools.managed_tool_gateway"] = types.SimpleNamespace(
|
||||||
|
resolve_managed_tool_gateway=lambda vendor: types.SimpleNamespace(
|
||||||
|
vendor=vendor,
|
||||||
|
gateway_origin="https://modal-gateway.example.com",
|
||||||
|
nous_user_token="user-token",
|
||||||
|
managed_mode=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return interrupt_event
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __init__(self, status_code: int, payload=None, text: str = ""):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload
|
||||||
|
self.text = text
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
if isinstance(self._payload, Exception):
|
||||||
|
raise self._payload
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_modal_execute_polls_until_completed(monkeypatch):
|
||||||
|
_install_fake_tools_package()
|
||||||
|
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
poll_count = {"value": 0}
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||||
|
calls.append((method, url, json, timeout))
|
||||||
|
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||||
|
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||||
|
if method == "POST" and url.endswith("/execs"):
|
||||||
|
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||||
|
if method == "GET" and "/execs/" in url:
|
||||||
|
poll_count["value"] += 1
|
||||||
|
if poll_count["value"] == 1:
|
||||||
|
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
|
||||||
|
return _FakeResponse(200, {
|
||||||
|
"execId": url.rsplit("/", 1)[-1],
|
||||||
|
"status": "completed",
|
||||||
|
"output": "hello",
|
||||||
|
"returncode": 0,
|
||||||
|
})
|
||||||
|
if method == "POST" and url.endswith("/terminate"):
|
||||||
|
return _FakeResponse(200, {"status": "terminated"})
|
||||||
|
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||||
|
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
|
||||||
|
|
||||||
|
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||||
|
result = env.execute("echo hello")
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
assert result == {"output": "hello", "returncode": 0}
|
||||||
|
assert any(call[0] == "POST" and call[1].endswith("/execs") for call in calls)
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch):
|
||||||
|
_install_fake_tools_package()
|
||||||
|
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||||
|
|
||||||
|
create_headers = []
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||||
|
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||||
|
create_headers.append(headers or {})
|
||||||
|
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||||
|
if method == "POST" and url.endswith("/terminate"):
|
||||||
|
return _FakeResponse(200, {"status": "terminated"})
|
||||||
|
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||||
|
|
||||||
|
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
assert len(create_headers) == 1
|
||||||
|
assert isinstance(create_headers[0].get("x-idempotency-key"), str)
|
||||||
|
assert create_headers[0]["x-idempotency-key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_modal_execute_cancels_on_interrupt(monkeypatch):
|
||||||
|
interrupt_event = _install_fake_tools_package()
|
||||||
|
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||||
|
calls.append((method, url, json, timeout))
|
||||||
|
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||||
|
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||||
|
if method == "POST" and url.endswith("/execs"):
|
||||||
|
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||||
|
if method == "GET" and "/execs/" in url:
|
||||||
|
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
|
||||||
|
if method == "POST" and url.endswith("/cancel"):
|
||||||
|
return _FakeResponse(202, {"status": "cancelling"})
|
||||||
|
if method == "POST" and url.endswith("/terminate"):
|
||||||
|
return _FakeResponse(200, {"status": "terminated"})
|
||||||
|
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||||
|
|
||||||
|
def fake_sleep(_seconds):
|
||||||
|
interrupt_event.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||||
|
monkeypatch.setattr(managed_modal.time, "sleep", fake_sleep)
|
||||||
|
|
||||||
|
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||||
|
result = env.execute("sleep 30")
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
assert result == {
|
||||||
|
"output": "[Command interrupted - Modal sandbox exec cancelled]",
|
||||||
|
"returncode": 130,
|
||||||
|
}
|
||||||
|
assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls)
|
||||||
|
poll_calls = [call for call in calls if call[0] == "GET" and "/execs/" in call[1]]
|
||||||
|
cancel_calls = [call for call in calls if call[0] == "POST" and call[1].endswith("/cancel")]
|
||||||
|
assert poll_calls[0][3] == (1.0, 5.0)
|
||||||
|
assert cancel_calls[0][3] == (1.0, 5.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeypatch):
|
||||||
|
_install_fake_tools_package()
|
||||||
|
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||||
|
|
||||||
|
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||||
|
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||||
|
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||||
|
if method == "POST" and url.endswith("/execs"):
|
||||||
|
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||||
|
if method == "GET" and "/execs/" in url:
|
||||||
|
return _FakeResponse(404, {"error": "not found"}, text="not found")
|
||||||
|
if method == "POST" and url.endswith("/terminate"):
|
||||||
|
return _FakeResponse(200, {"status": "terminated"})
|
||||||
|
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||||
|
|
||||||
|
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||||
|
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
|
||||||
|
|
||||||
|
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||||
|
result = env.execute("echo hello")
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
assert result["returncode"] == 1
|
||||||
|
assert "not found" in result["output"].lower()
|
||||||
70
tests/tools/test_managed_tool_gateway.py
Normal file
70
tests/tools/test_managed_tool_gateway.py
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
|
||||||
|
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
|
||||||
|
assert MODULE_SPEC and MODULE_SPEC.loader
|
||||||
|
managed_tool_gateway = module_from_spec(MODULE_SPEC)
|
||||||
|
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
|
||||||
|
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
|
||||||
|
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
|
||||||
|
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
||||||
|
result = resolve_managed_tool_gateway(
|
||||||
|
"firecrawl",
|
||||||
|
token_reader=lambda: "nous-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
|
||||||
|
assert result.nous_user_token == "nous-token"
|
||||||
|
assert result.managed_mode is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
||||||
|
with patch.dict(os.environ, {"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/"}, clear=False):
|
||||||
|
result = resolve_managed_tool_gateway(
|
||||||
|
"browserbase",
|
||||||
|
token_reader=lambda: "nous-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.gateway_origin == "http://browserbase-gateway.localhost:3009"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||||
|
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
||||||
|
result = resolve_managed_tool_gateway(
|
||||||
|
"firecrawl",
|
||||||
|
token_reader=lambda: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
|
||||||
|
(tmp_path / "auth.json").write_text(json.dumps({
|
||||||
|
"providers": {
|
||||||
|
"nous": {
|
||||||
|
"access_token": "stale-token",
|
||||||
|
"refresh_token": "refresh-token",
|
||||||
|
"expires_at": expires_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"hermes_cli.auth.resolve_nous_access_token",
|
||||||
|
lambda refresh_skew_seconds=120: "fresh-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
|
||||||
188
tests/tools/test_modal_snapshot_isolation.py
Normal file
188
tests/tools/test_modal_snapshot_isolation.py
Normal file
|
|
@ -0,0 +1,188 @@
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from importlib.util import module_from_spec, spec_from_file_location
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
TOOLS_DIR = REPO_ROOT / "tools"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_module(module_name: str, path: Path):
|
||||||
|
spec = spec_from_file_location(module_name, path)
|
||||||
|
assert spec and spec.loader
|
||||||
|
module = module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_modules(prefixes: tuple[str, ...]):
|
||||||
|
for name in list(sys.modules):
|
||||||
|
if name.startswith(prefixes):
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _install_modal_test_modules(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
fail_on_snapshot_ids: set[str] | None = None,
|
||||||
|
snapshot_id: str = "im-fresh",
|
||||||
|
):
|
||||||
|
_reset_modules(("tools", "hermes_cli", "swerex", "modal"))
|
||||||
|
|
||||||
|
hermes_cli = types.ModuleType("hermes_cli")
|
||||||
|
hermes_cli.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["hermes_cli"] = hermes_cli
|
||||||
|
hermes_home = tmp_path / "hermes-home"
|
||||||
|
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
|
||||||
|
get_hermes_home=lambda: hermes_home,
|
||||||
|
)
|
||||||
|
|
||||||
|
tools_package = types.ModuleType("tools")
|
||||||
|
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools"] = tools_package
|
||||||
|
|
||||||
|
env_package = types.ModuleType("tools.environments")
|
||||||
|
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||||
|
sys.modules["tools.environments"] = env_package
|
||||||
|
|
||||||
|
class _DummyBaseEnvironment:
|
||||||
|
def __init__(self, cwd: str, timeout: int, env=None):
|
||||||
|
self.cwd = cwd
|
||||||
|
self.timeout = timeout
|
||||||
|
self.env = env or {}
|
||||||
|
|
||||||
|
def _prepare_command(self, command: str):
|
||||||
|
return command, None
|
||||||
|
|
||||||
|
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
|
||||||
|
sys.modules["tools.interrupt"] = types.SimpleNamespace(is_interrupted=lambda: False)
|
||||||
|
|
||||||
|
from_id_calls: list[str] = []
|
||||||
|
registry_calls: list[tuple[str, list[str] | None]] = []
|
||||||
|
deployment_calls: list[dict] = []
|
||||||
|
|
||||||
|
class _FakeImage:
|
||||||
|
@staticmethod
|
||||||
|
def from_id(image_id: str):
|
||||||
|
from_id_calls.append(image_id)
|
||||||
|
return {"kind": "snapshot", "image_id": image_id}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_registry(image: str, setup_dockerfile_commands=None):
|
||||||
|
registry_calls.append((image, setup_dockerfile_commands))
|
||||||
|
return {"kind": "registry", "image": image}
|
||||||
|
|
||||||
|
class _FakeRuntime:
|
||||||
|
async def execute(self, _command):
|
||||||
|
return types.SimpleNamespace(stdout="ok", exit_code=0)
|
||||||
|
|
||||||
|
class _FakeModalDeployment:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
deployment_calls.append(dict(kwargs))
|
||||||
|
self.image = kwargs["image"]
|
||||||
|
self.runtime = _FakeRuntime()
|
||||||
|
|
||||||
|
async def _snapshot_aio():
|
||||||
|
return types.SimpleNamespace(object_id=snapshot_id)
|
||||||
|
|
||||||
|
self._sandbox = types.SimpleNamespace(
|
||||||
|
snapshot_filesystem=types.SimpleNamespace(aio=_snapshot_aio),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
image = self.image if isinstance(self.image, dict) else {}
|
||||||
|
image_id = image.get("image_id")
|
||||||
|
if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids:
|
||||||
|
raise RuntimeError(f"cannot restore {image_id}")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _FakeRexCommand:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
sys.modules["modal"] = types.SimpleNamespace(Image=_FakeImage)
|
||||||
|
|
||||||
|
swerex = types.ModuleType("swerex")
|
||||||
|
swerex.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["swerex"] = swerex
|
||||||
|
swerex_deployment = types.ModuleType("swerex.deployment")
|
||||||
|
swerex_deployment.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["swerex.deployment"] = swerex_deployment
|
||||||
|
sys.modules["swerex.deployment.modal"] = types.SimpleNamespace(ModalDeployment=_FakeModalDeployment)
|
||||||
|
swerex_runtime = types.ModuleType("swerex.runtime")
|
||||||
|
swerex_runtime.__path__ = [] # type: ignore[attr-defined]
|
||||||
|
sys.modules["swerex.runtime"] = swerex_runtime
|
||||||
|
sys.modules["swerex.runtime.abstract"] = types.SimpleNamespace(Command=_FakeRexCommand)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"snapshot_store": hermes_home / "modal_snapshots.json",
|
||||||
|
"deployment_calls": deployment_calls,
|
||||||
|
"from_id_calls": from_id_calls,
|
||||||
|
"registry_calls": registry_calls,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_environment_migrates_legacy_snapshot_key_and_uses_snapshot_id(tmp_path):
|
||||||
|
state = _install_modal_test_modules(tmp_path)
|
||||||
|
snapshot_store = state["snapshot_store"]
|
||||||
|
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
snapshot_store.write_text(json.dumps({"task-legacy": "im-legacy123"}))
|
||||||
|
|
||||||
|
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||||
|
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-legacy")
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert state["from_id_calls"] == ["im-legacy123"]
|
||||||
|
assert state["deployment_calls"][0]["image"] == {"kind": "snapshot", "image_id": "im-legacy123"}
|
||||||
|
assert json.loads(snapshot_store.read_text()) == {"direct:task-legacy": "im-legacy123"}
|
||||||
|
finally:
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_environment_prunes_stale_direct_snapshot_and_retries_base_image(tmp_path):
|
||||||
|
state = _install_modal_test_modules(tmp_path, fail_on_snapshot_ids={"im-stale123"})
|
||||||
|
snapshot_store = state["snapshot_store"]
|
||||||
|
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
snapshot_store.write_text(json.dumps({"direct:task-stale": "im-stale123"}))
|
||||||
|
|
||||||
|
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||||
|
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-stale")
|
||||||
|
|
||||||
|
try:
|
||||||
|
assert [call["image"] for call in state["deployment_calls"]] == [
|
||||||
|
{"kind": "snapshot", "image_id": "im-stale123"},
|
||||||
|
{"kind": "registry", "image": "python:3.11"},
|
||||||
|
]
|
||||||
|
assert json.loads(snapshot_store.read_text()) == {}
|
||||||
|
finally:
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path):
|
||||||
|
state = _install_modal_test_modules(tmp_path, snapshot_id="im-cleanup456")
|
||||||
|
snapshot_store = state["snapshot_store"]
|
||||||
|
|
||||||
|
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||||
|
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-cleanup")
|
||||||
|
env.cleanup()
|
||||||
|
|
||||||
|
assert json.loads(snapshot_store.read_text()) == {"direct:task-cleanup": "im-cleanup456"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_modal_image_uses_snapshot_ids_and_registry_images(tmp_path):
|
||||||
|
state = _install_modal_test_modules(tmp_path)
|
||||||
|
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||||
|
|
||||||
|
snapshot_image = modal_module._resolve_modal_image("im-snapshot123")
|
||||||
|
registry_image = modal_module._resolve_modal_image("python:3.11")
|
||||||
|
|
||||||
|
assert snapshot_image == {"kind": "snapshot", "image_id": "im-snapshot123"}
|
||||||
|
assert registry_image == {"kind": "registry", "image": "python:3.11"}
|
||||||
|
assert state["from_id_calls"] == ["im-snapshot123"]
|
||||||
|
assert state["registry_calls"][0][0] == "python:3.11"
|
||||||
|
assert "ensurepip" in state["registry_calls"][0][1][0]
|
||||||
|
|
@ -8,9 +8,11 @@ def _clear_terminal_env(monkeypatch):
|
||||||
"""Remove terminal env vars that could affect requirements checks."""
|
"""Remove terminal env vars that could affect requirements checks."""
|
||||||
keys = [
|
keys = [
|
||||||
"TERMINAL_ENV",
|
"TERMINAL_ENV",
|
||||||
|
"TERMINAL_MODAL_MODE",
|
||||||
"TERMINAL_SSH_HOST",
|
"TERMINAL_SSH_HOST",
|
||||||
"TERMINAL_SSH_USER",
|
"TERMINAL_SSH_USER",
|
||||||
"MODAL_TOKEN_ID",
|
"MODAL_TOKEN_ID",
|
||||||
|
"MODAL_TOKEN_SECRET",
|
||||||
"HOME",
|
"HOME",
|
||||||
"USERPROFILE",
|
"USERPROFILE",
|
||||||
]
|
]
|
||||||
|
|
@ -63,7 +65,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||||
monkeypatch.setenv("HOME", str(tmp_path))
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
# Pretend swerex is installed
|
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
|
||||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||||
|
|
||||||
with caplog.at_level(logging.ERROR):
|
with caplog.at_level(logging.ERROR):
|
||||||
|
|
@ -71,6 +73,45 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||||
|
|
||||||
assert ok is False
|
assert ok is False
|
||||||
assert any(
|
assert any(
|
||||||
"Modal backend selected but no MODAL_TOKEN_ID environment variable" in record.getMessage()
|
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" in record.getMessage()
|
||||||
|
for record in caplog.records
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
||||||
|
_clear_terminal_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
|
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
|
||||||
|
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"ensure_minisweagent_on_path",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module.importlib.util,
|
||||||
|
"find_spec",
|
||||||
|
lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert terminal_tool_module.check_terminal_requirements() is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, caplog, tmp_path):
|
||||||
|
_clear_terminal_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||||
|
monkeypatch.setenv("TERMINAL_MODAL_MODE", "direct")
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
|
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
ok = terminal_tool_module.check_terminal_requirements()
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert any(
|
||||||
|
"TERMINAL_MODAL_MODE=direct" in record.getMessage()
|
||||||
for record in caplog.records
|
for record in caplog.records
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,3 +26,30 @@ class TestTerminalRequirements:
|
||||||
names = {tool["function"]["name"] for tool in tools}
|
names = {tool["function"]["name"] for tool in tools}
|
||||||
assert "terminal" in names
|
assert "terminal" in names
|
||||||
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
||||||
|
|
||||||
|
def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"_get_env_config",
|
||||||
|
lambda: {"env_type": "modal", "modal_mode": "managed"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"is_managed_tool_gateway_ready",
|
||||||
|
lambda _vendor: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
terminal_tool_module,
|
||||||
|
"ensure_minisweagent_on_path",
|
||||||
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||||
|
)
|
||||||
|
|
||||||
|
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
|
||||||
|
names = {tool["function"]["name"] for tool in tools}
|
||||||
|
|
||||||
|
assert "terminal" in names
|
||||||
|
assert "execute_code" in names
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ class TestTranscribeGroq:
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["transcript"] == "hello world"
|
assert result["transcript"] == "hello world"
|
||||||
assert result["provider"] == "groq"
|
assert result["provider"] == "groq"
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
def test_whitespace_stripped(self, monkeypatch, sample_wav):
|
def test_whitespace_stripped(self, monkeypatch, sample_wav):
|
||||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||||
|
|
@ -272,6 +273,7 @@ class TestTranscribeGroq:
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "API error" in result["error"]
|
assert "API error" in result["error"]
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
def test_permission_error(self, monkeypatch, sample_wav):
|
def test_permission_error(self, monkeypatch, sample_wav):
|
||||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||||
|
|
@ -327,6 +329,7 @@ class TestTranscribeOpenAIExtended:
|
||||||
result = _transcribe_openai(sample_wav, "whisper-1")
|
result = _transcribe_openai(sample_wav, "whisper-1")
|
||||||
|
|
||||||
assert result["transcript"] == "hello"
|
assert result["transcript"] == "hello"
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
def test_permission_error(self, monkeypatch, sample_wav):
|
def test_permission_error(self, monkeypatch, sample_wav):
|
||||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||||
|
|
@ -341,6 +344,7 @@ class TestTranscribeOpenAIExtended:
|
||||||
|
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "Permission denied" in result["error"]
|
assert "Permission denied" in result["error"]
|
||||||
|
mock_client.close.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestTranscribeLocalCommand:
|
class TestTranscribeLocalCommand:
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,14 @@ Coverage:
|
||||||
constructor failure recovery, return value verification, edge cases.
|
constructor failure recovery, return value verification, edge cases.
|
||||||
_get_backend() — backend selection logic with env var combinations.
|
_get_backend() — backend selection logic with env var combinations.
|
||||||
_get_parallel_client() — Parallel client configuration, singleton caching.
|
_get_parallel_client() — Parallel client configuration, singleton caching.
|
||||||
check_web_api_key() — unified availability check.
|
check_web_api_key() — unified availability check across all web backends.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock, AsyncMock
|
||||||
|
|
||||||
|
|
||||||
class TestFirecrawlClientConfig:
|
class TestFirecrawlClientConfig:
|
||||||
|
|
@ -20,14 +22,30 @@ class TestFirecrawlClientConfig:
|
||||||
"""Reset client and env vars before each test."""
|
"""Reset client and env vars before each test."""
|
||||||
import tools.web_tools
|
import tools.web_tools
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
|
tools.web_tools._firecrawl_client_config = None
|
||||||
|
for key in (
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
):
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
def teardown_method(self):
|
def teardown_method(self):
|
||||||
"""Reset client after each test."""
|
"""Reset client after each test."""
|
||||||
import tools.web_tools
|
import tools.web_tools
|
||||||
tools.web_tools._firecrawl_client = None
|
tools.web_tools._firecrawl_client = None
|
||||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
|
tools.web_tools._firecrawl_client_config = None
|
||||||
|
for key in (
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
):
|
||||||
os.environ.pop(key, None)
|
os.environ.pop(key, None)
|
||||||
|
|
||||||
# ── Configuration matrix ─────────────────────────────────────────
|
# ── Configuration matrix ─────────────────────────────────────────
|
||||||
|
|
@ -67,10 +85,153 @@ class TestFirecrawlClientConfig:
|
||||||
def test_no_config_raises_with_helpful_message(self):
|
def test_no_config_raises_with_helpful_message(self):
|
||||||
"""Neither key nor URL → ValueError with guidance."""
|
"""Neither key nor URL → ValueError with guidance."""
|
||||||
with patch("tools.web_tools.Firecrawl"):
|
with patch("tools.web_tools.Firecrawl"):
|
||||||
|
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||||
from tools.web_tools import _get_firecrawl_client
|
from tools.web_tools import _get_firecrawl_client
|
||||||
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
|
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
|
||||||
_get_firecrawl_client()
|
_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"))]
|
||||||
|
|
||||||
|
fake_client = MagicMock(base_url="https://api.openrouter.ai/v1")
|
||||||
|
fake_client.chat.completions.create = AsyncMock(return_value=response)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"tools.web_tools.get_async_text_auxiliary_client",
|
||||||
|
side_effect=[(None, None), (fake_client, "test-model")],
|
||||||
|
):
|
||||||
|
assert tools.web_tools.check_auxiliary_model() is False
|
||||||
|
result = await tools.web_tools._call_summarizer_llm(
|
||||||
|
"Some content worth summarizing",
|
||||||
|
"Source: https://example.com\n\n",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "summary text"
|
||||||
|
fake_client.chat.completions.create.assert_awaited_once()
|
||||||
|
|
||||||
# ── Singleton caching ────────────────────────────────────────────
|
# ── Singleton caching ────────────────────────────────────────────
|
||||||
|
|
||||||
def test_singleton_returns_same_instance(self):
|
def test_singleton_returns_same_instance(self):
|
||||||
|
|
@ -117,6 +278,7 @@ class TestFirecrawlClientConfig:
|
||||||
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
|
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
|
||||||
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
|
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
|
||||||
with patch("tools.web_tools.Firecrawl"):
|
with patch("tools.web_tools.Firecrawl"):
|
||||||
|
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||||
from tools.web_tools import _get_firecrawl_client
|
from tools.web_tools import _get_firecrawl_client
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
_get_firecrawl_client()
|
_get_firecrawl_client()
|
||||||
|
|
@ -130,7 +292,16 @@ class TestBackendSelection:
|
||||||
setups.
|
setups.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
|
_ENV_KEYS = (
|
||||||
|
"PARALLEL_API_KEY",
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
|
|
@ -276,10 +447,47 @@ class TestParallelClientConfig:
|
||||||
assert client1 is client2
|
assert client1 is client2
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebSearchErrorHandling:
|
||||||
|
"""Test suite for web_search_tool() error responses."""
|
||||||
|
|
||||||
|
def test_search_error_response_does_not_expose_diagnostics(self):
|
||||||
|
import tools.web_tools
|
||||||
|
|
||||||
|
firecrawl_client = MagicMock()
|
||||||
|
firecrawl_client.search.side_effect = RuntimeError("boom")
|
||||||
|
|
||||||
|
with patch("tools.web_tools._get_backend", return_value="firecrawl"), \
|
||||||
|
patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \
|
||||||
|
patch("tools.interrupt.is_interrupted", return_value=False), \
|
||||||
|
patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \
|
||||||
|
patch.object(tools.web_tools._debug, "save"):
|
||||||
|
result = json.loads(tools.web_tools.web_search_tool("test query", limit=3))
|
||||||
|
|
||||||
|
assert result == {"error": "Error searching web: boom"}
|
||||||
|
|
||||||
|
debug_payload = mock_log_call.call_args.args[1]
|
||||||
|
assert debug_payload["error"] == "Error searching web: boom"
|
||||||
|
assert "traceback" not in debug_payload["error"]
|
||||||
|
assert "exception_type" not in debug_payload["error"]
|
||||||
|
assert "config" not in result
|
||||||
|
assert "exception_type" not in result
|
||||||
|
assert "exception_chain" not in result
|
||||||
|
assert "traceback" not in result
|
||||||
|
|
||||||
|
|
||||||
class TestCheckWebApiKey:
|
class TestCheckWebApiKey:
|
||||||
"""Test suite for check_web_api_key() unified availability check."""
|
"""Test suite for check_web_api_key() unified availability check."""
|
||||||
|
|
||||||
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
|
_ENV_KEYS = (
|
||||||
|
"PARALLEL_API_KEY",
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
)
|
||||||
|
|
||||||
def setup_method(self):
|
def setup_method(self):
|
||||||
for key in self._ENV_KEYS:
|
for key in self._ENV_KEYS:
|
||||||
|
|
@ -329,3 +537,22 @@ class TestCheckWebApiKey:
|
||||||
}):
|
}):
|
||||||
from tools.web_tools import check_web_api_key
|
from tools.web_tools import check_web_api_key
|
||||||
assert check_web_api_key() is True
|
assert check_web_api_key() is True
|
||||||
|
|
||||||
|
def test_tool_gateway_returns_true(self):
|
||||||
|
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||||
|
from tools.web_tools import check_web_api_key
|
||||||
|
assert check_web_api_key() is True
|
||||||
|
|
||||||
|
def test_configured_backend_must_match_available_provider(self):
|
||||||
|
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
|
||||||
|
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||||
|
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||||
|
from tools.web_tools import check_web_api_key
|
||||||
|
assert check_web_api_key() is False
|
||||||
|
|
||||||
|
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
|
||||||
|
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
|
||||||
|
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||||
|
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||||
|
from tools.web_tools import check_web_api_key
|
||||||
|
assert check_web_api_key() is True
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,57 @@
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
_pending_create_keys: Dict[str, str] = {}
|
||||||
|
_pending_create_keys_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_pending_create_key(task_id: str) -> str:
|
||||||
|
with _pending_create_keys_lock:
|
||||||
|
existing = _pending_create_keys.get(task_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
created = f"browserbase-session-create:{uuid.uuid4().hex}"
|
||||||
|
_pending_create_keys[task_id] = created
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_pending_create_key(task_id: str) -> None:
|
||||||
|
with _pending_create_keys_lock:
|
||||||
|
_pending_create_keys.pop(task_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_preserve_pending_create_key(response: requests.Response) -> bool:
|
||||||
|
if response.status_code >= 500:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if response.status_code != 409:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
error = payload.get("error")
|
||||||
|
if not isinstance(error, dict):
|
||||||
|
return False
|
||||||
|
|
||||||
|
message = str(error.get("message") or "").lower()
|
||||||
|
return "already in progress" in message
|
||||||
|
|
||||||
|
|
||||||
class BrowserbaseProvider(CloudBrowserProvider):
|
class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
|
|
@ -19,28 +62,46 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
return "Browserbase"
|
return "Browserbase"
|
||||||
|
|
||||||
def is_configured(self) -> bool:
|
def is_configured(self) -> bool:
|
||||||
return bool(
|
return self._get_config_or_none() is not None
|
||||||
os.environ.get("BROWSERBASE_API_KEY")
|
|
||||||
and os.environ.get("BROWSERBASE_PROJECT_ID")
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Session lifecycle
|
# Session lifecycle
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _get_config(self) -> Dict[str, str]:
|
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
||||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
||||||
if not api_key or not project_id:
|
if api_key and project_id:
|
||||||
|
return {
|
||||||
|
"api_key": api_key,
|
||||||
|
"project_id": project_id,
|
||||||
|
"base_url": os.environ.get("BROWSERBASE_BASE_URL", "https://api.browserbase.com").rstrip("/"),
|
||||||
|
"managed_mode": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
managed = resolve_managed_tool_gateway("browserbase")
|
||||||
|
if managed is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"api_key": managed.nous_user_token,
|
||||||
|
"project_id": "managed",
|
||||||
|
"base_url": managed.gateway_origin.rstrip("/"),
|
||||||
|
"managed_mode": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_config(self) -> Dict[str, Any]:
|
||||||
|
config = self._get_config_or_none()
|
||||||
|
if config is None:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"BROWSERBASE_API_KEY and BROWSERBASE_PROJECT_ID environment "
|
"Browserbase requires either direct BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID credentials "
|
||||||
"variables are required. Get your credentials at "
|
"or a managed Browserbase gateway configuration."
|
||||||
"https://browserbase.com"
|
|
||||||
)
|
)
|
||||||
return {"api_key": api_key, "project_id": project_id}
|
return config
|
||||||
|
|
||||||
def create_session(self, task_id: str) -> Dict[str, object]:
|
def create_session(self, task_id: str) -> Dict[str, object]:
|
||||||
config = self._get_config()
|
config = self._get_config()
|
||||||
|
managed_mode = bool(config.get("managed_mode"))
|
||||||
|
|
||||||
# Optional env-var knobs
|
# Optional env-var knobs
|
||||||
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
enable_proxies = os.environ.get("BROWSERBASE_PROXIES", "true").lower() != "false"
|
||||||
|
|
@ -80,8 +141,11 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-BB-API-Key": config["api_key"],
|
"X-BB-API-Key": config["api_key"],
|
||||||
}
|
}
|
||||||
|
if managed_mode:
|
||||||
|
headers["X-Idempotency-Key"] = _get_or_create_pending_create_key(task_id)
|
||||||
|
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
"https://api.browserbase.com/v1/sessions",
|
f"{config['base_url']}/v1/sessions",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=session_config,
|
json=session_config,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
|
@ -91,7 +155,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
keepalive_fallback = False
|
keepalive_fallback = False
|
||||||
|
|
||||||
# Handle 402 — paid features unavailable
|
# Handle 402 — paid features unavailable
|
||||||
if response.status_code == 402:
|
if response.status_code == 402 and not managed_mode:
|
||||||
if enable_keep_alive:
|
if enable_keep_alive:
|
||||||
keepalive_fallback = True
|
keepalive_fallback = True
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -100,7 +164,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
)
|
)
|
||||||
session_config.pop("keepAlive", None)
|
session_config.pop("keepAlive", None)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
"https://api.browserbase.com/v1/sessions",
|
f"{config['base_url']}/v1/sessions",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=session_config,
|
json=session_config,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
|
@ -114,20 +178,25 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
)
|
)
|
||||||
session_config.pop("proxies", None)
|
session_config.pop("proxies", None)
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
"https://api.browserbase.com/v1/sessions",
|
f"{config['base_url']}/v1/sessions",
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json=session_config,
|
json=session_config,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not response.ok:
|
if not response.ok:
|
||||||
|
if managed_mode and not _should_preserve_pending_create_key(response):
|
||||||
|
_clear_pending_create_key(task_id)
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Failed to create Browserbase session: "
|
f"Failed to create Browserbase session: "
|
||||||
f"{response.status_code} {response.text}"
|
f"{response.status_code} {response.text}"
|
||||||
)
|
)
|
||||||
|
|
||||||
session_data = response.json()
|
session_data = response.json()
|
||||||
|
if managed_mode:
|
||||||
|
_clear_pending_create_key(task_id)
|
||||||
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
session_name = f"hermes_{task_id}_{uuid.uuid4().hex[:8]}"
|
||||||
|
external_call_id = response.headers.get("x-external-call-id") if managed_mode else None
|
||||||
|
|
||||||
if enable_proxies and not proxies_fallback:
|
if enable_proxies and not proxies_fallback:
|
||||||
features_enabled["proxies"] = True
|
features_enabled["proxies"] = True
|
||||||
|
|
@ -146,6 +215,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
"bb_session_id": session_data["id"],
|
"bb_session_id": session_data["id"],
|
||||||
"cdp_url": session_data["connectUrl"],
|
"cdp_url": session_data["connectUrl"],
|
||||||
"features": features_enabled,
|
"features": features_enabled,
|
||||||
|
"external_call_id": external_call_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
def close_session(self, session_id: str) -> bool:
|
def close_session(self, session_id: str) -> bool:
|
||||||
|
|
@ -157,7 +227,7 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = requests.post(
|
response = requests.post(
|
||||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||||
headers={
|
headers={
|
||||||
"X-BB-API-Key": config["api_key"],
|
"X-BB-API-Key": config["api_key"],
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -184,20 +254,19 @@ class BrowserbaseProvider(CloudBrowserProvider):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def emergency_cleanup(self, session_id: str) -> None:
|
def emergency_cleanup(self, session_id: str) -> None:
|
||||||
api_key = os.environ.get("BROWSERBASE_API_KEY")
|
config = self._get_config_or_none()
|
||||||
project_id = os.environ.get("BROWSERBASE_PROJECT_ID")
|
if config is None:
|
||||||
if not api_key or not project_id:
|
|
||||||
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
|
logger.warning("Cannot emergency-cleanup Browserbase session %s — missing credentials", session_id)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
requests.post(
|
requests.post(
|
||||||
f"https://api.browserbase.com/v1/sessions/{session_id}",
|
f"{config['base_url']}/v1/sessions/{session_id}",
|
||||||
headers={
|
headers={
|
||||||
"X-BB-API-Key": api_key,
|
"X-BB-API-Key": config["api_key"],
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
json={
|
json={
|
||||||
"projectId": project_id,
|
"projectId": config["project_id"],
|
||||||
"status": "REQUEST_RELEASE",
|
"status": "REQUEST_RELEASE",
|
||||||
},
|
},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,7 @@ except Exception:
|
||||||
from tools.browser_providers.base import CloudBrowserProvider
|
from tools.browser_providers.base import CloudBrowserProvider
|
||||||
from tools.browser_providers.browserbase import BrowserbaseProvider
|
from tools.browser_providers.browserbase import BrowserbaseProvider
|
||||||
from tools.browser_providers.browser_use import BrowserUseProvider
|
from tools.browser_providers.browser_use import BrowserUseProvider
|
||||||
|
from tools.tool_backend_helpers import normalize_browser_cloud_provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -235,7 +236,9 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||||
"""Return the configured cloud browser provider, or None for local mode.
|
"""Return the configured cloud browser provider, or None for local mode.
|
||||||
|
|
||||||
Reads ``config["browser"]["cloud_provider"]`` once and caches the result
|
Reads ``config["browser"]["cloud_provider"]`` once and caches the result
|
||||||
for the process lifetime. If unset → local mode (None).
|
for the process lifetime. An explicit ``local`` provider disables cloud
|
||||||
|
fallback. If unset, fall back to Browserbase when direct or managed
|
||||||
|
Browserbase credentials are available.
|
||||||
"""
|
"""
|
||||||
global _cached_cloud_provider, _cloud_provider_resolved
|
global _cached_cloud_provider, _cloud_provider_resolved
|
||||||
if _cloud_provider_resolved:
|
if _cloud_provider_resolved:
|
||||||
|
|
@ -249,14 +252,45 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||||
import yaml
|
import yaml
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
cfg = yaml.safe_load(f) or {}
|
cfg = yaml.safe_load(f) or {}
|
||||||
provider_key = cfg.get("browser", {}).get("cloud_provider")
|
browser_cfg = cfg.get("browser", {})
|
||||||
|
provider_key = None
|
||||||
|
if isinstance(browser_cfg, dict) and "cloud_provider" in browser_cfg:
|
||||||
|
provider_key = normalize_browser_cloud_provider(
|
||||||
|
browser_cfg.get("cloud_provider")
|
||||||
|
)
|
||||||
|
if provider_key == "local":
|
||||||
|
_cached_cloud_provider = None
|
||||||
|
return None
|
||||||
if provider_key and provider_key in _PROVIDER_REGISTRY:
|
if provider_key and provider_key in _PROVIDER_REGISTRY:
|
||||||
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
|
_cached_cloud_provider = _PROVIDER_REGISTRY[provider_key]()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Could not read cloud_provider from config: %s", e)
|
logger.debug("Could not read cloud_provider from config: %s", e)
|
||||||
|
|
||||||
|
if _cached_cloud_provider is None:
|
||||||
|
fallback_provider = BrowserbaseProvider()
|
||||||
|
if fallback_provider.is_configured():
|
||||||
|
_cached_cloud_provider = fallback_provider
|
||||||
|
|
||||||
return _cached_cloud_provider
|
return _cached_cloud_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _get_browserbase_config_or_none() -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return Browserbase direct or managed config, or None when unavailable."""
|
||||||
|
return BrowserbaseProvider()._get_config_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_browserbase_config() -> Dict[str, Any]:
|
||||||
|
"""Return Browserbase config or raise when neither direct nor managed mode is available."""
|
||||||
|
return BrowserbaseProvider()._get_config()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_mode() -> bool:
|
||||||
|
"""Return True when the browser tool will use a local browser backend."""
|
||||||
|
if _get_cdp_override():
|
||||||
|
return False
|
||||||
|
return _get_cloud_provider() is None
|
||||||
|
|
||||||
|
|
||||||
def _socket_safe_tmpdir() -> str:
|
def _socket_safe_tmpdir() -> str:
|
||||||
"""Return a short temp directory path suitable for Unix domain sockets.
|
"""Return a short temp directory path suitable for Unix domain sockets.
|
||||||
|
|
||||||
|
|
@ -1845,7 +1879,7 @@ if __name__ == "__main__":
|
||||||
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
|
print(" Install: npm install -g agent-browser && agent-browser install --with-deps")
|
||||||
if _cp is not None and not _cp.is_configured():
|
if _cp is not None and not _cp.is_configured():
|
||||||
print(f" - {_cp.provider_name()} credentials not configured")
|
print(f" - {_cp.provider_name()} credentials not configured")
|
||||||
print(" Tip: remove cloud_provider from config to use free local mode instead")
|
print(" Tip: set browser.cloud_provider to 'local' to use free local mode instead")
|
||||||
|
|
||||||
print("\n📋 Available Browser Tools:")
|
print("\n📋 Available Browser Tools:")
|
||||||
for schema in BROWSER_TOOL_SCHEMAS:
|
for schema in BROWSER_TOOL_SCHEMAS:
|
||||||
|
|
|
||||||
|
|
@ -757,7 +757,8 @@ def build_execute_code_schema(enabled_sandbox_tools: set = None) -> dict:
|
||||||
f"Available via `from hermes_tools import ...`:\n\n"
|
f"Available via `from hermes_tools import ...`:\n\n"
|
||||||
f"{tool_lines}\n\n"
|
f"{tool_lines}\n\n"
|
||||||
"Limits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. "
|
"Limits: 5-minute timeout, 50KB stdout cap, max 50 tool calls per script. "
|
||||||
"terminal() is foreground-only (no background or pty).\n\n"
|
"terminal() is foreground-only (no background or pty). "
|
||||||
|
"If the session uses a cloud sandbox backend, treat it as resumable task state rather than a durable always-on machine.\n\n"
|
||||||
"Print your final result to stdout. Use Python stdlib (json, re, math, csv, "
|
"Print your final result to stdout. Use Python stdlib (json, re, math, csv, "
|
||||||
"datetime, collections, etc.) for processing between tool calls.\n\n"
|
"datetime, collections, etc.) for processing between tool calls.\n\n"
|
||||||
"Also available (no import needed — built into hermes_tools):\n"
|
"Also available (no import needed — built into hermes_tools):\n"
|
||||||
|
|
|
||||||
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 time
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from tools.environments.base import BaseEnvironment
|
||||||
|
from tools.interrupt import is_interrupted
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_timeout_env(name: str, default: float) -> float:
|
||||||
|
try:
|
||||||
|
value = float(os.getenv(name, str(default)))
|
||||||
|
return value if value > 0 else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
class ManagedModalEnvironment(BaseEnvironment):
|
||||||
|
"""Gateway-owned Modal sandbox with Hermes-compatible execute/cleanup."""
|
||||||
|
|
||||||
|
_CONNECT_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CONNECT_TIMEOUT_SECONDS", 1.0)
|
||||||
|
_POLL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_POLL_READ_TIMEOUT_SECONDS", 5.0)
|
||||||
|
_CANCEL_READ_TIMEOUT_SECONDS = _request_timeout_env("TERMINAL_MANAGED_MODAL_CANCEL_READ_TIMEOUT_SECONDS", 5.0)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
image: str,
|
||||||
|
cwd: str = "/root",
|
||||||
|
timeout: int = 60,
|
||||||
|
modal_sandbox_kwargs: Optional[Dict[str, Any]] = None,
|
||||||
|
persistent_filesystem: bool = True,
|
||||||
|
task_id: str = "default",
|
||||||
|
):
|
||||||
|
super().__init__(cwd=cwd, timeout=timeout)
|
||||||
|
|
||||||
|
gateway = resolve_managed_tool_gateway("modal")
|
||||||
|
if gateway is None:
|
||||||
|
raise ValueError("Managed Modal requires a configured tool gateway and Nous user token")
|
||||||
|
|
||||||
|
self._gateway_origin = gateway.gateway_origin.rstrip("/")
|
||||||
|
self._nous_user_token = gateway.nous_user_token
|
||||||
|
self._task_id = task_id
|
||||||
|
self._persistent = persistent_filesystem
|
||||||
|
self._image = image
|
||||||
|
self._sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
||||||
|
self._create_idempotency_key = str(uuid.uuid4())
|
||||||
|
self._sandbox_id = self._create_sandbox()
|
||||||
|
|
||||||
|
def execute(self, command: str, cwd: str = "", *,
|
||||||
|
timeout: int | None = None,
|
||||||
|
stdin_data: str | None = None) -> dict:
|
||||||
|
exec_command, sudo_stdin = self._prepare_command(command)
|
||||||
|
|
||||||
|
# When a sudo password is present, inject it via a shell-level pipe
|
||||||
|
# (same approach as the direct ModalEnvironment) since the gateway
|
||||||
|
# cannot pipe subprocess stdin directly.
|
||||||
|
if sudo_stdin is not None:
|
||||||
|
import shlex
|
||||||
|
exec_command = (
|
||||||
|
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
|
||||||
|
)
|
||||||
|
|
||||||
|
exec_cwd = cwd or self.cwd
|
||||||
|
effective_timeout = timeout or self.timeout
|
||||||
|
exec_id = str(uuid.uuid4())
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
"execId": exec_id,
|
||||||
|
"command": exec_command,
|
||||||
|
"cwd": exec_cwd,
|
||||||
|
"timeoutMs": int(effective_timeout * 1000),
|
||||||
|
}
|
||||||
|
if stdin_data is not None:
|
||||||
|
payload["stdinData"] = stdin_data
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/sandboxes/{self._sandbox_id}/execs",
|
||||||
|
json=payload,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"output": f"Managed Modal exec failed: {exc}",
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return {
|
||||||
|
"output": self._format_error("Managed Modal exec failed", response),
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
status = body.get("status")
|
||||||
|
if status in {"completed", "failed", "cancelled", "timeout"}:
|
||||||
|
return {
|
||||||
|
"output": body.get("output", ""),
|
||||||
|
"returncode": body.get("returncode", 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.get("execId") != exec_id:
|
||||||
|
return {
|
||||||
|
"output": "Managed Modal exec start did not return the expected exec id",
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
poll_interval = 0.25
|
||||||
|
deadline = time.monotonic() + effective_timeout + 10
|
||||||
|
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if is_interrupted():
|
||||||
|
self._cancel_exec(exec_id)
|
||||||
|
return {
|
||||||
|
"output": "[Command interrupted - Modal sandbox exec cancelled]",
|
||||||
|
"returncode": 130,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
status_response = self._request(
|
||||||
|
"GET",
|
||||||
|
f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}",
|
||||||
|
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._POLL_READ_TIMEOUT_SECONDS),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"output": f"Managed Modal exec poll failed: {exc}",
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status_response.status_code == 404:
|
||||||
|
return {
|
||||||
|
"output": "Managed Modal exec not found",
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
if status_response.status_code >= 400:
|
||||||
|
return {
|
||||||
|
"output": self._format_error("Managed Modal exec poll failed", status_response),
|
||||||
|
"returncode": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
status_body = status_response.json()
|
||||||
|
status = status_body.get("status")
|
||||||
|
if status in {"completed", "failed", "cancelled", "timeout"}:
|
||||||
|
return {
|
||||||
|
"output": status_body.get("output", ""),
|
||||||
|
"returncode": status_body.get("returncode", 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
time.sleep(poll_interval)
|
||||||
|
|
||||||
|
self._cancel_exec(exec_id)
|
||||||
|
return {
|
||||||
|
"output": f"Managed Modal exec timed out after {effective_timeout}s",
|
||||||
|
"returncode": 124,
|
||||||
|
}
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
if not getattr(self, "_sandbox_id", None):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/sandboxes/{self._sandbox_id}/terminate",
|
||||||
|
json={
|
||||||
|
"snapshotBeforeTerminate": self._persistent,
|
||||||
|
},
|
||||||
|
timeout=60,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Managed Modal cleanup failed: %s", exc)
|
||||||
|
finally:
|
||||||
|
self._sandbox_id = None
|
||||||
|
|
||||||
|
def _create_sandbox(self) -> str:
|
||||||
|
cpu = self._coerce_number(self._sandbox_kwargs.get("cpu"), 1)
|
||||||
|
memory = self._coerce_number(
|
||||||
|
self._sandbox_kwargs.get("memoryMiB", self._sandbox_kwargs.get("memory")),
|
||||||
|
5120,
|
||||||
|
)
|
||||||
|
disk = self._coerce_number(
|
||||||
|
self._sandbox_kwargs.get("ephemeral_disk", self._sandbox_kwargs.get("diskMiB")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
create_payload = {
|
||||||
|
"image": self._image,
|
||||||
|
"cwd": self.cwd,
|
||||||
|
"cpu": cpu,
|
||||||
|
"memoryMiB": memory,
|
||||||
|
"timeoutMs": 3_600_000,
|
||||||
|
"idleTimeoutMs": max(300_000, int(self.timeout * 1000)),
|
||||||
|
"persistentFilesystem": self._persistent,
|
||||||
|
"logicalKey": self._task_id,
|
||||||
|
}
|
||||||
|
if disk is not None:
|
||||||
|
create_payload["diskMiB"] = disk
|
||||||
|
|
||||||
|
response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/v1/sandboxes",
|
||||||
|
json=create_payload,
|
||||||
|
timeout=60,
|
||||||
|
extra_headers={
|
||||||
|
"x-idempotency-key": self._create_idempotency_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise RuntimeError(self._format_error("Managed Modal create failed", response))
|
||||||
|
|
||||||
|
body = response.json()
|
||||||
|
sandbox_id = body.get("id")
|
||||||
|
if not isinstance(sandbox_id, str) or not sandbox_id:
|
||||||
|
raise RuntimeError("Managed Modal create did not return a sandbox id")
|
||||||
|
return sandbox_id
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, *,
|
||||||
|
json: Dict[str, Any] | None = None,
|
||||||
|
timeout: int = 30,
|
||||||
|
extra_headers: Dict[str, str] | None = None) -> requests.Response:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {self._nous_user_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
|
||||||
|
return requests.request(
|
||||||
|
method,
|
||||||
|
f"{self._gateway_origin}{path}",
|
||||||
|
headers=headers,
|
||||||
|
json=json,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_exec(self, exec_id: str) -> None:
|
||||||
|
try:
|
||||||
|
self._request(
|
||||||
|
"POST",
|
||||||
|
f"/v1/sandboxes/{self._sandbox_id}/execs/{exec_id}/cancel",
|
||||||
|
timeout=(self._CONNECT_TIMEOUT_SECONDS, self._CANCEL_READ_TIMEOUT_SECONDS),
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Managed Modal exec cancel failed: %s", exc)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_number(value: Any, default: float) -> float:
|
||||||
|
try:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_error(prefix: str, response: requests.Response) -> str:
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
message = payload.get("error") or payload.get("message") or payload.get("code")
|
||||||
|
if isinstance(message, str) and message:
|
||||||
|
return f"{prefix}: {message}"
|
||||||
|
return f"{prefix}: {json.dumps(payload, ensure_ascii=False)}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
text = response.text.strip()
|
||||||
|
if text:
|
||||||
|
return f"{prefix}: {text}"
|
||||||
|
return f"{prefix}: HTTP {response.status_code}"
|
||||||
|
|
@ -20,6 +20,7 @@ from tools.interrupt import is_interrupted
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
|
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
|
||||||
|
_DIRECT_SNAPSHOT_NAMESPACE = "direct"
|
||||||
|
|
||||||
|
|
||||||
def _load_snapshots() -> Dict[str, str]:
|
def _load_snapshots() -> Dict[str, str]:
|
||||||
|
|
@ -38,12 +39,72 @@ def _save_snapshots(data: Dict[str, str]) -> None:
|
||||||
_SNAPSHOT_STORE.write_text(json.dumps(data, indent=2))
|
_SNAPSHOT_STORE.write_text(json.dumps(data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
class _AsyncWorker:
|
def _direct_snapshot_key(task_id: str) -> str:
|
||||||
"""Background thread with its own event loop for async-safe swe-rex calls.
|
return f"{_DIRECT_SNAPSHOT_NAMESPACE}:{task_id}"
|
||||||
|
|
||||||
Allows sync code to submit async coroutines and block for results,
|
|
||||||
even when called from inside another running event loop (e.g. Atropos).
|
def _get_snapshot_restore_candidate(task_id: str) -> tuple[str | None, bool]:
|
||||||
"""
|
"""Return a snapshot id for direct Modal restore and whether the key is legacy."""
|
||||||
|
snapshots = _load_snapshots()
|
||||||
|
|
||||||
|
namespaced_key = _direct_snapshot_key(task_id)
|
||||||
|
snapshot_id = snapshots.get(namespaced_key)
|
||||||
|
if isinstance(snapshot_id, str) and snapshot_id:
|
||||||
|
return snapshot_id, False
|
||||||
|
|
||||||
|
legacy_snapshot_id = snapshots.get(task_id)
|
||||||
|
if isinstance(legacy_snapshot_id, str) and legacy_snapshot_id:
|
||||||
|
return legacy_snapshot_id, True
|
||||||
|
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
|
||||||
|
def _store_direct_snapshot(task_id: str, snapshot_id: str) -> None:
|
||||||
|
"""Persist the direct Modal snapshot id under the direct namespace."""
|
||||||
|
snapshots = _load_snapshots()
|
||||||
|
snapshots[_direct_snapshot_key(task_id)] = snapshot_id
|
||||||
|
snapshots.pop(task_id, None)
|
||||||
|
_save_snapshots(snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_direct_snapshot(task_id: str, snapshot_id: str | None = None) -> None:
|
||||||
|
"""Remove direct Modal snapshot entries for a task, including legacy keys."""
|
||||||
|
snapshots = _load_snapshots()
|
||||||
|
updated = False
|
||||||
|
|
||||||
|
for key in (_direct_snapshot_key(task_id), task_id):
|
||||||
|
value = snapshots.get(key)
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if snapshot_id is None or value == snapshot_id:
|
||||||
|
snapshots.pop(key, None)
|
||||||
|
updated = True
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
_save_snapshots(snapshots)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_modal_image(image_spec: Any) -> Any:
|
||||||
|
"""Convert registry references or snapshot ids into Modal image objects."""
|
||||||
|
import modal as _modal
|
||||||
|
|
||||||
|
if not isinstance(image_spec, str):
|
||||||
|
return image_spec
|
||||||
|
|
||||||
|
if image_spec.startswith("im-"):
|
||||||
|
return _modal.Image.from_id(image_spec)
|
||||||
|
|
||||||
|
return _modal.Image.from_registry(
|
||||||
|
image_spec,
|
||||||
|
setup_dockerfile_commands=[
|
||||||
|
"RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; "
|
||||||
|
"python -m ensurepip --upgrade --default-pip 2>/dev/null || true",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _AsyncWorker:
|
||||||
|
"""Background thread with its own event loop for async-safe swe-rex calls."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
@ -101,42 +162,20 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
|
|
||||||
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
||||||
|
|
||||||
# If persistent, try to restore from a previous snapshot
|
restored_snapshot_id = None
|
||||||
restored_image = None
|
restored_from_legacy_key = False
|
||||||
if self._persistent:
|
if self._persistent:
|
||||||
snapshot_id = _load_snapshots().get(self._task_id)
|
restored_snapshot_id, restored_from_legacy_key = _get_snapshot_restore_candidate(self._task_id)
|
||||||
if snapshot_id:
|
if restored_snapshot_id:
|
||||||
try:
|
logger.info("Modal: restoring from snapshot %s", restored_snapshot_id[:20])
|
||||||
import modal
|
|
||||||
restored_image = modal.Image.from_id(snapshot_id)
|
|
||||||
logger.info("Modal: restoring from snapshot %s", snapshot_id[:20])
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Modal: failed to restore snapshot, using base image: %s", e)
|
|
||||||
restored_image = None
|
|
||||||
|
|
||||||
effective_image = restored_image if restored_image else image
|
|
||||||
|
|
||||||
# Pre-build a modal.Image with pip fix for Modal's legacy image builder.
|
|
||||||
# Some task images have broken pip; fix via ensurepip before Modal uses it.
|
|
||||||
import modal as _modal
|
|
||||||
if isinstance(effective_image, str):
|
|
||||||
effective_image = _modal.Image.from_registry(
|
|
||||||
effective_image,
|
|
||||||
setup_dockerfile_commands=[
|
|
||||||
"RUN rm -rf /usr/local/lib/python*/site-packages/pip* 2>/dev/null; "
|
|
||||||
"python -m ensurepip --upgrade --default-pip 2>/dev/null || true",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start the async worker thread and create the deployment on it
|
|
||||||
# so all gRPC channels are bound to the worker's event loop.
|
|
||||||
self._worker.start()
|
self._worker.start()
|
||||||
|
|
||||||
from swerex.deployment.modal import ModalDeployment
|
from swerex.deployment.modal import ModalDeployment
|
||||||
|
|
||||||
async def _create_and_start():
|
async def _create_and_start(image_spec: Any):
|
||||||
deployment = ModalDeployment(
|
deployment = ModalDeployment(
|
||||||
image=effective_image,
|
image=image_spec,
|
||||||
startup_timeout=180.0,
|
startup_timeout=180.0,
|
||||||
runtime_timeout=3600.0,
|
runtime_timeout=3600.0,
|
||||||
deployment_timeout=3600.0,
|
deployment_timeout=3600.0,
|
||||||
|
|
@ -146,7 +185,30 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
await deployment.start()
|
await deployment.start()
|
||||||
return deployment
|
return deployment
|
||||||
|
|
||||||
self._deployment = self._worker.run_coroutine(_create_and_start())
|
try:
|
||||||
|
target_image_spec = restored_snapshot_id or image
|
||||||
|
try:
|
||||||
|
effective_image = _resolve_modal_image(target_image_spec)
|
||||||
|
self._deployment = self._worker.run_coroutine(_create_and_start(effective_image))
|
||||||
|
except Exception as exc:
|
||||||
|
if not restored_snapshot_id:
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Modal: failed to restore snapshot %s, retrying with base image: %s",
|
||||||
|
restored_snapshot_id[:20],
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
_delete_direct_snapshot(self._task_id, restored_snapshot_id)
|
||||||
|
base_image = _resolve_modal_image(image)
|
||||||
|
self._deployment = self._worker.run_coroutine(_create_and_start(base_image))
|
||||||
|
else:
|
||||||
|
if restored_snapshot_id and restored_from_legacy_key:
|
||||||
|
_store_direct_snapshot(self._task_id, restored_snapshot_id)
|
||||||
|
logger.info("Modal: migrated legacy snapshot entry for task %s", self._task_id)
|
||||||
|
except Exception:
|
||||||
|
self._worker.stop()
|
||||||
|
raise
|
||||||
|
|
||||||
def execute(self, command: str, cwd: str = "", *,
|
def execute(self, command: str, cwd: str = "", *,
|
||||||
timeout: int | None = None,
|
timeout: int | None = None,
|
||||||
|
|
@ -175,7 +237,6 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
effective_cwd = cwd or self.cwd
|
effective_cwd = cwd or self.cwd
|
||||||
effective_timeout = timeout or self.timeout
|
effective_timeout = timeout or self.timeout
|
||||||
|
|
||||||
# Run in a background thread so we can poll for interrupts
|
|
||||||
result_holder = {"value": None, "error": None}
|
result_holder = {"value": None, "error": None}
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
|
|
@ -191,6 +252,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
merge_output_streams=True,
|
merge_output_streams=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
output = self._worker.run_coroutine(_do_execute())
|
output = self._worker.run_coroutine(_do_execute())
|
||||||
result_holder["value"] = {
|
result_holder["value"] = {
|
||||||
"output": output.stdout,
|
"output": output.stdout,
|
||||||
|
|
@ -227,7 +289,7 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
|
|
||||||
if self._persistent:
|
if self._persistent:
|
||||||
try:
|
try:
|
||||||
sandbox = getattr(self._deployment, '_sandbox', None)
|
sandbox = getattr(self._deployment, "_sandbox", None)
|
||||||
if sandbox:
|
if sandbox:
|
||||||
async def _snapshot():
|
async def _snapshot():
|
||||||
img = await sandbox.snapshot_filesystem.aio()
|
img = await sandbox.snapshot_filesystem.aio()
|
||||||
|
|
@ -239,11 +301,12 @@ class ModalEnvironment(BaseEnvironment):
|
||||||
snapshot_id = None
|
snapshot_id = None
|
||||||
|
|
||||||
if snapshot_id:
|
if snapshot_id:
|
||||||
snapshots = _load_snapshots()
|
_store_direct_snapshot(self._task_id, snapshot_id)
|
||||||
snapshots[self._task_id] = snapshot_id
|
logger.info(
|
||||||
_save_snapshots(snapshots)
|
"Modal: saved filesystem snapshot %s for task %s",
|
||||||
logger.info("Modal: saved filesystem snapshot %s for task %s",
|
snapshot_id[:20],
|
||||||
snapshot_id[:20], self._task_id)
|
self._task_id,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,13 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
from typing import Dict, Any, Optional, Union
|
from typing import Dict, Any, Optional, Union
|
||||||
|
from urllib.parse import urlencode
|
||||||
import fal_client
|
import fal_client
|
||||||
from tools.debug_helpers import DebugSession
|
from tools.debug_helpers import DebugSession
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -77,6 +81,137 @@ VALID_OUTPUT_FORMATS = ["jpeg", "png"]
|
||||||
VALID_ACCELERATION_MODES = ["none", "regular", "high"]
|
VALID_ACCELERATION_MODES = ["none", "regular", "high"]
|
||||||
|
|
||||||
_debug = DebugSession("image_tools", env_var="IMAGE_TOOLS_DEBUG")
|
_debug = DebugSession("image_tools", env_var="IMAGE_TOOLS_DEBUG")
|
||||||
|
_managed_fal_client = None
|
||||||
|
_managed_fal_client_config = None
|
||||||
|
_managed_fal_client_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_managed_fal_gateway():
|
||||||
|
"""Return managed fal-queue gateway config when direct FAL credentials are absent."""
|
||||||
|
if os.getenv("FAL_KEY"):
|
||||||
|
return None
|
||||||
|
return resolve_managed_tool_gateway("fal-queue")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:
|
||||||
|
normalized_origin = str(queue_run_origin or "").strip().rstrip("/")
|
||||||
|
if not normalized_origin:
|
||||||
|
raise ValueError("Managed FAL queue origin is required")
|
||||||
|
return f"{normalized_origin}/"
|
||||||
|
|
||||||
|
|
||||||
|
class _ManagedFalSyncClient:
|
||||||
|
"""Small per-instance wrapper around fal_client.SyncClient for managed queue hosts."""
|
||||||
|
|
||||||
|
def __init__(self, *, key: str, queue_run_origin: str):
|
||||||
|
sync_client_class = getattr(fal_client, "SyncClient", None)
|
||||||
|
if sync_client_class is None:
|
||||||
|
raise RuntimeError("fal_client.SyncClient is required for managed FAL gateway mode")
|
||||||
|
|
||||||
|
client_module = getattr(fal_client, "client", None)
|
||||||
|
if client_module is None:
|
||||||
|
raise RuntimeError("fal_client.client is required for managed FAL gateway mode")
|
||||||
|
|
||||||
|
self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)
|
||||||
|
self._sync_client = sync_client_class(key=key)
|
||||||
|
self._http_client = getattr(self._sync_client, "_client", None)
|
||||||
|
self._maybe_retry_request = getattr(client_module, "_maybe_retry_request", None)
|
||||||
|
self._raise_for_status = getattr(client_module, "_raise_for_status", None)
|
||||||
|
self._request_handle_class = getattr(client_module, "SyncRequestHandle", None)
|
||||||
|
self._add_hint_header = getattr(client_module, "add_hint_header", None)
|
||||||
|
self._add_priority_header = getattr(client_module, "add_priority_header", None)
|
||||||
|
self._add_timeout_header = getattr(client_module, "add_timeout_header", None)
|
||||||
|
|
||||||
|
if self._http_client is None:
|
||||||
|
raise RuntimeError("fal_client.SyncClient._client is required for managed FAL gateway mode")
|
||||||
|
if self._maybe_retry_request is None or self._raise_for_status is None:
|
||||||
|
raise RuntimeError("fal_client.client request helpers are required for managed FAL gateway mode")
|
||||||
|
if self._request_handle_class is None:
|
||||||
|
raise RuntimeError("fal_client.client.SyncRequestHandle is required for managed FAL gateway mode")
|
||||||
|
|
||||||
|
def submit(
|
||||||
|
self,
|
||||||
|
application: str,
|
||||||
|
arguments: Dict[str, Any],
|
||||||
|
*,
|
||||||
|
path: str = "",
|
||||||
|
hint: Optional[str] = None,
|
||||||
|
webhook_url: Optional[str] = None,
|
||||||
|
priority: Any = None,
|
||||||
|
headers: Optional[Dict[str, str]] = None,
|
||||||
|
start_timeout: Optional[Union[int, float]] = None,
|
||||||
|
):
|
||||||
|
url = self._queue_url_format + application
|
||||||
|
if path:
|
||||||
|
url += "/" + path.lstrip("/")
|
||||||
|
if webhook_url is not None:
|
||||||
|
url += "?" + urlencode({"fal_webhook": webhook_url})
|
||||||
|
|
||||||
|
request_headers = dict(headers or {})
|
||||||
|
if hint is not None and self._add_hint_header is not None:
|
||||||
|
self._add_hint_header(hint, request_headers)
|
||||||
|
if priority is not None:
|
||||||
|
if self._add_priority_header is None:
|
||||||
|
raise RuntimeError("fal_client.client.add_priority_header is required for priority requests")
|
||||||
|
self._add_priority_header(priority, request_headers)
|
||||||
|
if start_timeout is not None:
|
||||||
|
if self._add_timeout_header is None:
|
||||||
|
raise RuntimeError("fal_client.client.add_timeout_header is required for timeout requests")
|
||||||
|
self._add_timeout_header(start_timeout, request_headers)
|
||||||
|
|
||||||
|
response = self._maybe_retry_request(
|
||||||
|
self._http_client,
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
json=arguments,
|
||||||
|
timeout=getattr(self._sync_client, "default_timeout", 120.0),
|
||||||
|
headers=request_headers,
|
||||||
|
)
|
||||||
|
self._raise_for_status(response)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
return self._request_handle_class(
|
||||||
|
request_id=data["request_id"],
|
||||||
|
response_url=data["response_url"],
|
||||||
|
status_url=data["status_url"],
|
||||||
|
cancel_url=data["cancel_url"],
|
||||||
|
client=self._http_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_managed_fal_client(managed_gateway):
|
||||||
|
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
|
||||||
|
global _managed_fal_client, _managed_fal_client_config
|
||||||
|
|
||||||
|
client_config = (
|
||||||
|
managed_gateway.gateway_origin.rstrip("/"),
|
||||||
|
managed_gateway.nous_user_token,
|
||||||
|
)
|
||||||
|
with _managed_fal_client_lock:
|
||||||
|
if _managed_fal_client is not None and _managed_fal_client_config == client_config:
|
||||||
|
return _managed_fal_client
|
||||||
|
|
||||||
|
_managed_fal_client = _ManagedFalSyncClient(
|
||||||
|
key=managed_gateway.nous_user_token,
|
||||||
|
queue_run_origin=managed_gateway.gateway_origin,
|
||||||
|
)
|
||||||
|
_managed_fal_client_config = client_config
|
||||||
|
return _managed_fal_client
|
||||||
|
|
||||||
|
|
||||||
|
def _submit_fal_request(model: str, arguments: Dict[str, Any]):
|
||||||
|
"""Submit a FAL request using direct credentials or the managed queue gateway."""
|
||||||
|
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
|
||||||
|
managed_gateway = _resolve_managed_fal_gateway()
|
||||||
|
if managed_gateway is None:
|
||||||
|
return fal_client.submit(model, arguments=arguments, headers=request_headers)
|
||||||
|
|
||||||
|
managed_client = _get_managed_fal_client(managed_gateway)
|
||||||
|
return managed_client.submit(
|
||||||
|
model,
|
||||||
|
arguments=arguments,
|
||||||
|
headers=request_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_parameters(
|
def _validate_parameters(
|
||||||
|
|
@ -186,9 +321,9 @@ def _upscale_image(image_url: str, original_prompt: str) -> Dict[str, Any]:
|
||||||
# The async API (submit_async) caches a global httpx.AsyncClient via
|
# The async API (submit_async) caches a global httpx.AsyncClient via
|
||||||
# @cached_property, which breaks when asyncio.run() destroys the loop
|
# @cached_property, which breaks when asyncio.run() destroys the loop
|
||||||
# between calls (gateway thread-pool pattern).
|
# between calls (gateway thread-pool pattern).
|
||||||
handler = fal_client.submit(
|
handler = _submit_fal_request(
|
||||||
UPSCALER_MODEL,
|
UPSCALER_MODEL,
|
||||||
arguments=upscaler_arguments
|
arguments=upscaler_arguments,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the upscaled result (sync — blocks until done)
|
# Get the upscaled result (sync — blocks until done)
|
||||||
|
|
@ -280,8 +415,10 @@ def image_generate_tool(
|
||||||
raise ValueError("Prompt is required and must be a non-empty string")
|
raise ValueError("Prompt is required and must be a non-empty string")
|
||||||
|
|
||||||
# Check API key availability
|
# Check API key availability
|
||||||
if not os.getenv("FAL_KEY"):
|
if not (os.getenv("FAL_KEY") or _resolve_managed_fal_gateway()):
|
||||||
raise ValueError("FAL_KEY environment variable not set")
|
raise ValueError(
|
||||||
|
"FAL_KEY environment variable not set and managed FAL gateway is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate other parameters
|
# Validate other parameters
|
||||||
validated_params = _validate_parameters(
|
validated_params = _validate_parameters(
|
||||||
|
|
@ -312,9 +449,9 @@ def image_generate_tool(
|
||||||
logger.info(" Guidance: %s", validated_params['guidance_scale'])
|
logger.info(" Guidance: %s", validated_params['guidance_scale'])
|
||||||
|
|
||||||
# Submit request to FAL.ai using sync API (avoids cached event loop issues)
|
# Submit request to FAL.ai using sync API (avoids cached event loop issues)
|
||||||
handler = fal_client.submit(
|
handler = _submit_fal_request(
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
arguments=arguments
|
arguments=arguments,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get the result (sync — blocks until done)
|
# Get the result (sync — blocks until done)
|
||||||
|
|
@ -379,10 +516,12 @@ def image_generate_tool(
|
||||||
error_msg = f"Error generating image: {str(e)}"
|
error_msg = f"Error generating image: {str(e)}"
|
||||||
logger.error("%s", error_msg, exc_info=True)
|
logger.error("%s", error_msg, exc_info=True)
|
||||||
|
|
||||||
# Prepare error response - minimal format
|
# Include error details so callers can diagnose failures
|
||||||
response_data = {
|
response_data = {
|
||||||
"success": False,
|
"success": False,
|
||||||
"image": None
|
"image": None,
|
||||||
|
"error": str(e),
|
||||||
|
"error_type": type(e).__name__,
|
||||||
}
|
}
|
||||||
|
|
||||||
debug_call_data["error"] = error_msg
|
debug_call_data["error"] = error_msg
|
||||||
|
|
@ -400,7 +539,7 @@ def check_fal_api_key() -> bool:
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if API key is set, False otherwise
|
bool: True if API key is set, False otherwise
|
||||||
"""
|
"""
|
||||||
return bool(os.getenv("FAL_KEY"))
|
return bool(os.getenv("FAL_KEY") or _resolve_managed_fal_gateway())
|
||||||
|
|
||||||
|
|
||||||
def check_image_generation_requirements() -> bool:
|
def check_image_generation_requirements() -> bool:
|
||||||
|
|
@ -556,7 +695,7 @@ registry.register(
|
||||||
schema=IMAGE_GENERATE_SCHEMA,
|
schema=IMAGE_GENERATE_SCHEMA,
|
||||||
handler=_handle_image_generate,
|
handler=_handle_image_generate,
|
||||||
check_fn=check_image_generation_requirements,
|
check_fn=check_image_generation_requirements,
|
||||||
requires_env=["FAL_KEY"],
|
requires_env=[],
|
||||||
is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway
|
is_async=False, # Switched to sync fal_client API to fix "Event loop is closed" in gateway
|
||||||
emoji="🎨",
|
emoji="🎨",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
160
tools/managed_tool_gateway.py
Normal file
160
tools/managed_tool_gateway.py
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
"""Generic managed-tool gateway helpers for Nous-hosted vendor passthroughs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from hermes_cli.config import get_hermes_home
|
||||||
|
|
||||||
|
_DEFAULT_TOOL_GATEWAY_DOMAIN = "nousresearch.com"
|
||||||
|
_DEFAULT_TOOL_GATEWAY_SCHEME = "https"
|
||||||
|
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 120
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ManagedToolGatewayConfig:
|
||||||
|
vendor: str
|
||||||
|
gateway_origin: str
|
||||||
|
nous_user_token: str
|
||||||
|
managed_mode: bool
|
||||||
|
|
||||||
|
|
||||||
|
def auth_json_path():
|
||||||
|
"""Return the Hermes auth store path, respecting HERMES_HOME overrides."""
|
||||||
|
return get_hermes_home() / "auth.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nous_provider_state() -> Optional[dict]:
|
||||||
|
try:
|
||||||
|
path = auth_json_path()
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
providers = data.get("providers", {})
|
||||||
|
if not isinstance(providers, dict):
|
||||||
|
return None
|
||||||
|
nous_provider = providers.get("nous", {})
|
||||||
|
if isinstance(nous_provider, dict):
|
||||||
|
return nous_provider
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_timestamp(value: object) -> Optional[datetime]:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if normalized.endswith("Z"):
|
||||||
|
normalized = normalized[:-1] + "+00:00"
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
|
||||||
|
expires = _parse_timestamp(expires_at)
|
||||||
|
if expires is None:
|
||||||
|
return True
|
||||||
|
remaining = (expires - datetime.now(timezone.utc)).total_seconds()
|
||||||
|
return remaining <= max(0, int(skew_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def read_nous_access_token() -> Optional[str]:
|
||||||
|
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
|
||||||
|
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
|
||||||
|
if isinstance(explicit, str) and explicit.strip():
|
||||||
|
return explicit.strip()
|
||||||
|
|
||||||
|
nous_provider = _read_nous_provider_state() or {}
|
||||||
|
access_token = nous_provider.get("access_token")
|
||||||
|
cached_token = access_token.strip() if isinstance(access_token, str) and access_token.strip() else None
|
||||||
|
|
||||||
|
if cached_token and not _access_token_is_expiring(
|
||||||
|
nous_provider.get("expires_at"),
|
||||||
|
_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||||
|
):
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.auth import resolve_nous_access_token
|
||||||
|
|
||||||
|
refreshed_token = resolve_nous_access_token(
|
||||||
|
refresh_skew_seconds=_NOUS_ACCESS_TOKEN_REFRESH_SKEW_SECONDS,
|
||||||
|
)
|
||||||
|
if isinstance(refreshed_token, str) and refreshed_token.strip():
|
||||||
|
return refreshed_token.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return cached_token
|
||||||
|
|
||||||
|
|
||||||
|
def get_tool_gateway_scheme() -> str:
|
||||||
|
"""Return configured shared gateway URL scheme."""
|
||||||
|
scheme = os.getenv("TOOL_GATEWAY_SCHEME", "").strip().lower()
|
||||||
|
if not scheme:
|
||||||
|
return _DEFAULT_TOOL_GATEWAY_SCHEME
|
||||||
|
|
||||||
|
if scheme in {"http", "https"}:
|
||||||
|
return scheme
|
||||||
|
|
||||||
|
raise ValueError("TOOL_GATEWAY_SCHEME must be 'http' or 'https'")
|
||||||
|
|
||||||
|
|
||||||
|
def build_vendor_gateway_url(vendor: str) -> str:
|
||||||
|
"""Return the gateway origin for a specific vendor."""
|
||||||
|
vendor_key = f"{vendor.upper().replace('-', '_')}_GATEWAY_URL"
|
||||||
|
explicit_vendor_url = os.getenv(vendor_key, "").strip().rstrip("/")
|
||||||
|
if explicit_vendor_url:
|
||||||
|
return explicit_vendor_url
|
||||||
|
|
||||||
|
shared_scheme = get_tool_gateway_scheme()
|
||||||
|
shared_domain = os.getenv("TOOL_GATEWAY_DOMAIN", "").strip().strip("/")
|
||||||
|
if shared_domain:
|
||||||
|
return f"{shared_scheme}://{vendor}-gateway.{shared_domain}"
|
||||||
|
|
||||||
|
return f"{shared_scheme}://{vendor}-gateway.{_DEFAULT_TOOL_GATEWAY_DOMAIN}"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_managed_tool_gateway(
|
||||||
|
vendor: str,
|
||||||
|
gateway_builder: Optional[Callable[[str], str]] = None,
|
||||||
|
token_reader: Optional[Callable[[], Optional[str]]] = None,
|
||||||
|
) -> Optional[ManagedToolGatewayConfig]:
|
||||||
|
"""Resolve shared managed-tool gateway config for a vendor."""
|
||||||
|
resolved_gateway_builder = gateway_builder or build_vendor_gateway_url
|
||||||
|
resolved_token_reader = token_reader or read_nous_access_token
|
||||||
|
|
||||||
|
gateway_origin = resolved_gateway_builder(vendor)
|
||||||
|
nous_user_token = resolved_token_reader()
|
||||||
|
if not gateway_origin or not nous_user_token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ManagedToolGatewayConfig(
|
||||||
|
vendor=vendor,
|
||||||
|
gateway_origin=gateway_origin,
|
||||||
|
nous_user_token=nous_user_token,
|
||||||
|
managed_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_managed_tool_gateway_ready(
|
||||||
|
vendor: str,
|
||||||
|
gateway_builder: Optional[Callable[[str], str]] = None,
|
||||||
|
token_reader: Optional[Callable[[], Optional[str]]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when gateway URL and Nous access token are available."""
|
||||||
|
return resolve_managed_tool_gateway(
|
||||||
|
vendor,
|
||||||
|
gateway_builder=gateway_builder,
|
||||||
|
token_reader=token_reader,
|
||||||
|
) is not None
|
||||||
|
|
@ -3,12 +3,12 @@
|
||||||
Terminal Tool Module
|
Terminal Tool Module
|
||||||
|
|
||||||
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
|
A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments.
|
||||||
Supports local execution, Docker containers, and Modal cloud sandboxes.
|
Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode.
|
||||||
|
|
||||||
Environment Selection (via TERMINAL_ENV environment variable):
|
Environment Selection (via TERMINAL_ENV environment variable):
|
||||||
- "local": Execute directly on the host machine (default, fastest)
|
- "local": Execute directly on the host machine (default, fastest)
|
||||||
- "docker": Execute in Docker containers (isolated, requires Docker)
|
- "docker": Execute in Docker containers (isolated, requires Docker)
|
||||||
- "modal": Execute in Modal cloud sandboxes (scalable, requires Modal account)
|
- "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway)
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Multiple execution backends (local, docker, modal)
|
- Multiple execution backends (local, docker, modal)
|
||||||
|
|
@ -16,6 +16,10 @@ Features:
|
||||||
- VM/container lifecycle management
|
- VM/container lifecycle management
|
||||||
- Automatic cleanup after inactivity
|
- Automatic cleanup after inactivity
|
||||||
|
|
||||||
|
Cloud sandbox note:
|
||||||
|
- Persistent filesystems preserve working state across sandbox recreation
|
||||||
|
- Persistent filesystems do NOT guarantee the same live sandbox or long-running processes survive cleanup, idle reaping, or Hermes exit
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from terminal_tool import terminal_tool
|
from terminal_tool import terminal_tool
|
||||||
|
|
||||||
|
|
@ -50,12 +54,18 @@ logger = logging.getLogger(__name__)
|
||||||
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
|
from tools.interrupt import is_interrupted, _interrupt_event # noqa: F401 — re-exported
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_minisweagent_on_path(_repo_root: Path | None = None) -> None:
|
||||||
|
"""Backward-compatible no-op after minisweagent_path.py removal."""
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Custom Singularity Environment with more space
|
# Custom Singularity Environment with more space
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
|
# Singularity helpers (scratch dir, SIF cache) now live in tools/environments/singularity.py
|
||||||
from tools.environments.singularity import _get_scratch_dir
|
from tools.environments.singularity import _get_scratch_dir
|
||||||
|
from tools.tool_backend_helpers import has_direct_modal_credentials, normalize_modal_mode
|
||||||
|
|
||||||
|
|
||||||
# Disk usage warning threshold (in GB)
|
# Disk usage warning threshold (in GB)
|
||||||
|
|
@ -361,10 +371,12 @@ from tools.environments.singularity import SingularityEnvironment as _Singularit
|
||||||
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
|
from tools.environments.ssh import SSHEnvironment as _SSHEnvironment
|
||||||
from tools.environments.docker import DockerEnvironment as _DockerEnvironment
|
from tools.environments.docker import DockerEnvironment as _DockerEnvironment
|
||||||
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
|
from tools.environments.modal import ModalEnvironment as _ModalEnvironment
|
||||||
|
from tools.environments.managed_modal import ManagedModalEnvironment as _ManagedModalEnvironment
|
||||||
|
from tools.managed_tool_gateway import is_managed_tool_gateway_ready
|
||||||
|
|
||||||
|
|
||||||
# Tool description for LLM
|
# Tool description for LLM
|
||||||
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem persists between calls.
|
TERMINAL_TOOL_DESCRIPTION = """Execute shell commands on a Linux environment. Filesystem usually persists between calls.
|
||||||
|
|
||||||
Do NOT use cat/head/tail to read files — use read_file instead.
|
Do NOT use cat/head/tail to read files — use read_file instead.
|
||||||
Do NOT use grep/rg/find to search — use search_files instead.
|
Do NOT use grep/rg/find to search — use search_files instead.
|
||||||
|
|
@ -380,6 +392,7 @@ Working directory: Use 'workdir' for per-command cwd.
|
||||||
PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).
|
PTY mode: Set pty=true for interactive CLI tools (Codex, Claude Code, Python REPL).
|
||||||
|
|
||||||
Do NOT use vim/nano/interactive tools without pty=true — they hang without a pseudo-terminal. Pipe git output to cat if it might page.
|
Do NOT use vim/nano/interactive tools without pty=true — they hang without a pseudo-terminal. Pipe git output to cat if it might page.
|
||||||
|
Important: cloud sandboxes may be cleaned up, idled out, or recreated between turns. Persistent filesystem means files can resume later; it does NOT guarantee a continuously running machine or surviving background processes. Use terminal sandboxes for task work, not durable hosting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Global state for environment lifecycle management
|
# Global state for environment lifecycle management
|
||||||
|
|
@ -493,6 +506,7 @@ def _get_env_config() -> Dict[str, Any]:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"env_type": env_type,
|
"env_type": env_type,
|
||||||
|
"modal_mode": normalize_modal_mode(os.getenv("TERMINAL_MODAL_MODE", "auto")),
|
||||||
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
"docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image),
|
||||||
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
|
"docker_forward_env": _parse_env_var("TERMINAL_DOCKER_FORWARD_ENV", "[]", json.loads, "valid JSON"),
|
||||||
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
"singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"),
|
||||||
|
|
@ -525,6 +539,27 @@ def _get_env_config() -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]:
|
||||||
|
"""Resolve direct vs managed Modal backend selection."""
|
||||||
|
normalized_mode = normalize_modal_mode(modal_mode)
|
||||||
|
has_direct = has_direct_modal_credentials()
|
||||||
|
managed_ready = is_managed_tool_gateway_ready("modal")
|
||||||
|
|
||||||
|
if normalized_mode == "managed":
|
||||||
|
selected_backend = "managed" if managed_ready else None
|
||||||
|
elif normalized_mode == "direct":
|
||||||
|
selected_backend = "direct" if has_direct else None
|
||||||
|
else:
|
||||||
|
selected_backend = "direct" if has_direct else "managed" if managed_ready else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": normalized_mode,
|
||||||
|
"has_direct": has_direct,
|
||||||
|
"managed_ready": managed_ready,
|
||||||
|
"selected_backend": selected_backend,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||||
ssh_config: dict = None, container_config: dict = None,
|
ssh_config: dict = None, container_config: dict = None,
|
||||||
local_config: dict = None,
|
local_config: dict = None,
|
||||||
|
|
@ -591,6 +626,28 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
modal_state = _get_modal_backend_state(cc.get("modal_mode"))
|
||||||
|
|
||||||
|
if modal_state["selected_backend"] == "managed":
|
||||||
|
return _ManagedModalEnvironment(
|
||||||
|
image=image, cwd=cwd, timeout=timeout,
|
||||||
|
modal_sandbox_kwargs=sandbox_kwargs,
|
||||||
|
persistent_filesystem=persistent, task_id=task_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if modal_state["selected_backend"] != "direct":
|
||||||
|
if modal_state["mode"] == "managed":
|
||||||
|
raise ValueError(
|
||||||
|
"Modal backend is configured for managed mode, but the managed tool gateway is unavailable."
|
||||||
|
)
|
||||||
|
if modal_state["mode"] == "direct":
|
||||||
|
raise ValueError(
|
||||||
|
"Modal backend is configured for direct mode, but no direct Modal credentials/config were found."
|
||||||
|
)
|
||||||
|
raise ValueError(
|
||||||
|
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found."
|
||||||
|
)
|
||||||
|
|
||||||
return _ModalEnvironment(
|
return _ModalEnvironment(
|
||||||
image=image, cwd=cwd, timeout=timeout,
|
image=image, cwd=cwd, timeout=timeout,
|
||||||
modal_sandbox_kwargs=sandbox_kwargs,
|
modal_sandbox_kwargs=sandbox_kwargs,
|
||||||
|
|
@ -956,6 +1013,7 @@ def terminal_tool(
|
||||||
"container_memory": config.get("container_memory", 5120),
|
"container_memory": config.get("container_memory", 5120),
|
||||||
"container_disk": config.get("container_disk", 51200),
|
"container_disk": config.get("container_disk", 51200),
|
||||||
"container_persistent": config.get("container_persistent", True),
|
"container_persistent": config.get("container_persistent", True),
|
||||||
|
"modal_mode": config.get("modal_mode", "auto"),
|
||||||
"docker_volumes": config.get("docker_volumes", []),
|
"docker_volumes": config.get("docker_volumes", []),
|
||||||
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
|
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
|
||||||
}
|
}
|
||||||
|
|
@ -1173,10 +1231,14 @@ def terminal_tool(
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
tb_str = traceback.format_exc()
|
||||||
|
logger.error("terminal_tool exception:\n%s", tb_str)
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"output": "",
|
"output": "",
|
||||||
"exit_code": -1,
|
"exit_code": -1,
|
||||||
"error": f"Failed to execute command: {str(e)}",
|
"error": f"Failed to execute command: {str(e)}",
|
||||||
|
"traceback": tb_str,
|
||||||
"status": "error"
|
"status": "error"
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
@ -1216,18 +1278,35 @@ def check_terminal_requirements() -> bool:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif env_type == "modal":
|
elif env_type == "modal":
|
||||||
if importlib.util.find_spec("swerex") is None:
|
modal_state = _get_modal_backend_state(config.get("modal_mode"))
|
||||||
logger.error("swe-rex is required for modal terminal backend: pip install 'swe-rex[modal]'")
|
if modal_state["selected_backend"] == "managed":
|
||||||
return False
|
return True
|
||||||
has_token = os.getenv("MODAL_TOKEN_ID") is not None
|
|
||||||
has_config = Path.home().joinpath(".modal.toml").exists()
|
if modal_state["selected_backend"] != "direct":
|
||||||
if not (has_token or has_config):
|
if modal_state["mode"] == "managed":
|
||||||
logger.error(
|
logger.error(
|
||||||
"Modal backend selected but no MODAL_TOKEN_ID environment variable "
|
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but the managed "
|
||||||
"or ~/.modal.toml config file was found. Configure Modal or choose "
|
"tool gateway is unavailable. Configure the managed gateway or choose "
|
||||||
"a different TERMINAL_ENV."
|
"TERMINAL_MODAL_MODE=direct/auto."
|
||||||
|
)
|
||||||
|
elif modal_state["mode"] == "direct":
|
||||||
|
logger.error(
|
||||||
|
"Modal backend selected with TERMINAL_MODAL_MODE=direct, but no direct "
|
||||||
|
"Modal credentials/config were found. Configure Modal or choose "
|
||||||
|
"TERMINAL_MODAL_MODE=managed/auto."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
"Modal backend selected but no direct Modal credentials/config or managed "
|
||||||
|
"tool gateway was found. Configure Modal, set up the managed gateway, "
|
||||||
|
"or choose a different TERMINAL_ENV."
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if importlib.util.find_spec("swerex") is None:
|
||||||
|
logger.error("swe-rex is required for direct modal terminal backend: pip install 'swe-rex[modal]'")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif env_type == "daytona":
|
elif env_type == "daytona":
|
||||||
|
|
|
||||||
41
tools/tool_backend_helpers.py
Normal file
41
tools/tool_backend_helpers.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Shared helpers for tool backend selection."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
_DEFAULT_BROWSER_PROVIDER = "local"
|
||||||
|
_DEFAULT_MODAL_MODE = "auto"
|
||||||
|
_VALID_MODAL_MODES = {"auto", "direct", "managed"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_browser_cloud_provider(value: object | None) -> str:
|
||||||
|
"""Return a normalized browser provider key."""
|
||||||
|
provider = str(value or _DEFAULT_BROWSER_PROVIDER).strip().lower()
|
||||||
|
return provider or _DEFAULT_BROWSER_PROVIDER
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_modal_mode(value: object | None) -> str:
|
||||||
|
"""Return a normalized modal execution mode."""
|
||||||
|
mode = str(value or _DEFAULT_MODAL_MODE).strip().lower()
|
||||||
|
if mode in _VALID_MODAL_MODES:
|
||||||
|
return mode
|
||||||
|
return _DEFAULT_MODAL_MODE
|
||||||
|
|
||||||
|
|
||||||
|
def has_direct_modal_credentials() -> bool:
|
||||||
|
"""Return True when direct Modal credentials/config are available."""
|
||||||
|
return bool(
|
||||||
|
(os.getenv("MODAL_TOKEN_ID") and os.getenv("MODAL_TOKEN_SECRET"))
|
||||||
|
or (Path.home() / ".modal.toml").exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_openai_audio_api_key() -> str:
|
||||||
|
"""Prefer the voice-tools key, but fall back to the normal OpenAI key."""
|
||||||
|
return (
|
||||||
|
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
|
||||||
|
or os.getenv("OPENAI_API_KEY", "")
|
||||||
|
).strip()
|
||||||
|
|
@ -31,6 +31,10 @@ import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
from tools.tool_backend_helpers import resolve_openai_audio_api_key
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
|
|
||||||
|
|
@ -41,8 +45,17 @@ logger = logging.getLogger(__name__)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
import importlib.util as _ilu
|
import importlib.util as _ilu
|
||||||
_HAS_FASTER_WHISPER = _ilu.find_spec("faster_whisper") is not None
|
|
||||||
_HAS_OPENAI = _ilu.find_spec("openai") is not None
|
|
||||||
|
def _safe_find_spec(module_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _ilu.find_spec(module_name) is not None
|
||||||
|
except (ImportError, ValueError):
|
||||||
|
return module_name in globals() or module_name in os.sys.modules
|
||||||
|
|
||||||
|
|
||||||
|
_HAS_FASTER_WHISPER = _safe_find_spec("faster_whisper")
|
||||||
|
_HAS_OPENAI = _safe_find_spec("openai")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants
|
# Constants
|
||||||
|
|
@ -116,9 +129,9 @@ def is_stt_enabled(stt_config: Optional[dict] = None) -> bool:
|
||||||
return bool(enabled)
|
return bool(enabled)
|
||||||
|
|
||||||
|
|
||||||
def _resolve_openai_api_key() -> str:
|
def _has_openai_audio_backend() -> bool:
|
||||||
"""Prefer the voice-tools key, but fall back to the normal OpenAI key."""
|
"""Return True when OpenAI audio can use direct credentials or the managed gateway."""
|
||||||
return os.getenv("VOICE_TOOLS_OPENAI_KEY", "") or os.getenv("OPENAI_API_KEY", "")
|
return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio"))
|
||||||
|
|
||||||
|
|
||||||
def _find_binary(binary_name: str) -> Optional[str]:
|
def _find_binary(binary_name: str) -> Optional[str]:
|
||||||
|
|
@ -210,7 +223,7 @@ def _get_provider(stt_config: dict) -> str:
|
||||||
return "none"
|
return "none"
|
||||||
|
|
||||||
if provider == "openai":
|
if provider == "openai":
|
||||||
if _HAS_OPENAI and _resolve_openai_api_key():
|
if _HAS_OPENAI and _has_openai_audio_backend():
|
||||||
return "openai"
|
return "openai"
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"STT provider 'openai' configured but no API key available"
|
"STT provider 'openai' configured but no API key available"
|
||||||
|
|
@ -228,7 +241,7 @@ def _get_provider(stt_config: dict) -> str:
|
||||||
if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
|
if _HAS_OPENAI and os.getenv("GROQ_API_KEY"):
|
||||||
logger.info("No local STT available, using Groq Whisper API")
|
logger.info("No local STT available, using Groq Whisper API")
|
||||||
return "groq"
|
return "groq"
|
||||||
if _HAS_OPENAI and _resolve_openai_api_key():
|
if _HAS_OPENAI and _has_openai_audio_backend():
|
||||||
logger.info("No local STT available, using OpenAI Whisper API")
|
logger.info("No local STT available, using OpenAI Whisper API")
|
||||||
return "openai"
|
return "openai"
|
||||||
return "none"
|
return "none"
|
||||||
|
|
@ -404,7 +417,7 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||||
client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0)
|
client = OpenAI(api_key=api_key, base_url=GROQ_BASE_URL, timeout=30, max_retries=0)
|
||||||
|
try:
|
||||||
with open(file_path, "rb") as audio_file:
|
with open(file_path, "rb") as audio_file:
|
||||||
transcription = client.audio.transcriptions.create(
|
transcription = client.audio.transcriptions.create(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
|
|
@ -417,6 +430,10 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||||
Path(file_path).name, model_name, len(transcript_text))
|
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:
|
except PermissionError:
|
||||||
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
||||||
|
|
@ -437,12 +454,13 @@ def _transcribe_groq(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
|
def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||||
"""Transcribe using OpenAI Whisper API (paid)."""
|
"""Transcribe using OpenAI Whisper API (paid)."""
|
||||||
api_key = _resolve_openai_api_key()
|
try:
|
||||||
if not api_key:
|
api_key, base_url = _resolve_openai_audio_client_config()
|
||||||
|
except ValueError as exc:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
"transcript": "",
|
"transcript": "",
|
||||||
"error": "Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set",
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
if not _HAS_OPENAI:
|
if not _HAS_OPENAI:
|
||||||
|
|
@ -455,20 +473,24 @@ def _transcribe_openai(file_path: str, model_name: str) -> Dict[str, Any]:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
from openai import OpenAI, APIError, APIConnectionError, APITimeoutError
|
||||||
client = OpenAI(api_key=api_key, base_url=OPENAI_BASE_URL, timeout=30, max_retries=0)
|
client = OpenAI(api_key=api_key, base_url=base_url, timeout=30, max_retries=0)
|
||||||
|
try:
|
||||||
with open(file_path, "rb") as audio_file:
|
with open(file_path, "rb") as audio_file:
|
||||||
transcription = client.audio.transcriptions.create(
|
transcription = client.audio.transcriptions.create(
|
||||||
model=model_name,
|
model=model_name,
|
||||||
file=audio_file,
|
file=audio_file,
|
||||||
response_format="text",
|
response_format="text" if model_name == "whisper-1" else "json",
|
||||||
)
|
)
|
||||||
|
|
||||||
transcript_text = str(transcription).strip()
|
transcript_text = _extract_transcript_text(transcription)
|
||||||
logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
|
logger.info("Transcribed %s via OpenAI API (%s, %d chars)",
|
||||||
Path(file_path).name, model_name, len(transcript_text))
|
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:
|
except PermissionError:
|
||||||
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
return {"success": False, "transcript": "", "error": f"Permission denied: {file_path}"}
|
||||||
|
|
@ -554,3 +576,38 @@ def transcribe_audio(file_path: str, model: Optional[str] = None) -> Dict[str, A
|
||||||
"or OPENAI_API_KEY for the OpenAI Whisper API."
|
"or OPENAI_API_KEY for the OpenAI Whisper API."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
||||||
|
"""Return direct OpenAI audio config or a managed gateway fallback."""
|
||||||
|
direct_api_key = resolve_openai_audio_api_key()
|
||||||
|
if direct_api_key:
|
||||||
|
return direct_api_key, OPENAI_BASE_URL
|
||||||
|
|
||||||
|
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||||
|
if managed_gateway is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
|
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_transcript_text(transcription: Any) -> str:
|
||||||
|
"""Normalize text and JSON transcription responses to a plain string."""
|
||||||
|
if isinstance(transcription, str):
|
||||||
|
return transcription.strip()
|
||||||
|
|
||||||
|
if hasattr(transcription, "text"):
|
||||||
|
value = getattr(transcription, "text")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
if isinstance(transcription, dict):
|
||||||
|
value = transcription.get("text")
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
return str(transcription).strip()
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,15 @@ import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from typing import Callable, Dict, Any, Optional
|
from typing import Callable, Dict, Any, Optional
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||||
|
from tools.tool_backend_helpers import resolve_openai_audio_api_key
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Lazy imports -- providers are imported only when actually used to avoid
|
# Lazy imports -- providers are imported only when actually used to avoid
|
||||||
|
|
@ -74,6 +78,7 @@ DEFAULT_ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"
|
||||||
DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5"
|
DEFAULT_ELEVENLABS_STREAMING_MODEL_ID = "eleven_flash_v2_5"
|
||||||
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
|
DEFAULT_OPENAI_MODEL = "gpt-4o-mini-tts"
|
||||||
DEFAULT_OPENAI_VOICE = "alloy"
|
DEFAULT_OPENAI_VOICE = "alloy"
|
||||||
|
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||||
DEFAULT_OUTPUT_DIR = str(get_hermes_home() / "audio_cache")
|
DEFAULT_OUTPUT_DIR = str(get_hermes_home() / "audio_cache")
|
||||||
MAX_TEXT_LENGTH = 4000
|
MAX_TEXT_LENGTH = 4000
|
||||||
|
|
||||||
|
|
@ -233,14 +238,12 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||||
Returns:
|
Returns:
|
||||||
Path to the saved audio file.
|
Path to the saved audio file.
|
||||||
"""
|
"""
|
||||||
api_key = os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
|
api_key, base_url = _resolve_openai_audio_client_config()
|
||||||
if not api_key:
|
|
||||||
raise ValueError("VOICE_TOOLS_OPENAI_KEY not set. Get one at https://platform.openai.com/api-keys")
|
|
||||||
|
|
||||||
oai_config = tts_config.get("openai", {})
|
oai_config = tts_config.get("openai", {})
|
||||||
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
|
model = oai_config.get("model", DEFAULT_OPENAI_MODEL)
|
||||||
voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE)
|
voice = oai_config.get("voice", DEFAULT_OPENAI_VOICE)
|
||||||
base_url = oai_config.get("base_url", "https://api.openai.com/v1")
|
base_url = oai_config.get("base_url", base_url)
|
||||||
|
|
||||||
# Determine response format from extension
|
# Determine response format from extension
|
||||||
if output_path.endswith(".ogg"):
|
if output_path.endswith(".ogg"):
|
||||||
|
|
@ -250,15 +253,21 @@ def _generate_openai_tts(text: str, output_path: str, tts_config: Dict[str, Any]
|
||||||
|
|
||||||
OpenAIClient = _import_openai_client()
|
OpenAIClient = _import_openai_client()
|
||||||
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
client = OpenAIClient(api_key=api_key, base_url=base_url)
|
||||||
|
try:
|
||||||
response = client.audio.speech.create(
|
response = client.audio.speech.create(
|
||||||
model=model,
|
model=model,
|
||||||
voice=voice,
|
voice=voice,
|
||||||
input=text,
|
input=text,
|
||||||
response_format=response_format,
|
response_format=response_format,
|
||||||
|
extra_headers={"x-idempotency-key": str(uuid.uuid4())},
|
||||||
)
|
)
|
||||||
|
|
||||||
response.stream_to_file(output_path)
|
response.stream_to_file(output_path)
|
||||||
return output_path
|
return output_path
|
||||||
|
finally:
|
||||||
|
close = getattr(client, "close", None)
|
||||||
|
if callable(close):
|
||||||
|
close()
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -539,7 +548,7 @@ def check_tts_requirements() -> bool:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
_import_openai_client()
|
_import_openai_client()
|
||||||
if os.getenv("VOICE_TOOLS_OPENAI_KEY"):
|
if _has_openai_audio_backend():
|
||||||
return True
|
return True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
|
|
@ -548,6 +557,28 @@ def check_tts_requirements() -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_openai_audio_client_config() -> tuple[str, str]:
|
||||||
|
"""Return direct OpenAI audio config or a managed gateway fallback."""
|
||||||
|
direct_api_key = resolve_openai_audio_api_key()
|
||||||
|
if direct_api_key:
|
||||||
|
return direct_api_key, DEFAULT_OPENAI_BASE_URL
|
||||||
|
|
||||||
|
managed_gateway = resolve_managed_tool_gateway("openai-audio")
|
||||||
|
if managed_gateway is None:
|
||||||
|
raise ValueError(
|
||||||
|
"Neither VOICE_TOOLS_OPENAI_KEY nor OPENAI_API_KEY is set, and the managed OpenAI audio gateway is unavailable"
|
||||||
|
)
|
||||||
|
|
||||||
|
return managed_gateway.nous_user_token, urljoin(
|
||||||
|
f"{managed_gateway.gateway_origin.rstrip('/')}/", "v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_openai_audio_backend() -> bool:
|
||||||
|
"""Return True when OpenAI audio can use direct credentials or the managed gateway."""
|
||||||
|
return bool(resolve_openai_audio_api_key() or resolve_managed_tool_gateway("openai-audio"))
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Streaming TTS: sentence-by-sentence pipeline for ElevenLabs
|
# Streaming TTS: sentence-by-sentence pipeline for ElevenLabs
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -802,7 +833,10 @@ if __name__ == "__main__":
|
||||||
print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}")
|
print(f" ElevenLabs: {'installed' if _check(_import_elevenlabs, 'el') else 'not installed (pip install elevenlabs)'}")
|
||||||
print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}")
|
print(f" API Key: {'set' if os.getenv('ELEVENLABS_API_KEY') else 'not set'}")
|
||||||
print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}")
|
print(f" OpenAI: {'installed' if _check(_import_openai_client, 'oai') else 'not installed'}")
|
||||||
print(f" API Key: {'set' if os.getenv('VOICE_TOOLS_OPENAI_KEY') else 'not set (VOICE_TOOLS_OPENAI_KEY)'}")
|
print(
|
||||||
|
" API Key: "
|
||||||
|
f"{'set' if resolve_openai_audio_api_key() else 'not set (VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY)'}"
|
||||||
|
)
|
||||||
print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
|
print(f" ffmpeg: {'✅ found' if _has_ffmpeg() else '❌ not found (needed for Telegram Opus)'}")
|
||||||
print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}")
|
print(f"\n Output dir: {DEFAULT_OUTPUT_DIR}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,18 @@ Standalone Web Tools Module
|
||||||
|
|
||||||
This module provides generic web tools that work with multiple backend providers.
|
This module provides generic web tools that work with multiple backend providers.
|
||||||
Backend is selected during ``hermes tools`` setup (web.backend in config.yaml).
|
Backend is selected during ``hermes tools`` setup (web.backend in config.yaml).
|
||||||
|
When available, Hermes can route Firecrawl calls through a Nous-hosted tool-gateway
|
||||||
|
for Nous Subscribers only.
|
||||||
|
|
||||||
Available tools:
|
Available tools:
|
||||||
- web_search_tool: Search the web for information
|
- web_search_tool: Search the web for information
|
||||||
- web_extract_tool: Extract content from specific web pages
|
- web_extract_tool: Extract content from specific web pages
|
||||||
- web_crawl_tool: Crawl websites with specific instructions (Firecrawl only)
|
- web_crawl_tool: Crawl websites with specific instructions
|
||||||
|
|
||||||
Backend compatibility:
|
Backend compatibility:
|
||||||
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl)
|
- Firecrawl: https://docs.firecrawl.dev/introduction (search, extract, crawl; direct or derived firecrawl-gateway.<domain> for Nous Subscribers)
|
||||||
- Parallel: https://docs.parallel.ai (search, extract)
|
- Parallel: https://docs.parallel.ai (search, extract)
|
||||||
|
- Tavily: https://tavily.com (search, extract, crawl)
|
||||||
|
|
||||||
LLM Processing:
|
LLM Processing:
|
||||||
- Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction
|
- Uses OpenRouter API with Gemini 3 Flash Preview for intelligent content extraction
|
||||||
|
|
@ -44,8 +47,13 @@ import asyncio
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import httpx
|
import httpx
|
||||||
from firecrawl import Firecrawl
|
from firecrawl import Firecrawl
|
||||||
from agent.auxiliary_client import async_call_llm
|
from agent.auxiliary_client import get_async_text_auxiliary_client
|
||||||
from tools.debug_helpers import DebugSession
|
from tools.debug_helpers import DebugSession
|
||||||
|
from tools.managed_tool_gateway import (
|
||||||
|
build_vendor_gateway_url,
|
||||||
|
read_nous_access_token as _read_nous_access_token,
|
||||||
|
resolve_managed_tool_gateway,
|
||||||
|
)
|
||||||
from tools.url_safety import is_safe_url
|
from tools.url_safety import is_safe_url
|
||||||
from tools.website_policy import check_website_access
|
from tools.website_policy import check_website_access
|
||||||
|
|
||||||
|
|
@ -78,10 +86,13 @@ def _get_backend() -> str:
|
||||||
return configured
|
return configured
|
||||||
|
|
||||||
# Fallback for manual / legacy config — use whichever key is present.
|
# Fallback for manual / legacy config — use whichever key is present.
|
||||||
has_firecrawl = _has_env("FIRECRAWL_API_KEY") or _has_env("FIRECRAWL_API_URL")
|
has_firecrawl = (
|
||||||
|
_has_env("FIRECRAWL_API_KEY")
|
||||||
|
or _has_env("FIRECRAWL_API_URL")
|
||||||
|
or _is_tool_gateway_ready()
|
||||||
|
)
|
||||||
has_parallel = _has_env("PARALLEL_API_KEY")
|
has_parallel = _has_env("PARALLEL_API_KEY")
|
||||||
has_tavily = _has_env("TAVILY_API_KEY")
|
has_tavily = _has_env("TAVILY_API_KEY")
|
||||||
|
|
||||||
if has_tavily and not has_firecrawl and not has_parallel:
|
if has_tavily and not has_firecrawl and not has_parallel:
|
||||||
return "tavily"
|
return "tavily"
|
||||||
if has_parallel and not has_firecrawl:
|
if has_parallel and not has_firecrawl:
|
||||||
|
|
@ -90,35 +101,100 @@ def _get_backend() -> str:
|
||||||
# Default to firecrawl (backward compat, or when both are set)
|
# Default to firecrawl (backward compat, or when both are set)
|
||||||
return "firecrawl"
|
return "firecrawl"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_backend_available(backend: str) -> bool:
|
||||||
|
"""Return True when the selected backend is currently usable."""
|
||||||
|
if backend == "parallel":
|
||||||
|
return _has_env("PARALLEL_API_KEY")
|
||||||
|
if backend == "firecrawl":
|
||||||
|
return check_firecrawl_api_key()
|
||||||
|
if backend == "tavily":
|
||||||
|
return _has_env("TAVILY_API_KEY")
|
||||||
|
return False
|
||||||
|
|
||||||
# ─── Firecrawl Client ────────────────────────────────────────────────────────
|
# ─── Firecrawl Client ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_firecrawl_client = None
|
_firecrawl_client = None
|
||||||
|
_firecrawl_client_config = None
|
||||||
|
|
||||||
def _get_firecrawl_client():
|
|
||||||
"""Get or create the Firecrawl client (lazy initialization).
|
|
||||||
|
|
||||||
Uses the cloud API by default (requires FIRECRAWL_API_KEY).
|
def _get_direct_firecrawl_config() -> Optional[tuple[Dict[str, str], tuple[str, Optional[str], Optional[str]]]]:
|
||||||
Set FIRECRAWL_API_URL to point at a self-hosted instance instead —
|
"""Return explicit direct Firecrawl kwargs + cache key, or None when unset."""
|
||||||
in that case the API key is optional (set USE_DB_AUTHENTICATION=false
|
api_key = os.getenv("FIRECRAWL_API_KEY", "").strip()
|
||||||
on your Firecrawl server to disable auth entirely).
|
api_url = os.getenv("FIRECRAWL_API_URL", "").strip().rstrip("/")
|
||||||
"""
|
|
||||||
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:
|
if not api_key and not api_url:
|
||||||
logger.error("Firecrawl client initialization failed: missing configuration.")
|
return None
|
||||||
raise ValueError(
|
|
||||||
"Firecrawl client not configured. "
|
kwargs: Dict[str, str] = {}
|
||||||
"Set FIRECRAWL_API_KEY (cloud) or FIRECRAWL_API_URL (self-hosted). "
|
|
||||||
"This tool requires Firecrawl to be available."
|
|
||||||
)
|
|
||||||
kwargs = {}
|
|
||||||
if api_key:
|
if api_key:
|
||||||
kwargs["api_key"] = api_key
|
kwargs["api_key"] = api_key
|
||||||
if api_url:
|
if api_url:
|
||||||
kwargs["api_url"] = api_url
|
kwargs["api_url"] = api_url
|
||||||
|
|
||||||
|
return kwargs, ("direct", api_url or None, api_key or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_firecrawl_gateway_url() -> str:
|
||||||
|
"""Return configured Firecrawl gateway URL."""
|
||||||
|
return build_vendor_gateway_url("firecrawl")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_tool_gateway_ready() -> bool:
|
||||||
|
"""Return True when gateway URL and a Nous Subscriber token are available."""
|
||||||
|
return resolve_managed_tool_gateway("firecrawl", token_reader=_read_nous_access_token) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_direct_firecrawl_config() -> bool:
|
||||||
|
"""Return True when direct Firecrawl config is explicitly configured."""
|
||||||
|
return _get_direct_firecrawl_config() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _raise_web_backend_configuration_error() -> None:
|
||||||
|
"""Raise a clear error for unsupported web backend configuration."""
|
||||||
|
raise ValueError(
|
||||||
|
"Web tools are not configured. "
|
||||||
|
"Set FIRECRAWL_API_KEY for cloud Firecrawl, set FIRECRAWL_API_URL for a self-hosted Firecrawl instance, "
|
||||||
|
"or, if you are a Nous Subscriber, login to Nous (`hermes model`) and provide "
|
||||||
|
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_firecrawl_client():
|
||||||
|
"""Get or create Firecrawl client.
|
||||||
|
|
||||||
|
Direct Firecrawl takes precedence when explicitly configured. Otherwise
|
||||||
|
Hermes falls back to the Firecrawl tool-gateway for logged-in Nous Subscribers.
|
||||||
|
"""
|
||||||
|
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 = Firecrawl(**kwargs)
|
||||||
|
_firecrawl_client_config = client_config
|
||||||
return _firecrawl_client
|
return _firecrawl_client
|
||||||
|
|
||||||
# ─── Parallel Client ─────────────────────────────────────────────────────────
|
# ─── Parallel Client ─────────────────────────────────────────────────────────
|
||||||
|
|
@ -243,10 +319,112 @@ def _normalize_tavily_documents(response: dict, fallback_url: str = "") -> List[
|
||||||
return documents
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
def _to_plain_object(value: Any) -> Any:
|
||||||
|
"""Convert SDK objects to plain python data structures when possible."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, (dict, list, str, int, float, bool)):
|
||||||
|
return value
|
||||||
|
|
||||||
|
if hasattr(value, "model_dump"):
|
||||||
|
try:
|
||||||
|
return value.model_dump()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if hasattr(value, "__dict__"):
|
||||||
|
try:
|
||||||
|
return {k: v for k, v in value.__dict__.items() if not k.startswith("_")}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_result_list(values: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Normalize mixed SDK/list payloads into a list of dicts."""
|
||||||
|
if not isinstance(values, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
normalized: List[Dict[str, Any]] = []
|
||||||
|
for item in values:
|
||||||
|
plain = _to_plain_object(item)
|
||||||
|
if isinstance(plain, dict):
|
||||||
|
normalized.append(plain)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_web_search_results(response: Any) -> List[Dict[str, Any]]:
|
||||||
|
"""Extract Firecrawl search results across SDK/direct/gateway response shapes."""
|
||||||
|
response_plain = _to_plain_object(response)
|
||||||
|
|
||||||
|
if isinstance(response_plain, dict):
|
||||||
|
data = response_plain.get("data")
|
||||||
|
if isinstance(data, list):
|
||||||
|
return _normalize_result_list(data)
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data_web = _normalize_result_list(data.get("web"))
|
||||||
|
if data_web:
|
||||||
|
return data_web
|
||||||
|
data_results = _normalize_result_list(data.get("results"))
|
||||||
|
if data_results:
|
||||||
|
return data_results
|
||||||
|
|
||||||
|
top_web = _normalize_result_list(response_plain.get("web"))
|
||||||
|
if top_web:
|
||||||
|
return top_web
|
||||||
|
|
||||||
|
top_results = _normalize_result_list(response_plain.get("results"))
|
||||||
|
if top_results:
|
||||||
|
return top_results
|
||||||
|
|
||||||
|
if hasattr(response, "web"):
|
||||||
|
return _normalize_result_list(getattr(response, "web", []))
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_scrape_payload(scrape_result: Any) -> Dict[str, Any]:
|
||||||
|
"""Normalize Firecrawl scrape payload shape across SDK and gateway variants."""
|
||||||
|
result_plain = _to_plain_object(scrape_result)
|
||||||
|
if not isinstance(result_plain, dict):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
nested = result_plain.get("data")
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
return nested
|
||||||
|
|
||||||
|
return result_plain
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000
|
DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION = 5000
|
||||||
|
|
||||||
# Allow per-task override via env var
|
def _is_nous_auxiliary_client(client: Any) -> bool:
|
||||||
DEFAULT_SUMMARIZER_MODEL = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip() or None
|
"""Return True when the resolved auxiliary backend is Nous Portal."""
|
||||||
|
base_url = str(getattr(client, "base_url", "") or "").lower()
|
||||||
|
return "nousresearch.com" in base_url
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_web_extract_auxiliary(model: Optional[str] = None) -> tuple[Optional[Any], Optional[str], Dict[str, Any]]:
|
||||||
|
"""Resolve the current web-extract auxiliary client, model, and extra body."""
|
||||||
|
client, default_model = get_async_text_auxiliary_client("web_extract")
|
||||||
|
configured_model = os.getenv("AUXILIARY_WEB_EXTRACT_MODEL", "").strip()
|
||||||
|
effective_model = model or configured_model or default_model
|
||||||
|
|
||||||
|
extra_body: Dict[str, Any] = {}
|
||||||
|
if client is not None and _is_nous_auxiliary_client(client):
|
||||||
|
from agent.auxiliary_client import get_auxiliary_extra_body
|
||||||
|
extra_body = get_auxiliary_extra_body() or {"tags": ["product=hermes-agent"]}
|
||||||
|
|
||||||
|
return client, effective_model, extra_body
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_summarizer_model() -> Optional[str]:
|
||||||
|
"""Return the current default model for web extraction summarization."""
|
||||||
|
_, model, _ = _resolve_web_extract_auxiliary()
|
||||||
|
return model
|
||||||
|
|
||||||
_debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG")
|
_debug = DebugSession("web_tools", env_var="WEB_TOOLS_DEBUG")
|
||||||
|
|
||||||
|
|
@ -255,7 +433,7 @@ async def process_content_with_llm(
|
||||||
content: str,
|
content: str,
|
||||||
url: str = "",
|
url: str = "",
|
||||||
title: str = "",
|
title: str = "",
|
||||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
model: Optional[str] = None,
|
||||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -338,7 +516,7 @@ async def process_content_with_llm(
|
||||||
async def _call_summarizer_llm(
|
async def _call_summarizer_llm(
|
||||||
content: str,
|
content: str,
|
||||||
context_str: str,
|
context_str: str,
|
||||||
model: str,
|
model: Optional[str],
|
||||||
max_tokens: int = 20000,
|
max_tokens: int = 20000,
|
||||||
is_chunk: bool = False,
|
is_chunk: bool = False,
|
||||||
chunk_info: str = ""
|
chunk_info: str = ""
|
||||||
|
|
@ -404,22 +582,22 @@ Create a markdown summary that captures all key information in a well-organized,
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
call_kwargs = {
|
aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
|
||||||
"task": "web_extract",
|
if aux_client is None or not effective_model:
|
||||||
"messages": [
|
logger.warning("No auxiliary model available for web content processing")
|
||||||
|
return None
|
||||||
|
from agent.auxiliary_client import auxiliary_max_tokens_param
|
||||||
|
response = await aux_client.chat.completions.create(
|
||||||
|
model=effective_model,
|
||||||
|
messages=[
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_prompt}
|
{"role": "user", "content": user_prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.1,
|
temperature=0.1,
|
||||||
"max_tokens": max_tokens,
|
**auxiliary_max_tokens_param(max_tokens),
|
||||||
}
|
**({} if not extra_body else {"extra_body": extra_body}),
|
||||||
if model:
|
)
|
||||||
call_kwargs["model"] = model
|
|
||||||
response = await async_call_llm(**call_kwargs)
|
|
||||||
return response.choices[0].message.content.strip()
|
return response.choices[0].message.content.strip()
|
||||||
except RuntimeError:
|
|
||||||
logger.warning("No auxiliary model available for web content processing")
|
|
||||||
return None
|
|
||||||
except Exception as api_error:
|
except Exception as api_error:
|
||||||
last_error = api_error
|
last_error = api_error
|
||||||
if attempt < max_retries - 1:
|
if attempt < max_retries - 1:
|
||||||
|
|
@ -436,7 +614,7 @@ Create a markdown summary that captures all key information in a well-organized,
|
||||||
async def _process_large_content_chunked(
|
async def _process_large_content_chunked(
|
||||||
content: str,
|
content: str,
|
||||||
context_str: str,
|
context_str: str,
|
||||||
model: str,
|
model: Optional[str],
|
||||||
chunk_size: int,
|
chunk_size: int,
|
||||||
max_output_size: int
|
max_output_size: int
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
|
|
@ -523,18 +701,25 @@ Synthesize these into ONE cohesive, comprehensive summary that:
|
||||||
Create a single, unified markdown summary."""
|
Create a single, unified markdown summary."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
call_kwargs = {
|
aux_client, effective_model, extra_body = _resolve_web_extract_auxiliary(model)
|
||||||
"task": "web_extract",
|
if aux_client is None or not effective_model:
|
||||||
"messages": [
|
logger.warning("No auxiliary model for synthesis, concatenating summaries")
|
||||||
|
fallback = "\n\n".join(summaries)
|
||||||
|
if len(fallback) > max_output_size:
|
||||||
|
fallback = fallback[:max_output_size] + "\n\n[... truncated ...]"
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
from agent.auxiliary_client import auxiliary_max_tokens_param
|
||||||
|
response = await aux_client.chat.completions.create(
|
||||||
|
model=effective_model,
|
||||||
|
messages=[
|
||||||
{"role": "system", "content": "You synthesize multiple summaries into one cohesive, comprehensive summary. Be thorough but concise."},
|
{"role": "system", "content": "You synthesize multiple summaries into one cohesive, comprehensive summary. Be thorough but concise."},
|
||||||
{"role": "user", "content": synthesis_prompt}
|
{"role": "user", "content": synthesis_prompt}
|
||||||
],
|
],
|
||||||
"temperature": 0.1,
|
temperature=0.1,
|
||||||
"max_tokens": 20000,
|
**auxiliary_max_tokens_param(20000),
|
||||||
}
|
**({} if not extra_body else {"extra_body": extra_body}),
|
||||||
if model:
|
)
|
||||||
call_kwargs["model"] = model
|
|
||||||
response = await async_call_llm(**call_kwargs)
|
|
||||||
final_summary = response.choices[0].message.content.strip()
|
final_summary = response.choices[0].message.content.strip()
|
||||||
|
|
||||||
# Enforce hard cap
|
# Enforce hard cap
|
||||||
|
|
@ -750,35 +935,7 @@ def web_search_tool(query: str, limit: int = 5) -> str:
|
||||||
limit=limit
|
limit=limit
|
||||||
)
|
)
|
||||||
|
|
||||||
# The response is a SearchData object with web, news, and images attributes
|
web_results = _extract_web_search_results(response)
|
||||||
# When not scraping, the results are directly in these attributes
|
|
||||||
web_results = []
|
|
||||||
|
|
||||||
# Check if response has web attribute (SearchData object)
|
|
||||||
if hasattr(response, 'web'):
|
|
||||||
# Response is a SearchData object with web attribute
|
|
||||||
if response.web:
|
|
||||||
# Convert each SearchResultWeb object to dict
|
|
||||||
for result in response.web:
|
|
||||||
if hasattr(result, 'model_dump'):
|
|
||||||
# Pydantic model - use model_dump
|
|
||||||
web_results.append(result.model_dump())
|
|
||||||
elif hasattr(result, '__dict__'):
|
|
||||||
# Regular object - use __dict__
|
|
||||||
web_results.append(result.__dict__)
|
|
||||||
elif isinstance(result, dict):
|
|
||||||
# Already a dict
|
|
||||||
web_results.append(result)
|
|
||||||
elif hasattr(response, 'model_dump'):
|
|
||||||
# Response has model_dump method - use it to get dict
|
|
||||||
response_dict = response.model_dump()
|
|
||||||
if 'web' in response_dict and response_dict['web']:
|
|
||||||
web_results = response_dict['web']
|
|
||||||
elif isinstance(response, dict):
|
|
||||||
# Response is already a dictionary
|
|
||||||
if 'web' in response and response['web']:
|
|
||||||
web_results = response['web']
|
|
||||||
|
|
||||||
results_count = len(web_results)
|
results_count = len(web_results)
|
||||||
logger.info("Found %d search results", results_count)
|
logger.info("Found %d search results", results_count)
|
||||||
|
|
||||||
|
|
@ -819,7 +976,7 @@ async def web_extract_tool(
|
||||||
urls: List[str],
|
urls: List[str],
|
||||||
format: str = None,
|
format: str = None,
|
||||||
use_llm_processing: bool = True,
|
use_llm_processing: bool = True,
|
||||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
model: Optional[str] = None,
|
||||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -832,7 +989,7 @@ async def web_extract_tool(
|
||||||
urls (List[str]): List of URLs to extract content from
|
urls (List[str]): List of URLs to extract content from
|
||||||
format (str): Desired output format ("markdown" or "html", optional)
|
format (str): Desired output format ("markdown" or "html", optional)
|
||||||
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
|
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
|
||||||
model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview)
|
model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model)
|
||||||
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
|
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -929,39 +1086,11 @@ async def web_extract_tool(
|
||||||
formats=formats
|
formats=formats
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the result - properly handle object serialization
|
scrape_payload = _extract_scrape_payload(scrape_result)
|
||||||
metadata = {}
|
metadata = scrape_payload.get("metadata", {})
|
||||||
title = ""
|
title = ""
|
||||||
content_markdown = None
|
content_markdown = scrape_payload.get("markdown")
|
||||||
content_html = None
|
content_html = scrape_payload.get("html")
|
||||||
|
|
||||||
# Extract data from the scrape result
|
|
||||||
if hasattr(scrape_result, 'model_dump'):
|
|
||||||
# Pydantic model - use model_dump to get dict
|
|
||||||
result_dict = scrape_result.model_dump()
|
|
||||||
content_markdown = result_dict.get('markdown')
|
|
||||||
content_html = result_dict.get('html')
|
|
||||||
metadata = result_dict.get('metadata', {})
|
|
||||||
elif hasattr(scrape_result, '__dict__'):
|
|
||||||
# Regular object with attributes
|
|
||||||
content_markdown = getattr(scrape_result, 'markdown', None)
|
|
||||||
content_html = getattr(scrape_result, 'html', None)
|
|
||||||
|
|
||||||
# Handle metadata - convert to dict if it's an object
|
|
||||||
metadata_obj = getattr(scrape_result, 'metadata', {})
|
|
||||||
if hasattr(metadata_obj, 'model_dump'):
|
|
||||||
metadata = metadata_obj.model_dump()
|
|
||||||
elif hasattr(metadata_obj, '__dict__'):
|
|
||||||
metadata = metadata_obj.__dict__
|
|
||||||
elif isinstance(metadata_obj, dict):
|
|
||||||
metadata = metadata_obj
|
|
||||||
else:
|
|
||||||
metadata = {}
|
|
||||||
elif isinstance(scrape_result, dict):
|
|
||||||
# Already a dictionary
|
|
||||||
content_markdown = scrape_result.get('markdown')
|
|
||||||
content_html = scrape_result.get('html')
|
|
||||||
metadata = scrape_result.get('metadata', {})
|
|
||||||
|
|
||||||
# Ensure metadata is a dict (not an object)
|
# Ensure metadata is a dict (not an object)
|
||||||
if not isinstance(metadata, dict):
|
if not isinstance(metadata, dict):
|
||||||
|
|
@ -1019,9 +1148,11 @@ async def web_extract_tool(
|
||||||
|
|
||||||
debug_call_data["pages_extracted"] = pages_extracted
|
debug_call_data["pages_extracted"] = pages_extracted
|
||||||
debug_call_data["original_response_size"] = len(json.dumps(response))
|
debug_call_data["original_response_size"] = len(json.dumps(response))
|
||||||
|
effective_model = model or _get_default_summarizer_model()
|
||||||
|
auxiliary_available = check_auxiliary_model()
|
||||||
|
|
||||||
# Process each result with LLM if enabled
|
# Process each result with LLM if enabled
|
||||||
if use_llm_processing:
|
if use_llm_processing and auxiliary_available:
|
||||||
logger.info("Processing extracted content with LLM (parallel)...")
|
logger.info("Processing extracted content with LLM (parallel)...")
|
||||||
debug_call_data["processing_applied"].append("llm_processing")
|
debug_call_data["processing_applied"].append("llm_processing")
|
||||||
|
|
||||||
|
|
@ -1039,7 +1170,7 @@ async def web_extract_tool(
|
||||||
|
|
||||||
# Process content with LLM
|
# Process content with LLM
|
||||||
processed = await process_content_with_llm(
|
processed = await process_content_with_llm(
|
||||||
raw_content, url, title, model, min_length
|
raw_content, url, title, effective_model, min_length
|
||||||
)
|
)
|
||||||
|
|
||||||
if processed:
|
if processed:
|
||||||
|
|
@ -1055,7 +1186,7 @@ async def web_extract_tool(
|
||||||
"original_size": original_size,
|
"original_size": original_size,
|
||||||
"processed_size": processed_size,
|
"processed_size": processed_size,
|
||||||
"compression_ratio": compression_ratio,
|
"compression_ratio": compression_ratio,
|
||||||
"model_used": model
|
"model_used": effective_model
|
||||||
}
|
}
|
||||||
return result, metrics, "processed"
|
return result, metrics, "processed"
|
||||||
else:
|
else:
|
||||||
|
|
@ -1087,6 +1218,9 @@ async def web_extract_tool(
|
||||||
else:
|
else:
|
||||||
logger.warning("%s (no content to process)", url)
|
logger.warning("%s (no content to process)", url)
|
||||||
else:
|
else:
|
||||||
|
if use_llm_processing and not auxiliary_available:
|
||||||
|
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
|
||||||
|
debug_call_data["processing_applied"].append("llm_processing_unavailable")
|
||||||
# Print summary of extracted pages for debugging (original behavior)
|
# Print summary of extracted pages for debugging (original behavior)
|
||||||
for result in response.get('results', []):
|
for result in response.get('results', []):
|
||||||
url = result.get('url', 'Unknown URL')
|
url = result.get('url', 'Unknown URL')
|
||||||
|
|
@ -1141,7 +1275,7 @@ async def web_crawl_tool(
|
||||||
instructions: str = None,
|
instructions: str = None,
|
||||||
depth: str = "basic",
|
depth: str = "basic",
|
||||||
use_llm_processing: bool = True,
|
use_llm_processing: bool = True,
|
||||||
model: str = DEFAULT_SUMMARIZER_MODEL,
|
model: Optional[str] = None,
|
||||||
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
min_length: int = DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
|
|
@ -1155,7 +1289,7 @@ async def web_crawl_tool(
|
||||||
instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional)
|
instructions (str): Instructions for what to crawl/extract using LLM intelligence (optional)
|
||||||
depth (str): Depth of extraction ("basic" or "advanced", default: "basic")
|
depth (str): Depth of extraction ("basic" or "advanced", default: "basic")
|
||||||
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
|
use_llm_processing (bool): Whether to process content with LLM for summarization (default: True)
|
||||||
model (str): The model to use for LLM processing (default: google/gemini-3-flash-preview)
|
model (Optional[str]): The model to use for LLM processing (defaults to current auxiliary backend model)
|
||||||
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
|
min_length (int): Minimum content length to trigger LLM processing (default: 5000)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -1185,6 +1319,8 @@ async def web_crawl_tool(
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
effective_model = model or _get_default_summarizer_model()
|
||||||
|
auxiliary_available = check_auxiliary_model()
|
||||||
backend = _get_backend()
|
backend = _get_backend()
|
||||||
|
|
||||||
# Tavily supports crawl via its /crawl endpoint
|
# Tavily supports crawl via its /crawl endpoint
|
||||||
|
|
@ -1229,7 +1365,7 @@ async def web_crawl_tool(
|
||||||
debug_call_data["original_response_size"] = len(json.dumps(response))
|
debug_call_data["original_response_size"] = len(json.dumps(response))
|
||||||
|
|
||||||
# Process each result with LLM if enabled
|
# Process each result with LLM if enabled
|
||||||
if use_llm_processing:
|
if use_llm_processing and auxiliary_available:
|
||||||
logger.info("Processing crawled content with LLM (parallel)...")
|
logger.info("Processing crawled content with LLM (parallel)...")
|
||||||
debug_call_data["processing_applied"].append("llm_processing")
|
debug_call_data["processing_applied"].append("llm_processing")
|
||||||
|
|
||||||
|
|
@ -1240,12 +1376,12 @@ async def web_crawl_tool(
|
||||||
if not content:
|
if not content:
|
||||||
return result, None, "no_content"
|
return result, None, "no_content"
|
||||||
original_size = len(content)
|
original_size = len(content)
|
||||||
processed = await process_content_with_llm(content, page_url, title, model, min_length)
|
processed = await process_content_with_llm(content, page_url, title, effective_model, min_length)
|
||||||
if processed:
|
if processed:
|
||||||
result['raw_content'] = content
|
result['raw_content'] = content
|
||||||
result['content'] = processed
|
result['content'] = processed
|
||||||
metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed),
|
metrics = {"url": page_url, "original_size": original_size, "processed_size": len(processed),
|
||||||
"compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": model}
|
"compression_ratio": len(processed) / original_size if original_size else 1.0, "model_used": effective_model}
|
||||||
return result, metrics, "processed"
|
return result, metrics, "processed"
|
||||||
metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size,
|
metrics = {"url": page_url, "original_size": original_size, "processed_size": original_size,
|
||||||
"compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"}
|
"compression_ratio": 1.0, "model_used": None, "reason": "content_too_short"}
|
||||||
|
|
@ -1258,6 +1394,10 @@ async def web_crawl_tool(
|
||||||
debug_call_data["compression_metrics"].append(metrics)
|
debug_call_data["compression_metrics"].append(metrics)
|
||||||
debug_call_data["pages_processed_with_llm"] += 1
|
debug_call_data["pages_processed_with_llm"] += 1
|
||||||
|
|
||||||
|
if use_llm_processing and not auxiliary_available:
|
||||||
|
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
|
||||||
|
debug_call_data["processing_applied"].append("llm_processing_unavailable")
|
||||||
|
|
||||||
trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"),
|
trimmed_results = [{"url": r.get("url", ""), "title": r.get("title", ""), "content": r.get("content", ""), "error": r.get("error"),
|
||||||
**({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])]
|
**({ "blocked_by_policy": r["blocked_by_policy"]} if "blocked_by_policy" in r else {})} for r in response.get("results", [])]
|
||||||
result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False)
|
result_json = json.dumps({"results": trimmed_results}, indent=2, ensure_ascii=False)
|
||||||
|
|
@ -1267,10 +1407,12 @@ async def web_crawl_tool(
|
||||||
_debug.save()
|
_debug.save()
|
||||||
return cleaned_result
|
return cleaned_result
|
||||||
|
|
||||||
# web_crawl requires Firecrawl — Parallel has no crawl API
|
# web_crawl requires Firecrawl or the Firecrawl tool-gateway — Parallel has no crawl API
|
||||||
if not (os.getenv("FIRECRAWL_API_KEY") or os.getenv("FIRECRAWL_API_URL")):
|
if not check_firecrawl_api_key():
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, "
|
"error": "web_crawl requires Firecrawl. Set FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
|
||||||
|
"or, if you are a Nous Subscriber, login to Nous and use FIRECRAWL_GATEWAY_URL, "
|
||||||
|
"or TOOL_GATEWAY_DOMAIN, "
|
||||||
"or use web_search + web_extract instead.",
|
"or use web_search + web_extract instead.",
|
||||||
"success": False,
|
"success": False,
|
||||||
}, ensure_ascii=False)
|
}, ensure_ascii=False)
|
||||||
|
|
@ -1431,7 +1573,7 @@ async def web_crawl_tool(
|
||||||
debug_call_data["original_response_size"] = len(json.dumps(response))
|
debug_call_data["original_response_size"] = len(json.dumps(response))
|
||||||
|
|
||||||
# Process each result with LLM if enabled
|
# Process each result with LLM if enabled
|
||||||
if use_llm_processing:
|
if use_llm_processing and auxiliary_available:
|
||||||
logger.info("Processing crawled content with LLM (parallel)...")
|
logger.info("Processing crawled content with LLM (parallel)...")
|
||||||
debug_call_data["processing_applied"].append("llm_processing")
|
debug_call_data["processing_applied"].append("llm_processing")
|
||||||
|
|
||||||
|
|
@ -1449,7 +1591,7 @@ async def web_crawl_tool(
|
||||||
|
|
||||||
# Process content with LLM
|
# Process content with LLM
|
||||||
processed = await process_content_with_llm(
|
processed = await process_content_with_llm(
|
||||||
content, page_url, title, model, min_length
|
content, page_url, title, effective_model, min_length
|
||||||
)
|
)
|
||||||
|
|
||||||
if processed:
|
if processed:
|
||||||
|
|
@ -1465,7 +1607,7 @@ async def web_crawl_tool(
|
||||||
"original_size": original_size,
|
"original_size": original_size,
|
||||||
"processed_size": processed_size,
|
"processed_size": processed_size,
|
||||||
"compression_ratio": compression_ratio,
|
"compression_ratio": compression_ratio,
|
||||||
"model_used": model
|
"model_used": effective_model
|
||||||
}
|
}
|
||||||
return result, metrics, "processed"
|
return result, metrics, "processed"
|
||||||
else:
|
else:
|
||||||
|
|
@ -1497,6 +1639,9 @@ async def web_crawl_tool(
|
||||||
else:
|
else:
|
||||||
logger.warning("%s (no content to process)", page_url)
|
logger.warning("%s (no content to process)", page_url)
|
||||||
else:
|
else:
|
||||||
|
if use_llm_processing and not auxiliary_available:
|
||||||
|
logger.warning("LLM processing requested but no auxiliary model available, returning raw content")
|
||||||
|
debug_call_data["processing_applied"].append("llm_processing_unavailable")
|
||||||
# Print summary of crawled pages for debugging (original behavior)
|
# Print summary of crawled pages for debugging (original behavior)
|
||||||
for result in response.get('results', []):
|
for result in response.get('results', []):
|
||||||
page_url = result.get('url', 'Unknown URL')
|
page_url = result.get('url', 'Unknown URL')
|
||||||
|
|
@ -1540,38 +1685,34 @@ async def web_crawl_tool(
|
||||||
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
return json.dumps({"error": error_msg}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
# Convenience function to check if API key is available
|
# Convenience function to check Firecrawl credentials
|
||||||
def check_firecrawl_api_key() -> bool:
|
def check_firecrawl_api_key() -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the Firecrawl API key is available in environment variables.
|
Check whether the Firecrawl backend is available.
|
||||||
|
|
||||||
|
Availability is true when either:
|
||||||
|
1) direct Firecrawl config (`FIRECRAWL_API_KEY` or `FIRECRAWL_API_URL`), or
|
||||||
|
2) Firecrawl gateway origin + Nous Subscriber access token
|
||||||
|
(fallback when direct Firecrawl is not configured).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if API key is set, False otherwise
|
bool: True if direct Firecrawl or the tool-gateway can be used.
|
||||||
"""
|
"""
|
||||||
return bool(os.getenv("FIRECRAWL_API_KEY"))
|
return _has_direct_firecrawl_config() or _is_tool_gateway_ready()
|
||||||
|
|
||||||
|
|
||||||
def check_web_api_key() -> bool:
|
def check_web_api_key() -> bool:
|
||||||
"""Check if any web backend API key is available (Parallel, Firecrawl, or Tavily)."""
|
"""Check whether the configured web backend is available."""
|
||||||
return bool(
|
configured = _load_web_config().get("backend", "").lower().strip()
|
||||||
os.getenv("PARALLEL_API_KEY")
|
if configured in ("parallel", "firecrawl", "tavily"):
|
||||||
or os.getenv("FIRECRAWL_API_KEY")
|
return _is_backend_available(configured)
|
||||||
or os.getenv("FIRECRAWL_API_URL")
|
return any(_is_backend_available(backend) for backend in ("parallel", "firecrawl", "tavily"))
|
||||||
or os.getenv("TAVILY_API_KEY")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_auxiliary_model() -> bool:
|
def check_auxiliary_model() -> bool:
|
||||||
"""Check if an auxiliary text model is available for LLM content processing."""
|
"""Check if an auxiliary text model is available for LLM content processing."""
|
||||||
try:
|
client, _, _ = _resolve_web_extract_auxiliary()
|
||||||
from agent.auxiliary_client import resolve_provider_client
|
return client is not None
|
||||||
for p in ("openrouter", "nous", "custom", "codex"):
|
|
||||||
client, _ = resolve_provider_client(p)
|
|
||||||
if client is not None:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_debug_session_info() -> Dict[str, Any]:
|
def get_debug_session_info() -> Dict[str, Any]:
|
||||||
|
|
@ -1588,7 +1729,11 @@ if __name__ == "__main__":
|
||||||
|
|
||||||
# Check if API keys are available
|
# Check if API keys are available
|
||||||
web_available = check_web_api_key()
|
web_available = check_web_api_key()
|
||||||
|
tool_gateway_available = _is_tool_gateway_ready()
|
||||||
|
firecrawl_key_available = bool(os.getenv("FIRECRAWL_API_KEY", "").strip())
|
||||||
|
firecrawl_url_available = bool(os.getenv("FIRECRAWL_API_URL", "").strip())
|
||||||
nous_available = check_auxiliary_model()
|
nous_available = check_auxiliary_model()
|
||||||
|
default_summarizer_model = _get_default_summarizer_model()
|
||||||
|
|
||||||
if web_available:
|
if web_available:
|
||||||
backend = _get_backend()
|
backend = _get_backend()
|
||||||
|
|
@ -1598,17 +1743,28 @@ if __name__ == "__main__":
|
||||||
elif backend == "tavily":
|
elif backend == "tavily":
|
||||||
print(" Using Tavily API (https://tavily.com)")
|
print(" Using Tavily API (https://tavily.com)")
|
||||||
else:
|
else:
|
||||||
print(" Using Firecrawl API (https://firecrawl.dev)")
|
if firecrawl_url_available:
|
||||||
|
print(f" Using self-hosted Firecrawl: {os.getenv('FIRECRAWL_API_URL').strip().rstrip('/')}")
|
||||||
|
elif firecrawl_key_available:
|
||||||
|
print(" Using direct Firecrawl cloud API")
|
||||||
|
elif tool_gateway_available:
|
||||||
|
print(f" Using Firecrawl tool-gateway: {_get_firecrawl_gateway_url()}")
|
||||||
|
else:
|
||||||
|
print(" Firecrawl backend selected but not configured")
|
||||||
else:
|
else:
|
||||||
print("❌ No web search backend configured")
|
print("❌ No web search backend configured")
|
||||||
print("Set PARALLEL_API_KEY, TAVILY_API_KEY, or FIRECRAWL_API_KEY")
|
print(
|
||||||
|
"Set PARALLEL_API_KEY, TAVILY_API_KEY, FIRECRAWL_API_KEY, FIRECRAWL_API_URL, "
|
||||||
|
"or, if you are a Nous Subscriber, login to Nous and use "
|
||||||
|
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
|
||||||
|
)
|
||||||
|
|
||||||
if not nous_available:
|
if not nous_available:
|
||||||
print("❌ No auxiliary model available for LLM content processing")
|
print("❌ No auxiliary model available for LLM content processing")
|
||||||
print("Set OPENROUTER_API_KEY, configure Nous Portal, or set OPENAI_BASE_URL + OPENAI_API_KEY")
|
print("Set OPENROUTER_API_KEY, configure Nous Portal, or set OPENAI_BASE_URL + OPENAI_API_KEY")
|
||||||
print("⚠️ Without an auxiliary model, LLM content processing will be disabled")
|
print("⚠️ Without an auxiliary model, LLM content processing will be disabled")
|
||||||
else:
|
else:
|
||||||
print(f"✅ Auxiliary model available: {DEFAULT_SUMMARIZER_MODEL}")
|
print(f"✅ Auxiliary model available: {default_summarizer_model}")
|
||||||
|
|
||||||
if not web_available:
|
if not web_available:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
@ -1616,7 +1772,7 @@ if __name__ == "__main__":
|
||||||
print("🛠️ Web tools ready for use!")
|
print("🛠️ Web tools ready for use!")
|
||||||
|
|
||||||
if nous_available:
|
if nous_available:
|
||||||
print(f"🧠 LLM content processing available with {DEFAULT_SUMMARIZER_MODEL}")
|
print(f"🧠 LLM content processing available with {default_summarizer_model}")
|
||||||
print(f" Default min length for processing: {DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION} chars")
|
print(f" Default min length for processing: {DEFAULT_MIN_LENGTH_FOR_SUMMARIZATION} chars")
|
||||||
|
|
||||||
# Show debug mode status
|
# Show debug mode status
|
||||||
|
|
@ -1711,7 +1867,16 @@ registry.register(
|
||||||
schema=WEB_SEARCH_SCHEMA,
|
schema=WEB_SEARCH_SCHEMA,
|
||||||
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
|
handler=lambda args, **kw: web_search_tool(args.get("query", ""), limit=5),
|
||||||
check_fn=check_web_api_key,
|
check_fn=check_web_api_key,
|
||||||
requires_env=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"],
|
requires_env=[
|
||||||
|
"PARALLEL_API_KEY",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
],
|
||||||
emoji="🔍",
|
emoji="🔍",
|
||||||
)
|
)
|
||||||
registry.register(
|
registry.register(
|
||||||
|
|
@ -1721,7 +1886,16 @@ registry.register(
|
||||||
handler=lambda args, **kw: web_extract_tool(
|
handler=lambda args, **kw: web_extract_tool(
|
||||||
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
args.get("urls", [])[:5] if isinstance(args.get("urls"), list) else [], "markdown"),
|
||||||
check_fn=check_web_api_key,
|
check_fn=check_web_api_key,
|
||||||
requires_env=["PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "TAVILY_API_KEY"],
|
requires_env=[
|
||||||
|
"PARALLEL_API_KEY",
|
||||||
|
"TAVILY_API_KEY",
|
||||||
|
"FIRECRAWL_GATEWAY_URL",
|
||||||
|
"TOOL_GATEWAY_DOMAIN",
|
||||||
|
"TOOL_GATEWAY_SCHEME",
|
||||||
|
"TOOL_GATEWAY_USER_TOKEN",
|
||||||
|
"FIRECRAWL_API_KEY",
|
||||||
|
"FIRECRAWL_API_URL",
|
||||||
|
],
|
||||||
is_async=True,
|
is_async=True,
|
||||||
emoji="📄",
|
emoji="📄",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,9 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||||
| `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) |
|
| `FIRECRAWL_API_KEY` | Web scraping ([firecrawl.dev](https://firecrawl.dev/)) |
|
||||||
| `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) |
|
| `FIRECRAWL_API_URL` | Custom Firecrawl API endpoint for self-hosted instances (optional) |
|
||||||
| `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) |
|
| `TAVILY_API_KEY` | Tavily API key for AI-native web search, extract, and crawl ([app.tavily.com](https://app.tavily.com/home)) |
|
||||||
|
| `TOOL_GATEWAY_DOMAIN` | Shared tool-gateway domain suffix for Nous Subscribers only, used to derive vendor hosts, for example `nousresearch.com` -> `firecrawl-gateway.nousresearch.com` |
|
||||||
|
| `TOOL_GATEWAY_SCHEME` | Shared tool-gateway URL scheme for Nous Subscribers only, used to derive vendor hosts, `https` by default and `http` for local gateway testing |
|
||||||
|
| `TOOL_GATEWAY_USER_TOKEN` | Explicit Nous Subscriber access token for tool-gateway calls (optional; otherwise Hermes reads `~/.hermes/auth.json`) |
|
||||||
| `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) |
|
| `BROWSERBASE_API_KEY` | Browser automation ([browserbase.com](https://browserbase.com/)) |
|
||||||
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
|
| `BROWSERBASE_PROJECT_ID` | Browserbase project ID |
|
||||||
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) |
|
| `BROWSER_USE_API_KEY` | Browser Use cloud browser API key ([browser-use.com](https://browser-use.com/)) |
|
||||||
|
|
@ -114,6 +117,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
|
||||||
| `TERMINAL_CWD` | Working directory for all terminal sessions |
|
| `TERMINAL_CWD` | Working directory for all terminal sessions |
|
||||||
| `SUDO_PASSWORD` | Enable sudo without interactive prompt |
|
| `SUDO_PASSWORD` | Enable sudo without interactive prompt |
|
||||||
|
|
||||||
|
For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETIME_SECONDS` controls when Hermes cleans up an idle terminal session, and later resumes may recreate the sandbox rather than keep the same live processes running.
|
||||||
|
|
||||||
## SSH Backend
|
## SSH Backend
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
|
|
|
||||||
|
|
@ -695,6 +695,8 @@ terminal:
|
||||||
persistent_shell: true # Enabled by default for SSH backend
|
persistent_shell: true # Enabled by default for SSH backend
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For cloud sandboxes such as Modal and Daytona, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later.
|
||||||
|
|
||||||
### Common Terminal Backend Issues
|
### Common Terminal Backend Issues
|
||||||
|
|
||||||
If terminal commands fail immediately or the terminal tool is reported as disabled, check the following:
|
If terminal commands fail immediately or the terminal tool is reported as disabled, check the following:
|
||||||
|
|
@ -723,8 +725,9 @@ If terminal commands fail immediately or the terminal tool is reported as disabl
|
||||||
- If either value is missing, Hermes will log a clear error and refuse to use the SSH backend.
|
- If either value is missing, Hermes will log a clear error and refuse to use the SSH backend.
|
||||||
|
|
||||||
- **Modal backend**
|
- **Modal backend**
|
||||||
- You need either a `MODAL_TOKEN_ID` environment variable or a `~/.modal.toml` config file.
|
- Hermes can use either direct Modal credentials (`MODAL_TOKEN_ID` plus `MODAL_TOKEN_SECRET`, or `~/.modal.toml`) or a configured managed tool gateway with a Nous user token.
|
||||||
- If neither is present, the backend check fails and Hermes will report that the Modal backend is not available.
|
- Modal persistence is resumable filesystem state, not durable process continuity. If you need something to stay continuously up, use a deployment-oriented tool instead of the terminal sandbox.
|
||||||
|
- If neither direct credentials nor a managed gateway is present, Hermes will report that the Modal backend is not available.
|
||||||
|
|
||||||
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
|
When in doubt, set `terminal.backend` back to `local` and verify that commands run there first.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,13 @@ modal setup
|
||||||
hermes config set terminal.backend modal
|
hermes config set terminal.backend modal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Hermes can use Modal in two modes:
|
||||||
|
|
||||||
|
- **Direct Modal**: Hermes talks to your Modal account directly.
|
||||||
|
- **Managed Modal**: Hermes talks to a gateway that owns the vendor credentials.
|
||||||
|
|
||||||
|
In both cases, Modal is best treated as a task sandbox, not a deployment target. Persistent mode preserves filesystem state so later turns can resume your work, but Hermes may still clean up or recreate the live sandbox. Long-running servers and background processes are not guaranteed to survive idle cleanup, session teardown, or Hermes exit.
|
||||||
|
|
||||||
### Container Resources
|
### Container Resources
|
||||||
|
|
||||||
Configure CPU, memory, disk, and persistence for all container backends:
|
Configure CPU, memory, disk, and persistence for all container backends:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue