refactor: config auto-migration support floor at v12 + deprecated shim retirement

This commit is contained in:
teknium1 2026-07-29 15:42:56 -07:00 committed by Teknium
parent 5c07ba2f3a
commit 4b33e5663b
15 changed files with 520 additions and 166 deletions

6
cli.py
View file

@ -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:

View file

@ -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()
)

View file

@ -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)

View file

@ -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 <file>", "Browsing <url>" 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",

View file

@ -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),

View file

@ -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"),

View file

@ -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(

View file

@ -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

View file

@ -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 v1112 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

View file

@ -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)

View file

@ -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.

View file

@ -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) |

View file

@ -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)

View file

@ -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` | 自定义延迟范围最大值(毫秒) |

View file

@ -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" # 旧版外观字段,仍在某些摘要中显示