Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4411,6 +4411,12 @@ def __init__(
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:
Expand Down
21 changes: 10 additions & 11 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)

Expand Down Expand Up @@ -1346,14 +1346,13 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:

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


Expand Down Expand Up @@ -4769,7 +4768,7 @@ def _clarify_callback_sync(question: str, choices, multi_select: bool = False) -
# 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()
)

Expand Down
89 changes: 73 additions & 16 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,13 @@ def _reject_denylisted_env_var(key: str) -> None:
"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",
Expand Down Expand Up @@ -1779,6 +1781,25 @@ def _coerce_config_version(value: Any) -> int:
return max(version, 0)


def _raw_config_has_explicit_version() -> bool:
"""True when config.yaml exists, parses, and carries a ``_config_version`` key.

Distinguishes an ANCIENT config (explicit old version → refused by the
v12 support floor) from a fresh minimal/hand-written/cloned config with
no version key at all (→ migrated + stamped normally). Missing or
unparseable files return False so they never trip the floor gate.
"""
config_path = get_config_path()
if not config_path.exists():
return False
try:
with open(config_path, encoding="utf-8") as f:
raw = fast_safe_load(f) or {}
except Exception:
return False
return isinstance(raw, dict) and "_config_version" in raw


def check_config_version() -> Tuple[int, int]:
"""
Check the raw on-disk config schema version.
Expand Down Expand Up @@ -2139,15 +2160,51 @@ 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 with an EXPLICIT on-disk ``_config_version`` 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.
#
# A config with NO ``_config_version`` key at all is NOT floor-refused:
# that shape is a fresh minimal config (profile clones write bare keys;
# users hand-write two-line configs), not an ancient install. Those get
# the normal ladder (the retired <12 steps were no-ops for configs
# lacking the legacy keys they migrated) and a fresh version stamp —
# the historical behavior.
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
run_migrations,
support_floor_message,
)

_explicit_version = _raw_config_has_explicit_version()
floor_refused = (
_explicit_version
and 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
Expand Down Expand Up @@ -2201,7 +2258,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
Expand Down Expand Up @@ -2294,7 +2351,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)
Expand Down
21 changes: 13 additions & 8 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,10 @@
# 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
Expand Down Expand Up @@ -4143,13 +4146,15 @@
"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",
Expand Down
99 changes: 28 additions & 71 deletions hermes_cli/config_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,33 @@
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)."""
Expand All @@ -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()
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,11 @@ def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None
# 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"),
Expand Down
15 changes: 15 additions & 0 deletions scripts/docker_config_migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
get_env_path,
migrate_config,
)
from hermes_cli.config_migrations import (
SUPPORT_FLOOR_VERSION,
support_floor_message,
)
from utils import env_var_enabled


Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading