2026-03-07 18:18:37 -08:00
|
|
|
"""Tests for hermes_cli.tools_config platform tool persistence."""
|
|
|
|
|
|
2026-03-14 07:58:03 +01:00
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from hermes_cli.tools_config import (
|
2026-04-20 00:50:19 +01:00
|
|
|
_DEFAULT_OFF_TOOLSETS,
|
|
|
|
|
_apply_toolset_change,
|
2026-03-26 15:27:27 -07:00
|
|
|
_configure_provider,
|
2026-03-14 07:58:03 +01:00
|
|
|
_get_platform_tools,
|
|
|
|
|
_platform_toolset_summary,
|
|
|
|
|
_save_platform_tools,
|
|
|
|
|
_toolset_has_keys,
|
2026-04-13 10:32:31 +00:00
|
|
|
CONFIGURABLE_TOOLSETS,
|
2026-03-26 15:27:27 -07:00
|
|
|
TOOL_CATEGORIES,
|
|
|
|
|
_visible_providers,
|
|
|
|
|
tools_command,
|
2026-03-14 07:58:03 +01:00
|
|
|
)
|
2026-03-07 18:18:37 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_uses_default_when_platform_not_configured():
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
assert enabled
|
2026-04-20 00:50:19 +01:00
|
|
|
assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS)
|
2026-03-07 18:18:37 -08:00
|
|
|
|
|
|
|
|
|
2026-04-13 10:32:31 +00:00
|
|
|
def test_configurable_toolsets_include_messaging():
|
|
|
|
|
assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS)
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_default_telegram_includes_messaging():
|
|
|
|
|
enabled = _get_platform_tools({}, "telegram")
|
|
|
|
|
|
|
|
|
|
assert "messaging" in enabled
|
|
|
|
|
|
|
|
|
|
|
2026-04-20 00:50:19 +01:00
|
|
|
def test_get_platform_tools_homeassistant_platform_keeps_homeassistant_toolset():
|
|
|
|
|
enabled = _get_platform_tools({}, "homeassistant")
|
|
|
|
|
|
|
|
|
|
assert "homeassistant" in enabled
|
|
|
|
|
|
|
|
|
|
|
2026-03-07 18:18:37 -08:00
|
|
|
def test_get_platform_tools_preserves_explicit_empty_selection():
|
|
|
|
|
config = {"platform_toolsets": {"cli": []}}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
assert enabled == set()
|
2026-03-09 16:50:53 +03:00
|
|
|
|
|
|
|
|
|
2026-04-20 00:50:19 +01:00
|
|
|
def test_apply_toolset_change_from_default_does_not_enable_default_off_toolsets():
|
|
|
|
|
"""Disabling one default toolset on a fresh config must not persist
|
|
|
|
|
default-off toolsets as explicitly enabled.
|
|
|
|
|
"""
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_apply_toolset_change(config, "cli", ["memory"], "disable")
|
|
|
|
|
|
|
|
|
|
saved = set(config["platform_toolsets"]["cli"])
|
|
|
|
|
assert "memory" not in saved
|
|
|
|
|
assert "terminal" in saved
|
|
|
|
|
assert saved.isdisjoint(_DEFAULT_OFF_TOOLSETS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_toolset_change_can_enable_default_off_toolset_from_default():
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_apply_toolset_change(config, "cli", ["homeassistant"], "enable")
|
|
|
|
|
|
|
|
|
|
saved = set(config["platform_toolsets"]["cli"])
|
|
|
|
|
assert "homeassistant" in saved
|
|
|
|
|
assert "terminal" in saved
|
|
|
|
|
|
|
|
|
|
|
2026-04-17 05:40:44 -07:00
|
|
|
def test_get_platform_tools_handles_null_platform_toolsets():
|
|
|
|
|
"""YAML `platform_toolsets:` with no value parses as None — the old
|
|
|
|
|
``config.get("platform_toolsets", {})`` pattern would then crash with
|
|
|
|
|
``NoneType has no attribute 'get'`` on the next line. Guard against that.
|
|
|
|
|
"""
|
|
|
|
|
config = {"platform_toolsets": None}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
# Falls through to defaults instead of raising
|
|
|
|
|
assert enabled
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 16:50:53 +03:00
|
|
|
def test_platform_toolset_summary_uses_explicit_platform_list():
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
summary = _platform_toolset_summary(config, platforms=["cli"])
|
|
|
|
|
|
|
|
|
|
assert set(summary.keys()) == {"cli"}
|
|
|
|
|
assert summary["cli"] == _get_platform_tools(config, "cli")
|
2026-03-14 20:22:13 -07:00
|
|
|
|
|
|
|
|
|
2026-03-26 13:39:41 -07:00
|
|
|
def test_get_platform_tools_includes_enabled_mcp_servers_by_default():
|
|
|
|
|
config = {
|
|
|
|
|
"mcp_servers": {
|
|
|
|
|
"exa": {"url": "https://mcp.exa.ai/mcp"},
|
|
|
|
|
"web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
|
|
|
|
|
"disabled-server": {"url": "https://example.com/mcp", "enabled": False},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
assert "exa" in enabled
|
|
|
|
|
assert "web-search-prime" in enabled
|
|
|
|
|
assert "disabled-server" not in enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_keeps_enabled_mcp_servers_with_explicit_builtin_selection():
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {"cli": ["web", "memory"]},
|
|
|
|
|
"mcp_servers": {
|
|
|
|
|
"exa": {"url": "https://mcp.exa.ai/mcp"},
|
|
|
|
|
"web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
assert "web" in enabled
|
|
|
|
|
assert "memory" in enabled
|
|
|
|
|
assert "exa" in enabled
|
|
|
|
|
assert "web-search-prime" in enabled
|
|
|
|
|
|
|
|
|
|
|
2026-04-06 23:03:14 -05:00
|
|
|
def test_get_platform_tools_no_mcp_sentinel_excludes_all_mcp_servers():
|
|
|
|
|
"""The 'no_mcp' sentinel in platform_toolsets excludes all MCP servers."""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {"cli": ["web", "terminal", "no_mcp"]},
|
|
|
|
|
"mcp_servers": {
|
|
|
|
|
"exa": {"url": "https://mcp.exa.ai/mcp"},
|
|
|
|
|
"web-search-prime": {"url": "https://api.z.ai/api/mcp/web_search_prime/mcp"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
assert "web" in enabled
|
|
|
|
|
assert "terminal" in enabled
|
|
|
|
|
assert "exa" not in enabled
|
|
|
|
|
assert "web-search-prime" not in enabled
|
|
|
|
|
assert "no_mcp" not in enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_no_mcp_sentinel_does_not_affect_other_platforms():
|
|
|
|
|
"""The 'no_mcp' sentinel only affects the platform it's configured on."""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"api_server": ["web", "terminal", "no_mcp"],
|
|
|
|
|
},
|
|
|
|
|
"mcp_servers": {
|
|
|
|
|
"exa": {"url": "https://mcp.exa.ai/mcp"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# api_server should exclude MCP
|
|
|
|
|
api_enabled = _get_platform_tools(config, "api_server")
|
|
|
|
|
assert "exa" not in api_enabled
|
|
|
|
|
|
|
|
|
|
# cli (not configured with no_mcp) should include MCP
|
|
|
|
|
cli_enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
assert "exa" in cli_enabled
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 20:22:13 -07:00
|
|
|
def test_toolset_has_keys_for_vision_accepts_codex_auth(tmp_path, monkeypatch):
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
|
(tmp_path / "auth.json").write_text(
|
2026-03-14 07:58:03 +01:00
|
|
|
'{"active_provider":"openai-codex","providers":{"openai-codex":{"tokens":{"access_token": "codex-...oken","refresh_token": "codex-...oken"}}}}'
|
2026-03-14 20:22:13 -07:00
|
|
|
)
|
|
|
|
|
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
|
|
|
|
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
|
|
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
2026-04-13 04:59:26 -07:00
|
|
|
|
2026-03-26 15:27:27 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"agent.auxiliary_client.resolve_vision_provider_client",
|
|
|
|
|
lambda: ("openai-codex", object(), "gpt-4.1"),
|
|
|
|
|
)
|
2026-03-14 20:22:13 -07:00
|
|
|
|
|
|
|
|
assert _toolset_has_keys("vision") is True
|
2026-03-14 07:58:03 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_preserves_mcp_server_names():
|
|
|
|
|
"""Ensure MCP server names are preserved when saving platform tools.
|
|
|
|
|
|
|
|
|
|
Regression test for https://github.com/NousResearch/hermes-agent/issues/1247
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": ["web", "terminal", "time", "github", "custom-mcp-server"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new_selection = {"web", "browser"}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", new_selection)
|
|
|
|
|
|
|
|
|
|
saved_toolsets = config["platform_toolsets"]["cli"]
|
|
|
|
|
|
|
|
|
|
assert "time" in saved_toolsets
|
|
|
|
|
assert "github" in saved_toolsets
|
|
|
|
|
assert "custom-mcp-server" in saved_toolsets
|
|
|
|
|
assert "web" in saved_toolsets
|
|
|
|
|
assert "browser" in saved_toolsets
|
|
|
|
|
assert "terminal" not in saved_toolsets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_handles_empty_existing_config():
|
|
|
|
|
"""Saving platform tools works when no existing config exists."""
|
|
|
|
|
config = {}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "telegram", {"web", "terminal"})
|
|
|
|
|
|
|
|
|
|
saved_toolsets = config["platform_toolsets"]["telegram"]
|
|
|
|
|
assert "web" in saved_toolsets
|
|
|
|
|
assert "terminal" in saved_toolsets
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_handles_invalid_existing_config():
|
|
|
|
|
"""Saving platform tools works when existing config is not a list."""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": "invalid-string-value"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", {"web"})
|
|
|
|
|
|
|
|
|
|
saved_toolsets = config["platform_toolsets"]["cli"]
|
|
|
|
|
assert "web" in saved_toolsets
|
2026-03-23 07:06:51 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_does_not_preserve_platform_default_toolsets():
|
|
|
|
|
"""Platform default toolsets (hermes-cli, hermes-telegram, etc.) must NOT
|
|
|
|
|
be preserved across saves.
|
|
|
|
|
|
|
|
|
|
These "super" toolsets resolve to ALL tools, so if they survive in the
|
|
|
|
|
config, they silently override any tools the user unchecked. Previously,
|
|
|
|
|
the preserve filter only excluded configurable toolset keys (web, browser,
|
|
|
|
|
terminal, etc.) and treated platform defaults as unknown custom entries
|
|
|
|
|
(like MCP server names), causing them to be kept unconditionally.
|
|
|
|
|
|
|
|
|
|
Regression test: user unchecks image_gen and homeassistant via
|
|
|
|
|
``hermes tools``, but hermes-cli stays in the config and re-enables
|
|
|
|
|
everything on the next read.
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": [
|
|
|
|
|
"browser", "clarify", "code_execution", "cronjob",
|
|
|
|
|
"delegation", "file", "hermes-cli", # <-- the culprit
|
|
|
|
|
"memory", "session_search", "skills", "terminal",
|
|
|
|
|
"todo", "tts", "vision", "web",
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# User unchecks image_gen, homeassistant, moa — keeps the rest
|
|
|
|
|
new_selection = {
|
|
|
|
|
"browser", "clarify", "code_execution", "cronjob",
|
|
|
|
|
"delegation", "file", "memory", "session_search",
|
|
|
|
|
"skills", "terminal", "todo", "tts", "vision", "web",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", new_selection)
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["cli"]
|
|
|
|
|
|
|
|
|
|
# hermes-cli must NOT survive — it's a platform default, not an MCP server
|
|
|
|
|
assert "hermes-cli" not in saved
|
|
|
|
|
|
|
|
|
|
# The individual toolset keys the user selected must be present
|
|
|
|
|
assert "web" in saved
|
|
|
|
|
assert "terminal" in saved
|
|
|
|
|
assert "browser" in saved
|
|
|
|
|
|
|
|
|
|
# Tools the user unchecked must NOT be present
|
|
|
|
|
assert "image_gen" not in saved
|
|
|
|
|
assert "homeassistant" not in saved
|
|
|
|
|
assert "moa" not in saved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_does_not_preserve_hermes_telegram():
|
|
|
|
|
"""Same bug for Telegram — hermes-telegram must not be preserved."""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"telegram": [
|
|
|
|
|
"browser", "file", "hermes-telegram", "terminal", "web",
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new_selection = {"browser", "file", "terminal", "web"}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "telegram", new_selection)
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["telegram"]
|
|
|
|
|
assert "hermes-telegram" not in saved
|
|
|
|
|
assert "web" in saved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_still_preserves_mcp_with_platform_default_present():
|
|
|
|
|
"""MCP server names must still be preserved even when platform defaults
|
|
|
|
|
are being stripped out."""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": [
|
|
|
|
|
"web", "terminal", "hermes-cli", "my-mcp-server", "github-tools",
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
new_selection = {"web", "browser"}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", new_selection)
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["cli"]
|
|
|
|
|
|
|
|
|
|
# MCP servers preserved
|
|
|
|
|
assert "my-mcp-server" in saved
|
|
|
|
|
assert "github-tools" in saved
|
|
|
|
|
|
|
|
|
|
# Platform default stripped
|
|
|
|
|
assert "hermes-cli" not in saved
|
|
|
|
|
|
|
|
|
|
# User selections present
|
|
|
|
|
assert "web" in saved
|
|
|
|
|
assert "browser" in saved
|
|
|
|
|
|
|
|
|
|
# Deselected configurable toolset removed
|
|
|
|
|
assert "terminal" not in saved
|
2026-03-26 15:27:27 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_visible_providers_include_nous_subscription_when_logged_in(monkeypatch):
|
feat: ungate Tool Gateway — subscription-based access with per-tool opt-in
Replace the HERMES_ENABLE_NOUS_MANAGED_TOOLS env-var feature flag with
subscription-based detection. The Tool Gateway is now available to any
paid Nous subscriber without needing a hidden env var.
Core changes:
- managed_nous_tools_enabled() checks get_nous_auth_status() +
check_nous_free_tier() instead of an env var
- New use_gateway config flag per tool section (web, tts, browser,
image_gen) records explicit user opt-in and overrides direct API
keys at runtime
- New prefers_gateway(section) shared helper in tool_backend_helpers.py
used by all 4 tool runtimes (web, tts, image gen, browser)
UX flow:
- hermes model: after Nous login/model selection, shows a curses
prompt listing all gateway-eligible tools with current status.
User chooses to enable all, enable only unconfigured tools, or skip.
Defaults to Enable for new users, Skip when direct keys exist.
- hermes tools: provider selection now manages use_gateway flag —
selecting Nous Subscription sets it, selecting any other provider
clears it
- hermes status: renamed section to Nous Tool Gateway, added
free-tier upgrade nudge for logged-in free users
- curses_radiolist: new description parameter for multi-line context
that survives the screen clear
Runtime behavior:
- Each tool runtime (web_tools, tts_tool, image_generation_tool,
browser_use) checks prefers_gateway() before falling back to
direct env-var credentials
- get_nous_subscription_features() respects use_gateway flags,
suppressing direct credential detection when the user opted in
Removed:
- HERMES_ENABLE_NOUS_MANAGED_TOOLS env var and all references
- apply_nous_provider_defaults() silent TTS auto-set
- get_nous_subscription_explainer_lines() static text
- Override env var warnings (use_gateway handles this properly now)
2026-04-16 01:59:51 -04:00
|
|
|
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
|
2026-03-26 15:27:27 -07:00
|
|
|
config = {"model": {"provider": "nous"}}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
|
|
|
|
lambda: {"logged_in": True},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
|
|
|
|
|
|
|
|
|
|
assert providers[0]["name"].startswith("Nous Subscription")
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 13:28:10 +09:00
|
|
|
def test_visible_providers_hide_nous_subscription_when_feature_flag_is_off(monkeypatch):
|
feat: ungate Tool Gateway — subscription-based access with per-tool opt-in
Replace the HERMES_ENABLE_NOUS_MANAGED_TOOLS env-var feature flag with
subscription-based detection. The Tool Gateway is now available to any
paid Nous subscriber without needing a hidden env var.
Core changes:
- managed_nous_tools_enabled() checks get_nous_auth_status() +
check_nous_free_tier() instead of an env var
- New use_gateway config flag per tool section (web, tts, browser,
image_gen) records explicit user opt-in and overrides direct API
keys at runtime
- New prefers_gateway(section) shared helper in tool_backend_helpers.py
used by all 4 tool runtimes (web, tts, image gen, browser)
UX flow:
- hermes model: after Nous login/model selection, shows a curses
prompt listing all gateway-eligible tools with current status.
User chooses to enable all, enable only unconfigured tools, or skip.
Defaults to Enable for new users, Skip when direct keys exist.
- hermes tools: provider selection now manages use_gateway flag —
selecting Nous Subscription sets it, selecting any other provider
clears it
- hermes status: renamed section to Nous Tool Gateway, added
free-tier upgrade nudge for logged-in free users
- curses_radiolist: new description parameter for multi-line context
that survives the screen clear
Runtime behavior:
- Each tool runtime (web_tools, tts_tool, image_generation_tool,
browser_use) checks prefers_gateway() before falling back to
direct env-var credentials
- get_nous_subscription_features() respects use_gateway flags,
suppressing direct credential detection when the user opted in
Removed:
- HERMES_ENABLE_NOUS_MANAGED_TOOLS env var and all references
- apply_nous_provider_defaults() silent TTS auto-set
- get_nous_subscription_explainer_lines() static text
- Override env var warnings (use_gateway handles this properly now)
2026-04-16 01:59:51 -04:00
|
|
|
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: False)
|
2026-03-30 13:28:10 +09:00
|
|
|
config = {"model": {"provider": "nous"}}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
|
|
|
|
lambda: {"logged_in": True},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
providers = _visible_providers(TOOL_CATEGORIES["browser"], config)
|
|
|
|
|
|
|
|
|
|
assert all(not provider["name"].startswith("Nous Subscription") for provider in providers)
|
|
|
|
|
|
|
|
|
|
|
2026-03-26 15:27:27 -07:00
|
|
|
def test_local_browser_provider_is_saved_explicitly(monkeypatch):
|
|
|
|
|
config = {}
|
|
|
|
|
local_provider = next(
|
|
|
|
|
provider
|
|
|
|
|
for provider in TOOL_CATEGORIES["browser"]["providers"]
|
|
|
|
|
if provider.get("browser_provider") == "local"
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr("hermes_cli.tools_config._run_post_setup", lambda key: None)
|
|
|
|
|
|
|
|
|
|
_configure_provider(local_provider, config)
|
|
|
|
|
|
|
|
|
|
assert config["browser"]["cloud_provider"] == "local"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_first_install_nous_auto_configures_managed_defaults(monkeypatch):
|
feat: ungate Tool Gateway — subscription-based access with per-tool opt-in
Replace the HERMES_ENABLE_NOUS_MANAGED_TOOLS env-var feature flag with
subscription-based detection. The Tool Gateway is now available to any
paid Nous subscriber without needing a hidden env var.
Core changes:
- managed_nous_tools_enabled() checks get_nous_auth_status() +
check_nous_free_tier() instead of an env var
- New use_gateway config flag per tool section (web, tts, browser,
image_gen) records explicit user opt-in and overrides direct API
keys at runtime
- New prefers_gateway(section) shared helper in tool_backend_helpers.py
used by all 4 tool runtimes (web, tts, image gen, browser)
UX flow:
- hermes model: after Nous login/model selection, shows a curses
prompt listing all gateway-eligible tools with current status.
User chooses to enable all, enable only unconfigured tools, or skip.
Defaults to Enable for new users, Skip when direct keys exist.
- hermes tools: provider selection now manages use_gateway flag —
selecting Nous Subscription sets it, selecting any other provider
clears it
- hermes status: renamed section to Nous Tool Gateway, added
free-tier upgrade nudge for logged-in free users
- curses_radiolist: new description parameter for multi-line context
that survives the screen clear
Runtime behavior:
- Each tool runtime (web_tools, tts_tool, image_generation_tool,
browser_use) checks prefers_gateway() before falling back to
direct env-var credentials
- get_nous_subscription_features() respects use_gateway flags,
suppressing direct credential detection when the user opted in
Removed:
- HERMES_ENABLE_NOUS_MANAGED_TOOLS env var and all references
- apply_nous_provider_defaults() silent TTS auto-set
- get_nous_subscription_explainer_lines() static text
- Override env var warnings (use_gateway handles this properly now)
2026-04-16 01:59:51 -04:00
|
|
|
monkeypatch.setattr("hermes_cli.tools_config.managed_nous_tools_enabled", lambda: True)
|
|
|
|
|
monkeypatch.setattr("hermes_cli.nous_subscription.managed_nous_tools_enabled", lambda: True)
|
2026-03-26 15:27:27 -07:00
|
|
|
config = {
|
|
|
|
|
"model": {"provider": "nous"},
|
|
|
|
|
"platform_toolsets": {"cli": []},
|
|
|
|
|
}
|
|
|
|
|
for env_var in (
|
|
|
|
|
"VOICE_TOOLS_OPENAI_KEY",
|
|
|
|
|
"OPENAI_API_KEY",
|
|
|
|
|
"ELEVENLABS_API_KEY",
|
|
|
|
|
"FIRECRAWL_API_KEY",
|
|
|
|
|
"FIRECRAWL_API_URL",
|
|
|
|
|
"TAVILY_API_KEY",
|
|
|
|
|
"PARALLEL_API_KEY",
|
|
|
|
|
"BROWSERBASE_API_KEY",
|
|
|
|
|
"BROWSERBASE_PROJECT_ID",
|
|
|
|
|
"BROWSER_USE_API_KEY",
|
|
|
|
|
"FAL_KEY",
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.delenv(env_var, raising=False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.tools_config._prompt_toolset_checklist",
|
|
|
|
|
lambda *args, **kwargs: {"web", "image_gen", "tts", "browser"},
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr("hermes_cli.tools_config.save_config", lambda config: None)
|
fix(tests): fix several failing/flaky tests on main (#6777)
* fix(tests): mock is_safe_url in tests that use example.com
Tests using example.com URLs were failing because is_safe_url does a real DNS lookup which fails in environments where example.com doesn't resolve, causing the request to be blocked before reaching the already-mocked HTTP client. This should fix around 17 failing tests.
These tests test logic, caching, etc. so mocking this method should not modify them in any way. TestMattermostSendUrlAsFile was already doing this so we follow the same pattern.
* fix(test): use case-insensitive lookup for model context length check
DEFAULT_CONTEXT_LENGTHS uses inconsistent casing (MiniMax keys are lowercase, Qwen keys are mixed-case) so the test was broken in some cases since it couldn't find the model.
* fix(test): patch is_linux in systemd gateway restart test
The test only patched is_macos to False but didn't patch is_linux to True. On macOS hosts, is_linux() returns False and the systemd restart code path is skipped entirely, making the assertion fail.
* fix(test): use non-blocklisted env var in docker forward_env tests
GITHUB_TOKEN is in api_key_env_vars and thus in _HERMES_PROVIDER_ENV_BLOCKLIST so the env var is silently dropped, we replace it with a non-blocked one like DATABASE_URL so the tests actually work.
* fix(test): fully isolate _has_any_provider_configured from host env
_has_any_provider_configured() checks all env vars from PROVIDER_REGISTRY (not just the 5 the tests were clearing) and also calls get_auth_status() which detects gh auth token for Copilot. On machines with any of these set, the function returns True before reaching the code path under test.
Clear all registry vars and mock get_auth_status so host credentials don't interfere.
* fix(test): correct path to hermes_base_env.py in tool parser tests
Path(__file__).parent.parent resolved to tests/, not the project root.
The file lives at environments/hermes_base_env.py so we need one more parent level.
* fix(test): accept optional HTML fields in Matrix send payload
_send_matrix sometimes adds format and formatted_body when the markdown library is installed. The test was doing an exact dict equality check which broke. Check required fields instead.
* fix(test): add config.yaml to codex vision requirements test
The test only wrote auth.json but not config.yaml, so _read_main_provider() returned empty and vision auto-detect never tried the codex provider. Add a config.yaml pointing at openai-codex so the fallback path actually resolves the client.
* fix(test): clear OPENROUTER_API_KEY in _isolate_hermes_home
run_agent.py calls load_hermes_dotenv() at import time, which injects API keys from ~/.hermes/.env into os.environ before any test fixture runs. This caused test_agent_loop_tool_calling to make real API calls instead of skipping, which ends up making some tests fail.
* fix(test): add get_rate_limit_state to agent mock in usage report tests
_show_usage now calls agent.get_rate_limit_state() for rate limit
display. The SimpleNamespace mock was missing this method.
* fix(test): update expected Camofox config version from 12 to 13
* fix(test): mock _get_enabled_platforms in nous managed defaults test
Importing gateway.run leaks DISCORD_BOT_TOKEN into os.environ, which makes _get_enabled_platforms() return ["cli", "discord"] instead of just ["cli"]. tools_command loops per platform, so apply_nous_managed_defaults
runs twice: the first call sets config values, the second sees them as
already configured and returns an empty set, causing the assertion to
fail.
2026-04-09 17:17:06 -03:00
|
|
|
# Prevent leaked platform tokens (e.g. DISCORD_BOT_TOKEN from gateway.run
|
|
|
|
|
# import) from adding extra platforms. The loop in tools_command runs
|
|
|
|
|
# apply_nous_managed_defaults per platform; a second iteration sees values
|
|
|
|
|
# set by the first as "explicit" and skips them.
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.tools_config._get_enabled_platforms",
|
|
|
|
|
lambda: ["cli"],
|
|
|
|
|
)
|
2026-03-26 15:27:27 -07:00
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.nous_subscription.get_nous_auth_status",
|
|
|
|
|
lambda: {"logged_in": True},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
configured = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"hermes_cli.tools_config._configure_toolset",
|
|
|
|
|
lambda ts_key, config: configured.append(ts_key),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tools_command(first_install=True, config=config)
|
|
|
|
|
|
|
|
|
|
assert config["web"]["backend"] == "firecrawl"
|
|
|
|
|
assert config["tts"]["provider"] == "openai"
|
feat: switch managed browser provider from Browserbase to Browser Use (#5750)
* feat: switch managed browser provider from Browserbase to Browser Use
The Nous subscription tool gateway now routes browser automation through
Browser Use instead of Browserbase. This commit:
- Adds managed Nous gateway support to BrowserUseProvider (idempotency
keys, X-BB-API-Key auth header, external_call_id persistence)
- Removes managed gateway support from BrowserbaseProvider (now
direct-only via BROWSERBASE_API_KEY/BROWSERBASE_PROJECT_ID)
- Updates browser_tool.py fallback: prefers Browser Use over Browserbase
- Updates nous_subscription.py: gateway vendor 'browser-use', auto-config
sets cloud_provider='browser-use' for new subscribers
- Updates tools_config.py: Nous Subscription entry now uses Browser Use
- Updates setup.py, cli.py, status.py, prompt_builder.py display strings
- Updates all affected tests to match new behavior
Browserbase remains fully functional for users with direct API credentials.
The change only affects the managed/subscription path.
* chore: remove redundant Browser Use hint from system prompt
* fix: upgrade Browser Use provider to v3 API
- Base URL: api/v2 -> api/v3 (v2 is legacy)
- Unified all endpoints to use native Browser Use paths:
- POST /browsers (create session, returns cdpUrl)
- PATCH /browsers/{id} with {action: stop} (close session)
- Removed managed-mode branching that used Browserbase-style
/v1/sessions paths — v3 gateway now supports /browsers directly
- Removed unused managed_mode variable in close_session
* fix(browser-use): use X-Browser-Use-API-Key header for managed mode
The managed gateway expects X-Browser-Use-API-Key, not X-BB-API-Key
(which is a Browserbase-specific header). Using the wrong header caused
a 401 AUTH_ERROR on every managed-mode browser session create.
Simplified _headers() to always use X-Browser-Use-API-Key regardless
of direct vs managed mode.
* fix(nous_subscription): browserbase explicit provider is direct-only
Since managed Nous gateway now routes through Browser Use, the
browserbase explicit provider path should not check managed_browser_available
(which resolves against the browser-use gateway). Simplified to direct-only
with managed=False.
* fix(browser-use): port missing improvements from PR #5605
- CDP URL normalization: resolve HTTP discovery URLs to websocket after
cloud provider create_session() (prevents agent-browser failures)
- Managed session payload: send timeout=5 and proxyCountryCode=us for
gateway-backed sessions (prevents billing overruns)
- Update prompt builder, browser_close schema, and module docstring to
replace remaining Browserbase references with Browser Use
- Dynamic /browser status detection via _get_cloud_provider() instead
of hardcoded env var checks (future-proof for new providers)
- Rename post_setup key from 'browserbase' to 'agent_browser'
- Update setup hint to mention Browser Use alongside Browserbase
- Add tests: CDP normalization, browserbase direct-only guard,
managed browser-use gateway, direct browserbase fallback
---------
Co-authored-by: rob-maron <132852777+rob-maron@users.noreply.github.com>
2026-04-07 22:40:22 +10:00
|
|
|
assert config["browser"]["cloud_provider"] == "browser-use"
|
2026-03-26 15:27:27 -07:00
|
|
|
assert configured == []
|
2026-03-31 08:48:54 +09:00
|
|
|
|
2026-03-28 14:05:02 -07:00
|
|
|
# ── Platform / toolset consistency ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestPlatformToolsetConsistency:
|
|
|
|
|
"""Every platform in tools_config.PLATFORMS must have a matching toolset."""
|
|
|
|
|
|
|
|
|
|
def test_all_platforms_have_toolset_definitions(self):
|
|
|
|
|
"""Each platform's default_toolset must exist in TOOLSETS."""
|
|
|
|
|
from hermes_cli.tools_config import PLATFORMS
|
|
|
|
|
from toolsets import TOOLSETS
|
|
|
|
|
|
|
|
|
|
for platform, meta in PLATFORMS.items():
|
|
|
|
|
ts_name = meta["default_toolset"]
|
|
|
|
|
assert ts_name in TOOLSETS, (
|
|
|
|
|
f"Platform {platform!r} references toolset {ts_name!r} "
|
|
|
|
|
f"which is not defined in toolsets.py"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_gateway_toolset_includes_all_messaging_platforms(self):
|
|
|
|
|
"""hermes-gateway includes list should cover all messaging platforms."""
|
|
|
|
|
from hermes_cli.tools_config import PLATFORMS
|
|
|
|
|
from toolsets import TOOLSETS
|
|
|
|
|
|
|
|
|
|
gateway_includes = set(TOOLSETS["hermes-gateway"]["includes"])
|
|
|
|
|
# Exclude non-messaging platforms from the check
|
2026-04-23 15:48:50 -07:00
|
|
|
non_messaging = {"cli", "api_server", "cron"}
|
2026-03-28 14:05:02 -07:00
|
|
|
for platform, meta in PLATFORMS.items():
|
|
|
|
|
if platform in non_messaging:
|
|
|
|
|
continue
|
|
|
|
|
ts_name = meta["default_toolset"]
|
|
|
|
|
assert ts_name in gateway_includes, (
|
|
|
|
|
f"Platform {platform!r} toolset {ts_name!r} missing from "
|
|
|
|
|
f"hermes-gateway includes"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_skills_config_covers_tools_config_platforms(self):
|
|
|
|
|
"""skills_config.PLATFORMS should have entries for all gateway platforms."""
|
|
|
|
|
from hermes_cli.tools_config import PLATFORMS as TOOLS_PLATFORMS
|
|
|
|
|
from hermes_cli.skills_config import PLATFORMS as SKILLS_PLATFORMS
|
|
|
|
|
|
|
|
|
|
non_messaging = {"api_server"}
|
|
|
|
|
for platform in TOOLS_PLATFORMS:
|
|
|
|
|
if platform in non_messaging:
|
|
|
|
|
continue
|
|
|
|
|
assert platform in SKILLS_PLATFORMS, (
|
|
|
|
|
f"Platform {platform!r} in tools_config but missing from "
|
|
|
|
|
f"skills_config PLATFORMS"
|
|
|
|
|
)
|
2026-04-10 13:10:22 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_numeric_mcp_server_name_does_not_crash_sorted():
|
|
|
|
|
"""YAML parses bare numeric keys (e.g. ``12306:``) as int.
|
|
|
|
|
|
|
|
|
|
_get_platform_tools must normalise them to str so that sorted()
|
|
|
|
|
on the returned set never raises TypeError on mixed int/str.
|
|
|
|
|
|
|
|
|
|
Regression test for https://github.com/NousResearch/hermes-agent/issues/6901
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {"cli": ["web", 12306]},
|
|
|
|
|
"mcp_servers": {
|
|
|
|
|
12306: {"url": "https://example.com/mcp"},
|
|
|
|
|
"normal-server": {"url": "https://example.com/mcp2"},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
enabled = _get_platform_tools(config, "cli")
|
|
|
|
|
|
|
|
|
|
# All names must be str — no int leaking through
|
|
|
|
|
assert all(isinstance(name, str) for name in enabled), (
|
|
|
|
|
f"Non-string toolset names found: {enabled}"
|
|
|
|
|
)
|
|
|
|
|
assert "12306" in enabled
|
|
|
|
|
|
|
|
|
|
# sorted() must not raise TypeError
|
|
|
|
|
sorted(enabled)
|
feat(image_gen): multi-model FAL support with picker in hermes tools (#11265)
* feat(image_gen): multi-model FAL support with picker in hermes tools
Adds 8 FAL text-to-image models selectable via `hermes tools` →
Image Generation → (FAL.ai | Nous Subscription) → model picker.
Models supported:
- fal-ai/flux-2/klein/9b (new default, <1s, $0.006/MP)
- fal-ai/flux-2-pro (previous default, kept backward-compat upscaling)
- fal-ai/z-image/turbo (Tongyi-MAI, bilingual EN/CN)
- fal-ai/nano-banana (Gemini 2.5 Flash Image)
- fal-ai/gpt-image-1.5 (with quality tier: low/medium/high)
- fal-ai/ideogram/v3 (best typography)
- fal-ai/recraft-v3 (vector, brand styles)
- fal-ai/qwen-image (LLM-based)
Architecture:
- FAL_MODELS catalog declares per-model size family, defaults, supports
whitelist, and upscale flag. Three size families handled uniformly:
image_size_preset (flux family), aspect_ratio (nano-banana), and
gpt_literal (gpt-image-1.5).
- _build_fal_payload() translates unified inputs (prompt + aspect_ratio)
into model-specific payloads, merges defaults, applies caller overrides,
wires GPT quality_setting, then filters to the supports whitelist — so
models never receive rejected keys.
- IMAGEGEN_BACKENDS registry in tools_config prepares for future imagegen
providers (Replicate, Stability, etc.); each provider entry tags itself
with imagegen_backend: 'fal' to select the right catalog.
- Upscaler (Clarity) defaults off for new models (preserves <1s value
prop), on for flux-2-pro (backward-compat). Per-model via FAL_MODELS.
Config:
image_gen.model = fal-ai/flux-2/klein/9b (new)
image_gen.quality_setting = medium (new, GPT only)
image_gen.use_gateway = bool (existing)
Agent-facing schema unchanged (prompt + aspect_ratio only) — model
choice is a user-level config decision, not an agent-level arg.
Picker uses curses_radiolist (arrow keys, auto numbered-fallback on
non-TTY). Column-aligned: Model / Speed / Strengths / Price.
Docs: image-generation.md rewritten with the model table and picker
walkthrough. tools-reference, tool-gateway, overview updated to drop
the stale "FLUX 2 Pro" wording.
Tests: 42 new in tests/tools/test_image_generation.py covering catalog
integrity, all 3 size families, supports filter, default merging, GPT
quality wiring, model resolution fallback. 8 new in
tests/hermes_cli/test_tools_config.py for picker wiring (registry,
config writes, GPT quality follow-up prompt, corrupt-config repair).
* feat(image_gen): translate managed-gateway 4xx to actionable error
When the Nous Subscription managed FAL proxy rejects a model with 4xx
(likely portal-side allowlist miss or billing gate), surface a clear
message explaining:
1. The rejected model ID + HTTP status
2. Two remediation paths: set FAL_KEY for direct access, or
pick a different model via `hermes tools`
5xx, connection errors, and direct-FAL errors pass through unchanged
(those have different root causes and reasonable native messages).
Motivation: new FAL models added to this release (flux-2-klein-9b,
z-image-turbo, nano-banana, gpt-image-1.5, ideogram-v3, recraft-v3,
qwen-image) are untested against the Nous Portal proxy. If the portal
allowlists model IDs, users on Nous Subscription will hit cryptic
4xx errors without guidance on how to work around it.
Tests: 8 new cases covering status extraction across httpx/fal error
shapes and 4xx-vs-5xx-vs-ConnectionError translation policy.
Docs: brief note in image-generation.md for Nous subscribers.
Operator action (Nous Portal side): verify that fal-queue-gateway
passes through these 7 new FAL model IDs. If the proxy has an
allowlist, add them; otherwise Nous Subscription users will see the
new translated error and fall back to direct FAL.
* feat(image_gen): pin GPT-Image quality to medium (no user choice)
Previously the tools picker asked a follow-up question for GPT-Image
quality tier (low / medium / high) and persisted the answer to
`image_gen.quality_setting`. This created two problems:
1. Nous Portal billing complexity — the 22x cost spread between tiers
($0.009 low / $0.20 high) forces the gateway to meter per-tier per
user, which the portal team can't easily support at launch.
2. User footgun — anyone picking `high` by mistake burns through
credit ~6x faster than `medium`.
This commit pins quality at medium by baking it into FAL_MODELS
defaults for gpt-image-1.5 and removes all user-facing override paths:
- Removed `_resolve_gpt_quality()` runtime lookup
- Removed `honors_quality_setting` flag on the model entry
- Removed `_configure_gpt_quality_setting()` picker helper
- Removed `_GPT_QUALITY_CHOICES` constant
- Removed the follow-up prompt call in `_configure_imagegen_model()`
- Even if a user manually edits `image_gen.quality_setting` in
config.yaml, no code path reads it — always sends medium.
Tests:
- Replaced TestGptQualitySetting (6 tests) with TestGptQualityPinnedToMedium
(5 tests) — proves medium is baked in, config is ignored, flag is
removed, helper is removed, non-gpt models never get quality.
- Replaced test_picker_with_gpt_image_also_prompts_quality with
test_picker_with_gpt_image_does_not_prompt_quality — proves only 1
picker call fires when gpt-image is selected (no quality follow-up).
Docs updated: image-generation.md replaces the quality-tier table
with a short note explaining the pinning decision.
* docs(image_gen): drop stale 'wires GPT quality tier' line from internals section
Caught in a cleanup sweep after pinning quality to medium. The
"How It Works Internally" walkthrough still described the removed
quality-wiring step.
2026-04-16 20:19:53 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Imagegen Backend Picker Wiring ────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestImagegenBackendRegistry:
|
|
|
|
|
"""IMAGEGEN_BACKENDS tags drive the model picker flow in tools_config."""
|
|
|
|
|
|
|
|
|
|
def test_fal_backend_registered(self):
|
|
|
|
|
from hermes_cli.tools_config import IMAGEGEN_BACKENDS
|
|
|
|
|
assert "fal" in IMAGEGEN_BACKENDS
|
|
|
|
|
|
|
|
|
|
def test_fal_catalog_loads_lazily(self):
|
|
|
|
|
"""catalog_fn should defer import to avoid import cycles."""
|
|
|
|
|
from hermes_cli.tools_config import IMAGEGEN_BACKENDS
|
|
|
|
|
catalog, default = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]()
|
|
|
|
|
assert default == "fal-ai/flux-2/klein/9b"
|
|
|
|
|
assert "fal-ai/flux-2/klein/9b" in catalog
|
|
|
|
|
assert "fal-ai/flux-2-pro" in catalog
|
|
|
|
|
|
|
|
|
|
def test_image_gen_providers_tagged_with_fal_backend(self):
|
|
|
|
|
"""Both Nous Subscription and FAL.ai providers must carry the
|
|
|
|
|
imagegen_backend tag so _configure_provider fires the picker."""
|
|
|
|
|
from hermes_cli.tools_config import TOOL_CATEGORIES
|
|
|
|
|
providers = TOOL_CATEGORIES["image_gen"]["providers"]
|
|
|
|
|
for p in providers:
|
|
|
|
|
assert p.get("imagegen_backend") == "fal", (
|
|
|
|
|
f"{p['name']} missing imagegen_backend tag"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestImagegenModelPicker:
|
|
|
|
|
"""_configure_imagegen_model writes selection to config and respects
|
|
|
|
|
curses fallback semantics (returns default when stdin isn't a TTY)."""
|
|
|
|
|
|
|
|
|
|
def test_picker_writes_chosen_model_to_config(self):
|
|
|
|
|
from hermes_cli.tools_config import _configure_imagegen_model
|
|
|
|
|
config = {}
|
|
|
|
|
# Force _prompt_choice to pick index 1 (second-in-ordered-list).
|
|
|
|
|
with patch("hermes_cli.tools_config._prompt_choice", return_value=1):
|
|
|
|
|
_configure_imagegen_model("fal", config)
|
|
|
|
|
# ordered[0] == current (default klein), ordered[1] == first non-default
|
|
|
|
|
assert config["image_gen"]["model"] != "fal-ai/flux-2/klein/9b"
|
|
|
|
|
assert config["image_gen"]["model"].startswith("fal-ai/")
|
|
|
|
|
|
|
|
|
|
def test_picker_with_gpt_image_does_not_prompt_quality(self):
|
|
|
|
|
"""GPT-Image quality is pinned to medium in the tool's defaults —
|
|
|
|
|
no follow-up prompt, no config write for quality_setting."""
|
|
|
|
|
from hermes_cli.tools_config import (
|
|
|
|
|
_configure_imagegen_model,
|
|
|
|
|
IMAGEGEN_BACKENDS,
|
|
|
|
|
)
|
|
|
|
|
catalog, default_model = IMAGEGEN_BACKENDS["fal"]["catalog_fn"]()
|
|
|
|
|
model_ids = list(catalog.keys())
|
|
|
|
|
ordered = [default_model] + [m for m in model_ids if m != default_model]
|
|
|
|
|
gpt_idx = ordered.index("fal-ai/gpt-image-1.5")
|
|
|
|
|
|
|
|
|
|
# Only ONE picker call is expected (for model) — not two (model + quality).
|
|
|
|
|
call_count = {"n": 0}
|
|
|
|
|
def fake_prompt(*a, **kw):
|
|
|
|
|
call_count["n"] += 1
|
|
|
|
|
return gpt_idx
|
|
|
|
|
|
|
|
|
|
config = {}
|
|
|
|
|
with patch("hermes_cli.tools_config._prompt_choice", side_effect=fake_prompt):
|
|
|
|
|
_configure_imagegen_model("fal", config)
|
|
|
|
|
|
|
|
|
|
assert call_count["n"] == 1, (
|
|
|
|
|
f"Expected 1 picker call (model only), got {call_count['n']}"
|
|
|
|
|
)
|
|
|
|
|
assert config["image_gen"]["model"] == "fal-ai/gpt-image-1.5"
|
|
|
|
|
assert "quality_setting" not in config["image_gen"]
|
|
|
|
|
|
|
|
|
|
def test_picker_no_op_for_unknown_backend(self):
|
|
|
|
|
from hermes_cli.tools_config import _configure_imagegen_model
|
|
|
|
|
config = {}
|
|
|
|
|
_configure_imagegen_model("nonexistent-backend", config)
|
|
|
|
|
assert config == {} # untouched
|
|
|
|
|
|
|
|
|
|
def test_picker_repairs_corrupt_config_section(self):
|
|
|
|
|
"""When image_gen is a non-dict (user-edit YAML), the picker should
|
|
|
|
|
replace it with a fresh dict rather than crash."""
|
|
|
|
|
from hermes_cli.tools_config import _configure_imagegen_model
|
|
|
|
|
config = {"image_gen": "some-garbage-string"}
|
|
|
|
|
with patch("hermes_cli.tools_config._prompt_choice", return_value=0):
|
|
|
|
|
_configure_imagegen_model("fal", config)
|
|
|
|
|
assert isinstance(config["image_gen"], dict)
|
|
|
|
|
assert config["image_gen"]["model"] == "fal-ai/flux-2/klein/9b"
|
2026-04-24 15:55:51 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_normalizes_numeric_entries():
|
|
|
|
|
"""YAML may parse bare numeric toolset names as int. They should be
|
|
|
|
|
normalized to str so they survive the save round-trip.
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": ["web", "terminal", 12306, "custom-mcp"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", {"web", "browser"})
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["cli"]
|
|
|
|
|
assert "12306" in saved
|
|
|
|
|
assert 12306 not in saved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_clears_no_mcp_sentinel():
|
|
|
|
|
"""`hermes tools` has no UI for no_mcp, so saving from the picker clears
|
|
|
|
|
the sentinel unconditionally — otherwise a user who once set no_mcp by
|
|
|
|
|
hand could never re-enable MCP servers through the UI.
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": ["web", "terminal", "no_mcp"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", {"web", "browser"})
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["cli"]
|
|
|
|
|
assert "no_mcp" not in saved
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_preserves_mcp_server_names():
|
|
|
|
|
"""Non-sentinel passthrough entries (MCP server names) must still survive
|
|
|
|
|
the save — we only clear `no_mcp`, not every non-configurable entry.
|
|
|
|
|
"""
|
|
|
|
|
config = {
|
|
|
|
|
"platform_toolsets": {
|
|
|
|
|
"cli": ["web", "terminal", "custom-mcp", "another-mcp"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with patch("hermes_cli.tools_config.save_config"):
|
|
|
|
|
_save_platform_tools(config, "cli", {"web", "browser"})
|
|
|
|
|
|
|
|
|
|
saved = config["platform_toolsets"]["cli"]
|
|
|
|
|
assert "custom-mcp" in saved
|
|
|
|
|
assert "another-mcp" in saved
|
2026-04-24 15:22:18 +05:30
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_recovers_non_configurable_toolsets_from_composite():
|
|
|
|
|
"""Non-configurable toolsets whose tools are in the composite but not in
|
|
|
|
|
CONFIGURABLE_TOOLSETS should still appear in the result.
|
|
|
|
|
"""
|
|
|
|
|
from toolsets import TOOLSETS
|
|
|
|
|
from hermes_cli.tools_config import PLATFORMS
|
|
|
|
|
from unittest.mock import patch as mock_patch
|
|
|
|
|
|
|
|
|
|
fake_toolsets = dict(TOOLSETS)
|
|
|
|
|
fake_toolsets["_test_platform_tool"] = {
|
|
|
|
|
"description": "test",
|
|
|
|
|
"tools": ["_test_special_tool"],
|
|
|
|
|
"includes": [],
|
|
|
|
|
}
|
|
|
|
|
fake_toolsets["hermes-_test_platform"] = {
|
|
|
|
|
"description": "test composite",
|
|
|
|
|
"tools": ["web_search", "web_extract", "terminal", "process", "_test_special_tool"],
|
|
|
|
|
"includes": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test_platforms = {
|
|
|
|
|
"_test_platform": {"label": "Test", "default_toolset": "hermes-_test_platform"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with mock_patch("hermes_cli.tools_config.PLATFORMS", {**PLATFORMS, **test_platforms}):
|
|
|
|
|
with mock_patch("toolsets.TOOLSETS", fake_toolsets):
|
|
|
|
|
enabled = _get_platform_tools({}, "_test_platform")
|
|
|
|
|
|
|
|
|
|
assert "_test_platform_tool" in enabled
|
|
|
|
|
assert "web" in enabled
|
|
|
|
|
assert "terminal" in enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_second_pass_skips_fully_claimed_toolsets():
|
|
|
|
|
"""Toolsets whose tools are fully covered by configurable keys should NOT
|
|
|
|
|
be added by the second pass (prevents 'search', 'hermes-acp' noise).
|
|
|
|
|
"""
|
|
|
|
|
enabled = _get_platform_tools({}, "cli")
|
|
|
|
|
|
|
|
|
|
assert "search" not in enabled
|
2026-04-24 15:47:29 +05:30
|
|
|
|
|
|
|
|
|
2026-04-25 03:48:03 -07:00
|
|
|
def test_get_platform_tools_discord_both_off_by_default():
|
|
|
|
|
"""Both `discord` and `discord_admin` are opt-in via `hermes tools`,
|
|
|
|
|
even on the Discord platform itself. Users shouldn't auto-inherit 19
|
|
|
|
|
extra tools just because DISCORD_BOT_TOKEN is set."""
|
2026-04-24 15:47:29 +05:30
|
|
|
enabled = _get_platform_tools({}, "discord")
|
2026-04-25 03:48:03 -07:00
|
|
|
assert "discord" not in enabled
|
2026-04-24 15:47:29 +05:30
|
|
|
assert "discord_admin" not in enabled
|
|
|
|
|
|
|
|
|
|
|
2026-04-25 03:48:03 -07:00
|
|
|
def test_discord_toolsets_in_configurable_toolsets():
|
|
|
|
|
keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
|
|
|
|
|
assert "discord" in keys
|
|
|
|
|
assert "discord_admin" in keys
|
2026-04-24 15:47:29 +05:30
|
|
|
|
|
|
|
|
|
2026-04-25 03:48:03 -07:00
|
|
|
def test_discord_toolsets_in_default_off():
|
|
|
|
|
assert "discord" in _DEFAULT_OFF_TOOLSETS
|
2026-04-24 15:47:29 +05:30
|
|
|
assert "discord_admin" in _DEFAULT_OFF_TOOLSETS
|
2026-04-24 15:54:03 +05:30
|
|
|
|
|
|
|
|
|
2026-04-25 03:48:03 -07:00
|
|
|
def test_discord_toolsets_not_available_on_other_platforms():
|
|
|
|
|
"""Platform-scoping: discord / discord_admin should not appear on CLI,
|
|
|
|
|
Telegram, etc. — not even as an opt-in."""
|
|
|
|
|
from hermes_cli.tools_config import _toolset_allowed_for_platform
|
|
|
|
|
for plat in ["cli", "telegram", "slack", "whatsapp", "signal"]:
|
|
|
|
|
assert not _toolset_allowed_for_platform("discord", plat), (
|
|
|
|
|
f"`discord` toolset leaked onto {plat}"
|
|
|
|
|
)
|
|
|
|
|
assert not _toolset_allowed_for_platform("discord_admin", plat), (
|
|
|
|
|
f"`discord_admin` toolset leaked onto {plat}"
|
|
|
|
|
)
|
|
|
|
|
assert _toolset_allowed_for_platform("discord", "discord")
|
|
|
|
|
assert _toolset_allowed_for_platform("discord_admin", "discord")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_discord_toolsets_user_enabled_are_honored():
|
|
|
|
|
"""When the user opts in via `hermes tools`, the toolset appears."""
|
|
|
|
|
config = {"platform_toolsets": {"discord": ["web", "terminal", "discord"]}}
|
|
|
|
|
enabled = _get_platform_tools(config, "discord")
|
|
|
|
|
assert "discord" in enabled
|
|
|
|
|
assert "discord_admin" not in enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_save_platform_tools_strips_restricted_toolsets():
|
|
|
|
|
"""Hand-edited or all-platforms checklist with `discord` selected for
|
|
|
|
|
Telegram must be stripped at save time."""
|
|
|
|
|
from hermes_cli.tools_config import _save_platform_tools
|
|
|
|
|
config = {}
|
|
|
|
|
_save_platform_tools(config, "telegram", {"web", "terminal", "discord", "discord_admin"})
|
|
|
|
|
saved = config["platform_toolsets"]["telegram"]
|
|
|
|
|
assert "discord" not in saved
|
|
|
|
|
assert "discord_admin" not in saved
|
|
|
|
|
assert "web" in saved
|
|
|
|
|
assert "terminal" in saved
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 15:54:03 +05:30
|
|
|
def test_get_platform_tools_feishu_includes_doc_and_drive():
|
|
|
|
|
enabled = _get_platform_tools({}, "feishu")
|
|
|
|
|
assert "feishu_doc" in enabled
|
|
|
|
|
assert "feishu_drive" in enabled
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_platform_tools_feishu_tools_not_on_other_platforms():
|
|
|
|
|
for plat in ["cli", "telegram", "discord"]:
|
|
|
|
|
enabled = _get_platform_tools({}, plat)
|
|
|
|
|
assert "feishu_doc" not in enabled, f"feishu_doc leaked onto {plat}"
|
|
|
|
|
assert "feishu_drive" not in enabled, f"feishu_drive leaked onto {plat}"
|
2026-04-25 05:53:08 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_effective_configurable_toolsets_dedupes_bundled_plugins():
|
|
|
|
|
"""Bundled plugins (plugins/spotify) share their toolset key with the
|
|
|
|
|
built-in CONFIGURABLE_TOOLSETS entry. The effective list must not list
|
|
|
|
|
them twice — otherwise `hermes tools` → "reconfigure existing" shows
|
|
|
|
|
the same toolset two rows in a row.
|
|
|
|
|
"""
|
|
|
|
|
from hermes_cli.tools_config import _get_effective_configurable_toolsets
|
|
|
|
|
|
|
|
|
|
all_ts = _get_effective_configurable_toolsets()
|
|
|
|
|
keys = [ts_key for ts_key, _, _ in all_ts]
|
|
|
|
|
assert len(keys) == len(set(keys)), (
|
|
|
|
|
f"duplicate toolset keys in effective list: "
|
|
|
|
|
f"{[k for k in keys if keys.count(k) > 1]}"
|
|
|
|
|
)
|
|
|
|
|
# Spotify specifically — the bug that motivated the dedupe.
|
|
|
|
|
spotify_rows = [t for t in all_ts if t[0] == "spotify"]
|
|
|
|
|
assert len(spotify_rows) == 1, spotify_rows
|
|
|
|
|
# Built-in label wins over the plugin label.
|
|
|
|
|
assert spotify_rows[0][1] == "🎵 Spotify"
|