fix: normalize max turns config path
This commit is contained in:
9
cli.py
9
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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)}):
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user