m22: migrations ran save_config -> record_config_saved inside surfaced
processes (`hermes config migrate`, console migrate, dashboard/TUI profile
create), so e.g. v45 appending `connections` to platform_toolsets read as a
user re-enabling a toolset. _persist_migration (the single migration write
path) now wraps save_config in hermes_applied_write(), a ContextVar the hook
honours.
m23: the old side is the raw file (`memory_enabled: ${MEM_ON}`) and the new
side the env-expanded config, so any unrelated save reported `disabled` once a
day forever. A setting whose value on either side is an unexpanded `${...}`
string is now skipped (its value is unknown to the diff).
m24: dashboard handlers call save_config inside config_write_scope
(_CONFIG_MUTATION_LOCK), and the diff (tools_config._get_platform_tools per
platform) + record ran there. record_config_saved now runs only the cheap
gate inline (surface, enabled, surface/home resolution) and hands deep copies
to a non-daemon thread bound to the owning profile (non-daemon so a CLI
command exiting right after its write still records).
Test contract change: test_record_config_saved_needs_a_surface_and_collection
and test_real_config_writes_report_the_move_away_from_default now join the
recording thread before asserting (the record is asynchronous by design).
Probes (review/signals/mine):
p2_migration.py config|dashboard
before: re_enabled toolset connections row; after: <no db>
p4_envref_configset.py
before: memory.memory_enabled disabled after an unrelated save;
after: no row (config set curator.enabled false still records)
p3_lock_v2.py (times config_transitions, try-acquires the web lock)
before (p3_lock.py): record_config_saved 0.047s/0.004s, lock held=True
after: diff 0.003-0.008s, lock held=False
Tests test_migrations_and_env_templates_are_not_user_disables and
test_diff_and_record_run_off_the_callers_lock_in_the_owning_profile are RED on
base.
301 lines
12 KiB
Python
301 lines
12 KiB
Python
"""hermes.feature_disabled.count: a user turns off something Hermes ships on, or removes something shipped.
|
|
|
|
One chokepoint sees every write: ``save_config`` hands over the raw config it replaced and the one it
|
|
wrote, and :func:`config_transitions` diffs only what the user moved away from the shipped default
|
|
(or back to it): default-on toolsets, ``skills.disabled``, ``plugins.disabled`` and default-``True``
|
|
booleans. Names are public only when shipped (toolset key, bundled/catalog skill, bundled/catalog
|
|
plugin, ``DEFAULT_CONFIG`` key path; never a value); anything else reads ``custom``. The store counts
|
|
each (kind, name, event) at most once per day.
|
|
|
|
The surface is the entry point that is running: ``set_process_surface`` from the ``hermes`` command
|
|
dispatch (``tools`` / ``config`` / ``skills`` / ``plugins`` / chat slash commands / the web server) and
|
|
the TUI gateway. A process with no surface (setup wizard, updates) records nothing, nor does a write
|
|
Hermes makes itself inside a surfaced process (migrations, under :func:`hermes_applied_write`): those
|
|
are Hermes applying choices, not a user turning something off.
|
|
|
|
``save_config``'s callers may hold their own write lock (the dashboard's ``_CONFIG_MUTATION_LOCK``), so
|
|
the hook only runs the cheap gate inline; the diff and the record run on a thread bound to the owning
|
|
profile.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import copy
|
|
import functools
|
|
import logging
|
|
import threading
|
|
from contextvars import ContextVar
|
|
from typing import Any, Iterable, Iterator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
CUSTOM = "custom"
|
|
_COMMAND_SURFACES = {
|
|
None: "cli_slash", "chat": "cli_slash", "tools": "cli_tools", "config": "cli_config",
|
|
"skills": "cli_config", "plugins": "cli_config",
|
|
"dashboard": "web", "serve": "web", "gui": "web", "desktop": "web",
|
|
}
|
|
_SETTING_KINDS = {"memory": "memory", "curator": "curator", "compression": "compression"}
|
|
_OFF = frozenset({"false", "off", "no", "0", "none", ""})
|
|
_process_surface: str | None = None
|
|
_hermes_write: ContextVar[bool] = ContextVar("hermes_feature_disabled_hermes_write", default=False)
|
|
|
|
|
|
def set_process_surface(command: Any) -> None:
|
|
"""Called once by the ``hermes`` dispatch (``command`` is the subcommand, None for chat) or the
|
|
TUI gateway (``"tui_gateway"``). Unknown commands clear it."""
|
|
global _process_surface
|
|
_process_surface = "tui_gateway" if command == "tui_gateway" else _COMMAND_SURFACES.get(command)
|
|
|
|
|
|
def current_surface() -> str | None:
|
|
surface = _process_surface
|
|
if surface == "web":
|
|
from .shared_metrics_setup import web_setup_surface
|
|
|
|
return web_setup_surface()
|
|
if surface == "tui_gateway":
|
|
try:
|
|
from tui_gateway.server import _resolve_session_platform
|
|
|
|
return _resolve_session_platform()
|
|
except Exception:
|
|
return "tui"
|
|
return surface
|
|
|
|
|
|
# ---- names -----------------------------------------------------------------------------------
|
|
|
|
@functools.cache
|
|
def _bundled_plugin_names() -> frozenset[str]:
|
|
from .shared_metrics_catalog import _REPO_ROOT
|
|
|
|
root = _REPO_ROOT / "plugins"
|
|
try:
|
|
return frozenset(
|
|
p.parent.name.lower() for p in [*root.glob("*/plugin.yaml"), *root.glob("*/*/plugin.yaml")]
|
|
)
|
|
except OSError:
|
|
return frozenset()
|
|
|
|
|
|
def plugin_metric_name(raw: Any) -> str:
|
|
from .shared_metrics_catalog import _norm, _safe, plugin_catalog_names
|
|
|
|
name = _norm(raw).rsplit("/", 1)[-1]
|
|
return name if name and (name in _safe(_bundled_plugin_names) or name in _safe(plugin_catalog_names)) else CUSTOM
|
|
|
|
|
|
def skill_metric_name(raw: Any) -> str:
|
|
from .shared_metrics_catalog import skill_metric_name as catalog_skill_name
|
|
|
|
return catalog_skill_name(raw)
|
|
|
|
|
|
@functools.cache
|
|
def default_on_settings() -> frozenset[str]:
|
|
"""Dotted ``DEFAULT_CONFIG`` paths whose shipped value is ``True`` (the closed setting vocabulary)."""
|
|
from hermes_cli.config import DEFAULT_CONFIG
|
|
|
|
from .shared_metrics_contract import FEATURE_DISABLED_NAME_MAX_LENGTH, _metric_identifier
|
|
|
|
paths: set[str] = set()
|
|
|
|
def walk(node: Any, prefix: str) -> None:
|
|
for key, value in node.items():
|
|
if not isinstance(key, str) or key.startswith("_"):
|
|
continue
|
|
path = f"{prefix}{key}"
|
|
if isinstance(value, dict):
|
|
walk(value, f"{path}.")
|
|
elif value is True and _metric_identifier(path, max_length=FEATURE_DISABLED_NAME_MAX_LENGTH) == path:
|
|
paths.add(path)
|
|
|
|
walk(DEFAULT_CONFIG, "")
|
|
return frozenset(paths)
|
|
|
|
|
|
# ---- transitions -----------------------------------------------------------------------------
|
|
|
|
def _get(config: Any, path: str) -> Any:
|
|
node = config
|
|
for part in path.split("."):
|
|
if not isinstance(node, dict) or part not in node:
|
|
return True # absent = the shipped default (True for every tracked path)
|
|
node = node[part]
|
|
return node
|
|
|
|
|
|
def _on(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
return value.strip().lower() not in _OFF
|
|
return bool(value)
|
|
|
|
|
|
def _setting_transitions(old: dict, new: dict) -> Iterable[tuple[str, str, str]]:
|
|
for path in sorted(default_on_settings()):
|
|
before, after = _get(old, path), _get(new, path)
|
|
if any(isinstance(value, str) and "${" in value for value in (before, after)):
|
|
continue # an unexpanded ``${VAR}`` template (the raw side): its value is unknown here
|
|
before, after = _on(before), _on(after)
|
|
if before != after:
|
|
yield _SETTING_KINDS.get(path.split(".", 1)[0], "setting"), path, "re_enabled" if after else "disabled"
|
|
|
|
|
|
def _names(config: Any, section: str, key: str, *, per_platform: str | None = None) -> set[str]:
|
|
from agent.skill_utils import parse_config_string_list
|
|
|
|
block = config.get(section) if isinstance(config, dict) else None
|
|
if not isinstance(block, dict):
|
|
return set()
|
|
names = set(parse_config_string_list(block.get(key)) or ())
|
|
platforms = block.get(per_platform) if per_platform else None
|
|
if isinstance(platforms, dict):
|
|
for value in platforms.values():
|
|
names |= set(parse_config_string_list(value) or ())
|
|
return {str(n) for n in names}
|
|
|
|
|
|
def _plugin_kind(name: str) -> str:
|
|
"""A messaging-platform plugin (bundled or catalog) reports as a platform, other plugins as plugins."""
|
|
from .shared_metrics_catalog import _safe, bundled_platform_names, catalog_platform_names
|
|
|
|
platforms = _safe(bundled_platform_names) | _safe(catalog_platform_names)
|
|
return "platform" if name.strip().lower().rsplit("/", 1)[-1] in platforms else "plugin"
|
|
|
|
|
|
def _list_transitions(old_names: set[str], new_names: set[str], kind: str, namer) -> Iterable[tuple[str, str, str]]:
|
|
for names, event in ((new_names - old_names, "disabled"), (old_names - new_names, "re_enabled")):
|
|
for name in sorted(names):
|
|
yield (_plugin_kind(name) if kind == "plugin" else kind), namer(name), event
|
|
|
|
|
|
def _toolset_transitions(old: dict, new: dict) -> Iterable[tuple[str, str, str]]:
|
|
def scope(config: dict) -> tuple[Any, Any]:
|
|
agent = config.get("agent") if isinstance(config.get("agent"), dict) else {}
|
|
return config.get("platform_toolsets"), agent.get("disabled_toolsets")
|
|
|
|
if scope(old) == scope(new):
|
|
return
|
|
from hermes_cli.tools_config import _configurable_keys, _get_platform_tools
|
|
|
|
platforms = {"cli"}
|
|
for config in (old, new):
|
|
if isinstance(config.get("platform_toolsets"), dict):
|
|
platforms |= {str(p) for p in config["platform_toolsets"]}
|
|
shipped = _configurable_keys()
|
|
for platform in sorted(platforms):
|
|
default = _get_platform_tools({}, platform, include_default_mcp_servers=False) & shipped
|
|
before = _get_platform_tools(old, platform, include_default_mcp_servers=False)
|
|
after = _get_platform_tools(new, platform, include_default_mcp_servers=False)
|
|
for toolset in sorted(default):
|
|
if (toolset in before) != (toolset in after):
|
|
yield "toolset", toolset, "re_enabled" if toolset in after else "disabled"
|
|
|
|
|
|
def config_transitions(old: Any, new: Any) -> list[tuple[str, str, str]]:
|
|
"""(kind, name, event) for each shipped-on thing the write turned off or back on (deduped)."""
|
|
old = old if isinstance(old, dict) else {}
|
|
new = new if isinstance(new, dict) else {}
|
|
found: list[tuple[str, str, str]] = []
|
|
for batch in (
|
|
_setting_transitions(old, new),
|
|
_list_transitions(_names(old, "skills", "disabled", per_platform="platform_disabled"),
|
|
_names(new, "skills", "disabled", per_platform="platform_disabled"), "skill", skill_metric_name),
|
|
_list_transitions(_names(old, "plugins", "disabled"), _names(new, "plugins", "disabled"), "plugin",
|
|
plugin_metric_name),
|
|
_toolset_transitions(old, new),
|
|
):
|
|
try:
|
|
found.extend(t for t in batch if t not in found)
|
|
except Exception:
|
|
logger.debug("Feature-disabled transitions skipped", exc_info=True)
|
|
return found
|
|
|
|
|
|
# ---- recording -------------------------------------------------------------------------------
|
|
|
|
def _emit(transitions: Iterable[tuple[str, str, str]], surface: str) -> None:
|
|
from .relay_shared_metrics import record_process_mark
|
|
from .shared_metrics_contract import FEATURE_DISABLED_MARK
|
|
|
|
for kind, name, event in transitions:
|
|
record_process_mark(FEATURE_DISABLED_MARK, {"event": event, "kind": kind, "name": name, "surface": surface})
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def hermes_applied_write() -> Iterator[None]:
|
|
"""Config writes Hermes makes on its own (migrations) record nothing, whatever the surface."""
|
|
token = _hermes_write.set(True)
|
|
try:
|
|
yield
|
|
finally:
|
|
_hermes_write.reset(token)
|
|
|
|
|
|
def _record(old: Any, new: Any, surface: str, home: str) -> None:
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
|
|
token = set_hermes_home_override(home) # a thread does not inherit the profile binding
|
|
try:
|
|
_emit(config_transitions(old, new), surface)
|
|
except Exception:
|
|
logger.debug("Feature-disabled not recorded", exc_info=True)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
|
|
def record_config_saved(old_raw: Any, new_config: Any) -> None:
|
|
"""``save_config``'s hook, after the write and outside its lock. Never raises."""
|
|
if _process_surface is None or _hermes_write.get():
|
|
return
|
|
try:
|
|
from hermes_constants import get_hermes_home
|
|
|
|
from .relay_shared_metrics import enabled
|
|
|
|
if not enabled() or (surface := current_surface()) is None:
|
|
return
|
|
# Not a daemon: a CLI command that exits right after its write still records.
|
|
threading.Thread(
|
|
target=_record, args=(copy.deepcopy(old_raw), copy.deepcopy(new_config), surface, str(get_hermes_home())),
|
|
name="hermes-feature-disabled",
|
|
).start()
|
|
except Exception:
|
|
logger.debug("Feature-disabled not recorded", exc_info=True)
|
|
|
|
|
|
def recording_raw_config_write(config_path: Any, user_config: Any, write) -> None:
|
|
"""``hermes config set/unset`` write the raw file directly (not via ``save_config``): diff the file
|
|
they replace against what they write. The write itself runs unguarded, so its errors are the caller's."""
|
|
before = None
|
|
if _process_surface is not None:
|
|
try:
|
|
from .relay_shared_metrics import enabled
|
|
|
|
if enabled():
|
|
from hermes_cli.config import read_user_config_raw
|
|
|
|
before = read_user_config_raw(config_path)
|
|
except Exception:
|
|
logger.debug("Config snapshot for feature-disabled skipped", exc_info=True)
|
|
write(config_path, user_config)
|
|
if before is not None:
|
|
record_config_saved(before, user_config)
|
|
|
|
|
|
def record_skill_removed(name: Any) -> None:
|
|
"""A catalog skill (bundled/optional) was uninstalled; third-party hub skills were never shipped."""
|
|
if _process_surface is None:
|
|
return
|
|
try:
|
|
from .relay_shared_metrics import enabled
|
|
|
|
if not enabled() or (surface := current_surface()) is None:
|
|
return
|
|
if (metric_name := skill_metric_name(name)) != CUSTOM:
|
|
_emit([("skill", metric_name, "disabled")], surface)
|
|
except Exception:
|
|
logger.debug("Skill removal not recorded", exc_info=True)
|