e0ef0eb9c3
* feat(connections): manage_connections covers local MCP servers; setup_mcp leaves the schema
One model tool now connects the user to apps of both kinds. A target
`{"name": "linear", "mcp": true}` is a locally configured MCP server;
`install` / `enable` / `authorize` are its verbs. Bare strings and
`{"name": ...}` stay managed connectors and that leg is unchanged.
MCP targets run through one backend-owned connection operation
(tools/connections_tool_operation.py): created with a server-side
deadline from the new config key `connections.wait_timeout_seconds`
(default 120, floor 5, no ceiling), per-target state, and exactly-once
settlement (all resolved / Continue / deadline / interrupt). Unresolved
targets freeze as `not_connected` with the settle reason.
Why the fold works now: the approval card is reached through
`agent.connection_callback` via the agent-level inline executor table,
which is the only path that carries a GUI callback. Registry dispatch
(every non-GUI surface) settles MCP targets as `unavailable` with the
`hermes mcp install / login` hint; managed targets in the same call
are unaffected.
`setup_mcp` is removed from every advertised toolset and from the
deferral list; an inline-table shim keeps calls from conversations
opened before this change dispatching (prompt-cache protection).
`_LEGACY_TOOL_ALIASES` is not the mechanism: inline tools bypass it.
Gateway: `mcp.setup.request/respond` are replaced by
`connection.request/respond/expire` (no wire compat; desktop ships
with this). The bridge waits exactly the operation's deadline. The
`session.resume` snapshot gains `pending_connection` so a reopened
window restores the card with the original deadline.
`manage_connections` joins `_SEQUENTIAL_DEADLINE_EXEMPT_TOOLS`: the
operation owns its wait; the 420s guard must not report `tool_timeout`
while the card is live.
The portal `check_fn` on the tool is dropped in favour of a
handler-level gate on the managed leg, so signed-out sessions can still
approve local MCPs.
* wip(desktop): connection.request store, resume restore, card routing for MCP targets
Renderer half of the setup_mcp fold, first slice: connection-request store
(mirrors clarify), connection.request/expire handling, pending_connection
resume restore, mcpTargets() + isCardTool(name, args) so MCP-target
manage_connections calls classify as cards. Not yet: the card component
rewrite (mcp-setup-tool.tsx), mcp-directory.ts removal, vitest, docs.
Does not typecheck until the card rewrite lands.
* fix(config): hermes update turns on the connections toolset for saved toolset lists
`hermes tools` writes an explicit `platform_toolsets.<platform>` list, and the
resolver reads absence from that list as "unchecked". The `connections`
toolset (#106842) shipped after most users last saved, so `manage_connections`
is stripped from the schema on every install that ever opened the picker.
The Nous entitlement gate never runs; the agent reports the tool as missing.
Migration 44 -> 45 (renumbered when folded into #109517; main was already at 44) appends `connections` to each explicit per-platform list
that lacks it and records the offer in `known_builtin_toolsets` where that
record exists, so a later uncheck reads as a decline. It skips: platforms
whose record already holds `connections` (the user saw the checkbox and left
it off), bare composite lists ([hermes-cli]) that already inherit it, platforms
where the toolset is not allowed, and any config whose `agent.disabled_toolsets`
names `connections` (Blank Slate, `hermes tools --disable`), because the
resolver subtracts that list last and the enable would never take effect.
The explicit-list test is the resolver's own: any configurable or plugin key.
`hermes update` runs migrations post-pull for the active profile and every
sibling, so one update is enough. Fresh installs and composite users were
never affected.
* refactor: anti-slop pass on the desktop slice; shorten added comments
Parse connection.request at the boundary with a typed wire interface instead of
unknown + typeof; mcpTargets reuses connectorText; comments cut to one or two
lines. slop-ratchet: no net-new findings in 13 touched files.
* feat(desktop): the MCP approval card answers manage_connections; MCP Directory removed
The existing card (mcp-setup-tool.tsx) now reads the connection-request store,
renders for manage_connections calls with mcp:true targets, answers through
connection.respond with a per-target outcome, and no longer calls reload.mcp
after Install; the new server's tools arrive on the between-turns refresh.
A settled operation renders the first target's frozen state.
session.resume restores a pending card with its original deadline on both the
activate and cold-resume paths.
lib/mcp-directory.ts is deleted along with its two fallback branches
(suggestion provider, card install). The catalog was already primary in both;
a catalog miss now yields no suggestion / a notInCatalog error. The GitHub
never-suggest test is rewritten on catalog-shaped data.
vitest: connection-request store (6), suggestion provider, clarify restore.
slop-ratchet: no net-new findings in 19 touched files.
* chore: drop __pycache__ files swept in by an over-broad git add
* fix(desktop): correlate the connection.request row with the model's tool call by reason
The synthetic row from connection.request and the tool.start row carried
different ids and no shared match value (op_id is not in the model's args),
so the card mounted twice. reason is the arg both sides carry.
* docs: manage_connections covers local MCP servers; connections.wait_timeout_seconds
* fix(connections): settle reason derives from target state, never from the renderer
A card that answers one of two targets and claims all_resolved must settle as
continue with the other target not_connected; found live with a two-target call.
* fix(desktop): a pending connection card re-arms on resume and activate
The store entry was restored but the transcript row was not, so navigating
away and back (or reloading) lost the card while the backend kept waiting.
restorePendingClarifyToolCall's core is generalized to any blocking tool
name and both resume paths project the connection row through it.
Verified live: card restored after navigate-away and after a full renderer
reload, deadline_at unchanged, approve settles connected.
* style: literal wording in added comments, docstrings and docs
* fix: shared gateway-event contract and config-schema category for the connection events
connection.request/expire replace mcp.setup.* in apps/shared gateway-events
(json list, BACKEND_EVENT_NAMES, GatewayEventMap) so the renderer's event
union includes them and the tui_gateway contract test passes. The new
`connections` config section folds into the agent tab like the other
single-field sections.
* style: import order (perfectionist) in the desktop and shared files this PR touches
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
724 lines
32 KiB
Python
724 lines
32 KiB
Python
"""Table-driven config migration registry.
|
|
|
|
Each step is ``_migrate_to_N(results, quiet)``; the version gate and strict ascending order live
|
|
in :func:`run_migrations`. Every write goes through ``hermes_cli.config._persist_migration`` so a
|
|
step may only persist values that differ from the schema default (plus removals/renames).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
import functools
|
|
import re
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
from urllib.parse import urlparse
|
|
|
|
#: Auto-migration support floor. Configs whose on-disk ``_config_version`` is below this are NOT
|
|
#: auto-migrated (v12 predates ~two years of releases; carrying the sub-v12 steps and the env
|
|
#: bridges they consumed forever is not worth it). Below-floor configs are left byte-for-byte
|
|
#: untouched — the process continues with defaults deep-merged at read time, matching the
|
|
#: non-fatal posture for unparseable configs — and a message tells the user how to proceed.
|
|
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, monkeypatch-friendly)."""
|
|
from hermes_cli import config
|
|
|
|
return config
|
|
|
|
|
|
def read_raw_config():
|
|
return _cfg().read_raw_config()
|
|
|
|
|
|
def _persist_migration(config):
|
|
_cfg()._persist_migration(config)
|
|
|
|
|
|
def _dict_at(config: Dict[str, Any], key: str) -> Dict[str, Any]:
|
|
"""``config[key]`` when it is a mapping (same object, so writes alias), else a fresh ``{}``."""
|
|
value = config.get(key)
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
|
|
def _commit(
|
|
config: Dict[str, Any],
|
|
results: Dict[str, Any],
|
|
quiet: bool,
|
|
added: Optional[str],
|
|
message: Optional[str]) -> None:
|
|
"""Persist *config*, record *added* under ``config_added`` and print *message* unless quiet."""
|
|
_persist_migration(config)
|
|
if added:
|
|
results["config_added"].append(added)
|
|
if message and not quiet:
|
|
print(message)
|
|
|
|
|
|
def _rewrite_key(
|
|
results: Dict[str, Any],
|
|
quiet: bool,
|
|
*,
|
|
section: str,
|
|
key: str,
|
|
match: Callable[[Any], bool],
|
|
new: Any,
|
|
added: str,
|
|
message: str,
|
|
extra_guard: Callable[[Dict[str, Any]], bool] = lambda _m: True,
|
|
create_section: bool = False) -> None:
|
|
"""Rewrite ``<section>.<key>`` to *new* (None = delete) when ``match(current)`` holds; a
|
|
missing section is skipped unless *create_section*."""
|
|
config = read_raw_config()
|
|
raw = config.get(section)
|
|
if not isinstance(raw, dict):
|
|
if not create_section:
|
|
return
|
|
raw = {}
|
|
if match(raw.get(key)) and extra_guard(raw):
|
|
if new is None:
|
|
del raw[key]
|
|
else:
|
|
raw[key] = new
|
|
config[section] = raw
|
|
_commit(config, results, quiet, added, message)
|
|
|
|
|
|
def _rewrite_stale_default(*, old: Any, **kw: Any) -> Callable[[Dict[str, Any], bool], None]:
|
|
"""Step rewriting a key only while it still equals the OLD default — never clobbers a value
|
|
the user customized; unset keys inherit the new default at read time."""
|
|
return functools.partial(_rewrite_key, match=lambda cur: cur == old, **kw)
|
|
|
|
|
|
def _lower_is(word: str) -> Callable[[Any], bool]:
|
|
return lambda cur: isinstance(cur, str) and cur.strip().lower() == word
|
|
|
|
|
|
def _migrate_to_12(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 11 → 12: custom_providers list → providers dict.
|
|
_custom_provider_entry_to_provider_config = _cfg()._custom_provider_entry_to_provider_config
|
|
|
|
config = read_raw_config()
|
|
custom_list = config.get("custom_providers")
|
|
if not (isinstance(custom_list, list) and custom_list):
|
|
return
|
|
providers_dict = _dict_at(config, "providers")
|
|
migrated_count = 0
|
|
for entry in custom_list:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
old_name = entry.get("name", "")
|
|
old_url = entry.get("base_url", "") or entry.get("url", "") or entry.get("api", "") or ""
|
|
if not old_url:
|
|
continue
|
|
|
|
# kebab-case key from the display name; fall back to the URL hostname.
|
|
key = old_name.strip().lower().replace(" ", "-").replace("(", "").replace(")", "")
|
|
key = re.sub(r"-{2,}", "-", key).strip("-")
|
|
if not key:
|
|
try:
|
|
key = (urlparse(old_url).hostname or "endpoint").replace(".", "-")
|
|
except Exception:
|
|
key = f"endpoint-{migrated_count}"
|
|
|
|
# Don't overwrite existing entries
|
|
base_key = key
|
|
suffix = migrated_count
|
|
while key in providers_dict:
|
|
key = f"{base_key}-{suffix}"
|
|
suffix += 1
|
|
|
|
new_entry = _custom_provider_entry_to_provider_config(entry, provider_key=key)
|
|
if new_entry is None:
|
|
continue
|
|
if not old_name:
|
|
new_entry.pop("name", None)
|
|
if new_entry.get("api_key") in {"no-key", "no-key-required", ""}:
|
|
new_entry.pop("api_key", None)
|
|
|
|
providers_dict[key] = new_entry
|
|
migrated_count += 1
|
|
|
|
if migrated_count > 0:
|
|
config["providers"] = providers_dict
|
|
# Runtime reads the list view via get_compatible_custom_providers().
|
|
config.pop("custom_providers", None)
|
|
_persist_migration(config)
|
|
if not quiet:
|
|
print(f" ✓ Migrated {migrated_count} custom provider(s) to providers: section")
|
|
for key in list(providers_dict.keys())[-migrated_count:]:
|
|
print(f" → {key}: {providers_dict[key].get('api', '')}")
|
|
|
|
|
|
def _migrate_to_13(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 12 → 13: clear dead LLM_MODEL / OPENAI_MODEL from .env (written by the old setup wizard;
|
|
# nothing reads them — config.yaml is the sole source of truth).
|
|
_c = _cfg()
|
|
for dead_var in ("LLM_MODEL", "OPENAI_MODEL"):
|
|
try:
|
|
if _c.get_env_value(dead_var):
|
|
_c.save_env_value(dead_var, "")
|
|
if not quiet:
|
|
print(f" ✓ Cleared {dead_var} from .env (no longer used — config.yaml is source of truth)")
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
_LOCAL_WHISPER_MODELS = frozenset({
|
|
"tiny.en", "tiny", "base.en", "base", "small.en", "small",
|
|
"medium.en", "medium", "large-v1", "large-v2", "large-v3",
|
|
"large", "distil-large-v2", "distil-medium.en",
|
|
"distil-small.en", "distil-large-v3", "distil-large-v3.5",
|
|
"large-v3-turbo", "turbo"})
|
|
|
|
|
|
def _migrate_to_14(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 13 → 14: legacy flat stt.model → provider section. A provider-agnostic `stt.model` fed
|
|
# OpenAI names to faster-whisper ("Invalid model size"). Only the raw (user-written) config
|
|
# decides; a nested model the user already set is never overwritten.
|
|
raw_stt = read_raw_config().get("stt", {})
|
|
if not (isinstance(raw_stt, dict) and "model" in raw_stt):
|
|
return
|
|
legacy_model = raw_stt["model"]
|
|
provider = raw_stt.get("provider", "local")
|
|
config = read_raw_config()
|
|
stt = config.get("stt", {})
|
|
stt.pop("model", None)
|
|
|
|
def _place(section: str) -> None:
|
|
existing = raw_stt.get(section, {})
|
|
if not isinstance(existing, dict) or "model" not in existing:
|
|
stt.setdefault(section, {})["model"] = legacy_model
|
|
|
|
if provider in {"local", "local_command"}:
|
|
# An OpenAI model name is dropped; the local section already defaults to "base".
|
|
if legacy_model in _LOCAL_WHISPER_MODELS:
|
|
_place("local")
|
|
else:
|
|
_place(provider)
|
|
config["stt"] = stt
|
|
_commit(
|
|
config, results, quiet, None, " ✓ Migrated legacy stt.model to provider-specific config")
|
|
|
|
|
|
def _migrate_to_16(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 15 → 16: display.tool_progress_overrides → display.platforms.<plat>.tool_progress.
|
|
config = read_raw_config()
|
|
display = _dict_at(config, "display")
|
|
old_overrides = display.get("tool_progress_overrides")
|
|
if not (isinstance(old_overrides, dict) and old_overrides):
|
|
return
|
|
platforms = _dict_at(display, "platforms")
|
|
for plat, mode in old_overrides.items():
|
|
if plat not in platforms:
|
|
platforms[plat] = {}
|
|
if "tool_progress" not in platforms[plat]:
|
|
platforms[plat]["tool_progress"] = mode
|
|
display["platforms"] = platforms
|
|
config["display"] = display
|
|
migrated = ", ".join(f"{p}={m}" for p, m in old_overrides.items())
|
|
_commit(
|
|
config, results, quiet,
|
|
"display.platforms (migrated from tool_progress_overrides)",
|
|
f" ✓ Migrated tool_progress_overrides → display.platforms: {migrated}")
|
|
|
|
|
|
def _migrate_to_17(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 16 → 17: remove legacy compression.summary_* keys; non-empty, non-default values move to
|
|
# auxiliary.compression without overriding an explicit (non-"auto") aux value.
|
|
config = read_raw_config()
|
|
comp = config.get("compression", {})
|
|
if not isinstance(comp, dict):
|
|
return
|
|
legacy = {k: comp.pop(f"summary_{k}", None) for k in ("model", "provider", "base_url")}
|
|
migrated_keys = []
|
|
for k, raw in legacy.items():
|
|
val = str(raw).strip() if raw else ""
|
|
if not val or (k == "provider" and val == "auto"):
|
|
continue
|
|
aux_comp = config.setdefault("auxiliary", {}).setdefault("compression", {})
|
|
cur = aux_comp.get(k)
|
|
if not cur or (k == "provider" and cur == "auto"):
|
|
aux_comp[k] = val
|
|
migrated_keys.append(f"{k}={raw}")
|
|
if migrated_keys or any(v is not None for v in legacy.values()):
|
|
config["compression"] = comp
|
|
message = (
|
|
" ✓ Migrated compression.summary_* → auxiliary.compression: "
|
|
f"{', '.join(migrated_keys)}"
|
|
if migrated_keys else " ✓ Removed unused compression.summary_* keys")
|
|
_commit(config, results, quiet, None, message)
|
|
|
|
|
|
def _installed_user_plugins(disabled: set) -> List[str]:
|
|
"""Names of plugins under ``$HERMES_HOME/plugins/`` with a manifest, minus *disabled*."""
|
|
_c = _cfg()
|
|
found: List[str] = []
|
|
try:
|
|
user_plugins_dir = _c.get_hermes_home() / "plugins"
|
|
if user_plugins_dir.is_dir():
|
|
for child in sorted(user_plugins_dir.iterdir()):
|
|
if not child.is_dir():
|
|
continue
|
|
manifest_file = child / "plugin.yaml"
|
|
if not manifest_file.exists():
|
|
manifest_file = child / "plugin.yml"
|
|
if not manifest_file.exists():
|
|
continue
|
|
try:
|
|
with open(manifest_file, encoding="utf-8") as _mf:
|
|
manifest = _c.fast_safe_load(_mf) or {}
|
|
except Exception:
|
|
manifest = {}
|
|
name = manifest.get("name") or child.name
|
|
if name not in disabled:
|
|
found.append(name)
|
|
except Exception:
|
|
return []
|
|
return found
|
|
|
|
|
|
def _migrate_to_21(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 20 → 21: plugins are now opt-in (loader requires ``plugins.enabled``). Grandfather installed
|
|
# user plugins not already disabled; bundled plugins ship off and need explicit opt-in.
|
|
config = read_raw_config()
|
|
plugins_cfg = _dict_at(config, "plugins")
|
|
if "enabled" in plugins_cfg:
|
|
return
|
|
disabled = plugins_cfg.get("disabled", []) or []
|
|
grandfathered = _installed_user_plugins(set(disabled) if isinstance(disabled, list) else set())
|
|
plugins_cfg["enabled"] = grandfathered
|
|
config["plugins"] = plugins_cfg
|
|
message = (
|
|
f" ✓ Plugins now opt-in: grandfathered "
|
|
f"{len(grandfathered)} existing plugin(s) into plugins.enabled"
|
|
if grandfathered else
|
|
" ✓ Plugins now opt-in: no existing plugins to grandfather. "
|
|
"Use `hermes plugins enable <name>` to activate.")
|
|
_commit(
|
|
config, results, quiet,
|
|
f"plugins.enabled (opt-in allow-list, {len(grandfathered)} grandfathered)", message)
|
|
|
|
|
|
def _migrate_to_23(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 22 → 23: seed curator defaults + create logs/curator/. Older configs never wrote the curator
|
|
# section; deep-merge made it work but users could not see/edit it and `hermes curator status`
|
|
# had no stable logs dir. Only keys the user hasn't set are written.
|
|
_c = _cfg()
|
|
DEFAULT_CONFIG = _c.DEFAULT_CONFIG
|
|
|
|
try:
|
|
curator_dir = _c.get_hermes_home() / "logs" / "curator"
|
|
curator_dir.mkdir(parents=True, exist_ok=True)
|
|
except Exception as e:
|
|
results["warnings"].append(f"Could not create {curator_dir}: {e}")
|
|
|
|
config = read_raw_config()
|
|
|
|
def _seed_missing(section: Dict[str, Any], defaults: Dict[str, Any]) -> List[str]:
|
|
added = [k for k in defaults if k not in section]
|
|
for k in added:
|
|
section[k] = copy.deepcopy(defaults[k])
|
|
return added
|
|
|
|
raw_curator = _dict_at(config, "curator")
|
|
added_curator = _seed_missing(raw_curator, DEFAULT_CONFIG.get("curator", {}))
|
|
if added_curator:
|
|
config["curator"] = raw_curator
|
|
|
|
raw_aux = _dict_at(config, "auxiliary")
|
|
raw_aux_curator = _dict_at(raw_aux, "curator")
|
|
added_aux = _seed_missing(
|
|
raw_aux_curator, DEFAULT_CONFIG.get("auxiliary", {}).get("curator", {}))
|
|
if added_aux:
|
|
raw_aux["curator"] = raw_aux_curator
|
|
config["auxiliary"] = raw_aux
|
|
|
|
if added_curator or added_aux:
|
|
_persist_migration(config)
|
|
for label, added in (("curator", added_curator), ("auxiliary.curator", added_aux)):
|
|
if not added:
|
|
continue
|
|
results["config_added"].append(f"{label} ({len(added)} default key(s))")
|
|
if not quiet:
|
|
print(
|
|
f" ✓ {'Curator' if label == 'curator' else label} settings now available "
|
|
f"({', '.join(added)}) — edit via `hermes config set`")
|
|
|
|
|
|
def _migrate_to_29(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 28 → 29: memory/skills tri-state write_mode (on|off|approve) → boolean write_approval.
|
|
# Only "approve" carried gating intent → true; the old "off = block writes" mode is dropped
|
|
# (memory_enabled: false disables memory). Only a persisted key is rewritten.
|
|
config = read_raw_config()
|
|
touched = False
|
|
for subsystem in ("memory", "skills"):
|
|
sub = config.get(subsystem)
|
|
if not isinstance(sub, dict) or "write_mode" not in sub:
|
|
continue
|
|
old = sub.pop("write_mode")
|
|
old_norm = old.strip().lower() if isinstance(old, str) else old
|
|
sub["write_approval"] = (old_norm == "approve")
|
|
config[subsystem] = sub
|
|
touched = True
|
|
results["config_added"].append(
|
|
f"{subsystem}.write_mode → write_approval={sub['write_approval']}")
|
|
if touched:
|
|
_commit(config, results, quiet, None,
|
|
" ✓ Renamed write_mode → write_approval (boolean gate)")
|
|
|
|
|
|
# 29 → 30 (curator.consolidate defaults to false) is schema-default-only: deep-merge supplies it
|
|
# at read time and persisting a default would only bloat a lean config. No registry entry.
|
|
|
|
|
|
def _migrate_to_33(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 32 → 33: max_async_children is deprecated; fold a raised value into max_concurrent_children
|
|
# (take the max so nobody loses headroom), then drop it.
|
|
config = read_raw_config()
|
|
raw_deleg = config.get("delegation")
|
|
if not (isinstance(raw_deleg, dict) and "max_async_children" in raw_deleg):
|
|
return
|
|
old_async = raw_deleg.pop("max_async_children")
|
|
try:
|
|
old_async_i = int(old_async)
|
|
except (TypeError, ValueError):
|
|
old_async_i = None
|
|
if old_async_i is not None and old_async_i > 3:
|
|
try:
|
|
cur_children = int(raw_deleg.get("max_concurrent_children", 3))
|
|
except (TypeError, ValueError):
|
|
cur_children = 3
|
|
if old_async_i > cur_children:
|
|
raw_deleg["max_concurrent_children"] = old_async_i
|
|
results["config_added"].append(
|
|
f"delegation.max_concurrent_children={old_async_i} "
|
|
f"(folded from deprecated max_async_children)")
|
|
config["delegation"] = raw_deleg
|
|
_commit(
|
|
config, results, quiet, None,
|
|
" ✓ Removed deprecated delegation.max_async_children — "
|
|
"delegation.max_concurrent_children now caps background "
|
|
"delegations too.")
|
|
|
|
|
|
def _migrate_to_34(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 33 → 34: one-time personality reset. Persistence used to be split (TUI/desktop wrote the
|
|
# NAME to display.personality, CLI/gateway wrote rendered TEXT to agent.system_prompt), so
|
|
# once display.personality became authoritative, stale names resurrected personalities users
|
|
# had turned off. Reset display.personality → "" and scrub agent.system_prompt ONLY when it
|
|
# verbatim-equals a known personality's rendered text; any other text is user-owned.
|
|
from hermes_cli.personality import (
|
|
available_personalities, normalize_personality_name, prompt_text, render_personality_prompt)
|
|
|
|
config = read_raw_config()
|
|
touched = False
|
|
|
|
raw_display = config.get("display")
|
|
old_name = ""
|
|
if isinstance(raw_display, dict):
|
|
old_name = normalize_personality_name(raw_display.get("personality", ""))
|
|
if old_name:
|
|
raw_display["personality"] = ""
|
|
config["display"] = raw_display
|
|
touched = True
|
|
|
|
raw_agent = config.get("agent")
|
|
scrubbed_text = False
|
|
if isinstance(raw_agent, dict):
|
|
manual = prompt_text(raw_agent.get("system_prompt", ""))
|
|
if manual:
|
|
rendered = {
|
|
render_personality_prompt(defn) for defn in available_personalities(config).values()
|
|
}
|
|
if manual in rendered:
|
|
raw_agent["system_prompt"] = ""
|
|
config["agent"] = raw_agent
|
|
touched = True
|
|
scrubbed_text = True
|
|
|
|
if not touched:
|
|
return
|
|
_commit(config, results, quiet, "display.personality=none (one-time reset)", None)
|
|
if quiet:
|
|
return
|
|
if old_name:
|
|
print(
|
|
f" ✓ Personality reset to none (was '{old_name}'). Personality "
|
|
"state was previously saved inconsistently across surfaces and "
|
|
"could re-enable a personality you had turned off. "
|
|
f"Run /personality {old_name} to turn it back on.")
|
|
if scrubbed_text:
|
|
print(
|
|
" ✓ Removed personality text from agent.system_prompt (written "
|
|
"by an older /personality). That field is now reserved for "
|
|
"manual system prompts; personalities live in display.personality.")
|
|
|
|
|
|
def _migrate_to_38(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 37 → 38: the bundled observability/nemo_relay plugin was removed (Relay lifecycle moved
|
|
# into the agent core); drop it from plugins.enabled.
|
|
from hermes_cli.relay_plugin_cutover import legacy_relay_plugin_keys
|
|
|
|
config = read_raw_config()
|
|
plugins = config.get("plugins")
|
|
if not isinstance(plugins, dict):
|
|
return
|
|
enabled = plugins.get("enabled")
|
|
removed = legacy_relay_plugin_keys(enabled)
|
|
if not removed or not isinstance(enabled, list):
|
|
return
|
|
|
|
plugins["enabled"] = [value for value in enabled if value not in removed]
|
|
config["plugins"] = plugins
|
|
_persist_migration(config)
|
|
message = (
|
|
"Removed legacy Relay plugin from plugins.enabled: "
|
|
f"{', '.join(removed)}. Configure native Relay plugins with "
|
|
"HERMES_NEMO_RELAY_PLUGINS_TOML.")
|
|
results["warnings"].append(message)
|
|
if not quiet:
|
|
print(f" ⚠ {message}")
|
|
|
|
|
|
def _migrate_to_39(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 38 → 39: strip the retired `bfl` toolset wherever a backfill/picker save wrote it, so stale
|
|
# config can't resurrect an unknown toolset.
|
|
config = read_raw_config()
|
|
changed = False
|
|
for section in ("platform_toolsets", "known_builtin_toolsets"):
|
|
mapping = config.get(section)
|
|
if not isinstance(mapping, dict):
|
|
continue
|
|
for platform, toolsets in mapping.items():
|
|
if isinstance(toolsets, list) and "bfl" in toolsets:
|
|
mapping[platform] = [ts for ts in toolsets if ts != "bfl"]
|
|
changed = True
|
|
if changed:
|
|
config[section] = mapping
|
|
if changed:
|
|
_commit(
|
|
config, results, quiet,
|
|
"removed retired 'bfl' toolset from saved toolset lists",
|
|
" ✓ Removed the retired BFL FLUX 3 toolset from saved toolset "
|
|
"lists — video generation now lives under `hermes tools` → "
|
|
"Video Generation (Nous Subscription or FAL).")
|
|
|
|
|
|
def _migrate_to_41(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 40 → 41: drop the plugin-era "## Messaging other agents" append from every SOUL.md. The
|
|
# server injects the live Bot Mode section in Bot Chat sessions; the frozen SOUL copy taxed
|
|
# every other session (~600 tok) and shadowed the live roster in Bot Chat itself.
|
|
from hermes_constants import get_hermes_home
|
|
from tools.bot_mode_probe import _PROTOCOL_HEADING, _hermes_root, _roster, strip_legacy_protocol
|
|
|
|
cleaned: List[str] = []
|
|
for name, profile_dir in _roster(_hermes_root(get_hermes_home())):
|
|
soul = profile_dir / "SOUL.md"
|
|
try:
|
|
text = soul.read_text(encoding="utf-8") if soul.is_file() else ""
|
|
if _PROTOCOL_HEADING in text:
|
|
soul.write_text(strip_legacy_protocol(text), encoding="utf-8")
|
|
cleaned.append(name)
|
|
except OSError:
|
|
continue
|
|
if cleaned:
|
|
results["config_added"].append(f"removed legacy Bot Mode section from SOUL.md ({', '.join(cleaned)})")
|
|
if not quiet:
|
|
print(f" ✓ Removed the plugin-era 'Messaging other agents' section from SOUL.md "
|
|
f"({', '.join(cleaned)}) — Bot Chat sessions now get the live roster instead.")
|
|
|
|
|
|
def _migrate_to_45(results: Dict[str, Any], quiet: bool) -> None:
|
|
# 44 → 45: append `connections` to every saved `platform_toolsets` list that predates it
|
|
# (an explicit list treats absence as unchecked). Skipped when `known_builtin_toolsets`
|
|
# already records `connections` (a decline) or `agent.disabled_toolsets` names it (the
|
|
# resolver subtracts that list last, so the append would have no effect).
|
|
from agent.skill_utils import parse_config_string_list
|
|
from hermes_cli.tools_config import _configurable_keys, _get_plugin_toolset_keys
|
|
from hermes_cli.toolset_scope import toolset_allowed_for_platform
|
|
|
|
config = read_raw_config()
|
|
saved = config.get("platform_toolsets")
|
|
if not isinstance(saved, dict):
|
|
return
|
|
if "connections" in parse_config_string_list(_dict_at(config, "agent").get("disabled_toolsets")):
|
|
return
|
|
known = _dict_at(config, "known_builtin_toolsets")
|
|
# Same predicate the resolver uses to pick its explicit branch: any configurable or plugin key.
|
|
explicit_keys = _configurable_keys() | _get_plugin_toolset_keys()
|
|
enabled_for: List[str] = []
|
|
for platform, toolsets in saved.items():
|
|
if not isinstance(toolsets, list) or "connections" in toolsets:
|
|
continue
|
|
if not toolset_allowed_for_platform("connections", platform):
|
|
continue
|
|
# A composite like [hermes-cli] already inherits every core tool at read time.
|
|
if not any(str(ts) in explicit_keys for ts in toolsets):
|
|
continue
|
|
offered = known.get(platform)
|
|
if isinstance(offered, list) and "connections" in offered:
|
|
continue
|
|
saved[platform] = sorted({*map(str, toolsets), "connections"})
|
|
if isinstance(offered, list):
|
|
known[platform] = sorted({*map(str, offered), "connections"})
|
|
enabled_for.append(str(platform))
|
|
if not enabled_for:
|
|
return
|
|
config["platform_toolsets"] = saved
|
|
if known:
|
|
config["known_builtin_toolsets"] = known
|
|
platforms = ", ".join(sorted(enabled_for))
|
|
_commit(
|
|
config, results, quiet,
|
|
f"enabled the connections toolset for {platforms}",
|
|
f" ✓ Enabled the Connections toolset (Gmail, Linear, Notion, local MCP servers) for {platforms}. "
|
|
"Uncheck Connections in `hermes tools` to turn it off.")
|
|
|
|
|
|
#: Registry of (target_version, step), strictly ascending; simple default-flip steps are
|
|
#: declared inline via _rewrite_stale_default / _rewrite_key partials. Later steps observe
|
|
#: earlier steps' writes via read_raw_config() (filesystem state). v12 is the support floor:
|
|
#: configs already AT v12 still get every step below; only configs BELOW 12 are refused by the
|
|
#: floor gate in run_migrations()'s caller. Versions absent here (15, 18-20, 22, 24, 26-28, 30)
|
|
#: only added a schema default that runtime merging supplies without a write.
|
|
MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = (
|
|
(12, _migrate_to_12),
|
|
(13, _migrate_to_13),
|
|
(14, _migrate_to_14),
|
|
(16, _migrate_to_16),
|
|
(17, _migrate_to_17),
|
|
(21, _migrate_to_21),
|
|
(23, _migrate_to_23),
|
|
# 24 → 25: model_catalog TTL 24h → 1h (only the OLD default 24).
|
|
(25, _rewrite_stale_default(
|
|
section="model_catalog", key="ttl_hours", old=24, new=1,
|
|
added="model_catalog.ttl_hours 24→1",
|
|
message=" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")),
|
|
(29, _migrate_to_29),
|
|
# 30 → 31: verify_on_stop OFF (one-time). The "auto" sentinel was more noise than signal.
|
|
# Rewrite only when missing or still "auto" — an explicit user true/false is preserved.
|
|
(31, functools.partial(
|
|
_rewrite_key, section="agent", key="verify_on_stop", new=False, create_section=True,
|
|
match=lambda cur: cur is None or _lower_is("auto")(cur),
|
|
added="agent.verify_on_stop=false",
|
|
message=(
|
|
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false). "
|
|
"Set it to true to re-enable, or \"auto\" for the legacy "
|
|
"surface-aware behavior."))),
|
|
# 31 → 32: flip the BAKED-IN literal true to OFF (one-time). v30 defaulted verify_on_stop to a
|
|
# literal True and migrate_config persisted defaults, so installs that updated through v30 have
|
|
# `verify_on_stop: true` written literally — never a user choice (no off-switch existed until
|
|
# v31). A true set AFTER v32 is never touched.
|
|
(32, _rewrite_stale_default(
|
|
section="agent", key="verify_on_stop", old=True, new=False,
|
|
added="agent.verify_on_stop=false",
|
|
message=(
|
|
" ✓ Turned off verify-on-stop (agent.verify_on_stop: false) — "
|
|
"the old default was written into your config as a literal "
|
|
"true. Set it to true again to re-enable, or \"auto\" for the "
|
|
"legacy surface-aware behavior."),
|
|
extra_guard=lambda raw: raw.get("verify_on_stop") is True)),
|
|
(33, _migrate_to_33),
|
|
(34, _migrate_to_34),
|
|
# 34 → 35: background_process_notifications 'all' (old implicit default, rarely chosen on
|
|
# purpose) → 'concise'. Explicit result/error/off choices are preserved.
|
|
(35, functools.partial(
|
|
_rewrite_key, section="display", key="background_process_notifications",
|
|
match=_lower_is("all"), new="concise",
|
|
added="display.background_process_notifications=concise (was: all)",
|
|
message=(
|
|
" ✓ Background process notifications switched from 'all' to "
|
|
"'concise' — completions now show a one-line status message "
|
|
"instead of the raw output dump. Set "
|
|
"display.background_process_notifications: all to restore "
|
|
"the old behavior."))),
|
|
# 35 → 36: subagent iteration cap 50 → 250 (50 truncated substantial delegated work).
|
|
(36, _rewrite_stale_default(
|
|
section="delegation", key="max_iterations", old=50, new=250,
|
|
added="delegation.max_iterations=250 (was: 50)",
|
|
message=(
|
|
" ✓ Raised delegation.max_iterations from 50 to 250 — subagents "
|
|
"now get a larger per-child tool-call budget so delegated work "
|
|
"finishes instead of truncating. Set delegation.max_iterations "
|
|
"back to 50 to restore the old cap."))),
|
|
# 36 → 37: delegation concurrency 3 → 10 (stays at/below the high-cost warning threshold).
|
|
(37, _rewrite_stale_default(
|
|
section="delegation", key="max_concurrent_children", old=3, new=10,
|
|
added="delegation.max_concurrent_children=10 (was: 3)",
|
|
message=(
|
|
" ✓ Raised delegation.max_concurrent_children from 3 to 10 — "
|
|
"independent delegated children now fan out wider in parallel. "
|
|
"Each child consumes API tokens independently; set "
|
|
"delegation.max_concurrent_children back to 3 to restore the old cap."))),
|
|
(38, _migrate_to_38),
|
|
(39, _migrate_to_39),
|
|
# 39 → 40: model_catalog.ttl_hours → ttl_minutes (default 20). Only the OLD default
|
|
# (ttl_hours: 1, written by v25) is dropped; any other explicit ttl_hours is still honoured.
|
|
(40, _rewrite_stale_default(
|
|
section="model_catalog", key="ttl_hours", old=1, new=None,
|
|
added="model_catalog.ttl_hours 1 → ttl_minutes 20 (default)",
|
|
message=" ✓ Model catalog now refreshes every 20 minutes (model_catalog.ttl_minutes)",
|
|
extra_guard=lambda raw: "ttl_minutes" not in raw)),
|
|
(41, _migrate_to_41),
|
|
# 41 → 42: cron.model_drift_guard is gone. Unpinned jobs now run on their creation snapshot
|
|
# instead of failing closed when the global model changes, so the toggle has nothing to gate.
|
|
(42, functools.partial(
|
|
_rewrite_key, section="cron", key="model_drift_guard", new=None,
|
|
match=lambda cur: cur is not None,
|
|
added="removed cron.model_drift_guard",
|
|
message=(
|
|
" ✓ Removed cron.model_drift_guard — unpinned cron jobs now keep running on the "
|
|
"model/provider they were created under when the global default changes, instead "
|
|
"of being skipped. Pin a job or set cron.model to move it."))),
|
|
# 42 → 43: gateway.multiplex_profile_allowlist is gone. A multiplexing default gateway serves
|
|
# every live profile under profiles/; a profile that must not be served is archived or deleted.
|
|
(43, functools.partial(
|
|
_rewrite_key, section="gateway", key="multiplex_profile_allowlist", new=None,
|
|
match=lambda _cur: True,
|
|
added="removed gateway.multiplex_profile_allowlist",
|
|
message=(
|
|
" ✓ Removed gateway.multiplex_profile_allowlist — the multiplexing gateway now serves "
|
|
"every profile under profiles/. Delete or archive a profile you do not want served."),
|
|
extra_guard=lambda raw: "multiplex_profile_allowlist" in raw)),
|
|
# 43 → 44: curator prunes faster — stale 30→14 days, archive 90→30 days. A skill nobody has
|
|
# touched in a month is prompt weight, not knowledge; archival is recoverable. Only the OLD
|
|
# defaults are rewritten; an explicit user value is preserved.
|
|
(44, _rewrite_stale_default(
|
|
section="curator", key="stale_after_days", old=30, new=14,
|
|
added="curator.stale_after_days=14 (was: 30)",
|
|
message=" ✓ curator.stale_after_days 30→14 — unused skills are flagged stale after two weeks.")),
|
|
(44, _rewrite_stale_default(
|
|
section="curator", key="archive_after_days", old=90, new=30,
|
|
added="curator.archive_after_days=30 (was: 90)",
|
|
message=(
|
|
" ✓ curator.archive_after_days 90→30 — skills unused for a month are archived to "
|
|
"skills/.archive/ (recoverable with `hermes curator restore`). Set it back to 90 to keep the old window."))),
|
|
# 44 → 45: saved platform_toolsets lists predate the connections toolset (see _migrate_to_45).
|
|
(45, _migrate_to_45),
|
|
)
|
|
|
|
|
|
def run_migrations(current_ver: int, results: Dict[str, Any], quiet: bool) -> None:
|
|
"""Apply every registered migration whose target version exceeds *current_ver*.
|
|
|
|
*current_ver* is the on-disk schema version captured ONCE before any step runs and does not
|
|
advance between steps — each step is gated on the same initial value.
|
|
"""
|
|
for target_ver, migration_fn in MIGRATIONS:
|
|
if current_ver < target_ver:
|
|
migration_fn(results, quiet)
|