From 4bd579f915940d4ef7845ab814c8ed2f1b0170bb Mon Sep 17 00:00:00 2001 From: stablegenius49 <16443023+stablegenius49@users.noreply.github.com> Date: Sat, 7 Mar 2026 21:01:23 -0800 Subject: [PATCH] fix: normalize max turns config path --- cli.py | 9 ++++++-- hermes_cli/config.py | 39 ++++++++++++++++++++++++++----- hermes_cli/setup.py | 5 ++-- tests/hermes_cli/test_config.py | 30 +++++++++++++++++++++--- tests/test_cli_init.py | 41 ++++++++++++++++++++++++++------- 5 files changed, 103 insertions(+), 21 deletions(-) diff --git a/cli.py b/cli.py index 4c75e0dbd..fb3db4a20 100755 --- a/cli.py +++ b/cli.py @@ -257,8 +257,13 @@ def load_cli_config() -> Dict[str, Any]: if key not in defaults and key != "model": defaults[key] = file_config[key] - # Handle root-level max_turns (backwards compat) - copy to agent.max_turns - if "max_turns" in file_config and "agent" not in file_config: + # Handle legacy root-level max_turns (backwards compat) - copy to + # agent.max_turns whenever the nested key is missing. + agent_file_config = file_config.get("agent") + if "max_turns" in file_config and not ( + isinstance(agent_file_config, dict) + and agent_file_config.get("max_turns") is not None + ): defaults["agent"]["max_turns"] = file_config["max_turns"] except Exception as e: logger.warning("Failed to load cli-config.yaml: %s", e) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 0f99aac7a..7e81962fa 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -63,7 +63,9 @@ def ensure_hermes_home(): DEFAULT_CONFIG = { "model": "anthropic/claude-opus-4.6", "toolsets": ["hermes-cli"], - "max_turns": 100, + "agent": { + "max_turns": 90, + }, "terminal": { "backend": "local", @@ -758,6 +760,23 @@ def _deep_merge(base: dict, override: dict) -> dict: return result +def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Normalize legacy root-level max_turns into agent.max_turns.""" + config = dict(config) + agent_config = dict(config.get("agent") or {}) + + if "max_turns" in config and "max_turns" not in agent_config: + agent_config["max_turns"] = config["max_turns"] + + if "max_turns" not in agent_config: + agent_config["max_turns"] = DEFAULT_CONFIG["agent"]["max_turns"] + + config["agent"] = agent_config + config.pop("max_turns", None) + return config + + + def load_config() -> Dict[str, Any]: """Load configuration from ~/.hermes/config.yaml.""" import copy @@ -770,11 +789,18 @@ def load_config() -> Dict[str, Any]: with open(config_path, encoding="utf-8") as f: user_config = yaml.safe_load(f) or {} + if "max_turns" in user_config: + agent_user_config = dict(user_config.get("agent") or {}) + if agent_user_config.get("max_turns") is None: + agent_user_config["max_turns"] = user_config["max_turns"] + user_config["agent"] = agent_user_config + user_config.pop("max_turns", None) + config = _deep_merge(config, user_config) except Exception as e: print(f"Warning: Failed to load config: {e}") - return config + return _normalize_max_turns_config(config) _COMMENTED_SECTIONS = """ @@ -811,17 +837,18 @@ def save_config(config: Dict[str, Any]): """Save configuration to ~/.hermes/config.yaml.""" ensure_hermes_home() config_path = get_config_path() + normalized = _normalize_max_turns_config(config) with open(config_path, 'w', encoding="utf-8") as f: - yaml.dump(config, f, default_flow_style=False, sort_keys=False) + yaml.dump(normalized, f, default_flow_style=False, sort_keys=False) # Append commented-out sections for features that are off by default # or only relevant when explicitly configured. Skip sections the # user has already uncommented and configured. sections = [] - sec = config.get("security", {}) + sec = normalized.get("security", {}) if not sec or sec.get("redact_secrets") is None: sections.append("security") - fb = config.get("fallback_model", {}) + fb = normalized.get("fallback_model", {}) if not fb or not (fb.get("provider") and fb.get("model")): sections.append("fallback") if sections: @@ -949,7 +976,7 @@ def show_config(): print() print(color("◆ Model", Colors.CYAN, Colors.BOLD)) print(f" Model: {config.get('model', 'not set')}") - print(f" Max turns: {config.get('max_turns', 100)}") + print(f" Max turns: {config.get('agent', {}).get('max_turns', DEFAULT_CONFIG['agent']['max_turns'])}") print(f" Toolsets: {', '.join(config.get('toolsets', ['all']))}") # Terminal diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5880b7ef3..67958aa2a 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1309,7 +1309,7 @@ def setup_agent_settings(config: dict): # ── Max Iterations ── print_header("Agent Settings") - current_max = get_env_value('HERMES_MAX_ITERATIONS') or '90' + current_max = get_env_value('HERMES_MAX_ITERATIONS') or str(config.get('agent', {}).get('max_turns', 90)) print_info("Maximum tool-calling iterations per conversation.") print_info("Higher = more complex tasks, but costs more tokens.") print_info("Recommended: 30-60 for most tasks, 100+ for open exploration.") @@ -1319,7 +1319,8 @@ def setup_agent_settings(config: dict): max_iter = int(max_iter_str) if max_iter > 0: save_env_value("HERMES_MAX_ITERATIONS", str(max_iter)) - config['max_turns'] = max_iter + config.setdefault('agent', {})['max_turns'] = max_iter + config.pop('max_turns', None) print_success(f"Max iterations set to {max_iter}") except ValueError: print_warning("Invalid number, keeping current value") diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index e14078d5f..f3b9f9355 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -4,6 +4,8 @@ import os from pathlib import Path from unittest.mock import patch +import yaml + from hermes_cli.config import ( DEFAULT_CONFIG, get_hermes_home, @@ -41,22 +43,44 @@ class TestLoadConfigDefaults: with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() assert config["model"] == DEFAULT_CONFIG["model"] - assert config["max_turns"] == DEFAULT_CONFIG["max_turns"] + assert config["agent"]["max_turns"] == DEFAULT_CONFIG["agent"]["max_turns"] + assert "max_turns" not in config assert "terminal" in config assert config["terminal"]["backend"] == "local" + def test_legacy_root_level_max_turns_migrates_to_agent_config(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + config_path = tmp_path / "config.yaml" + config_path.write_text("max_turns: 42\n") + + config = load_config() + assert config["agent"]["max_turns"] == 42 + assert "max_turns" not in config + class TestSaveAndLoadRoundtrip: def test_roundtrip(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): config = load_config() config["model"] = "test/custom-model" - config["max_turns"] = 42 + config["agent"]["max_turns"] = 42 save_config(config) reloaded = load_config() assert reloaded["model"] == "test/custom-model" - assert reloaded["max_turns"] == 42 + assert reloaded["agent"]["max_turns"] == 42 + + saved = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert saved["agent"]["max_turns"] == 42 + assert "max_turns" not in saved + + def test_save_config_normalizes_legacy_root_level_max_turns(self, tmp_path): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + save_config({"model": "test/custom-model", "max_turns": 37}) + + saved = yaml.safe_load((tmp_path / "config.yaml").read_text()) + assert saved["agent"]["max_turns"] == 37 + assert "max_turns" not in saved def test_nested_values_preserved(self, tmp_path): with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index 2e6d7f583..1afb7c912 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -3,15 +3,15 @@ that only manifest at runtime (not in mocked unit tests).""" import os import sys -from unittest.mock import patch +from unittest.mock import MagicMock, patch sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) -def _make_cli(env_overrides=None, **kwargs): +def _make_cli(env_overrides=None, config_overrides=None, **kwargs): """Create a HermesCLI instance with minimal mocking.""" - import cli as _cli_mod - from cli import HermesCLI + import importlib + _clean_config = { "model": { "default": "anthropic/claude-opus-4.6", @@ -22,13 +22,34 @@ def _make_cli(env_overrides=None, **kwargs): "agent": {}, "terminal": {"env_type": "local"}, } + if config_overrides: + _clean_config.update(config_overrides) clean_env = {"LLM_MODEL": "", "HERMES_MAX_ITERATIONS": ""} if env_overrides: clean_env.update(env_overrides) - 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) + prompt_toolkit_stubs = { + "prompt_toolkit": MagicMock(), + "prompt_toolkit.history": MagicMock(), + "prompt_toolkit.styles": MagicMock(), + "prompt_toolkit.patch_stdout": MagicMock(), + "prompt_toolkit.application": MagicMock(), + "prompt_toolkit.layout": MagicMock(), + "prompt_toolkit.layout.processors": MagicMock(), + "prompt_toolkit.filters": MagicMock(), + "prompt_toolkit.layout.dimension": MagicMock(), + "prompt_toolkit.layout.menus": MagicMock(), + "prompt_toolkit.widgets": MagicMock(), + "prompt_toolkit.key_binding": MagicMock(), + "prompt_toolkit.completion": MagicMock(), + "prompt_toolkit.formatted_text": MagicMock(), + } + with patch.dict(sys.modules, prompt_toolkit_stubs), \ + patch.dict("os.environ", clean_env, clear=False): + import cli as _cli_mod + _cli_mod = importlib.reload(_cli_mod) + with patch.object(_cli_mod, "get_tool_definitions", return_value=[]), \ + patch.dict(_cli_mod.__dict__, {"CLI_CONFIG": _clean_config}): + return _cli_mod.HermesCLI(**kwargs) class TestMaxTurnsResolution: @@ -53,6 +74,10 @@ class TestMaxTurnsResolution: cli_obj = _make_cli(env_overrides={"HERMES_MAX_ITERATIONS": "42"}) assert cli_obj.max_turns == 42 + def test_legacy_root_max_turns_is_used_when_agent_key_exists_without_value(self): + cli_obj = _make_cli(config_overrides={"agent": {}, "max_turns": 77}) + assert cli_obj.max_turns == 77 + def test_max_turns_never_none_for_agent(self): """The value passed to AIAgent must never be None (causes TypeError in run_conversation).""" cli = _make_cli()