diff --git a/cli.py b/cli.py index 5eb9577bb..a24571d4e 100755 --- a/cli.py +++ b/cli.py @@ -115,7 +115,7 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]: def _parse_reasoning_config(effort: str) -> dict | None: """Parse a reasoning effort level into an OpenRouter reasoning config dict. - Valid levels: "xhigh", "high", "medium", "low", "minimal", "none". + Valid levels: "none", "low", "medium", "high", "xhigh". Returns None to use the default (medium), or a config dict to override. """ if not effort or not effort.strip(): @@ -123,7 +123,7 @@ def _parse_reasoning_config(effort: str) -> dict | None: effort = effort.strip().lower() if effort == "none": return {"enabled": False} - valid = ("xhigh", "high", "medium", "low", "minimal") + valid = ("low", "medium", "high", "xhigh") if effort in valid: return {"enabled": True, "effort": effort} logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) @@ -2366,6 +2366,37 @@ class HermesCLI: print(" Usage: /personality ") print() + def _handle_reasoning_command(self, cmd: str): + """Handle the /reasoning command to view or set reasoning effort.""" + parts = cmd.split(maxsplit=1) + valid_efforts = ("none", "low", "medium", "high", "xhigh") + + if len(parts) == 1: + if self.reasoning_config is None: + current = "medium (default)" + elif self.reasoning_config.get("enabled") is False: + current = "none (reasoning disabled)" + else: + current = self.reasoning_config.get("effort", "medium") + + print(f" Reasoning effort: {current}") + print(" Usage: /reasoning ") + return + + effort = parts[1].strip().lower() + if effort not in valid_efforts: + print(f" (._.) Invalid reasoning level: '{effort}'") + print(" Valid levels: none, low, medium, high, xhigh") + return + + self.reasoning_config = _parse_reasoning_config(effort) + self.agent = None # Force re-init with new reasoning config + + if save_config_value("agent.reasoning_effort", effort): + print(f" (^_^)b Reasoning effort set to: {effort} (saved to config)") + else: + print(f" (^_^) Reasoning effort set to: {effort} (session only)") + def _handle_cron_command(self, cmd: str): """Handle the /cron command to manage scheduled tasks.""" parts = cmd.split(maxsplit=2) @@ -2830,6 +2861,8 @@ class HermesCLI: elif cmd_lower.startswith("/personality"): # Use original case (handler lowercases the personality name itself) self._handle_personality_command(cmd_original) + elif cmd_lower.startswith("/reasoning"): + self._handle_reasoning_command(cmd_original) elif cmd_lower == "/retry": retry_msg = self.retry_last() if retry_msg and hasattr(self, '_pending_input'): diff --git a/gateway/run.py b/gateway/run.py index 63131dcec..f5a97fe7c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -366,7 +366,7 @@ class GatewayRunner: """Load reasoning effort from config or env var. Checks HERMES_REASONING_EFFORT env var first, then agent.reasoning_effort - in config.yaml. Valid: "xhigh", "high", "medium", "low", "minimal", "none". + in config.yaml. Valid: "none", "low", "medium", "high", "xhigh". Returns None to use default (medium). """ effort = os.getenv("HERMES_REASONING_EFFORT", "") @@ -385,7 +385,7 @@ class GatewayRunner: effort = effort.lower().strip() if effort == "none": return {"enabled": False} - valid = ("xhigh", "high", "medium", "low", "minimal") + valid = ("low", "medium", "high", "xhigh") if effort in valid: return {"enabled": True, "effort": effort} logger.warning("Unknown reasoning_effort '%s', using default (medium)", effort) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 22e56b3fc..eb274531c 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -34,6 +34,7 @@ COMMANDS_BY_CATEGORY = { "/provider": "Show available providers and current provider", "/prompt": "View/set custom system prompt", "/personality": "Set a predefined personality", + "/reasoning": "Show or change reasoning effort (none|low|medium|high|xhigh)", "/verbose": "Cycle tool progress display: off → new → all → verbose", "/skin": "Show or change the display skin/theme", }, diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 0aead5c33..eae3be98a 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -12,7 +12,7 @@ EXPECTED_COMMANDS = { "/personality", "/clear", "/history", "/new", "/reset", "/retry", "/undo", "/save", "/config", "/cron", "/skills", "/platforms", "/verbose", "/compress", "/title", "/usage", "/insights", "/paste", - "/reload-mcp", "/rollback", "/background", "/skin", "/quit", + "/reload-mcp", "/rollback", "/background", "/skin", "/reasoning", "/quit", } diff --git a/tests/test_cli_reasoning_command.py b/tests/test_cli_reasoning_command.py new file mode 100644 index 000000000..e82a75c00 --- /dev/null +++ b/tests/test_cli_reasoning_command.py @@ -0,0 +1,137 @@ +"""Tests for /reasoning slash command in HermesCLI.""" + +from unittest.mock import patch + +import pytest + + +def _make_cli(**kwargs): + """Create a HermesCLI instance with minimal mocking.""" + import cli as _cli_mod + from cli import HermesCLI + + clean_config = { + "model": { + "default": "anthropic/claude-opus-4.6", + "base_url": "https://openrouter.ai/api/v1", + "provider": "auto", + }, + "display": {"compact": False, "tool_progress": "all"}, + "agent": {"reasoning_effort": "medium"}, + "terminal": {"env_type": "local"}, + } + + clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} + + with ( + patch("cli.get_tool_definitions", return_value=[]), + patch.dict("os.environ", clean_env, clear=False), + patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": clean_config}), + ): + return HermesCLI(**kwargs) + + +# -- setting valid effort levels ------------------------------------------- + + +@pytest.mark.parametrize("level", ["low", "medium", "high", "xhigh"]) +def test_reasoning_command_sets_effort_and_persists(level): + cli_obj = _make_cli() + cli_obj.agent = object() # ensure command forces re-init + + with patch("cli.save_config_value", return_value=True) as mock_save: + keep_running = cli_obj.process_command(f"/reasoning {level}") + + assert keep_running is True + assert cli_obj.reasoning_config == {"enabled": True, "effort": level} + assert cli_obj.agent is None + mock_save.assert_called_once_with("agent.reasoning_effort", level) + + +def test_reasoning_command_sets_none_disables_reasoning(): + cli_obj = _make_cli() + cli_obj.agent = object() + + with patch("cli.save_config_value", return_value=True) as mock_save: + keep_running = cli_obj.process_command("/reasoning none") + + assert keep_running is True + assert cli_obj.reasoning_config == {"enabled": False} + assert cli_obj.agent is None + mock_save.assert_called_once_with("agent.reasoning_effort", "none") + + +# -- rejecting invalid levels --------------------------------------------- + + +@pytest.mark.parametrize("bad_level", ["ultra", "minimal", "max", "off", "0", "150"]) +def test_reasoning_command_rejects_invalid_level(capsys, bad_level): + cli_obj = _make_cli() + before = cli_obj.reasoning_config + + with patch("cli.save_config_value", return_value=True) as mock_save: + keep_running = cli_obj.process_command(f"/reasoning {bad_level}") + + out = capsys.readouterr().out + assert keep_running is True + assert "Invalid reasoning level" in out + assert cli_obj.reasoning_config == before + mock_save.assert_not_called() + + +# -- display current level ------------------------------------------------- + + +def test_reasoning_shows_current_effort(capsys): + cli_obj = _make_cli() + cli_obj.reasoning_config = {"enabled": True, "effort": "high"} + + cli_obj.process_command("/reasoning") + + out = capsys.readouterr().out + assert "Reasoning effort: high" in out + + +def test_reasoning_shows_default_when_none_set(capsys): + cli_obj = _make_cli() + cli_obj.reasoning_config = None + + cli_obj.process_command("/reasoning") + + out = capsys.readouterr().out + assert "medium (default)" in out + + +def test_reasoning_shows_disabled_when_none_effort(capsys): + cli_obj = _make_cli() + cli_obj.reasoning_config = {"enabled": False} + + cli_obj.process_command("/reasoning") + + out = capsys.readouterr().out + assert "none (reasoning disabled)" in out + + +# -- case insensitivity ---------------------------------------------------- + + +def test_reasoning_command_is_case_insensitive(capsys): + cli_obj = _make_cli() + + with patch("cli.save_config_value", return_value=True): + cli_obj.process_command("/reasoning HIGH") + + assert cli_obj.reasoning_config == {"enabled": True, "effort": "high"} + + +# -- config save failure --------------------------------------------------- + + +def test_reasoning_shows_session_only_on_save_failure(capsys): + cli_obj = _make_cli() + + with patch("cli.save_config_value", return_value=False): + cli_obj.process_command("/reasoning high") + + out = capsys.readouterr().out + assert "session only" in out