Files
hermes-agent/hermes_cli/observability/shared_metrics_disabled.py
teknium1 ab056dba47 fix(telemetry): feature_disabled skips migrations and ${VAR} templates, diffs outside caller locks (m22, m23, m24)
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.
2026-09-28 12:43:03 -07:00

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)