Merge branch 'main' of github.com:NousResearch/hermes-agent into feat/ink-refactor

This commit is contained in:
Brooklyn Nicholson 2026-04-16 14:48:33 -05:00
commit cb2a737bc8
49 changed files with 1055 additions and 229 deletions

View file

@ -28,19 +28,45 @@ except ImportError:
logger = logging.getLogger(__name__)
THINKING_BUDGET = {"xhigh": 32000, "high": 16000, "medium": 8000, "low": 4000}
# Hermes effort → Anthropic adaptive-thinking effort (output_config.effort).
# Anthropic exposes 5 levels on 4.7+: low, medium, high, xhigh, max.
# Opus/Sonnet 4.6 only expose 4 levels: low, medium, high, max — no xhigh.
# We preserve xhigh as xhigh on 4.7+ (the recommended default for coding/
# agentic work) and downgrade it to max on pre-4.7 adaptive models (which
# is the strongest level they accept). "minimal" is a legacy alias that
# maps to low on every model. See:
# https://platform.claude.com/docs/en/about-claude/models/migration-guide
ADAPTIVE_EFFORT_MAP = {
"xhigh": "max",
"high": "high",
"medium": "medium",
"low": "low",
"max": "max",
"xhigh": "xhigh",
"high": "high",
"medium": "medium",
"low": "low",
"minimal": "low",
}
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7")
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7")
# ── Max output token limits per Anthropic model ───────────────────────
# Source: Anthropic docs + Cline model catalog. Anthropic's API requires
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Claude 4.7
"claude-opus-4-7": 128_000,
# Claude 4.6
"claude-opus-4-6": 128_000,
"claude-sonnet-4-6": 64_000,
@ -91,11 +117,37 @@ def _get_anthropic_max_output(model: str) -> int:
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude 4.6 models that support adaptive thinking."""
return any(v in model for v in ("4-6", "4.6"))
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
# Beta headers for enhanced features (sent with ALL auth types)
def _supports_xhigh_effort(model: str) -> bool:
"""Return True for models that accept the 'xhigh' adaptive effort level.
Opus 4.7 introduced xhigh as a distinct level between high and max.
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhighmax
when this returns False.
"""
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
"""
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
# Beta headers for enhanced features (sent with ALL auth types).
# As of Opus 4.7 (2026-04-16), both of these are GA on Claude 4.6+ — the
# beta headers are still accepted (harmless no-op) but not required. Kept
# here so older Claude (4.5, 4.1) + third-party Anthropic-compat endpoints
# that still gate on the headers continue to get the enhanced features.
# Migration guide: remove these if you no longer support ≤4.5 models.
_COMMON_BETAS = [
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
@ -1341,18 +1393,31 @@ def build_anthropic_kwargs(
kwargs["tool_choice"] = {"type": "tool", "name": tool_choice}
# Map reasoning_config to Anthropic's thinking parameter.
# Claude 4.6 models use adaptive thinking + output_config.effort.
# Claude 4.6+ models use adaptive thinking + output_config.effort.
# Older models use manual thinking with budget_tokens.
# MiniMax Anthropic-compat endpoints support thinking (manual mode only,
# not adaptive). Haiku does NOT support extended thinking — skip entirely.
#
# On 4.7+ the `thinking.display` field defaults to "omitted", which
# silently hides reasoning text that Hermes surfaces in its CLI. We
# request "summarized" so the reasoning blocks stay populated — matching
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
if reasoning_config and isinstance(reasoning_config, dict):
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
effort = str(reasoning_config.get("effort", "medium")).lower()
budget = THINKING_BUDGET.get(effort, 8000)
if _supports_adaptive_thinking(model):
kwargs["thinking"] = {"type": "adaptive"}
kwargs["thinking"] = {
"type": "adaptive",
"display": "summarized",
}
adaptive_effort = ADAPTIVE_EFFORT_MAP.get(effort, "medium")
# Downgrade xhigh→max on models that don't list xhigh as a
# supported level (Opus/Sonnet 4.6). Opus 4.7+ keeps xhigh.
if adaptive_effort == "xhigh" and not _supports_xhigh_effort(model):
adaptive_effort = "max"
kwargs["output_config"] = {
"effort": ADAPTIVE_EFFORT_MAP.get(effort, "medium")
"effort": adaptive_effort,
}
else:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
@ -1360,6 +1425,15 @@ def build_anthropic_kwargs(
kwargs["temperature"] = 1
kwargs["max_tokens"] = max(effective_max_tokens, budget + 4096)
# ── Strip sampling params on 4.7+ ─────────────────────────────────
# Opus 4.7 rejects any non-default temperature/top_p/top_k with a 400.
# Callers (auxiliary_client, flush_memories, etc.) may set these for
# older models; drop them here as a safety net so upstream 4.6 → 4.7
# migrations don't require coordinated edits everywhere.
if _forbids_sampling_params(model):
for _sampling_key in ("temperature", "top_p", "top_k"):
kwargs.pop(_sampling_key, None)
# ── Fast mode (Opus 4.6 only) ────────────────────────────────────
# Adds extra_body.speed="fast" + the fast-mode beta header for ~2.5x
# output speed. Only for native Anthropic endpoints — third-party
@ -1417,12 +1491,20 @@ def normalize_anthropic_response(
)
)
# Map Anthropic stop_reason to OpenAI finish_reason
# Map Anthropic stop_reason to OpenAI finish_reason.
# Newer stop reasons added in Claude 4.5+ / 4.7:
# - refusal: the model declined to answer (cyber safeguards, CSAM, etc.)
# - model_context_window_exceeded: hit context limit (not max_tokens)
# Both need distinct handling upstream — a refusal should surface to the
# user with a clear message, and a context-window overflow should trigger
# compression/truncation rather than be treated as normal end-of-turn.
stop_reason_map = {
"end_turn": "stop",
"tool_use": "tool_calls",
"max_tokens": "length",
"stop_sequence": "stop",
"refusal": "content_filter",
"model_context_window_exceeded": "length",
}
finish_reason = stop_reason_map.get(response.stop_reason, "stop")

View file

@ -518,8 +518,13 @@ class _AnthropicCompletionsAdapter:
tool_choice=normalized_tool_choice,
is_oauth=self._is_oauth,
)
# Opus 4.7+ rejects any non-default temperature/top_p/top_k; only set
# temperature for models that still accept it. build_anthropic_kwargs
# additionally strips these keys as a safety net — keep both layers.
if temperature is not None:
anthropic_kwargs["temperature"] = temperature
from agent.anthropic_adapter import _forbids_sampling_params
if not _forbids_sampling_params(model):
anthropic_kwargs["temperature"] = temperature
response = self._client.messages.create(**anthropic_kwargs)
assistant_message, finish_reason = normalize_anthropic_response(response)
@ -2288,6 +2293,15 @@ def _build_call_kwargs(
"timeout": timeout,
}
# Opus 4.7+ rejects any non-default temperature/top_p/top_k — silently
# drop here so auxiliary callers that hardcode temperature (e.g. 0.3 on
# flush_memories, 0 on structured-JSON extraction) don't 400 the moment
# the aux model is flipped to 4.7.
if temperature is not None:
from agent.anthropic_adapter import _forbids_sampling_params
if _forbids_sampling_params(model):
temperature = None
if temperature is not None:
kwargs["temperature"] = temperature

View file

@ -313,9 +313,25 @@ class CopilotACPClient:
tools=tools,
tool_choice=tool_choice,
)
# Normalise timeout: run_agent.py may pass an httpx.Timeout object
# (used natively by the OpenAI SDK) rather than a plain float.
if timeout is None:
_effective_timeout = _DEFAULT_TIMEOUT_SECONDS
elif isinstance(timeout, (int, float)):
_effective_timeout = float(timeout)
else:
# httpx.Timeout or similar — pick the largest component so the
# subprocess has enough wall-clock time for the full response.
_candidates = [
getattr(timeout, attr, None)
for attr in ("read", "write", "connect", "pool", "timeout")
]
_numeric = [float(v) for v in _candidates if isinstance(v, (int, float))]
_effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS
response_text, reasoning_text = self._run_prompt(
prompt_text,
timeout_seconds=float(timeout or _DEFAULT_TIMEOUT_SECONDS),
timeout_seconds=_effective_timeout,
)
tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)

View file

@ -102,6 +102,8 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-opus-4-7": 1000000,
"claude-opus-4.7": 1000000,
"claude-opus-4-6": 1000000,
"claude-sonnet-4-6": 1000000,
"claude-opus-4.6": 1000000,

View file

