mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor: config auto-migration support floor at v12 + deprecated shim retirement
This commit is contained in:
parent
5c07ba2f3a
commit
4b33e5663b
15 changed files with 520 additions and 166 deletions
6
cli.py
6
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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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` | 自定义延迟范围最大值(毫秒) |
|
||||
|
|
|
|||
|
|
@ -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" # 旧版外观字段,仍在某些摘要中显示
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue