Compare commits

..
Author SHA1 Message Date
Teknium 8393e7abc5 refactor(cli): simplify safe-mode startup wiring
Since safe mode already landed on main via #45488, reduce this branch to cleanup: centralize env setup, remove duplicated comments, and tighten tests.
2026-06-13 06:52:15 -07:00
8 changed files with 104 additions and 180 deletions
+1 -26
View File
@@ -22,31 +22,9 @@ TitleCallback = Callable[[str], None]
_TITLE_PROMPT = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in the same language the user is writing in. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
_TITLE_PROMPT_PINNED_LANGUAGE = (
"Generate a short, descriptive title (3-7 words) for a conversation that starts with the "
"following exchange. The title should capture the main topic or intent. "
"Write the title in {language}. "
"Return ONLY the title text, nothing else. No quotes, no punctuation at the end, no prefixes."
)
def _title_language() -> str:
"""Return configured title language, or empty string to match the user."""
try:
from hermes_cli.config import load_config
return str(
((load_config() or {}).get("auxiliary") or {})
.get("title_generation", {})
.get("language", "")
).strip()
except Exception:
return ""
def generate_title(
user_message: str,
@@ -70,11 +48,8 @@ def generate_title(
user_snippet = user_message[:500] if user_message else ""
assistant_snippet = assistant_response[:500] if assistant_response else ""
language = _title_language()
prompt = _TITLE_PROMPT_PINNED_LANGUAGE.format(language=language) if language else _TITLE_PROMPT
messages = [
{"role": "system", "content": prompt},
{"role": "system", "content": _TITLE_PROMPT},
{"role": "user", "content": f"User: {user_snippet}\n\nAssistant: {assistant_snippet}"},
]
-1
View File
@@ -1309,7 +1309,6 @@ DEFAULT_CONFIG = {
"api_key": "",
"timeout": 30,
"extra_body": {},
"language": "",
},
"tts_audio_tags": {
"provider": "auto",
+12 -12
View File
@@ -2089,6 +2089,8 @@ def cmd_chat(args):
"""Run interactive chat CLI."""
use_tui = _resolve_use_tui(args)
_apply_safe_mode(args)
# Resolve --continue into --resume with the latest session or by name
continue_val = getattr(args, "continue_last", None)
if continue_val and not getattr(args, "resume", None):
@@ -2199,18 +2201,6 @@ def cmd_chat(args):
if getattr(args, "yolo", False):
os.environ["HERMES_YOLO_MODE"] = "1"
# --safe-mode: troubleshooting mode that disables ALL customizations.
# Inspired by Claude Code v2.1.169's --safe-mode (June 2026): run with a
# pristine environment to isolate whether a problem comes from the user's
# setup (config, rules files, plugins, MCP servers) or from Hermes itself.
# Implemented as a superset of --ignore-user-config + --ignore-rules plus
# plugin/MCP discovery suppression (HERMES_SAFE_MODE is checked by
# hermes_cli/plugins.py and tools/mcp_tool.py).
if getattr(args, "safe_mode", False):
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
# --ignore-user-config: make load_cli_config() / load_config() skip the
# user's ~/.hermes/config.yaml and return built-in defaults. Set BEFORE
# importing cli (which runs `CLI_CONFIG = load_cli_config()` at module
@@ -10821,6 +10811,8 @@ def _should_background_mcp_startup(args) -> bool:
def _prepare_agent_startup(args) -> None:
"""Discover plugins/MCP/hooks for commands that can run an agent turn."""
_apply_safe_mode(args)
_sub_attr, _sub_set = _AGENT_SUBCOMMANDS.get(args.command, (None, None))
if not (
args.command in _AGENT_COMMANDS
@@ -10886,6 +10878,14 @@ def _prepare_agent_startup(args) -> None:
)
def _apply_safe_mode(args) -> None:
if not getattr(args, "safe_mode", False):
return
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
def _set_chat_arg_defaults(args) -> None:
for attr, default in [
("query", None),
-4
View File
@@ -1124,10 +1124,6 @@ class PluginManager:
"""
if self._discovered and not force:
return
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run
# with all customizations disabled. Skip plugin discovery entirely so
# no third-party code (hooks, tools, platforms) loads. Mark as
# discovered so callers see a clean empty registry, not a retry loop.
if env_var_enabled("HERMES_SAFE_MODE"):
logger.info("HERMES_SAFE_MODE=1 — plugin discovery skipped")
self._discovered = True
-37
View File
@@ -7,7 +7,6 @@ from agent.title_generator import (
generate_title,
auto_title_session,
maybe_auto_title,
_title_language,
)
@@ -23,42 +22,6 @@ class TestGenerateTitle:
title = generate_title("help me fix this import", "Sure, let me check...")
assert title == "Debugging Python Import Errors"
def test_default_prompt_matches_user_language(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Some Title"
with patch("agent.title_generator.call_llm", return_value=mock_response) as llm:
generate_title("質問です", "回答です")
system_prompt = llm.call_args.kwargs["messages"][0]["content"]
assert "same language the user is writing in" in system_prompt
def test_configured_language_pins_prompt(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "Some Title"
with (
patch("agent.title_generator.call_llm", return_value=mock_response) as llm,
patch("agent.title_generator._title_language", return_value="Japanese"),
):
generate_title("hello", "hi")
system_prompt = llm.call_args.kwargs["messages"][0]["content"]
assert "Write the title in Japanese" in system_prompt
assert "same language the user" not in system_prompt
def test_title_language_reads_config(self):
cfg = {"auxiliary": {"title_generation": {"language": " French "}}}
with patch("hermes_cli.config.load_config", return_value=cfg):
assert _title_language() == "French"
with patch("hermes_cli.config.load_config", return_value={}):
assert _title_language() == ""
with patch("hermes_cli.config.load_config", side_effect=RuntimeError("bad config")):
assert _title_language() == ""
def test_strips_quotes(self):
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
+90 -88
View File
@@ -1,18 +1,10 @@
"""Tests for `hermes chat --safe-mode` — pristine troubleshooting runs.
Inspired by Claude Code v2.1.169's ``--safe-mode`` flag (June 2026), which
disables all customizations (CLAUDE.md, plugins, skills, hooks, MCP) for
troubleshooting. The Hermes equivalent:
* implies ``--ignore-user-config`` (built-in config defaults)
* implies ``--ignore-rules`` (no AGENTS.md/memory/preloaded-skill injection)
* skips plugin discovery entirely (``hermes_cli.plugins``)
* loads zero MCP servers (``tools.mcp_tool._load_mcp_config``)
"""
"""Tests for `hermes chat --safe-mode` isolation."""
from __future__ import annotations
import os
import sys
import types
import pytest
@@ -29,102 +21,112 @@ def _clean_env(monkeypatch):
os.environ.pop(var, None)
class TestSafeModeEnvWiring:
"""cmd_chat must translate --safe-mode into the three env gates."""
def test_cmd_chat_safe_mode_sets_env_before_startup(monkeypatch):
import hermes_cli.main as main_mod
from hermes_cli._parser import build_top_level_parser
def test_safe_mode_sets_all_gates(self):
# Mirrors the cmd_chat logic in hermes_cli/main.py.
class Args:
safe_mode = True
parser, _subparsers, chat_parser = build_top_level_parser()
chat_parser.set_defaults(func=main_mod.cmd_chat)
args = parser.parse_args(["chat", "--safe-mode"])
captured: dict[str, object] = {}
fake_cli = types.ModuleType("cli")
args = Args()
if getattr(args, "safe_mode", False):
os.environ["HERMES_SAFE_MODE"] = "1"
os.environ["HERMES_IGNORE_USER_CONFIG"] = "1"
os.environ["HERMES_IGNORE_RULES"] = "1"
def fake_has_provider() -> bool:
assert os.environ["HERMES_SAFE_MODE"] == "1"
assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1"
assert os.environ["HERMES_IGNORE_RULES"] == "1"
return True
assert os.environ.get("HERMES_SAFE_MODE") == "1"
assert os.environ.get("HERMES_IGNORE_USER_CONFIG") == "1"
assert os.environ.get("HERMES_IGNORE_RULES") == "1"
def fake_main(**kwargs):
captured.update(kwargs)
monkeypatch.setattr(main_mod, "_has_any_provider_configured", fake_has_provider)
monkeypatch.setattr(main_mod, "_pin_kanban_board_env", lambda: None)
monkeypatch.setattr(main_mod, "_sync_bundled_skills_for_startup", lambda: None)
monkeypatch.setattr(main_mod, "_termux_should_prefetch_update_check", lambda: False)
setattr(fake_cli, "main", fake_main)
monkeypatch.setitem(sys.modules, "cli", fake_cli)
main_mod.cmd_chat(args)
assert captured["ignore_user_config"] is True
assert captured["ignore_rules"] is True
class TestSafeModePluginDiscovery:
"""Plugin discovery must be a no-op under HERMES_SAFE_MODE=1."""
def test_prepare_agent_startup_applies_safe_mode_before_plugin_discovery(monkeypatch):
import hermes_cli.main as main_mod
def test_discovery_skipped(self, monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from hermes_cli.plugins import PluginManager
args = types.SimpleNamespace(command="chat", safe_mode=True, tui=False)
plugins = types.ModuleType("hermes_cli.plugins")
mgr = PluginManager()
called = []
monkeypatch.setattr(
mgr, "_discover_and_load_inner", lambda: called.append(True)
)
mgr.discover_and_load()
assert called == [] # inner sweep never ran
assert mgr._discovered is True # registry settled as clean-empty
assert mgr._plugins == {}
def discover_plugins() -> None:
assert os.environ["HERMES_SAFE_MODE"] == "1"
assert os.environ["HERMES_IGNORE_USER_CONFIG"] == "1"
assert os.environ["HERMES_IGNORE_RULES"] == "1"
def test_discovery_runs_without_safe_mode(self, monkeypatch):
monkeypatch.delenv("HERMES_SAFE_MODE", raising=False)
from hermes_cli.plugins import PluginManager
setattr(plugins, "discover_plugins", discover_plugins)
monkeypatch.setitem(sys.modules, "hermes_cli.plugins", plugins)
monkeypatch.setattr(main_mod, "_should_background_mcp_startup", lambda _args: False)
monkeypatch.setattr(main_mod, "_command_has_dedicated_mcp_startup", lambda _args: True)
mgr = PluginManager()
called = []
monkeypatch.setattr(
mgr, "_discover_and_load_inner", lambda: called.append(True)
)
mgr.discover_and_load()
assert called == [True]
main_mod._prepare_agent_startup(args)
class TestSafeModeMCP:
"""_load_mcp_config must return no servers under HERMES_SAFE_MODE=1."""
def test_plugin_discovery_skipped(monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from hermes_cli.plugins import PluginManager
def test_mcp_servers_empty(self, monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from tools.mcp_tool import _load_mcp_config
mgr = PluginManager()
called = []
monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True))
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert _load_mcp_config() == {}
mgr.discover_and_load()
def test_mcp_servers_load_without_safe_mode(self, monkeypatch):
monkeypatch.delenv("HERMES_SAFE_MODE", raising=False)
from tools.mcp_tool import _load_mcp_config
with pytest.MonkeyPatch.context() as mp:
mp.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
servers = _load_mcp_config()
assert "github" in servers
assert called == []
assert mgr._discovered is True
assert mgr._plugins == {}
class TestSafeModeParser:
"""--safe-mode must parse on both the root parser and `hermes chat`."""
def test_plugin_discovery_runs_without_safe_mode(monkeypatch):
from hermes_cli.plugins import PluginManager
def test_chat_subcommand_accepts_flag(self):
from hermes_cli._parser import build_top_level_parser
mgr = PluginManager()
called = []
monkeypatch.setattr(mgr, "_discover_and_load_inner", lambda: called.append(True))
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["chat", "--safe-mode"])
assert getattr(args, "safe_mode", False) is True
mgr.discover_and_load()
def test_root_parser_accepts_flag(self):
from hermes_cli._parser import build_top_level_parser
assert called == [True]
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["--safe-mode"])
assert getattr(args, "safe_mode", False) is True
def test_default_is_off(self):
from hermes_cli._parser import build_top_level_parser
def test_mcp_servers_empty(monkeypatch):
monkeypatch.setenv("HERMES_SAFE_MODE", "1")
from tools.mcp_tool import _load_mcp_config
parser, _subparsers, _chat = build_top_level_parser()
args = parser.parse_args(["chat"])
assert getattr(args, "safe_mode", False) is False
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert _load_mcp_config() == {}
def test_mcp_servers_load_without_safe_mode(monkeypatch):
from tools.mcp_tool import _load_mcp_config
monkeypatch.setattr(
"hermes_cli.config.load_config",
lambda: {"mcp_servers": {"github": {"url": "https://example.com/mcp"}}},
)
assert "github" in _load_mcp_config()
def test_parser_accepts_safe_mode_on_root_and_chat():
from hermes_cli._parser import build_top_level_parser
parser, _subparsers, _chat = build_top_level_parser()
assert parser.parse_args(["--safe-mode"]).safe_mode is True
assert parser.parse_args(["chat", "--safe-mode"]).safe_mode is True
assert parser.parse_args(["chat"]).safe_mode is False
+1 -2
View File
@@ -2686,9 +2686,8 @@ def _load_mcp_config() -> Dict[str, dict]:
"""
try:
from hermes_cli.config import load_config
# Safe mode (--safe-mode / HERMES_SAFE_MODE=1): troubleshooting run
# with all customizations disabled — no MCP servers connect.
from utils import env_var_enabled as _env_enabled
if _env_enabled("HERMES_SAFE_MODE"):
return {}
config = load_config()
-10
View File
@@ -938,16 +938,6 @@ auxiliary:
compression:
timeout: 120 # seconds — compression summarizes long conversations, needs more time
# Auto-generated session titles. Empty language follows the conversation;
# set e.g. "English" or "Japanese" to pin titles to one language.
title_generation:
provider: "auto"
model: ""
base_url: ""
api_key: ""
timeout: 30
language: ""
# Skills hub — skill matching and search
skills_hub:
provider: "auto"