@ -561,7 +561,10 @@ class BatchRunner:
provider_sort (str): Sort providers by price/throughput/latency (optional)
max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set)
reasoning_config (Dict): OpenRouter reasoning config override (e.g. {"effort": "none"} to disable thinking)
prefill_messages (List[Dict]): Messages to prepend as prefilled conversation context (few-shot priming)
prefill_messages (List[Dict]): Messages to prepend as prefilled conversation context (few-shot priming).
NOTE: Anthropic Sonnet 4.6+ and Opus 4.6+ reject a trailing assistant-role prefill
(400 error). For those models use output_config.format or structured-output
schemas instead. Safe here for user-role priming and for older Claude / non-Claude models.
max_samples (int): Only process the first N samples from the dataset (optional, processes all if not set)
"""
self.dataset_file = Path(dataset_file)

12
cli.py
View file

@ -6328,13 +6328,21 @@ class HermesCLI:
def _toggle_yolo(self):
"""Toggle YOLO mode — skip all dangerous command approval prompts."""
import os
from hermes_cli.colors import Colors as _Colors
current = bool(os.environ.get("HERMES_YOLO_MODE"))
if current:
os.environ.pop("HERMES_YOLO_MODE", None)
self.console.print(" ⚠ YOLO mode [bold red]OFF[/] — dangerous commands will require approval.")
_cprint(
f" ⚠ YOLO mode {_Colors.BOLD}{_Colors.RED}OFF{_Colors.RESET}"
" — dangerous commands will require approval."
)
else:
os.environ["HERMES_YOLO_MODE"] = "1"
self.console.print(" ⚡ YOLO mode [bold green]ON[/] — all commands auto-approved. Use with caution.")
_cprint(
f" ⚡ YOLO mode {_Colors.BOLD}{_Colors.GREEN}ON{_Colors.RESET}"
" — all commands auto-approved. Use with caution."
)
def _handle_reasoning_command(self, cmd: str):
"""Handle /reasoning — manage effort level and display toggle.

View file

@ -23,7 +23,6 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from tools.tool_backend_helpers import managed_nous_tools_enabled as _managed_nous_tools_enabled
_IS_WINDOWS = platform.system() == "Windows"
_ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@ -1646,14 +1645,8 @@ OPTIONAL_ENV_VARS = {
},
}
if not _managed_nous_tools_enabled():
for _hidden_var in (
"FIRECRAWL_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN",
"TOOL_GATEWAY_SCHEME",
"TOOL_GATEWAY_USER_TOKEN",
):
OPTIONAL_ENV_VARS.pop(_hidden_var, None)
# Tool Gateway env vars are always visible — they're useful for
# self-hosted / custom gateway setups regardless of subscription state.
def get_missing_env_vars(required_only: bool = False) -> List[Dict[str, Any]]:

View file

@ -166,6 +166,7 @@ def curses_radiolist(
selected: int = 0,
*,
cancel_returns: int | None = None,
description: str | None = None,
) -> int:
"""Curses single-select radio list. Returns the selected index.
@ -174,6 +175,9 @@ def curses_radiolist(
items: Display labels for each row.
selected: Index that starts selected (pre-selected).
cancel_returns: Returned on ESC/q. Defaults to the original *selected*.
description: Optional multi-line text shown between the title and
the item list. Useful for context that should survive the
curses screen clear.
"""
if cancel_returns is None:
cancel_returns = selected
@ -181,6 +185,10 @@ def curses_radiolist(
if not sys.stdin.isatty():
return cancel_returns
desc_lines: list[str] = []
if description:
desc_lines = description.splitlines()
try:
import curses
result_holder: list = [None]
@ -199,22 +207,35 @@ def curses_radiolist(
stdscr.clear()
max_y, max_x = stdscr.getmaxyx()
row = 0
# Header
try:
hattr = curses.A_BOLD
if curses.has_colors():
hattr |= curses.color_pair(2)
stdscr.addnstr(0, 0, title, max_x - 1, hattr)
stdscr.addnstr(row, 0, title, max_x - 1, hattr)
row += 1
# Description lines
for dline in desc_lines:
if row >= max_y - 1:
break
stdscr.addnstr(row, 0, dline, max_x - 1, curses.A_NORMAL)
row += 1
stdscr.addnstr(
1, 0,
row, 0,
" \u2191\u2193 navigate ENTER/SPACE select ESC cancel",
max_x - 1, curses.A_DIM,
)
row += 1
except curses.error:
pass
# Scrollable item list
visible_rows = max_y - 4
items_start = row + 1
visible_rows = max_y - items_start - 1
if cursor < scroll_offset:
scroll_offset = cursor
elif cursor >= scroll_offset + visible_rows:
@ -223,7 +244,7 @@ def curses_radiolist(
for draw_i, i in enumerate(
range(scroll_offset, min(len(items), scroll_offset + visible_rows))
):
y = draw_i + 3
y = draw_i + items_start
if y >= max_y - 1:
break
radio = "\u25cf" if i == selected else "\u25cb"

View file

@ -1564,11 +1564,8 @@ def _model_flow_nous(config, current_model="", args=None):
AuthError, format_auth_error,
_login_nous, PROVIDER_REGISTRY,
)
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,
)
from hermes_cli.config import get_env_value, load_config, save_config, save_env_value
from hermes_cli.nous_subscription import prompt_enable_tool_gateway
import argparse
state = get_provider_auth_state("nous")
@ -1587,9 +1584,12 @@ def _model_flow_nous(config, current_model="", args=None):
insecure=bool(getattr(args, "insecure", False)),
)
_login_nous(mock_args, PROVIDER_REGISTRY["nous"])
print()
for line in get_nous_subscription_explainer_lines():
print(line)
# Offer Tool Gateway enablement for paid subscribers
try:
_refreshed = load_config() or {}
prompt_enable_tool_gateway(_refreshed)
except Exception:
pass
except SystemExit:
print("Login cancelled or failed.")
return
@ -1697,18 +1697,10 @@ def _model_flow_nous(config, current_model="", args=None):
if get_env_value("OPENAI_BASE_URL"):
save_env_value("OPENAI_BASE_URL", "")
save_env_value("OPENAI_API_KEY", "")
changed_defaults = apply_nous_provider_defaults(config)
save_config(config)
print(f"Default model set to: {selected} (via Nous Portal)")
if "tts" in changed_defaults:
print("TTS provider set to: OpenAI TTS via your Nous subscription")
else:
current_tts = str(config.get("tts", {}).get("provider") or "edge")
if current_tts.lower() not in {"", "edge"}:
print(f"Keeping your existing TTS provider: {current_tts}")
print()
for line in get_nous_subscription_explainer_lines():
print(line)
# Offer Tool Gateway enablement for paid subscribers
prompt_enable_tool_gateway(config)
else:
print("No change.")

View file

@ -26,7 +26,8 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Fallback OpenRouter snapshot used when the live catalog is unavailable.
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
("anthropic/claude-opus-4.6", "recommended"),
("anthropic/claude-opus-4.7", "recommended"),
("anthropic/claude-opus-4.6", ""),
("anthropic/claude-sonnet-4.6", ""),
("qwen/qwen3.6-plus", ""),
("anthropic/claude-sonnet-4.5", ""),
@ -181,6 +182,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2",
],
"anthropic": [
"claude-opus-4-7",
"claude-opus-4-6",
"claude-sonnet-4-6",
"claude-opus-4-5-20251101",

View file

@ -258,6 +258,15 @@ def get_nous_subscription_features(
terminal_cfg.get("modal_mode")
)
# use_gateway flags — when True, the user explicitly opted into the
# Tool Gateway via `hermes model`, so direct credentials should NOT
# prevent gateway routing.
web_use_gateway = bool(web_cfg.get("use_gateway"))
tts_use_gateway = bool(tts_cfg.get("use_gateway"))
browser_use_gateway = bool(browser_cfg.get("use_gateway"))
image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}
image_use_gateway = bool(image_gen_cfg.get("use_gateway"))
direct_exa = bool(get_env_value("EXA_API_KEY"))
direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL"))
direct_parallel = bool(get_env_value("PARALLEL_API_KEY"))
@ -270,6 +279,21 @@ def get_nous_subscription_features(
direct_browser_use = bool(get_env_value("BROWSER_USE_API_KEY"))
direct_modal = has_direct_modal_credentials()
# When use_gateway is set, suppress direct credentials for managed detection
if web_use_gateway:
direct_firecrawl = False
direct_exa = False
direct_parallel = False
direct_tavily = False
if image_use_gateway:
direct_fal = False
if tts_use_gateway:
direct_openai_tts = False
direct_elevenlabs = False
if browser_use_gateway:
direct_browser_use = False
direct_browserbase = False
managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl")
managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue")
managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio")
@ -440,37 +464,7 @@ def get_nous_subscription_features(
)
def get_nous_subscription_explainer_lines() -> list[str]:
if not managed_nous_tools_enabled():
return []
return [
"Nous subscription enables managed web tools, image generation, OpenAI TTS, and browser automation by default.",
"Those managed tools bill to your Nous subscription. Modal execution is optional and can bill to your subscription too.",
"Change these later with: hermes setup tools, hermes setup terminal, or hermes status.",
]
def apply_nous_provider_defaults(config: Dict[str, object]) -> set[str]:
"""Apply provider-level Nous defaults shared by `hermes setup` and `hermes model`."""
if not managed_nous_tools_enabled():
return set()
features = get_nous_subscription_features(config)
if not features.provider_is_nous:
return set()
tts_cfg = config.get("tts")
if not isinstance(tts_cfg, dict):
tts_cfg = {}
config["tts"] = tts_cfg
current_tts = str(tts_cfg.get("provider") or "edge").strip().lower()
if current_tts not in {"", "edge"}:
return set()
tts_cfg["provider"] = "openai"
return {"tts"}
def apply_nous_managed_defaults(
@ -530,3 +524,255 @@ def apply_nous_managed_defaults(
changed.add("image_gen")
return changed
# ---------------------------------------------------------------------------
# Tool Gateway offer — single Y/n prompt after model selection
# ---------------------------------------------------------------------------
_GATEWAY_TOOL_LABELS = {
"web": "Web search & extract (Firecrawl)",
"image_gen": "Image generation (FAL)",
"tts": "Text-to-speech (OpenAI TTS)",
"browser": "Browser automation (Browser Use)",
}
def _get_gateway_direct_credentials() -> Dict[str, bool]:
"""Return a dict of tool_key -> has_direct_credentials."""
return {
"web": bool(
get_env_value("FIRECRAWL_API_KEY")
or get_env_value("FIRECRAWL_API_URL")
or get_env_value("PARALLEL_API_KEY")
or get_env_value("TAVILY_API_KEY")
or get_env_value("EXA_API_KEY")
),
"image_gen": bool(get_env_value("FAL_KEY")),
"tts": bool(
resolve_openai_audio_api_key()
or get_env_value("ELEVENLABS_API_KEY")
),
"browser": bool(
get_env_value("BROWSER_USE_API_KEY")
or (get_env_value("BROWSERBASE_API_KEY") and get_env_value("BROWSERBASE_PROJECT_ID"))
),
}
_GATEWAY_DIRECT_LABELS = {
"web": "Firecrawl/Exa/Parallel/Tavily key",
"image_gen": "FAL key",
"tts": "OpenAI/ElevenLabs key",
"browser": "Browser Use/Browserbase key",
}
_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser")
def get_gateway_eligible_tools(
config: Optional[Dict[str, object]] = None,
) -> tuple[list[str], list[str], list[str]]:
"""Return (unconfigured, has_direct, already_managed) tool key lists.
- unconfigured: tools with no direct credentials (easy switch)
- has_direct: tools where the user has their own API keys
- already_managed: tools already routed through the gateway
All lists are empty when the user is not a paid Nous subscriber or
is not using Nous as their provider.
"""
if not managed_nous_tools_enabled():
return [], [], []
if config is None:
from hermes_cli.config import load_config
config = load_config() or {}
# Quick provider check without the heavy get_nous_subscription_features call
model_cfg = config.get("model")
if not isinstance(model_cfg, dict) or str(model_cfg.get("provider") or "").strip().lower() != "nous":
return [], [], []
direct = _get_gateway_direct_credentials()
# Check which tools the user has explicitly opted into the gateway for.
# This is distinct from managed_by_nous which fires implicitly when
# no direct keys exist — we only skip the prompt for tools where
# use_gateway was explicitly set.
opted_in = {
"web": bool((config.get("web") if isinstance(config.get("web"), dict) else {}).get("use_gateway")),
"image_gen": bool((config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {}).get("use_gateway")),
"tts": bool((config.get("tts") if isinstance(config.get("tts"), dict) else {}).get("use_gateway")),
"browser": bool((config.get("browser") if isinstance(config.get("browser"), dict) else {}).get("use_gateway")),
}
unconfigured: list[str] = []
has_direct: list[str] = []
already_managed: list[str] = []
for key in _ALL_GATEWAY_KEYS:
if opted_in.get(key):
already_managed.append(key)
elif direct.get(key):
has_direct.append(key)
else:
unconfigured.append(key)
return unconfigured, has_direct, already_managed
def apply_gateway_defaults(
config: Dict[str, object],
tool_keys: list[str],
) -> set[str]:
"""Apply Tool Gateway config for the given tool keys.
Sets ``use_gateway: true`` in each tool's config section so the
runtime prefers the gateway even when direct API keys are present.
Returns the set of tools that were actually changed.
"""
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 tool_keys:
web_cfg["backend"] = "firecrawl"
web_cfg["use_gateway"] = True
changed.add("web")
if "tts" in tool_keys:
tts_cfg["provider"] = "openai"
tts_cfg["use_gateway"] = True
changed.add("tts")
if "browser" in tool_keys:
browser_cfg["cloud_provider"] = "browser-use"
browser_cfg["use_gateway"] = True
changed.add("browser")
if "image_gen" in tool_keys:
image_cfg = config.get("image_gen")
if not isinstance(image_cfg, dict):
image_cfg = {}
config["image_gen"] = image_cfg
image_cfg["use_gateway"] = True
changed.add("image_gen")
return changed
def prompt_enable_tool_gateway(config: Dict[str, object]) -> set[str]:
"""If eligible tools exist, prompt the user to enable the Tool Gateway.
Uses prompt_choice() with a description parameter so the curses TUI
shows the tool context alongside the choices.
Returns the set of tools that were enabled, or empty set if the user
declined or no tools were eligible.
"""
unconfigured, has_direct, already_managed = get_gateway_eligible_tools(config)
if not unconfigured and not has_direct:
return set()
try:
from hermes_cli.setup import prompt_choice
except Exception:
return set()
# Build description lines showing full status of all gateway tools
desc_parts: list[str] = [
"",
" The Tool Gateway gives you access to web search, image generation,",
" text-to-speech, and browser automation through your Nous subscription.",
" No need to sign up for separate API keys — just pick the tools you want.",
"",
]
if already_managed:
for k in already_managed:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using Tool Gateway")
if unconfigured:
for k in unconfigured:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — not configured")
if has_direct:
for k in has_direct:
desc_parts.append(f"{_GATEWAY_TOOL_LABELS[k]} — using {_GATEWAY_DIRECT_LABELS[k]}")
# Build short choice labels — detail is in the description above
choices: list[str] = []
choice_keys: list[str] = [] # maps choice index -> action
if unconfigured and has_direct:
choices.append("Enable for all tools (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Enable only for tools without existing keys")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
elif unconfigured:
choices.append("Enable Tool Gateway")
choice_keys.append("unconfigured")
choices.append("Skip")
choice_keys.append("skip")
else:
choices.append("Enable Tool Gateway (existing keys kept, not used)")
choice_keys.append("all")
choices.append("Skip")
choice_keys.append("skip")
description = "\n".join(desc_parts) if desc_parts else None
# Default to "Enable" when user has no direct keys (new user),
# default to "Skip" when they have existing keys to preserve.
default_idx = 0 if not has_direct else len(choices) - 1
try:
idx = prompt_choice(
"Your Nous subscription includes the Tool Gateway.",
choices,
default_idx,
description=description,
)
except (KeyboardInterrupt, EOFError, OSError, SystemExit):
return set()
action = choice_keys[idx]
if action == "skip":
return set()
if action == "all":
# Apply to switchable tools + ensure already-managed tools also
# have use_gateway persisted in config for consistency.
to_apply = list(_ALL_GATEWAY_KEYS)
else:
to_apply = unconfigured
changed = apply_gateway_defaults(config, to_apply)
if changed:
from hermes_cli.config import save_config
save_config(config)
# Only report the tools that actually switched (not already-managed ones)
newly_switched = changed - set(already_managed)
for key in sorted(newly_switched):
label = _GATEWAY_TOOL_LABELS.get(key, key)
print(f"{label}: enabled via Nous subscription")
if already_managed and not newly_switched:
print(" (all tools already using Tool Gateway)")
return changed

View file

@ -20,10 +20,7 @@ import copy
from pathlib import Path
from typing import Optional, Dict, Any
from hermes_cli.nous_subscription import (
apply_nous_provider_defaults,
get_nous_subscription_features,
)
from hermes_cli.nous_subscription import get_nous_subscription_features
from tools.tool_backend_helpers import managed_nous_tools_enabled
from hermes_constants import get_optional_skills_dir
@ -213,20 +210,20 @@ def prompt(question: str, default: str = None, password: bool = False) -> str:
sys.exit(1)
def _curses_prompt_choice(question: str, choices: list, default: int = 0) -> int:
def _curses_prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
"""Single-select menu using curses. Delegates to curses_radiolist."""
from hermes_cli.curses_ui import curses_radiolist
return curses_radiolist(question, choices, selected=default, cancel_returns=-1)
return curses_radiolist(question, choices, selected=default, cancel_returns=-1, description=description)
def prompt_choice(question: str, choices: list, default: int = 0) -> int:
def prompt_choice(question: str, choices: list, default: int = 0, description: str | None = None) -> int:
"""Prompt for a choice from a list with arrow key navigation.
Escape keeps the current default (skips the question).
Ctrl+C exits the wizard.
"""
idx = _curses_prompt_choice(question, choices, default)
idx = _curses_prompt_choice(question, choices, default, description=description)
if idx >= 0:
if idx == default:
print_info(" Skipped (keeping current)")
@ -835,14 +832,7 @@ def setup_model_provider(config: dict, *, quick: bool = False):
print_info("Skipped — add later with 'hermes setup' or configure AUXILIARY_VISION_* settings")
if selected_provider == "nous" and nous_subscription_selected:
changed_defaults = apply_nous_provider_defaults(config)
current_tts = str(config.get("tts", {}).get("provider") or "edge")
if "tts" in changed_defaults:
print_success("TTS provider set to: OpenAI TTS via your Nous subscription")
else:
print_info(f"Keeping your existing TTS provider: {current_tts}")
# Tool Gateway prompt is already shown by _model_flow_nous() above.
save_config(config)
if not quick and selected_provider != "nous":

View file

@ -212,7 +212,7 @@ def show_status(args):
if managed_nous_tools_enabled():
features = get_nous_subscription_features(config)
print()
print(color("◆ Nous Subscription Features", Colors.CYAN, Colors.BOLD))
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
if not features.nous_auth_present:
print(" Nous Portal ✗ not logged in")
else:
@ -230,6 +230,18 @@ def show_status(args):
else:
state = "not configured"
print(f" {feature.label:<15} {check_mark(feature.available or feature.active or feature.managed_by_nous)} {state}")
elif nous_logged_in:
# Logged into Nous but on the free tier — show upgrade nudge
print()
print(color("◆ Nous Tool Gateway", Colors.CYAN, Colors.BOLD))
print(" Your free-tier Nous account does not include Tool Gateway access.")
print(" Upgrade your subscription to unlock managed web, image, TTS, and browser tools.")
try:
portal_url = nous_status.get("portal_base_url", "").rstrip("/")
if portal_url:
print(f" Upgrade: {portal_url}")
except Exception:
pass
# =========================================================================
# API-Key Providers

View file

@ -954,34 +954,49 @@ def _configure_provider(provider: dict, config: dict):
# Set TTS provider in config if applicable
if provider.get("tts_provider"):
config.setdefault("tts", {})["provider"] = provider["tts_provider"]
tts_cfg = config.setdefault("tts", {})
tts_cfg["provider"] = provider["tts_provider"]
tts_cfg["use_gateway"] = bool(managed_feature)
# Set browser cloud provider in config if applicable
if "browser_provider" in provider:
bp = provider["browser_provider"]
browser_cfg = config.setdefault("browser", {})
if bp == "local":
config.setdefault("browser", {})["cloud_provider"] = "local"
browser_cfg["cloud_provider"] = "local"
_print_success(" Browser set to local mode")
elif bp:
config.setdefault("browser", {})["cloud_provider"] = bp
browser_cfg["cloud_provider"] = bp
_print_success(f" Browser cloud provider set to: {bp}")
browser_cfg["use_gateway"] = bool(managed_feature)
# Set web search backend in config if applicable
if provider.get("web_backend"):
config.setdefault("web", {})["backend"] = provider["web_backend"]
web_cfg = config.setdefault("web", {})
web_cfg["backend"] = provider["web_backend"]
web_cfg["use_gateway"] = bool(managed_feature)
_print_success(f" Web backend set to: {provider['web_backend']}")
# For tools without a specific config key (e.g. image_gen), still
# track use_gateway so the runtime knows the user's intent.
if managed_feature and managed_feature not in ("web", "tts", "browser"):
config.setdefault(managed_feature, {})["use_gateway"] = True
elif not managed_feature:
# User picked a non-gateway provider — find which category this
# belongs to and clear use_gateway if it was previously set.
for cat_key, cat in TOOL_CATEGORIES.items():
if provider in cat.get("providers", []):
section = config.get(cat_key)
if isinstance(section, dict) and section.get("use_gateway"):
section["use_gateway"] = False
break
if not env_vars:
if provider.get("post_setup"):
_run_post_setup(provider["post_setup"])
_print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return
# Prompt for each required env var
@ -1187,11 +1202,6 @@ def _reconfigure_provider(provider: dict, config: dict):
_print_success(f" {provider['name']} - no configuration needed!")
if managed_feature:
_print_info(" Requests for this tool will be billed to your Nous subscription.")
override_envs = provider.get("override_env_vars", [])
if any(get_env_value(env_var) for env_var in override_envs):
_print_warning(
" Direct credentials are still configured and may take precedence until you remove them from ~/.hermes/.env."
)
return
for var in env_vars:

View file

@ -160,11 +160,11 @@ CONCLUDE_SCHEMA = {
"properties": {
"conclusion": {
"type": "string",
"description": "A factual statement to persist. Required when not using delete_id.",
"description": "A factual statement to persist. Provide this when creating a conclusion. Do not send it together with delete_id.",
},
"delete_id": {
"type": "string",
"description": "Conclusion ID to delete (for PII removal). Required when not using conclusion.",
"description": "Conclusion ID to delete for PII removal. Provide this when deleting a conclusion. Do not send it together with conclusion.",
},
"peer": {
"type": "string",
@ -1008,16 +1008,20 @@ class HonchoMemoryProvider(MemoryProvider):
return json.dumps({"result": "\n\n".join(parts) or "No context available."})
elif tool_name == "honcho_conclude":
delete_id = args.get("delete_id")
delete_id = (args.get("delete_id") or "").strip()
conclusion = args.get("conclusion", "").strip()
peer = args.get("peer", "user")
if delete_id:
has_delete_id = bool(delete_id)
has_conclusion = bool(conclusion)
if has_delete_id == has_conclusion:
return tool_error("Exactly one of conclusion or delete_id must be provided.")
if has_delete_id:
ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer)
if ok:
return json.dumps({"result": f"Conclusion {delete_id} deleted."})
return tool_error(f"Failed to delete conclusion {delete_id}.")
conclusion = args.get("conclusion", "")
if not conclusion:
return tool_error("Missing required parameter: conclusion or delete_id")
ok = self._manager.create_conclusion(self._session_key, conclusion, peer=peer)
if ok:
return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"})

View file

@ -641,6 +641,9 @@ class AIAgent:
prefill_messages (List[Dict]): Messages to prepend to conversation history as prefilled context.
Useful for injecting a few-shot example or priming the model's response style.
Example: [{"role": "user", "content": "Hi!"}, {"role": "assistant", "content": "Hello!"}]
NOTE: Anthropic Sonnet 4.6+ and Opus 4.6+ reject a conversation that ends on an
assistant-role message (400 error). For those models use structured outputs or
output_config.format instead of a trailing-assistant prefill.
platform (str): The interface platform the user is on (e.g. "cli", "telegram", "discord", "whatsapp").
Used to inject platform-specific formatting hints into the system prompt.
skip_context_files (bool): If True, skip auto-injection of SOUL.md, AGENTS.md, and .cursorrules

View file

@ -46,6 +46,7 @@ AUTHOR_MAP = {
# contributors (from noreply pattern)
"35742124+0xbyt4@users.noreply.github.com": "0xbyt4",
"82637225+kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
"kshitijk4poor@users.noreply.github.com": "kshitijk4poor",
"16443023+stablegenius49@users.noreply.github.com": "stablegenius49",
"185121704+stablegenius49@users.noreply.github.com": "stablegenius49",
"101283333+batuhankocyigit@users.noreply.github.com": "batuhankocyigit",
@ -190,6 +191,9 @@ AUTHOR_MAP = {
"cola-runner@users.noreply.github.com": "cola-runner",
"ygd58@users.noreply.github.com": "ygd58",
"vominh1919@users.noreply.github.com": "vominh1919",
"trevmanthony@gmail.com": "trevthefoolish",
"ziliangpeng@users.noreply.github.com": "ziliangpeng",
"centripetal-star@users.noreply.github.com": "centripetal-star",
"LeonSGP43@users.noreply.github.com": "LeonSGP43",
"Lubrsy706@users.noreply.github.com": "Lubrsy706",
"niyant@spicefi.xyz": "spniyant",

View file

@ -951,13 +951,21 @@ class TestBuildAnthropicKwargs:
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "high"},
)
assert kwargs["thinking"] == {"type": "adaptive"}
# Adaptive thinking + display="summarized" keeps reasoning text
# populated in the response stream (Opus 4.7 default is "omitted").
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "high"}
assert "budget_tokens" not in kwargs["thinking"]
assert "temperature" not in kwargs
assert kwargs["max_tokens"] == 4096
def test_reasoning_config_maps_xhigh_to_max_effort_for_4_6_models(self):
def test_reasoning_config_downgrades_xhigh_to_max_for_4_6_models(self):
# Opus 4.7 added "xhigh" as a distinct effort level (low/medium/high/
# xhigh/max). Opus 4.6 only supports low/medium/high/max — sending
# "xhigh" there returns an API 400. Preserve the pre-migration
# behavior of aliasing xhigh→max on pre-4.7 adaptive models so users
# who prefer xhigh as their default don't 400 every request when
# switching back to 4.6.
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-6",
messages=[{"role": "user", "content": "think harder"}],
@ -965,9 +973,53 @@ class TestBuildAnthropicKwargs:
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "xhigh"},
)
assert kwargs["thinking"] == {"type": "adaptive"}
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "max"}
def test_reasoning_config_preserves_xhigh_for_4_7_models(self):
# On 4.7+ xhigh is a real level and the recommended default for
# coding/agentic work — keep it distinct from max.
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "think harder"}],
tools=None,
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "xhigh"},
)
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "xhigh"}
def test_reasoning_config_maps_max_effort_for_4_7_models(self):
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "maximum reasoning please"}],
tools=None,
max_tokens=4096,
reasoning_config={"enabled": True, "effort": "max"},
)
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
assert kwargs["output_config"] == {"effort": "max"}
def test_opus_4_7_strips_sampling_params(self):
# Opus 4.7 returns 400 on non-default temperature/top_p/top_k.
# build_anthropic_kwargs must strip them as a safety net even if an
# upstream caller injects them for older-model compatibility.
kwargs = build_anthropic_kwargs(
model="claude-opus-4-7",
messages=[{"role": "user", "content": "hi"}],
tools=None,
max_tokens=1024,
reasoning_config=None,
)
# Manually inject sampling params then re-run through the guard.
# Because build_anthropic_kwargs doesn't currently accept sampling
# params through its signature, we exercise the strip behavior by
# calling the internal predicate directly.
from agent.anthropic_adapter import _forbids_sampling_params
assert _forbids_sampling_params("claude-opus-4-7") is True
assert _forbids_sampling_params("claude-opus-4-6") is False
assert _forbids_sampling_params("claude-sonnet-4-5") is False
def test_reasoning_disabled(self):
kwargs = build_anthropic_kwargs(
model="claude-sonnet-4-20250514",
@ -1248,6 +1300,21 @@ class TestNormalizeResponse:
assert r2 == "tool_calls"
assert r3 == "length"
def test_stop_reason_refusal_and_context_exceeded(self):
# Claude 4.5+ introduced two new stop_reason values the Messages API
# returns. We map both to OpenAI-style finish_reasons upstream
# handlers already understand, instead of silently collapsing to
# "stop" (old behavior).
block = SimpleNamespace(type="text", text="")
_, refusal_reason = normalize_anthropic_response(
self._make_response([block], "refusal")
)
_, overflow_reason = normalize_anthropic_response(
self._make_response([block], "model_context_window_exceeded")
)
assert refusal_reason == "content_filter"
assert overflow_reason == "length"
def test_no_text_content(self):
block = SimpleNamespace(
type="tool_use", id="tc_1", name="search", input={"q": "hi"}

View file

@ -113,8 +113,10 @@ class TestDefaultContextLengths:
for key, value in DEFAULT_CONTEXT_LENGTHS.items():
if "claude" not in key:
continue
# Claude 4.6 models have 1M context
if "4.6" in key or "4-6" in key:
# Claude 4.6+ models (4.6 and 4.7) have 1M context at standard
# API pricing (no long-context premium). Older Claude 4.x and
# 3.x models cap at 200k.
if any(tag in key for tag in ("4.6", "4-6", "4.7", "4-7")):
assert value == 1000000, f"{key} should be 1000000"
else:
assert value == 200000, f"{key} should be 200000"

View file

@ -413,7 +413,7 @@ class TestBuildSkillsSystemPrompt:
class TestBuildNousSubscriptionPrompt:
def test_includes_active_subscription_features(self, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures(
@ -437,7 +437,7 @@ class TestBuildNousSubscriptionPrompt:
assert "do not ask the user for Firecrawl, FAL, OpenAI TTS, or Browser-Use API keys" in prompt
def test_non_subscriber_prompt_includes_relevant_upgrade_guidance(self, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(
"hermes_cli.nous_subscription.get_nous_subscription_features",
lambda config=None: NousSubscriptionFeatures(
@ -460,7 +460,7 @@ class TestBuildNousSubscriptionPrompt:
assert "Do not mention subscription unless" in prompt
def test_feature_flag_off_returns_empty_prompt(self, monkeypatch):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: False)
prompt = build_nous_subscription_prompt({"web_search"})

View file

@ -308,7 +308,7 @@ def test_codex_provider_replaces_incompatible_default_model(monkeypatch):
def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_tts(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "elevenlabs"},
@ -333,21 +333,17 @@ def test_model_flow_nous_prints_subscription_guidance_without_mutating_explicit_
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "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 "Default model set to:" in out
assert config["tts"]["provider"] == "elevenlabs"
assert config["browser"]["cloud_provider"] == "browser-use"
def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
def test_model_flow_nous_offers_tool_gateway_prompt_when_unconfigured(monkeypatch, capsys):
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
config = {
"model": {"provider": "nous", "default": "claude-opus-4-6"},
"tts": {"provider": "edge"},
@ -355,13 +351,13 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat
monkeypatch.setattr(
"hermes_cli.auth.get_provider_auth_state",
lambda provider: {"access_token": "nous-token"},
lambda provider: {"access_token": "***"},
)
monkeypatch.setattr(
"hermes_cli.auth.resolve_nous_runtime_credentials",
lambda *args, **kwargs: {
"base_url": "https://inference.example.com/v1",
"api_key": "nous-key",
"api_key": "***",
},
)
monkeypatch.setattr(
@ -371,17 +367,12 @@ def test_model_flow_nous_applies_managed_tts_default_when_unconfigured(monkeypat
monkeypatch.setattr("hermes_cli.auth._prompt_model_selection", lambda model_ids, current_model="", pricing=None, **kw: "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"
# Tool Gateway prompt should be shown (input() raises OSError in pytest
# which is caught, so the prompt text appears but nothing is applied)
assert "Tool Gateway" in out
def test_codex_provider_uses_config_model(monkeypatch):

View file

@ -24,7 +24,7 @@ def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatc
def test_get_nous_subscription_features_prefers_managed_modal_in_auto_mode(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
monkeypatch.setattr(ns, "get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda: True)

View file

@ -363,7 +363,7 @@ def test_codex_setup_uses_runtime_access_token_for_live_model_list(tmp_path, mon
def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config = load_config()
@ -405,7 +405,7 @@ def test_modal_setup_can_use_nous_subscription_without_modal_creds(tmp_path, mon
def test_modal_setup_persists_direct_mode_when_user_chooses_their_own_account(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.setup.managed_nous_tools_enabled", lambda: True)
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)

View file

@ -64,7 +64,7 @@ def test_show_status_displays_legacy_string_model_and_custom_endpoint(monkeypatc
def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: True)
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
@ -98,13 +98,13 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Subscription Features" in out
assert "Nous Tool Gateway" in out
assert "Browser automation" in out
assert "active via Nous subscription" in out
def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(monkeypatch, capsys, tmp_path):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
monkeypatch.setattr("hermes_cli.status.managed_nous_tools_enabled", lambda: False)
from hermes_cli import status as status_mod
_patch_common_status_deps(monkeypatch, status_mod, tmp_path)
@ -121,4 +121,4 @@ def test_show_status_hides_nous_subscription_section_when_feature_flag_is_off(mo
status_mod.show_status(SimpleNamespace(all=False, deep=False))
out = capsys.readouterr().out
assert "Nous Subscription Features" not in out
assert "Nous Tool Gateway" not in out

View file

@ -296,7 +296,7 @@ def test_save_platform_tools_still_preserves_mcp_with_platform_default_present()
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
config = {"model": {"provider": "nous"}}
monkeypatch.setattr(
@ -310,7 +310,7 @@ def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch)
def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: False)
config = {"model": {"provider": "nous"}}
monkeypatch.setattr(
@ -338,7 +338,8 @@ def test_local_browser_provider_is_saved_explicitly(monkeypatch):
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
config = {
"model": {"provider": "nous"},
"platform_toolsets": {"cli": []},

View file

@ -366,6 +366,17 @@ class TestPeerLookupHelpers:
class TestConcludeToolDispatch:
def test_conclude_schema_has_no_anyof(self):
"""anyOf/oneOf/allOf breaks Anthropic and Fireworks APIs — schema must be plain object."""
from plugins.memory.honcho import CONCLUDE_SCHEMA
params = CONCLUDE_SCHEMA["parameters"]
assert params["type"] == "object"
assert "conclusion" in params["properties"]
assert "delete_id" in params["properties"]
assert "anyOf" not in params
assert "oneOf" not in params
assert "allOf" not in params
def test_honcho_conclude_defaults_to_user_peer(self):
provider = HonchoMemoryProvider()
provider._session_initialized = True
@ -470,10 +481,50 @@ class TestConcludeToolDispatch:
result = provider.handle_tool_call("honcho_conclude", {})
parsed = json.loads(result)
assert "error" in parsed or "Missing required" in parsed.get("result", "")
assert parsed == {"error": "Exactly one of conclusion or delete_id must be provided."}
provider._manager.create_conclusion.assert_not_called()
provider._manager.delete_conclusion.assert_not_called()
def test_honcho_conclude_rejects_both_params_at_once(self):
"""Sending both conclusion and delete_id should be rejected."""
import json
provider = HonchoMemoryProvider()
provider._session_initialized = True
provider._session_key = "telegram:123"
provider._manager = MagicMock()
result = provider.handle_tool_call(
"honcho_conclude",
{"conclusion": "User prefers dark mode", "delete_id": "conc-123"},
)
parsed = json.loads(result)
assert parsed == {"error": "Exactly one of conclusion or delete_id must be provided."}
provider._manager.create_conclusion.assert_not_called()
provider._manager.delete_conclusion.assert_not_called()
def test_honcho_conclude_rejects_whitespace_only_conclusion(self):
"""Whitespace-only conclusion should be treated as empty."""
import json
provider = HonchoMemoryProvider()
provider._session_initialized = True
provider._session_key = "telegram:123"
provider._manager = MagicMock()
result = provider.handle_tool_call("honcho_conclude", {"conclusion": " "})
parsed = json.loads(result)
assert parsed == {"error": "Exactly one of conclusion or delete_id must be provided."}
provider._manager.create_conclusion.assert_not_called()
def test_honcho_conclude_rejects_whitespace_only_delete_id(self):
"""Whitespace-only delete_id should be treated as empty."""
import json
provider = HonchoMemoryProvider()
provider._session_initialized = True
provider._session_key = "telegram:123"
provider._manager = MagicMock()
result = provider.handle_tool_call("honcho_conclude", {"delete_id": " "})
parsed = json.loads(result)
assert parsed == {"error": "Exactly one of conclusion or delete_id must be provided."}
provider._manager.delete_conclusion.assert_not_called()
# ---------------------------------------------------------------------------
# Message chunking

View file

@ -47,7 +47,15 @@ def _restore_tool_and_agent_modules():
@pytest.fixture(autouse=True)
def _enable_managed_nous_tools(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
"""Ensure managed_nous_tools_enabled() returns True even after module reloads.
The _install_fake_tools_package() helper resets and reimports tool modules,
so a simple monkeypatch on tool_backend_helpers doesn't survive. We patch
the *source* modules that the reimported modules will import from both
hermes_cli.auth and hermes_cli.models so the function body returns True.
"""
monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False)
def _install_fake_tools_package():

View file

@ -46,7 +46,10 @@ def _restore_tool_and_agent_modules():
@pytest.fixture(autouse=True)
def _enable_managed_nous_tools(monkeypatch):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
"""Patch the source modules so managed_nous_tools_enabled() returns True
even after tool modules are dynamically reloaded."""
monkeypatch.setattr("hermes_cli.auth.get_nous_auth_status", lambda: {"logged_in": True})
monkeypatch.setattr("hermes_cli.models.check_nous_free_tier", lambda: False)
def _install_fake_tools_package():

View file

@ -19,11 +19,10 @@ def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain()
with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
):
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",
@ -39,11 +38,10 @@ def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"BROWSER_USE_GATEWAY_URL": "http://browser-use-gateway.localhost:3009/",
},
clear=False,
):
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"browser-use",
token_reader=lambda: "nous-token",
@ -57,11 +55,10 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
with patch.dict(
os.environ,
{
"HERMES_ENABLE_NOUS_MANAGED_TOOLS": "1",
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
},
clear=False,
):
), patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: None,
@ -70,8 +67,9 @@ def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
assert result is None
def test_resolve_managed_tool_gateway_is_disabled_without_feature_flag():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
def test_resolve_managed_tool_gateway_is_disabled_without_subscription():
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
result = resolve_managed_tool_gateway(
"firecrawl",
token_reader=lambda: "nous-token",

View file

@ -7,7 +7,6 @@ terminal_tool_module = importlib.import_module("tools.terminal_tool")
def _clear_terminal_env(monkeypatch):
"""Remove terminal env vars that could affect requirements checks."""
keys = [
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"TERMINAL_ENV",
"TERMINAL_MODAL_MODE",
"TERMINAL_SSH_HOST",
@ -19,6 +18,11 @@ def _clear_terminal_env(monkeypatch):
]
for key in keys:
monkeypatch.delenv(key, raising=False)
# Default: no Nous subscription — patch both the terminal_tool local
# binding and tool_backend_helpers (used by resolve_modal_backend_state).
monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: False)
import tools.tool_backend_helpers as _tbh
monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: False)
def test_local_terminal_requirements(monkeypatch, caplog):
@ -81,7 +85,9 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True)
import tools.tool_backend_helpers as _tbh
monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
@ -98,7 +104,9 @@ def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_min
def test_modal_backend_auto_mode_prefers_managed_gateway_over_direct_creds(monkeypatch, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True)
import tools.tool_backend_helpers as _tbh
monkeypatch.setattr(_tbh, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setenv("TERMINAL_ENV", "modal")
monkeypatch.setenv("MODAL_TOKEN_ID", "tok-id")
monkeypatch.setenv("MODAL_TOKEN_SECRET", "tok-secret")
@ -147,7 +155,7 @@ def test_modal_backend_managed_mode_does_not_fall_back_to_direct(monkeypatch, ca
assert ok is False
assert any(
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage()
"paid Nous subscription is required" in record.getMessage()
for record in caplog.records
)
@ -165,6 +173,6 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey
assert ok is False
assert any(
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled" in record.getMessage()
"paid Nous subscription is required" in record.getMessage()
for record in caplog.records
)

View file

@ -28,7 +28,8 @@ class TestTerminalRequirements:
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
monkeypatch.setattr("tools.tool_backend_helpers.managed_nous_tools_enabled", lambda: True)
monkeypatch.setattr(terminal_tool_module, "managed_nous_tools_enabled", lambda: True)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)

View file

@ -1,7 +1,7 @@
"""Unit tests for tools/tool_backend_helpers.py.
Tests cover:
- managed_nous_tools_enabled() feature flag
- managed_nous_tools_enabled() subscription-based gate
- normalize_browser_cloud_provider() coercion
- coerce_modal_mode() / normalize_modal_mode() validation
- has_direct_modal_credentials() detection
@ -27,24 +27,51 @@ from tools.tool_backend_helpers import (
)
def _raise_import():
raise ImportError("simulated missing module")
# ---------------------------------------------------------------------------
# managed_nous_tools_enabled
# ---------------------------------------------------------------------------
class TestManagedNousToolsEnabled:
"""Feature flag driven by HERMES_ENABLE_NOUS_MANAGED_TOOLS."""
"""Subscription-based gate: True for paid Nous subscribers."""
def test_disabled_by_default(self, monkeypatch):
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
def test_disabled_when_not_logged_in(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {},
)
assert managed_nous_tools_enabled() is False
@pytest.mark.parametrize("val", ["1", "true", "True", "yes"])
def test_enabled_when_truthy(self, monkeypatch, val):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
def test_disabled_for_free_tier(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.models.check_nous_free_tier",
lambda: True,
)
assert managed_nous_tools_enabled() is False
def test_enabled_for_paid_subscriber(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.models.check_nous_free_tier",
lambda: False,
)
assert managed_nous_tools_enabled() is True
@pytest.mark.parametrize("val", ["0", "false", "no", ""])
def test_disabled_when_falsy(self, monkeypatch, val):
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
def test_returns_false_on_exception(self, monkeypatch):
"""Should never crash — returns False on any exception."""
monkeypatch.setattr(
"hermes_cli.auth.get_nous_auth_status",
_raise_import,
)
assert managed_nous_tools_enabled() is False
@ -171,10 +198,10 @@ class TestResolveModalBackendState:
@staticmethod
def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False):
"""Helper to call resolve_modal_backend_state with feature flag control."""
if nous_enabled:
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
else:
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "")
monkeypatch.setattr(
"tools.tool_backend_helpers.managed_nous_tools_enabled",
lambda: nous_enabled,
)
return resolve_modal_backend_state(
mode, has_direct=has_direct, managed_ready=managed_ready
)

View file

@ -26,7 +26,6 @@ class TestFirecrawlClientConfig:
tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None
for key in (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
@ -35,7 +34,15 @@ class TestFirecrawlClientConfig:
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None)
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
# Enable managed tools by default for these tests — patch both the
# local web_tools import and the managed_tool_gateway import so the
# full firecrawl client init path sees True.
self._managed_patchers = [
patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
"""Reset client after each test."""
@ -43,7 +50,6 @@ class TestFirecrawlClientConfig:
tools.web_tools._firecrawl_client = None
tools.web_tools._firecrawl_client_config = None
for key in (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"FIRECRAWL_API_KEY",
"FIRECRAWL_API_URL",
"FIRECRAWL_GATEWAY_URL",
@ -52,6 +58,8 @@ class TestFirecrawlClientConfig:
"TOOL_GATEWAY_USER_TOKEN",
):
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
# ── Configuration matrix ─────────────────────────────────────────
@ -298,7 +306,6 @@ class TestBackendSelection:
"""
_ENV_KEYS = (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"EXA_API_KEY",
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
@ -311,14 +318,20 @@ class TestBackendSelection:
)
def setup_method(self):
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
for key in self._ENV_KEYS:
if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
os.environ.pop(key, None)
os.environ.pop(key, None)
self._managed_patchers = [
patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
# ── Config-based selection (web.backend in config.yaml) ───────────
@ -523,7 +536,6 @@ class TestCheckWebApiKey:
"""Test suite for check_web_api_key() unified availability check."""
_ENV_KEYS = (
"HERMES_ENABLE_NOUS_MANAGED_TOOLS",
"EXA_API_KEY",
"PARALLEL_API_KEY",
"FIRECRAWL_API_KEY",
@ -536,14 +548,20 @@ class TestCheckWebApiKey:
)
def setup_method(self):
os.environ["HERMES_ENABLE_NOUS_MANAGED_TOOLS"] = "1"
for key in self._ENV_KEYS:
if key != "HERMES_ENABLE_NOUS_MANAGED_TOOLS":
os.environ.pop(key, None)
os.environ.pop(key, None)
self._managed_patchers = [
patch("tools.web_tools.managed_nous_tools_enabled", return_value=True),
patch("tools.managed_tool_gateway.managed_nous_tools_enabled", return_value=True),
]
for p in self._managed_patchers:
p.start()
def teardown_method(self):
for key in self._ENV_KEYS:
os.environ.pop(key, None)
for p in self._managed_patchers:
p.stop()
def test_parallel_key_only(self):
with patch.dict(os.environ, {"PARALLEL_API_KEY": "test-key"}):

View file

@ -10,7 +10,7 @@ import requests
from tools.browser_providers.base import CloudBrowserProvider
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
logger = logging.getLogger(__name__)
_pending_create_keys: Dict[str, str] = {}
@ -75,7 +75,7 @@ class BrowserUseProvider(CloudBrowserProvider):
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
api_key = os.environ.get("BROWSER_USE_API_KEY")
if api_key:
if api_key and not prefers_gateway("browser"):
return {
"api_key": api_key,
"base_url": _BASE_URL,

View file

@ -39,7 +39,7 @@ from urllib.parse import urlencode
import fal_client
from tools.debug_helpers import DebugSession
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
logger = logging.getLogger(__name__)
@ -87,8 +87,9 @@ _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 managed fal-queue gateway config when the user prefers the gateway
or direct FAL credentials are absent."""
if os.getenv("FAL_KEY") and not prefers_gateway("image_gen"):
return None
return resolve_managed_tool_gateway("fal-queue")

View file

@ -762,8 +762,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int,
if modal_state["managed_mode_blocked"]:
raise ValueError(
"Modal backend is configured for managed mode, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag or "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` or "
"choose TERMINAL_MODAL_MODE=direct/auto."
)
if modal_state["mode"] == "managed":
@ -1577,8 +1577,8 @@ def check_terminal_requirements() -> bool:
if modal_state["managed_mode_blocked"]:
logger.error(
"Modal backend selected with TERMINAL_MODAL_MODE=managed, but "
"HERMES_ENABLE_NOUS_MANAGED_TOOLS is not enabled and no direct "
"Modal credentials/config were found. Enable the feature flag "
"a paid Nous subscription is required for the Tool Gateway and no direct "
"Modal credentials/config were found. Log in with `hermes model` "
"or choose TERMINAL_MODAL_MODE=direct/auto."
)
return False

View file

@ -6,7 +6,6 @@ import os
from pathlib import Path
from typing import Any, Dict
from utils import env_var_enabled
_DEFAULT_BROWSER_PROVIDER = "local"
_DEFAULT_MODAL_MODE = "auto"
@ -14,8 +13,26 @@ _VALID_MODAL_MODES = {"auto", "direct", "managed"}
def managed_nous_tools_enabled() -> bool:
"""Return True when the hidden Nous-managed tools feature flag is enabled."""
return env_var_enabled("HERMES_ENABLE_NOUS_MANAGED_TOOLS")
"""Return True when the user has an active paid Nous subscription.
The Tool Gateway is available to any Nous subscriber who is NOT on
the free tier. We intentionally catch all exceptions and return
False never block the agent startup path.
"""
try:
from hermes_cli.auth import get_nous_auth_status
status = get_nous_auth_status()
if not status.get("logged_in"):
return False
from hermes_cli.models import check_nous_free_tier
if check_nous_free_tier():
return False # free-tier users don't get gateway access
return True
except Exception:
return False
def normalize_browser_cloud_provider(value: object | None) -> str:
@ -87,3 +104,18 @@ def resolve_openai_audio_api_key() -> str:
os.getenv("VOICE_TOOLS_OPENAI_KEY", "")
or os.getenv("OPENAI_API_KEY", "")
).strip()
def prefers_gateway(config_section: str) -> bool:
"""Return True when the user opted into the Tool Gateway for this tool.
Reads ``<section>.use_gateway`` from config.yaml. Never raises.
"""
try:
from hermes_cli.config import load_config
section = (load_config() or {}).get(config_section)
if isinstance(section, dict):
return bool(section.get("use_gateway"))
except Exception:
pass
return False

View file

@ -44,7 +44,7 @@ from hermes_constants import display_hermes_home
logger = logging.getLogger(__name__)
from tools.managed_tool_gateway import resolve_managed_tool_gateway
from tools.tool_backend_helpers import managed_nous_tools_enabled, resolve_openai_audio_api_key
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway, resolve_openai_audio_api_key
from tools.xai_http import hermes_xai_user_agent
# ---------------------------------------------------------------------------
@ -823,9 +823,13 @@ def check_tts_requirements() -> bool:
def _resolve_openai_audio_client_config() -> tuple[str, str]:
"""Return direct OpenAI audio config or a managed gateway fallback."""
"""Return direct OpenAI audio config or a managed gateway fallback.
When ``tts.use_gateway`` is set in config, the Tool Gateway is preferred
even if direct OpenAI credentials are present.
"""
direct_api_key = resolve_openai_audio_api_key()
if direct_api_key:
if direct_api_key and not prefers_gateway("tts"):
return direct_api_key, DEFAULT_OPENAI_BASE_URL
managed_gateway = resolve_managed_tool_gateway("openai-audio")

View file

@ -59,7 +59,7 @@ from tools.managed_tool_gateway import (
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)
from tools.tool_backend_helpers import managed_nous_tools_enabled
from tools.tool_backend_helpers import managed_nous_tools_enabled, prefers_gateway
from tools.url_safety import is_safe_url
from tools.website_policy import check_website_access
@ -165,8 +165,8 @@ def _raise_web_backend_configuration_error() -> None:
)
if managed_nous_tools_enabled():
message += (
" If you have the hidden Nous-managed tools flag enabled, you can also login to Nous "
"(`hermes model`) and provide FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN."
" With your Nous subscription you can also use the Tool Gateway — "
"run `hermes tools` and select Nous Subscription as the web provider."
)
raise ValueError(message)
@ -176,8 +176,8 @@ def _firecrawl_backend_help_suffix() -> str:
if not managed_nous_tools_enabled():
return ""
return (
", or, if you have the hidden Nous-managed tools flag enabled, login to Nous and use "
"FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN"
", or use the Nous Tool Gateway via your subscription "
"(FIRECRAWL_GATEWAY_URL or TOOL_GATEWAY_DOMAIN)"
)
@ -205,13 +205,14 @@ def _web_requires_env() -> list[str]:
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.
When ``web.use_gateway`` is set in config, the Tool Gateway is preferred
even if direct Firecrawl credentials are present. Otherwise direct
Firecrawl takes precedence when explicitly configured.
"""
global _firecrawl_client, _firecrawl_client_config
direct_config = _get_direct_firecrawl_config()
if direct_config is not None:
if direct_config is not None and not prefers_gateway("web"):
kwargs, client_config = direct_config
else:
managed_gateway = resolve_managed_tool_gateway(

View file

@ -267,7 +267,6 @@ Run `nix build .#configKeys && cat result` to see every leaf config key extracte
# ── Documents ──────────────────────────────────────────────────────
documents = {
"SOUL.md" = builtins.readFile /home/user/.hermes/SOUL.md;
"USER.md" = ./documents/USER.md;
};
@ -316,7 +315,7 @@ Quick reference for the most common things Nix users want to customize:
| Change the LLM model | `settings.model.default` | `"anthropic/claude-sonnet-4"` |
| Use a different provider endpoint | `settings.model.base_url` | `"https://openrouter.ai/api/v1"` |
| Add API keys | `environmentFiles` | `[ config.sops.secrets."hermes-env".path ]` |
| Give the agent a personality | `documents."SOUL.md"` | `builtins.readFile ./my-soul.md` |
| Give the agent a personality | `${services.hermes-agent.stateDir}/.hermes/SOUL.md` | manage the file directly |
| Add MCP tool servers | `mcpServers.<name>` | See [MCP Servers](#mcp-servers) |
| Mount host directories into container | `container.extraVolumes` | `[ "/data:/data:rw" ]` |
| Pass GPU access to container | `container.extraOptions` | `[ "--gpus" "all" ]` |
@ -397,17 +396,14 @@ The file is only copied if `auth.json` doesn't already exist (unless `authFileFo
The `documents` option installs files into the agent's working directory (the `workingDirectory`, which the agent reads as its workspace). Hermes looks for specific filenames by convention:
- **`SOUL.md`** — the agent's system prompt / personality. Hermes reads this on startup and uses it as persistent instructions that shape its behavior across all conversations.
- **`USER.md`** — context about the user the agent is interacting with.
- Any other files you place here are visible to the agent as workspace files.
The agent identity file is separate: Hermes loads its primary `SOUL.md` from `$HERMES_HOME/SOUL.md`, which in the NixOS module is `${services.hermes-agent.stateDir}/.hermes/SOUL.md`. Putting `SOUL.md` in `documents` only creates a workspace file and will not replace the main persona file.
```nix
{
services.hermes-agent.documents = {
"SOUL.md" = ''
You are a helpful research assistant specializing in NixOS packaging.
Always cite sources and prefer reproducible solutions.
'';
"USER.md" = ./documents/USER.md; # path reference, copied from Nix store
};
}

View file

@ -65,11 +65,7 @@ If `git status --short` shows unexpected changes after `hermes update`, stop and
hermes version
```
Compare against the latest release at the [GitHub releases page](https://github.com/NousResearch/hermes-agent/releases) or check for available updates:
```bash
hermes update --check
```
Compare against the latest release at the [GitHub releases page](https://github.com/NousResearch/hermes-agent/releases).
### Updating from Messaging Platforms

View file

@ -49,6 +49,10 @@ The OpenAI Codex provider authenticates via device code (open a URL, enter a cod
Even when using Nous Portal, Codex, or a custom endpoint, some tools (vision, web summarization, MoA) use a separate "auxiliary" model — by default Gemini Flash via OpenRouter. An `OPENROUTER_API_KEY` enables these tools automatically. You can also configure which model and provider these tools use — see [Auxiliary Models](/docs/user-guide/configuration#auxiliary-models).
:::
:::tip Nous Tool Gateway
Paid Nous Portal subscribers also get access to the **[Tool Gateway](/docs/user-guide/features/tool-gateway)** — web search, image generation, TTS, and browser automation routed through your subscription. No extra API keys needed. It's offered automatically during `hermes model` setup, or enable it later with `hermes tools`.
:::
### Two Commands for Model Management
Hermes has **two** model commands that serve different purposes:

View file

@ -110,6 +110,17 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe
| `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) |
| `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) |
### Nous Tool Gateway
These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gateway) for paid Nous subscribers or self-hosted gateway deployments. Most users don't need to set these — the gateway is configured automatically via `hermes model` or `hermes tools`.
| Variable | Description |
|----------|-------------|
| `TOOL_GATEWAY_DOMAIN` | Base domain for Tool Gateway routing (default: `nousresearch.com`) |
| `TOOL_GATEWAY_SCHEME` | HTTP or HTTPS scheme for gateway URLs (default: `https`) |
| `TOOL_GATEWAY_USER_TOKEN` | Auth token for the Tool Gateway (normally auto-populated from Nous auth) |
| `FIRECRAWL_GATEWAY_URL` | Override URL for the Firecrawl gateway endpoint specifically |
## Terminal Backend
| Variable | Description |

View file

@ -33,6 +33,10 @@ Key capabilities:
## Setup
:::tip Nous Subscribers
If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, you can use browser automation through the **[Tool Gateway](tool-gateway.md)** without any separate API keys. Run `hermes model` or `hermes tools` to enable it.
:::
### Browserbase cloud mode
To use Browserbase-managed cloud browsers, add:

View file

@ -11,6 +11,10 @@ Hermes Agent can generate images from text prompts using FAL.ai's **FLUX 2 Pro**
## Setup
:::tip Nous Subscribers
If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, you can use image generation through the **[Tool Gateway](tool-gateway.md)** without a FAL API key. Run `hermes model` or `hermes tools` to enable it.
:::
### Get a FAL API Key
1. Sign up at [fal.ai](https://fal.ai/)

View file

@ -0,0 +1,187 @@
---
title: "Nous Tool Gateway"
description: "Route web search, image generation, text-to-speech, and browser automation through your Nous subscription — no extra API keys needed"
sidebar_label: "Tool Gateway"
sidebar_position: 2
---
# Nous Tool Gateway
:::tip Get Started
The Tool Gateway is included with paid Nous Portal subscriptions. **[Manage your subscription →](https://portal.nousresearch.com/manage-subscription)**
:::
The **Tool Gateway** lets paid [Nous Portal](https://portal.nousresearch.com) subscribers use web search, image generation, text-to-speech, and browser automation through their existing subscription — no need to sign up for separate API keys from Firecrawl, FAL, OpenAI, or Browser Use.
## What's Included
| Tool | What It Does | Direct Alternative |
|------|--------------|--------------------|
| **Web search & extract** | Search the web and extract page content via Firecrawl | `FIRECRAWL_API_KEY`, `EXA_API_KEY`, `PARALLEL_API_KEY`, `TAVILY_API_KEY` |
| **Image generation** | Generate images via FAL (FLUX 2 Pro + upscaling) | `FAL_KEY` |
| **Text-to-speech** | Convert text to speech via OpenAI TTS | `VOICE_TOOLS_OPENAI_KEY`, `ELEVENLABS_API_KEY` |
| **Browser automation** | Control cloud browsers via Browser Use | `BROWSER_USE_API_KEY`, `BROWSERBASE_API_KEY` |
All four tools bill to your Nous subscription. You can enable any combination — for example, use the gateway for web and image generation while keeping your own ElevenLabs key for TTS.
## Eligibility
The Tool Gateway is available to **paid** [Nous Portal](https://portal.nousresearch.com/manage-subscription) subscribers. Free-tier accounts do not have access — [upgrade your subscription](https://portal.nousresearch.com/manage-subscription) to unlock it.
To check your status:
```bash
hermes status
```
Look for the **Nous Tool Gateway** section. It shows which tools are active via the gateway, which use direct keys, and which aren't configured.
## Enabling the Tool Gateway
### During model setup
When you run `hermes model` and select Nous Portal as your provider, Hermes automatically offers to enable the Tool Gateway:
```
Your Nous subscription includes the Tool Gateway.
The Tool Gateway gives you access to web search, image generation,
text-to-speech, and browser automation through your Nous subscription.
No need to sign up for separate API keys — just pick the tools you want.
○ Web search & extract (Firecrawl) — not configured
○ Image generation (FAL) — not configured
○ Text-to-speech (OpenAI TTS) — not configured
○ Browser automation (Browser Use) — not configured
● Enable Tool Gateway
○ Skip
```
Select **Enable Tool Gateway** and you're done.
If you already have direct API keys for some tools, the prompt adapts — you can enable the gateway for all tools (your existing keys are kept in `.env` but not used at runtime), enable only for unconfigured tools, or skip entirely.
### Via `hermes tools`
You can also enable the gateway tool-by-tool through the interactive tool configuration:
```bash
hermes tools
```
Select a tool category (Web, Browser, Image Generation, or TTS), then choose **Nous Subscription** as the provider. This sets `use_gateway: true` for that tool in your config.
### Manual configuration
Set the `use_gateway` flag directly in `~/.hermes/config.yaml`:
```yaml
web:
backend: firecrawl
use_gateway: true
image_gen:
use_gateway: true
tts:
provider: openai
use_gateway: true
browser:
cloud_provider: browser-use
use_gateway: true
```
## How It Works
When `use_gateway: true` is set for a tool, the runtime routes API calls through the Nous Tool Gateway instead of using direct API keys:
1. **Web tools**`web_search` and `web_extract` use the gateway's Firecrawl endpoint
2. **Image generation**`image_generate` uses the gateway's FAL endpoint
3. **TTS**`text_to_speech` uses the gateway's OpenAI Audio endpoint
4. **Browser**`browser_navigate` and other browser tools use the gateway's Browser Use endpoint
The gateway authenticates using your Nous Portal credentials (stored in `~/.hermes/auth.json` after `hermes model`).
### Precedence
Each tool checks `use_gateway` first:
- **`use_gateway: true`** → route through the gateway, even if direct API keys exist in `.env`
- **`use_gateway: false`** (or absent) → use direct API keys if available, fall back to gateway only when no direct keys exist
This means you can switch between gateway and direct keys at any time without deleting your `.env` credentials.
## Switching Back to Direct Keys
To stop using the gateway for a specific tool:
```bash
hermes tools # Select the tool → choose a direct provider
```
Or set `use_gateway: false` in config:
```yaml
web:
backend: firecrawl
use_gateway: false # Now uses FIRECRAWL_API_KEY from .env
```
When you select a non-gateway provider in `hermes tools`, the `use_gateway` flag is automatically set to `false` to prevent contradictory config.
## Checking Status
```bash
hermes status
```
The **Nous Tool Gateway** section shows:
```
◆ Nous Tool Gateway
Nous Portal ✓ managed tools available
Web tools ✓ active via Nous subscription
Image gen ✓ active via Nous subscription
TTS ✓ active via Nous subscription
Browser ○ active via Browser Use key
Modal ○ available via subscription (optional)
```
Tools marked "active via Nous subscription" are routed through the gateway. Tools with their own keys show which provider is active.
## Advanced: Self-Hosted Gateway
For self-hosted or custom gateway deployments, you can override the gateway endpoints via environment variables in `~/.hermes/.env`:
```bash
TOOL_GATEWAY_DOMAIN=nousresearch.com # Base domain for gateway routing
TOOL_GATEWAY_SCHEME=https # HTTP or HTTPS (default: https)
TOOL_GATEWAY_USER_TOKEN=your-token # Auth token (normally auto-populated)
FIRECRAWL_GATEWAY_URL=https://... # Override for the Firecrawl endpoint specifically
```
These env vars are always visible in the configuration regardless of subscription status — they're useful for custom infrastructure setups.
## FAQ
### Do I need to delete my existing API keys?
No. When `use_gateway: true` is set, the runtime skips direct API keys and routes through the gateway. Your keys stay in `.env` untouched. If you later disable the gateway, they'll be used again automatically.
### Can I use the gateway for some tools and direct keys for others?
Yes. The `use_gateway` flag is per-tool. You can mix and match — for example, gateway for web and image generation, your own ElevenLabs key for TTS, and Browserbase for browser automation.
### What if my subscription expires?
Tools that were routed through the gateway will stop working until you [renew your subscription](https://portal.nousresearch.com/manage-subscription) or switch to direct API keys via `hermes tools`.
### Does the gateway work with the messaging gateway?
Yes. The Tool Gateway routes tool API calls regardless of whether you're using the CLI, Telegram, Discord, or any other messaging platform. It operates at the tool runtime level, not the entry point level.
### Is Modal included?
Modal (serverless terminal backend) is available as an optional add-on through the Nous subscription. It's not enabled by the Tool Gateway prompt — configure it separately via `hermes setup terminal` or in `config.yaml`.

View file

@ -31,6 +31,10 @@ High-level categories:
For the authoritative code-derived registry, see [Built-in Tools Reference](/docs/reference/tools-reference) and [Toolsets Reference](/docs/reference/toolsets-reference).
:::tip Nous Tool Gateway
Paid [Nous Portal](https://portal.nousresearch.com) subscribers can use web search, image generation, TTS, and browser automation through the **[Tool Gateway](tool-gateway.md)** — no separate API keys needed. Run `hermes model` to enable it, or configure individual tools with `hermes tools`.
:::
## Using Toolsets
```bash

View file

@ -8,6 +8,10 @@ description: "Text-to-speech and voice message transcription across all platform
Hermes Agent supports both text-to-speech output and voice message transcription across all messaging platforms.
:::tip Nous Subscribers
If you have a paid [Nous Portal](https://portal.nousresearch.com) subscription, OpenAI TTS is available through the **[Tool Gateway](tool-gateway.md)** without a separate OpenAI API key. Run `hermes model` or `hermes tools` to enable it.
:::
## Text-to-Speech
Convert text to speech with six providers:

View file

@ -36,6 +36,7 @@ const sidebars: SidebarsConfig = {
collapsed: true,
items: [
'user-guide/features/overview',
'user-guide/features/tool-gateway',
{
type: 'category',
label: 'Core',