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
49 changed files with 1055 additions and 229 deletions

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"}):