diff --git a/cli.py b/cli.py index 8a64966fc81..16c6fd5545c 100644 --- a/cli.py +++ b/cli.py @@ -4411,6 +4411,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): elif CLI_CONFIG["agent"].get("max_turns"): self.max_turns = CLI_CONFIG["agent"]["max_turns"] elif CLI_CONFIG.get("max_turns"): # Backwards compat: root-level max_turns + # KEEP (evaluated for the v12 support-floor cleanup, July 2026): + # no versioned config migration ever rewrote root-level max_turns + # to agent.max_turns on disk — only load-time normalization + # (_normalize_max_turns_config) folds it, and configs read through + # other paths may bypass it. This fallback is therefore the only + # safety net for configs that still carry the root key. self.max_turns = CLI_CONFIG["max_turns"] elif os.getenv("HERMES_MAX_ITERATIONS"): try: diff --git a/gateway/run.py b/gateway/run.py index 57cf1755ad0..c69dac1eb16 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1237,21 +1237,21 @@ def _build_gateway_agent_history( # Strip interrupted tool-call tails so the LLM doesn't re-execute # tools that were killed mid-flight. - agent_history = _strip_interrupted_tool_tails(agent_history) + agent_history = strip_interrupted_tool_tails(agent_history) # Strip a dangling assistant(tool_calls) tail with no tool answers — # the signature of a SIGKILL mid-tool-call (e.g. the tool itself ran # `docker restart`/`kill` and took the gateway down before the result # was persisted). Without this the model re-issues the unanswered call # on resume and loops the restart forever (#49201). - agent_history = _strip_dangling_tool_call_tail(agent_history) + agent_history = strip_dangling_tool_call_tail(agent_history) # Strip stale dangerous-confirmation text in user messages (#59607). # A high-risk confirmation phrase (e.g. "confirm forced restart") that # is older than the expiry window must not be replayed to the model, # otherwise an unrelated follow-up message can be interpreted as a # fresh confirmation and trigger the destructive action a second time. - agent_history = _strip_stale_dangerous_confirmations( + agent_history = strip_stale_dangerous_confirmations( agent_history, now=time.time() ) @@ -1346,14 +1346,13 @@ _AUTO_APPEND_MEDIA_TOOL_NAMES = { # Replay-tail sanitization lives in agent/replay_cleanup.py so every resume # surface (this messaging gateway AND the TUI/WebUI gateway) shares one -# implementation. Re-exported under the historical private names so existing -# call sites and tests keep working. +# implementation. Import the canonical names directly — the historical +# private ``_``-prefixed aliases were retired once the last external +# consumers (tests) moved to agent.replay_cleanup. from agent.replay_cleanup import ( # noqa: E402 - is_interrupted_tool_result as _is_interrupted_tool_result, - strip_interrupted_tool_tails as _strip_interrupted_tool_tails, - strip_dangling_tool_call_tail as _strip_dangling_tool_call_tail, - strip_stale_dangerous_confirmations as _strip_stale_dangerous_confirmations, - is_dangerous_confirmation as _is_dangerous_confirmation, + strip_interrupted_tool_tails, + strip_dangling_tool_call_tail, + strip_stale_dangerous_confirmations, ) @@ -4769,7 +4768,7 @@ class TurnRunner: # dangerous confirmation can't slip through this path # either. Idempotent; messages without timestamps are # untouched. - agent_history = _strip_stale_dangerous_confirmations( + agent_history = strip_stale_dangerous_confirmations( _selected, now=time.time() ) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 38669ed3521..32ba7f3c593 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -292,11 +292,13 @@ _EXTRA_ENV_KEYS = frozenset({ "IRC_SERVER", "IRC_PORT", "IRC_NICKNAME", "IRC_CHANNEL", "IRC_USE_TLS", "IRC_SERVER_PASSWORD", "IRC_NICKSERV_PASSWORD", "TERMINAL_ENV", "TERMINAL_SSH_KEY", "TERMINAL_SSH_PORT", - # Deprecated tool-progress env vars — replaced by display.tool_progress in - # config.yaml. Kept known here so reload and compatibility paths still - # handle them for existing users (gateway reads them as a back-compat fallback), - # without surfacing them in user-facing OPTIONAL_ENV_VARS listings. - "HERMES_TOOL_PROGRESS", "HERMES_TOOL_PROGRESS_MODE", + # HERMES_TOOL_PROGRESS_MODE is deprecated (replaced by display.tool_progress + # in config.yaml) but STILL READ at runtime by the gateway as a back-compat + # fallback, so it must stay known to reload/compat paths. The boolean + # HERMES_TOOL_PROGRESS variant is fully unsupported since the v12 config + # support floor retired its only consumer (the v3→4 migration): it is no + # longer listed here and doctor flags it as ignored. + "HERMES_TOOL_PROGRESS_MODE", "WHATSAPP_MODE", "WHATSAPP_ENABLED", "MATTERMOST_HOME_CHANNEL", "MATTERMOST_HOME_CHANNEL_NAME", "MATTERMOST_REPLY_MODE", "MATRIX_PASSWORD", "MATRIX_ENCRYPTION", "MATRIX_DEVICE_ID", "MATRIX_HOME_ROOM", @@ -2139,15 +2141,38 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A # Check config version current_ver, latest_ver = check_config_version() - # ── Versioned migration ladder (table-driven) ── - # The per-version steps live in hermes_cli.config_migrations as a - # (target_version, fn) registry; the driver applies every step whose - # target exceeds current_ver, in strict ascending order, preserving the - # original sequential if-block semantics byte-for-byte. Imported lazily - # to avoid a module-level import cycle (the steps call back into this - # module for read_raw_config/_persist_migration/etc. at call time). - from hermes_cli.config_migrations import run_migrations - run_migrations(current_ver, results, quiet) + # ── Auto-migration support floor (policy: v12, July 2026) ── + # A config below the floor is NOT auto-migrated and NOT rewritten: we + # surface a clear, actionable message and leave the file byte-for-byte + # untouched. This matches the fail-safe posture for unparseable configs + # (warn on stderr, continue — load_config() deep-merges defaults at read + # time), so the CLI never crashes on an ancient config. The floor gate + # lives here in the wrapper (not in run_migrations) so the registry + # driver stays a pure mechanism that tests can exercise directly. + from hermes_cli.config_migrations import ( + SUPPORT_FLOOR_VERSION, + run_migrations, + support_floor_message, + ) + + floor_refused = current_ver < SUPPORT_FLOOR_VERSION and current_ver < latest_ver + if floor_refused: + msg = support_floor_message() + results["warnings"].append(msg) + # stderr so it is visible even on quiet startup paths, matching the + # corrupt-config warning posture in _warn_config_parse_failure(). + sys.stderr.write(f"⚠ hermes config: {msg}\n") + if not quiet: + print(f" ⚠ {msg}") + else: + # ── Versioned migration ladder (table-driven) ── + # The per-version steps live in hermes_cli.config_migrations as a + # (target_version, fn) registry; the driver applies every step whose + # target exceeds current_ver, in strict ascending order, preserving the + # original sequential if-block semantics byte-for-byte. Imported lazily + # to avoid a module-level import cycle (the steps call back into this + # module for read_raw_config/_persist_migration/etc. at call time). + run_migrations(current_ver, results, quiet) # ── Post-migration: disable exfiltration-shaped MCP stdio entries ── # Users can hand-edit mcp_servers, and older installs may already contain a @@ -2201,7 +2226,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A # best-effort; never block migration on validation logger.debug("platform_toolsets validation skipped: %s", _ts_val_err) - if current_ver < latest_ver and not quiet: + if current_ver < latest_ver and not quiet and not floor_refused: print(f"Config version: {current_ver} → {latest_ver}") # Check for missing required env vars @@ -2294,7 +2319,7 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A if missing_config: results["config_added"].extend(field["key"] for field in missing_config) - if current_ver < latest_ver: + if current_ver < latest_ver and not floor_refused: config = read_raw_config() config["_config_version"] = latest_ver _persist_migration(config) diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index b4c786950b2..7a3dd750364 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1089,7 +1089,10 @@ DEFAULT_CONFIG = { # only visible when show_reasoning is enabled. "show_commentary": True, "tool_progress_command": False, # Enable /verbose command in messaging gateway - "tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead + # NOTE: display.tool_progress_overrides is deprecated and no longer + # seeded here — use display.platforms. A user-set value is still + # honored at runtime (gateway display_config back-compat read) and + # folded into display.platforms by the v15→16 migration. "tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands) # Human-phrased tool status labels for built-in tools: "Searching the # web for ...", "Reading ", "Browsing " instead of the raw @@ -4143,13 +4146,15 @@ OPTIONAL_ENV_VARS = { "password": True, "category": "setting", }, - # HERMES_TOOL_PROGRESS and HERMES_TOOL_PROGRESS_MODE are deprecated — - # now configured via display.tool_progress in config.yaml (off|new|all|verbose|log). - # The gateway still falls back to these env vars for backward compatibility, - # so they live in _EXTRA_ENV_KEYS (known to reload and compatibility paths) but - # are intentionally NOT listed here: OPTIONAL_ENV_VARS feeds user-facing - # surfaces (dashboard keys page, setup checklists) and deprecated knobs - # shouldn't be offered there. + # HERMES_TOOL_PROGRESS_MODE is deprecated — tool progress is configured via + # display.tool_progress in config.yaml (off|new|all|verbose|log). The + # gateway still falls back to HERMES_TOOL_PROGRESS_MODE for backward + # compatibility, so it lives in _EXTRA_ENV_KEYS (known to reload and + # compatibility paths) but is intentionally NOT listed here: + # OPTIONAL_ENV_VARS feeds user-facing surfaces (dashboard keys page, setup + # checklists) and deprecated knobs shouldn't be offered there. The boolean + # HERMES_TOOL_PROGRESS is fully unsupported since the v12 config support + # floor retired its only consumer (the v3→4 migration). "HERMES_PREFILL_MESSAGES_FILE": { "description": "Path to JSON file with ephemeral prefill messages for few-shot priming", "prompt": "Prefill messages file path", diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index 4f45b185534..8ab29066fd9 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -38,9 +38,33 @@ reference. from __future__ import annotations import copy -import os from typing import Any, Callable, Dict, List, Tuple +#: Auto-migration support floor. Configs whose on-disk ``_config_version`` is +#: below this are NOT auto-migrated any more (policy decision, July 2026): +#: v12 predates roughly two years of releases, and carrying the sub-v12 +#: migration steps (plus the env bridges they consumed, e.g. +#: HERMES_TOOL_PROGRESS*) forever is not worth it. Below-floor configs are +#: left byte-for-byte untouched — the process continues with the config as-is +#: (defaults deep-merged at read time, matching the non-fatal posture used +#: for unparseable configs) and a clear message tells the user how to +#: proceed. The removed steps were the <12 targets: v4 (tool-progress .env → +#: config.yaml), v5 (timezone seed), v9 (clear ANTHROPIC_TOKEN). +SUPPORT_FLOOR_VERSION = 12 + + +def support_floor_message() -> str: + """Human-facing explanation shown when a config is below the floor.""" + from hermes_constants import display_hermes_home + + return ( + f"This config predates version {SUPPORT_FLOOR_VERSION} (~2 years old) " + "and can no longer be auto-migrated. Back up " + f"{display_hermes_home()}/config.yaml and run `hermes setup` to " + f"regenerate, or manually set _config_version: {SUPPORT_FLOOR_VERSION} " + "after reviewing the changelog." + ) + def _cfg(): """Return the live ``hermes_cli.config`` module (lazy, cycle-free).""" @@ -49,73 +73,6 @@ def _cfg(): return config -def _migrate_to_4(results: Dict[str, Any], quiet: bool) -> None: - # ── Version 3 → 4: migrate tool progress from .env to config.yaml ── - _c = _cfg() - read_raw_config = _c.read_raw_config - get_env_value = _c.get_env_value - _persist_migration = _c._persist_migration - - config = read_raw_config() - display = config.get("display", {}) - if not isinstance(display, dict): - display = {} - if "tool_progress" not in display: - old_enabled = get_env_value("HERMES_TOOL_PROGRESS") - old_mode = get_env_value("HERMES_TOOL_PROGRESS_MODE") - if old_enabled and old_enabled.lower() in {"false", "0", "no"}: - display["tool_progress"] = "off" - results["config_added"].append("display.tool_progress=off (from HERMES_TOOL_PROGRESS=false)") - elif old_mode and old_mode.lower() in {"new", "all", "verbose"}: - display["tool_progress"] = old_mode.lower() - results["config_added"].append(f"display.tool_progress={old_mode.lower()} (from HERMES_TOOL_PROGRESS_MODE)") - else: - display["tool_progress"] = "all" - results["config_added"].append("display.tool_progress=all (default)") - config["display"] = display - _persist_migration(config) - if not quiet: - print(f" ✓ Migrated tool progress to config.yaml: {display['tool_progress']}") - - -def _migrate_to_5(results: Dict[str, Any], quiet: bool) -> None: - # ── Version 4 → 5: add timezone field ── - _c = _cfg() - read_raw_config = _c.read_raw_config - _persist_migration = _c._persist_migration - - config = read_raw_config() - if "timezone" not in config: - old_tz = os.getenv("HERMES_TIMEZONE", "") - if old_tz and old_tz.strip(): - config["timezone"] = old_tz.strip() - results["config_added"].append(f"timezone={old_tz.strip()} (from HERMES_TIMEZONE)") - else: - config["timezone"] = "" - results["config_added"].append("timezone= (empty, uses server-local)") - _persist_migration(config) - if not quiet: - tz_display = config["timezone"] or "(server-local)" - print(f" ✓ Added timezone to config.yaml: {tz_display}") - - -def _migrate_to_9(results: Dict[str, Any], quiet: bool) -> None: - # ── Version 8 → 9: clear ANTHROPIC_TOKEN from .env ── - # The new Anthropic auth flow no longer uses this env var. - _c = _cfg() - get_env_value = _c.get_env_value - save_env_value = _c.save_env_value - - try: - old_token = get_env_value("ANTHROPIC_TOKEN") - if old_token: - save_env_value("ANTHROPIC_TOKEN", "") - if not quiet: - print(" ✓ Cleared ANTHROPIC_TOKEN from .env (no longer used)") - except Exception: - pass - - def _migrate_to_12(results: Dict[str, Any], quiet: bool) -> None: # ── Version 11 → 12: migrate custom_providers list → providers dict ── _c = _cfg() @@ -692,9 +649,9 @@ def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None: #: version captured before the ladder started. Order matters: later steps may #: observe earlier steps' writes via read_raw_config() (filesystem state). MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = ( - (4, _migrate_to_4), - (5, _migrate_to_5), - (9, _migrate_to_9), + # v12 is the support floor: configs already AT v12 (or newer) still get + # every remaining step below. Only configs BELOW 12 are refused by the + # floor gate in run_migrations(). (12, _migrate_to_12), (13, _migrate_to_13), (14, _migrate_to_14), diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 2a6d5b82ac1..8b07e7ab1af 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -242,7 +242,11 @@ _DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = ( # Deprecated env vars (checked in the .env file, not process env, so config→env # bridges like terminal.cwd → TERMINAL_CWD do not false-positive). _DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = ( - ("HERMES_TOOL_PROGRESS", "display.tool_progress in config.yaml"), + # HERMES_TOOL_PROGRESS is fully unsupported since the v12 config support + # floor removed its only consumer (the v3→4 migration) — it is silently + # ignored. HERMES_TOOL_PROGRESS_MODE is still read by the gateway as a + # back-compat fallback but remains deprecated. + ("HERMES_TOOL_PROGRESS", "display.tool_progress in config.yaml — ignored/unsupported since config floor v12"), ("HERMES_TOOL_PROGRESS_MODE", "display.tool_progress in config.yaml"), ("TERMINAL_CWD", "terminal.cwd in config.yaml"), ("MESSAGING_CWD", "terminal.cwd in config.yaml"), diff --git a/scripts/docker_config_migrate.py b/scripts/docker_config_migrate.py index b563cdb8405..11a84961bc8 100644 --- a/scripts/docker_config_migrate.py +++ b/scripts/docker_config_migrate.py @@ -14,6 +14,10 @@ from hermes_cli.config import ( get_env_path, migrate_config, ) +from hermes_cli.config_migrations import ( + SUPPORT_FLOOR_VERSION, + support_floor_message, +) from utils import env_var_enabled @@ -59,6 +63,17 @@ def main() -> int: if current_ver >= latest_ver: return 0 + # Below the auto-migration support floor: migrate_config() refuses (and + # leaves the file untouched), so don't run the backup/verify dance that + # would raise "did not advance config version" and block the boot. + # Warn-and-continue matches the CLI's fail-safe posture. + if current_ver < SUPPORT_FLOOR_VERSION: + print( + f"[config-migrate] WARNING: {support_floor_message()}", + file=sys.stderr, + ) + return 0 + backups = _backup_existing((get_config_path(), get_env_path())) backup_text = ", ".join(str(path) for path in backups.values()) if backups else "none" print( diff --git a/tests/gateway/test_stale_confirmation_expiry.py b/tests/gateway/test_stale_confirmation_expiry.py index 3a4cccc1d3d..7a1dd40cf97 100644 --- a/tests/gateway/test_stale_confirmation_expiry.py +++ b/tests/gateway/test_stale_confirmation_expiry.py @@ -19,11 +19,11 @@ import time import pytest from typing import Dict, List -from gateway.run import ( - _build_gateway_agent_history, - _is_dangerous_confirmation, - _strip_stale_dangerous_confirmations, +from agent.replay_cleanup import ( + is_dangerous_confirmation as _is_dangerous_confirmation, + strip_stale_dangerous_confirmations as _strip_stale_dangerous_confirmations, ) +from gateway.run import _build_gateway_agent_history # High-risk confirmation patterns. A user message matching one of these diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 70a556fd158..c2db04cd966 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -616,30 +616,270 @@ class TestConfigVersionDetection: assert check_config_version() == (0, DEFAULT_CONFIG["_config_version"]) -class TestAnthropicTokenMigration: - """Test that config version 8→9 clears ANTHROPIC_TOKEN.""" +class TestConfigSupportFloor: + """Auto-migration support floor (v12). - def _write_config_version(self, tmp_path, version): + Configs below ``SUPPORT_FLOOR_VERSION`` are refused: the file stays + byte-for-byte untouched, a clear actionable message is surfaced (stdout + when not quiet + stderr always + results['warnings']), and the process + continues without crashing — matching the fail-safe posture for + unparseable configs. Configs at or above the floor migrate exactly as + before the floor was introduced (parity fixtures below). + """ + + def _write_config(self, tmp_path, data): config_path = tmp_path / "config.yaml" - import yaml - config_path.write_text(yaml.safe_dump({"_config_version": version})) + text = yaml.safe_dump(data) + config_path.write_text(text, encoding="utf-8") + return config_path, text - def test_clears_token_on_upgrade_to_v9(self, tmp_path): - """ANTHROPIC_TOKEN is cleared unconditionally when upgrading to v9.""" - self._write_config_version(tmp_path, 8) + def test_v11_config_is_refused_and_untouched(self, tmp_path, capsys): + config_path, original = self._write_config( + tmp_path, + { + "_config_version": 11, + "custom_providers": [ + {"name": "Old", "base_url": "http://localhost:1234/v1"} + ], + }, + ) (tmp_path / ".env").write_text("ANTHROPIC_TOKEN=old-token\n") - with patch.dict(os.environ, { - "HERMES_HOME": str(tmp_path), - "ANTHROPIC_TOKEN": "old-token", - }): + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + results = migrate_config(interactive=False, quiet=False) + + # File untouched — no migration, no version bump, no rewrite. + assert config_path.read_text(encoding="utf-8") == original + # .env untouched too (the retired <12 steps used to clear tokens). + assert load_env().get("ANTHROPIC_TOKEN") == "old-token" + + captured = capsys.readouterr() + expected_fragment = ( + "This config predates version 12 (~2 years old) and can no " + "longer be auto-migrated." + ) + assert expected_fragment in captured.out + assert expected_fragment in captured.err + assert "run `hermes setup` to regenerate" in captured.out + assert "_config_version: 12" in captured.out + assert any(expected_fragment in w for w in results["warnings"]) + # No 'Config version: X → Y' line — nothing was migrated. + assert "Config version:" not in captured.out + + def test_v11_quiet_still_warns_on_stderr_only(self, tmp_path, capsys): + config_path, original = self._write_config( + tmp_path, {"_config_version": 11} + ) + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + results = migrate_config(interactive=False, quiet=True) + assert config_path.read_text(encoding="utf-8") == original + captured = capsys.readouterr() + assert "can no longer be auto-migrated" in captured.err + assert captured.out == "" + assert results["warnings"] + + def test_floor_message_uses_display_hermes_home(self): + from hermes_cli.config_migrations import support_floor_message + from hermes_constants import display_hermes_home + + msg = support_floor_message() + assert f"{display_hermes_home()}/config.yaml" in msg + + def test_registry_has_no_targets_below_floor(self): + from hermes_cli.config_migrations import ( + MIGRATIONS, + SUPPORT_FLOOR_VERSION, + ) + + assert SUPPORT_FLOOR_VERSION == 12 + assert all(target >= SUPPORT_FLOOR_VERSION for target, _ in MIGRATIONS) + # v12's own step is retained: a config AT v11 is refused, but a + # config AT v12 must still receive every remaining migration. + assert MIGRATIONS[0][0] == 12 + + # ── Parity fixtures ────────────────────────────────────────────── + # Expected outputs captured by running migrate_config from origin/main + # (commit 28524adb0e, pre-floor) in a subprocess against these exact + # fixtures. The floor must not change behavior for v12+ configs. + + _V12_FIXTURE = { + "_config_version": 12, + "model": {"default": "openai/gpt-5.4", "provider": "openrouter"}, + "display": {"tool_progress_overrides": {"telegram": "verbose"}}, + "stt": {"model": "base", "provider": "local"}, + "compression": {"summary_model": "gpt-x", "summary_provider": "auto"}, + "model_catalog": {"ttl_hours": 24}, + "memory": {"write_mode": "approve"}, + "delegation": {"max_async_children": 8}, + "agent": {"verify_on_stop": True}, + } + _V12_EXPECTED = { + "_config_version": 33, + "agent": {"verify_on_stop": False}, + "auxiliary": {"compression": {"model": "gpt-x"}}, + "compression": {}, + "delegation": {"max_concurrent_children": 8}, + "display": { + "platforms": {"telegram": {"tool_progress": "verbose"}}, + "tool_progress_overrides": {"telegram": "verbose"}, + }, + "memory": {"write_approval": True}, + "model": {"default": "openai/gpt-5.4", "provider": "openrouter"}, + "model_catalog": {"ttl_hours": 1}, + "plugins": {"enabled": []}, + "stt": {"provider": "local"}, + } + + _V20_FIXTURE = { + "_config_version": 20, + "model": {"default": "anthropic/claude-fable-5", "provider": "nous"}, + "plugins": {"disabled": ["foo"]}, + "skills": {"write_mode": "on"}, + "model_catalog": {"ttl_hours": 24}, + "agent": {}, + } + _V20_EXPECTED = { + "_config_version": 33, + "agent": {"verify_on_stop": False}, + "model": {"default": "anthropic/claude-fable-5", "provider": "nous"}, + "model_catalog": {"ttl_hours": 1}, + "plugins": {"disabled": ["foo"], "enabled": []}, + } + + _ENV_FIXTURE = ( + "LLM_MODEL=old-model\nOPENAI_MODEL=old-openai\nOPENROUTER_API_KEY=test\n" + ) + + @pytest.mark.parametrize( + "fixture,expected,expected_env", + [ + ( + _V12_FIXTURE, + _V12_EXPECTED, + # v12→13 clears LLM_MODEL/OPENAI_MODEL for configs below 13. + "LLM_MODEL=\nOPENAI_MODEL=\nOPENROUTER_API_KEY=test\n", + ), + (_V20_FIXTURE, _V20_EXPECTED, _ENV_FIXTURE), + ], + ids=["v12", "v20"], + ) + def test_at_or_above_floor_migrates_identically_to_pre_floor( + self, tmp_path, fixture, expected, expected_env + ): + config_path, _ = self._write_config(tmp_path, fixture) + (tmp_path / ".env").write_text(self._ENV_FIXTURE, encoding="utf-8") + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): migrate_config(interactive=False, quiet=True) - assert load_env().get("ANTHROPIC_TOKEN") == "" + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + # Pin the golden version the fixtures were captured at, then compare + # the rest against the same-latest expectation. If _config_version has + # advanced past 33, only the version key may differ. + assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] + raw.pop("_config_version") + exp = dict(expected) + exp.pop("_config_version") + if DEFAULT_CONFIG["_config_version"] == 33: + assert raw == exp + else: # future migrations appended — golden subset must still hold + for key, val in exp.items(): + assert raw.get(key) == val, f"parity drift on {key!r}" + assert (tmp_path / ".env").read_text(encoding="utf-8") == expected_env class TestCustomProviderCompatibility: - """Custom provider compatibility across legacy and v12+ config schemas.""" + """Custom provider compatibility across legacy and v12+ config schemas. + + The v11→12 step (_migrate_to_12) is retained in the registry per the + support-floor policy, but migrate_config() refuses sub-v12 configs, so + these tests drive run_migrations() directly to keep the step covered. + """ + + @staticmethod + def _run_ladder(current_ver: int): + from hermes_cli.config_migrations import run_migrations + + results = {"env_added": [], "config_added": [], "warnings": []} + run_migrations(current_ver, results, quiet=True) + return results + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + self._run_ladder(11) + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + assert raw["providers"]["openai-direct"] == { + "api": "https://api.openai.com/v1", + "api_key": "test-key", + "default_model": "gpt-5-mini", + "name": "OpenAI Direct", + "transport": "codex_responses", + } + # custom_providers removed by migration — runtime reads via compat layer + assert "custom_providers" not in raw + + def test_v11_upgrade_preserves_custom_provider_model_metadata(self, tmp_path): + config_path = tmp_path / "config.yaml" + model_map = { + "kimi-k2.6": {"context_length": 262144}, + "moonshotai/Kimi-K2.6-ACED": {"context_length": 131072}, + } + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 11, + "custom_providers": [ + { + "name": "Kimi Coding Plan", + "base_url": "https://api.kimi.example.com/coding", + "api_key_env": "KIMI_CODING_API_KEY", + "api_mode": "anthropic_messages", + "model": "kimi-k2.6", + "models": model_map, + "context_length": 262144, + "rate_limit_delay": 0.25, + "discover_models": False, + "extra_body": { + "chat_template_kwargs": {"enable_thinking": False} + }, + }, + { + "name": "List Models", + "base_url": "https://list.example.com/v1", + "models": ["alpha", "beta"], + }, + ], + } + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + self._run_ladder(11) + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + compatible = get_compatible_custom_providers(raw) + + assert "custom_providers" not in raw + provider = raw["providers"]["kimi-coding-plan"] + assert provider["api"] == "https://api.kimi.example.com/coding" + assert provider["key_env"] == "KIMI_CODING_API_KEY" + assert provider["transport"] == "anthropic_messages" + assert provider["default_model"] == "kimi-k2.6" + assert provider["models"] == model_map + assert provider["context_length"] == 262144 + assert provider["rate_limit_delay"] == 0.25 + assert provider["discover_models"] is False + assert provider["extra_body"] == { + "chat_template_kwargs": {"enable_thinking": False} + } + assert raw["providers"]["list-models"]["models"] == { + "alpha": {}, + "beta": {}, + } + + compatible_provider = next( + entry for entry in compatible if entry["provider_key"] == "kimi-coding-plan" + ) + assert compatible_provider["models"] == model_map + assert compatible_provider["key_env"] == "KIMI_CODING_API_KEY" def test_providers_dict_resolves_at_runtime(self, tmp_path): """After migration deleted custom_providers, get_compatible_custom_providers @@ -759,7 +999,7 @@ class TestDiscordChannelPromptsConfig: config_path = tmp_path / "config.yaml" config_path.write_text( yaml.safe_dump({ - "_config_version": 3, + "_config_version": 11, "model": {"default": "test-model", "provider": "openrouter"}, "custom_providers": [ {"name": "local-llm", "base_url": "http://localhost:8080/v1", @@ -769,8 +1009,13 @@ class TestDiscordChannelPromptsConfig: encoding="utf-8", ) + results = {"env_added": [], "config_added": [], "warnings": []} + from hermes_cli.config_migrations import run_migrations with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): - migrate_config(interactive=False, quiet=True) + # Drive the ladder directly: migrate_config() refuses sub-v12 + # configs since the support floor, but the write-invariant this + # test guards (#40821) lives in the steps themselves. + run_migrations(11, results, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) # custom_providers migrated to providers dict (by design, v11->v12) @@ -895,11 +1140,12 @@ class TestMigrationWriteInvariant: """ - @pytest.mark.parametrize("start_version", [1, "latest_minus_one"]) + @pytest.mark.parametrize("start_version", [12, "latest_minus_one"]) def test_version_bump_keeps_config_lean(self, tmp_path, start_version): """A lean config migrated to the latest version must never be rewritten - into a defaults dump — neither across the whole range (start=1, where - per-version seeds also fire) nor on a bare one-version bump (where only + into a defaults dump — neither across the whole supported range + (start=12, the auto-migration floor, where per-version seeds also + fire) nor on a bare one-version bump (where only the catch-all finalizer runs). In both cases no default-only top-level section the user never wrote may land on disk, the merged view still exposes every default, and the user's explicit non-default value diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 5c1988c310d..681cee606e0 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -755,6 +755,21 @@ class TestDoctorDeprecatedConfigAndEnv: assert doctor_mod.collect_deprecated_env_vars({}) == [] assert doctor_mod.collect_deprecated_env_vars(None) == [] + def test_hermes_tool_progress_warning_says_unsupported_since_floor(self): + """HERMES_TOOL_PROGRESS lost its last consumer (the retired v3→4 + migration) when the v12 support floor landed — doctor must say the + variable is ignored rather than merely 'deprecated but read'.""" + findings = dict( + doctor_mod.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS": "true"}) + ) + assert "ignored/unsupported since config floor v12" in findings["HERMES_TOOL_PROGRESS"] + # The MODE variant is still read by the gateway fallback → keeps the + # plain deprecation wording. + mode = dict( + doctor_mod.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS_MODE": "all"}) + ) + assert mode["HERMES_TOOL_PROGRESS_MODE"] == "display.tool_progress in config.yaml" + def _run_doctor_with_config(self, monkeypatch, tmp_path, *, config_yaml: str, env_text: str = ""): hermes_home = tmp_path / ".hermes" hermes_home.mkdir(parents=True) diff --git a/tests/tools/test_docker_config_migrate.py b/tests/tools/test_docker_config_migrate.py index 8e53249331d..5a8ec0cd8cc 100644 --- a/tests/tools/test_docker_config_migrate.py +++ b/tests/tools/test_docker_config_migrate.py @@ -45,26 +45,12 @@ def _run_migration(hermes_home: Path, **env_overrides: str) -> subprocess.Comple def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Path) -> None: config_path = tmp_path / "config.yaml" env_path = tmp_path / ".env" - model_map = { - "local-small": {"context_length": 8192}, - "local-large": {"context_length": 32768}, - } config_path.write_text( yaml.safe_dump( { - "_config_version": 11, - "custom_providers": [ - { - "name": "Local API", - "base_url": "http://localhost:8080/v1", - "api_key": "test-key", - "api_mode": "chat_completions", - "model": "local-small", - "models": model_map, - "context_length": 32768, - "discover_models": False, - } - ], + "_config_version": 12, + "model_catalog": {"ttl_hours": 24}, + "delegation": {"max_async_children": 8}, } ), encoding="utf-8", @@ -74,17 +60,115 @@ def test_docker_config_migrate_backs_up_and_migrates_legacy_config(tmp_path: Pat proc = _run_migration(tmp_path) assert proc.returncode == 0, proc.stderr - assert "Migrating config schema 11 ->" in proc.stdout + assert "Migrating config schema 12 ->" in proc.stdout raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] - assert "custom_providers" not in raw - provider = raw["providers"]["local-api"] - assert provider["api"] == "http://localhost:8080/v1" - assert provider["transport"] == "chat_completions" - assert provider["default_model"] == "local-small" - assert provider["models"] == model_map - assert provider["context_length"] == 32768 - assert provider["discover_models"] is False + # v24→25 lowers the old default model_catalog TTL; v32→33 folds + # max_async_children into max_concurrent_children. + assert raw["model_catalog"]["ttl_hours"] == 1 + assert raw["delegation"] == {"max_concurrent_children": 8} + assert list(tmp_path.glob("config.yaml.bak-*")) + assert list(tmp_path.glob(".env.bak-*")) + + +def test_docker_config_migrate_skips_below_floor_config_untouched(tmp_path: Path) -> None: + """Configs below the v12 auto-migration support floor are refused with a + warning: no migration, no backup, no rewrite — and the boot continues.""" + config_path = tmp_path / "config.yaml" + original = ( + yaml.safe_dump( + { + "_config_version": 11, + "custom_providers": [ + { + "name": "Local API", + "base_url": "http://localhost:8080/v1", + "api_key": "test-key", + } + ], + } + ) + ) + config_path.write_text(original, encoding="utf-8") + + proc = _run_migration(tmp_path) + + assert proc.returncode == 0, proc.stderr + assert "Migrating config schema" not in proc.stdout + assert "can no longer be auto-migrated" in proc.stderr + assert config_path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob("*.bak-*")) + + +def test_docker_config_migrate_skips_unversioned_config_untouched(tmp_path: Path) -> None: + """Unversioned configs coerce to version 0 — below the floor, so refused.""" + config_path = tmp_path / "config.yaml" + original = yaml.safe_dump({"model": {"default": "m", "provider": "openrouter"}}) + config_path.write_text(original, encoding="utf-8") + + proc = _run_migration(tmp_path) + + assert proc.returncode == 0, proc.stderr + assert "Migrating config schema" not in proc.stdout + assert "can no longer be auto-migrated" in proc.stderr + assert config_path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob("*.bak-*")) + + +def test_docker_config_migrate_does_not_rewrite_invalid_yaml(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + original = "model: [unterminated\n" + config_path.write_text(original, encoding="utf-8") + + proc = _run_migration(tmp_path) + + assert proc.returncode == 0, proc.stderr + assert "Migrating config schema" not in proc.stdout + assert "hermes config:" in proc.stderr + assert config_path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob("*.bak-*")) + + +def test_docker_config_migrate_skip_env_leaves_config_unchanged(tmp_path: Path) -> None: + config_path = tmp_path / "config.yaml" + original = yaml.safe_dump({"_config_version": 11}) + config_path.write_text(original, encoding="utf-8") + + proc = _run_migration(tmp_path, HERMES_SKIP_CONFIG_MIGRATION="1") + + assert proc.returncode == 0, proc.stderr + assert "skipping config migration" in proc.stdout + assert config_path.read_text(encoding="utf-8") == original + assert not list(tmp_path.glob("*.bak-*")) + + +def test_docker_config_migrate_restores_backups_after_failed_migration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script_module() + config_path = tmp_path / "config.yaml" + env_path = tmp_path / ".env" + original_config = yaml.safe_dump({"_config_version": 12, "gateway": {"provider": "telegram"}}) + original_env = "TELEGRAM_BOT_TOKEN=test-token\n" + config_path.write_text(original_config, encoding="utf-8") + env_path.write_text(original_env, encoding="utf-8") + + monkeypatch.setattr(module, "check_config_version", lambda: (12, DEFAULT_CONFIG["_config_version"])) + monkeypatch.setattr(module, "get_config_path", lambda: config_path) + monkeypatch.setattr(module, "get_env_path", lambda: env_path) + + def _failing_migrate(*, interactive: bool, quiet: bool): + config_path.write_text("gateway: {}\n", encoding="utf-8") + env_path.write_text("", encoding="utf-8") + raise RuntimeError("boom") + + monkeypatch.setattr(module, "migrate_config", _failing_migrate) + + with pytest.raises(RuntimeError, match="boom"): + module.main() + + assert config_path.read_text(encoding="utf-8") == original_config + assert env_path.read_text(encoding="utf-8") == original_env assert list(tmp_path.glob("config.yaml.bak-*")) assert list(tmp_path.glob(".env.bak-*")) @@ -95,12 +179,12 @@ def test_docker_config_migrate_restores_backups_when_version_does_not_advance( module = _load_script_module() config_path = tmp_path / "config.yaml" env_path = tmp_path / ".env" - original_config = yaml.safe_dump({"_config_version": 11, "gateway": {"provider": "telegram"}}) + original_config = yaml.safe_dump({"_config_version": 12, "gateway": {"provider": "telegram"}}) original_env = "TELEGRAM_BOT_TOKEN=test-token\n" config_path.write_text(original_config, encoding="utf-8") env_path.write_text(original_env, encoding="utf-8") - calls = iter([(11, DEFAULT_CONFIG["_config_version"]), (11, DEFAULT_CONFIG["_config_version"])]) + calls = iter([(12, DEFAULT_CONFIG["_config_version"]), (12, DEFAULT_CONFIG["_config_version"])]) monkeypatch.setattr(module, "check_config_version", lambda: next(calls)) monkeypatch.setattr(module, "get_config_path", lambda: config_path) monkeypatch.setattr(module, "get_env_path", lambda: env_path) @@ -134,7 +218,7 @@ def test_docker_config_migrate_second_boot_preserves_env_byte_for_byte(tmp_path: config_path.write_text( yaml.safe_dump( { - "_config_version": 11, + "_config_version": 12, "gateway": {"provider": "telegram"}, } ), @@ -151,7 +235,7 @@ def test_docker_config_migrate_second_boot_preserves_env_byte_for_byte(tmp_path: # ── First boot: stale config migrates, version advances. ── first = _run_migration(tmp_path) assert first.returncode == 0, first.stderr - assert "Migrating config schema 11 ->" in first.stdout + assert "Migrating config schema 12 ->" in first.stdout raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) assert raw["_config_version"] == DEFAULT_CONFIG["_config_version"] # The token (and every other credential) must survive the migration. diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b256ae9b876..869c3065841 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -779,8 +779,8 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us | `HERMES_IGNORE_USER_CONFIG` | Skip `~/.hermes/config.yaml` and use built-in defaults (credentials in `.env` still load). Equivalent to `--ignore-user-config`. | | `HERMES_IGNORE_RULES` | Skip auto-injection of `AGENTS.md`, `SOUL.md`, `.cursorrules`, memory, and preloaded skills. Equivalent to `--ignore-rules`. | | `HERMES_SAFE_MODE` | Troubleshooting mode: disable ALL customizations — skips plugin discovery, MCP server loading, and shell-hook registration. Set automatically by `--safe-mode` (which also sets the two flags above). | -| `HERMES_TOOL_PROGRESS` | Deprecated compatibility variable for tool progress display. Prefer `display.tool_progress` in `config.yaml`. | -| `HERMES_TOOL_PROGRESS_MODE` | Deprecated compatibility variable for tool progress mode. Prefer `display.tool_progress` in `config.yaml`. | +| `HERMES_TOOL_PROGRESS` | Unsupported since the config-v12 support floor — the variable is ignored. Use `display.tool_progress` in `config.yaml`. | +| `HERMES_TOOL_PROGRESS_MODE` | Deprecated compatibility variable for tool progress mode (still read by the gateway as a fallback). Prefer `display.tool_progress` in `config.yaml`. | | `HERMES_HUMAN_DELAY_MODE` | Response pacing: `off`/`natural`/`custom` | | `HERMES_HUMAN_DELAY_MIN_MS` | Custom delay range minimum (ms) | | `HERMES_HUMAN_DELAY_MAX_MS` | Custom delay range maximum (ms) | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index db5b9817f7e..749eec22253 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1535,7 +1535,6 @@ display: tool_progress_command: false # Enable /verbose slash command in messaging gateway focus_view: false # CLI focus view (/focus) — reduced output, display-only platforms: {} # Per-platform display overrides (see below) - tool_progress_overrides: {} # DEPRECATED — use display.platforms instead interim_assistant_messages: true # Gateway: send natural mid-turn assistant updates as separate messages show_commentary: true # Codex models: deliver commentary-channel progress narration as visible mid-turn updates skin: default # Built-in or custom CLI skin (see user-guide/features/skins) diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index 99914065ea3..6060c497fbb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -534,8 +534,8 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听 | `HERMES_IGNORE_RULES` | 跳过 `AGENTS.md`、`SOUL.md`、`.cursorrules`、记忆和预加载技能的自动注入。等同于 `--ignore-rules`。 | | `HERMES_SAFE_MODE` | 故障排查模式:禁用**所有**自定义项——跳过插件发现、MCP 服务器加载和 shell hook 注册。由 `--safe-mode` 自动设置(同时也会设置上面两个 flag)。 | | `HERMES_MD_NAMES` | 自动注入的规则文件名逗号分隔列表(默认:`AGENTS.md,CLAUDE.md,.cursorrules,SOUL.md`)。 | -| `HERMES_TOOL_PROGRESS` | 工具进度显示的已弃用兼容变量。优先使用 `config.yaml` 中的 `display.tool_progress`。 | -| `HERMES_TOOL_PROGRESS_MODE` | 工具进度模式的已弃用兼容变量。优先使用 `config.yaml` 中的 `display.tool_progress`。 | +| `HERMES_TOOL_PROGRESS` | 自配置 v12 支持底线起不再受支持——该变量会被忽略。请使用 `config.yaml` 中的 `display.tool_progress`。 | +| `HERMES_TOOL_PROGRESS_MODE` | 工具进度模式的已弃用兼容变量(网关仍作为回退读取)。优先使用 `config.yaml` 中的 `display.tool_progress`。 | | `HERMES_HUMAN_DELAY_MODE` | 响应节奏:`off`/`natural`/`custom` | | `HERMES_HUMAN_DELAY_MIN_MS` | 自定义延迟范围最小值(毫秒) | | `HERMES_HUMAN_DELAY_MAX_MS` | 自定义延迟范围最大值(毫秒) | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md index 2fc64c0c76a..b782f9f71bb 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/configuration.md @@ -1146,7 +1146,6 @@ display: tool_progress: all # off | new | all | verbose tool_progress_command: false # 在消息 gateway 中启用 /verbose 斜杠命令 platforms: {} # 每平台显示覆盖(见下文) - tool_progress_overrides: {} # 已弃用 —— 改用 display.platforms interim_assistant_messages: true # Gateway:将自然的轮次中 assistant 更新作为单独消息发送 skin: default # 内置或自定义 CLI 皮肤(参阅 user-guide/features/skins) personality: "kawaii" # 旧版外观字段,仍在某些摘要中显示