diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 8781ee6799..0dcc33c395 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -18,6 +18,10 @@ from pathlib import Path from typing import Any, Callable from hermes_constants import get_hermes_home +from hermes_cli.relay_plugin_cutover import ( + RELAY_PLUGINS_CONFIG_ENV, + configured_legacy_relay_env_vars, +) logger = logging.getLogger(__name__) @@ -27,7 +31,6 @@ LOGICAL_LLM_SCOPE = "hermes.logical_llm_call" RUNTIME_SCHEMA_KEY = "hermes.relay.schema_version" RUNTIME_SCHEMA_VERSION = "hermes.relay.runtime.v1" RUNTIME_INSTANCE_KEY = "hermes.relay.runtime_instance" -RELAY_PLUGINS_CONFIG_ENV = "HERMES_NEMO_RELAY_PLUGINS_TOML" RELAY_PLUGINS_EXECUTION_CONSUMER = "hermes.nemo_relay.plugins" _PROFILE_KEY_CACHE: dict[str, str] = {} @@ -265,6 +268,23 @@ class _ProcessRelayPluginConfiguration: ) return False + try: + existing_report = relay.plugin.report() + except Exception: + logger.warning( + "Hermes could not determine whether a process-global Relay " + "plugin configuration is already active; refusing to replace it", + exc_info=True, + ) + return False + if existing_report is not None: + logger.warning( + "A process-global Relay plugin configuration is already active " + "outside Hermes native ownership; leaving it unchanged and " + "disabling Hermes-managed Relay middleware for this process" + ) + return False + try: configured_inputs = _configured_plugin_inputs(relay) if configured_inputs is None: @@ -1894,6 +1914,15 @@ def _configured_plugin_inputs( """Load environment-selected plugin inputs, or leave plugins disabled.""" configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip() if not configured: + legacy_vars = configured_legacy_relay_env_vars(os.environ) + if legacy_vars: + logger.warning( + "Legacy NeMo Relay exporter variables are set but no %s was " + "provided. %s no longer activate Relay exporters; migrate the " + "exporter configuration to a Relay plugins.toml file.", + RELAY_PLUGINS_CONFIG_ENV, + ", ".join(legacy_vars), + ) return None config_path = Path(configured).expanduser() diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 2d053cfbe6..34a876ee53 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -9,6 +9,15 @@ Hermes execution remains available, while Relay scopes, middleware, plugins, and subscribers are unavailable. The `hermes-agent[nemo-relay]` extra remains as a no-op compatibility alias for existing installation commands. +> [!WARNING] +> This removes the Hermes `observability/nemo_relay` plugin. Existing users +> must remove `observability/nemo_relay` (or its legacy `nemo_relay` alias) +> from `plugins.enabled` and move exporter configuration into a Relay +> `plugins.toml` selected with `HERMES_NEMO_RELAY_PLUGINS_TOML`. The legacy +> `HERMES_NEMO_RELAY_ATOF_*` and `HERMES_NEMO_RELAY_ATIF_*` variables no +> longer activate exporters. Without the new variable, Hermes does not run +> Relay plugin discovery, configuration layering, middleware, or exporters. + Hermes requires NeMo Relay 0.7.1 or later within the 0.7 release line. That release establishes the lossless provider-codec contract used for Anthropic Messages, OpenAI Chat Completions, and OpenAI Responses requests. diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 29ae25d7f7..985d104001 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -3557,7 +3557,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 37, + "_config_version": 38, } # Optional environment variables that enhance functionality diff --git a/hermes_cli/config_migrations.py b/hermes_cli/config_migrations.py index b1464604fa..44281f7a15 100644 --- a/hermes_cli/config_migrations.py +++ b/hermes_cli/config_migrations.py @@ -815,6 +815,37 @@ def _migrate_to_37(results: Dict[str, Any], quiet: bool) -> None: ) +def _migrate_to_38(results: Dict[str, Any], quiet: bool) -> None: + # Version 37 → 38: the bundled observability/nemo_relay plugin was + # removed when Relay lifecycle ownership moved into the agent core. + _c = _cfg() + read_raw_config = _c.read_raw_config + _persist_migration = _c._persist_migration + + 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}") + + #: Registry of (target_version, migration_fn), strictly ascending. The driver #: applies every entry whose target version is greater than the on-disk #: observe earlier steps' writes via read_raw_config() (filesystem state). @@ -839,6 +870,7 @@ MIGRATIONS: Tuple[Tuple[int, Callable[[Dict[str, Any], bool], None]], ...] = ( (35, _migrate_to_35), (36, _migrate_to_36), (37, _migrate_to_37), + (38, _migrate_to_38), ) diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 253fb1c7bc..2599546da3 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -525,6 +525,46 @@ def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]: return findings +def collect_relay_plugin_cutover_findings( + raw_config: dict | None, + env_map: dict | None, +) -> list[tuple[str, str]]: + """Return actionable findings for the removed Hermes Relay plugin.""" + from hermes_cli.relay_plugin_cutover import ( + LEGACY_RELAY_EXPORT_ENV_VARS, + RELAY_PLUGINS_CONFIG_ENV, + configured_legacy_relay_env_vars, + legacy_relay_plugin_keys, + ) + + findings: list[tuple[str, str]] = [] + if isinstance(raw_config, dict): + plugins = raw_config.get("plugins") + if isinstance(plugins, dict): + for key in legacy_relay_plugin_keys(plugins.get("enabled")): + findings.append( + ( + f"plugins.enabled: {key}", + f"remove it and configure {RELAY_PLUGINS_CONFIG_ENV}", + ) + ) + + effective_env = dict(env_map or {}) + for name in (*LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV): + if name not in effective_env and os.environ.get(name) is not None: + effective_env[name] = os.environ[name] + if not str(effective_env.get(RELAY_PLUGINS_CONFIG_ENV, "")).strip(): + for name in configured_legacy_relay_env_vars(effective_env): + findings.append( + ( + name, + f"move exporter settings to {RELAY_PLUGINS_CONFIG_ENV}; " + "this variable is now ignored", + ) + ) + return findings + + def report_deprecated_config_and_env( raw_config: dict | None = None, env_map: dict | None = None, @@ -535,18 +575,26 @@ def report_deprecated_config_and_env( (empty when nothing deprecated is present). Does not mutate config/env and does not append to the blocking ``issues`` list. """ - findings = collect_deprecated_config_keys(raw_config) - findings.extend(collect_deprecated_env_vars(env_map)) + deprecated = collect_deprecated_config_keys(raw_config) + deprecated.extend(collect_deprecated_env_vars(env_map)) + relay_cutover = collect_relay_plugin_cutover_findings(raw_config, env_map) + findings = deprecated + relay_cutover if not findings: check_ok("No deprecated config keys or env vars") return findings - for legacy, replacement in findings: + for legacy, replacement in deprecated: check_warn( f"Deprecated: {legacy}", f"(use {replacement} instead)", ) check_info(f"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)") + for legacy, replacement in relay_cutover: + check_warn( + f"Breaking Relay migration: {legacy}", + f"({replacement})", + ) + check_info(f"Migrate {legacy}: {replacement}") return findings diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9ef8e47d06..2493d8f21e 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -71,6 +71,11 @@ from hermes_cli.plugin_capabilities import ( # noqa: F401 — re-exported from hermes_cli.plugin_capabilities import ( parse_declared_capabilities as _parse_declared_capabilities, ) +from hermes_cli.relay_plugin_cutover import ( + LEGACY_RELAY_PLUGIN_KEYS, + RELAY_PLUGINS_CONFIG_ENV, + legacy_relay_plugin_keys, +) def get_bundled_plugins_dir() -> Path: @@ -3892,6 +3897,14 @@ class PluginManager: # don't collide even when both manifests say ``name: openai``. disabled = _get_disabled_plugins() enabled = _get_enabled_plugins() # None = opt-in default (nothing enabled) + stale_relay_keys = legacy_relay_plugin_keys(enabled) + if stale_relay_keys: + logger.warning( + "Removed Hermes plugin %s is still listed in plugins.enabled; " + "remove it and configure native Relay plugins with %s", + ", ".join(stale_relay_keys), + RELAY_PLUGINS_CONFIG_ENV, + ) winners: Dict[str, PluginManifest] = {} for manifest in manifests: winners[manifest.key or manifest.name] = manifest @@ -3902,6 +3915,26 @@ class PluginManager: for manifest in winners.values(): lookup_key = manifest.key or manifest.name + # Relay lifecycle ownership now lives in the Hermes core. Loading + # an old user or entry-point copy would let plugin.initialize() + # compete for the same process-global Relay registries. + if ( + lookup_key in LEGACY_RELAY_PLUGIN_KEYS + or manifest.name in LEGACY_RELAY_PLUGIN_KEYS + ): + loaded = LoadedPlugin(manifest=manifest, enabled=False) + loaded.error = ( + "removed — Relay lifecycle is owned by Hermes core; configure " + f"{RELAY_PLUGINS_CONFIG_ENV} instead" + ) + self._plugins[lookup_key] = loaded + logger.warning( + "Refusing to load removed Hermes Relay plugin '%s'; %s", + lookup_key, + loaded.error, + ) + continue + # Explicit disable always wins (matches on key or on legacy # bare name for back-compat with existing user configs). if lookup_key in disabled or manifest.name in disabled: diff --git a/hermes_cli/relay_plugin_cutover.py b/hermes_cli/relay_plugin_cutover.py new file mode 100644 index 0000000000..84aec0dae8 --- /dev/null +++ b/hermes_cli/relay_plugin_cutover.py @@ -0,0 +1,62 @@ +"""Shared migration guards for Hermes' native NeMo Relay ownership.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + + +RELAY_PLUGINS_CONFIG_ENV = "HERMES_NEMO_RELAY_PLUGINS_TOML" + +LEGACY_RELAY_PLUGIN_KEYS = frozenset( + { + "nemo_relay", + "observability/nemo_relay", + } +) + +LEGACY_RELAY_EXPORT_ENV_VARS = frozenset( + { + "HERMES_NEMO_RELAY_ATOF_ENABLED", + "HERMES_NEMO_RELAY_ATOF_OUTPUT_DIRECTORY", + "HERMES_NEMO_RELAY_ATOF_FILENAME", + "HERMES_NEMO_RELAY_ATOF_MODE", + "HERMES_NEMO_RELAY_ATIF_ENABLED", + "HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", + "HERMES_NEMO_RELAY_ATIF_FILENAME_TEMPLATE", + "HERMES_NEMO_RELAY_ATIF_AGENT_NAME", + "HERMES_NEMO_RELAY_ATIF_AGENT_VERSION", + "HERMES_NEMO_RELAY_ATIF_MODEL_NAME", + "HERMES_NEMO_RELAY_ATIF_SUBAGENT_EXPORT_MODE", + } +) + + +def legacy_relay_plugin_keys(values: Any) -> tuple[str, ...]: + """Return removed Relay plugin identities present in a config value.""" + if not isinstance(values, (list, tuple, set, frozenset)): + return () + return tuple( + sorted( + { + value + for value in values + if isinstance(value, str) and value in LEGACY_RELAY_PLUGIN_KEYS + } + ) + ) + + +def configured_legacy_relay_env_vars( + env: Mapping[str, Any] | None, +) -> tuple[str, ...]: + """Return non-empty legacy Relay exporter variables in *env*.""" + if env is None: + return () + return tuple( + sorted( + name + for name in LEGACY_RELAY_EXPORT_ENV_VARS + if env.get(name) is not None and str(env[name]).strip() + ) + ) diff --git a/tests/agent/test_relay_runtime_plugins.py b/tests/agent/test_relay_runtime_plugins.py index 79b1fae451..7e24af307d 100644 --- a/tests/agent/test_relay_runtime_plugins.py +++ b/tests/agent/test_relay_runtime_plugins.py @@ -20,11 +20,15 @@ class _FakeRelay: initialize_error: Exception | None = None, dynamic_initialize_error: Exception | None = None, activation_close_error: Exception | None = None, + active_report: Any = None, + report_error: Exception | None = None, ) -> None: self.events: list[tuple[Any, ...]] = [] self.initialize_error = initialize_error self.dynamic_initialize_error = dynamic_initialize_error self.activation_close_error = activation_close_error + self.active_report = active_report + self.report_error = report_error self.dynamic_plugin_specs: list[dict[str, Any]] = [] self.ScopeType = SimpleNamespace(Agent="agent") self.plugin = SimpleNamespace( @@ -32,6 +36,7 @@ class _FakeRelay: initialize_with_dynamic_plugins=self._initialize_dynamic_plugins, load_dynamic_plugin_activation_specs=self._load_dynamic_plugin_specs, clear_async=self._clear_plugins_async, + report=self._report_plugins, ) self.scope = SimpleNamespace( push=self._scope_push, @@ -74,6 +79,11 @@ class _FakeRelay: async def _clear_plugins_async(self) -> None: self.events.append(("plugin.clear_async",)) + def _report_plugins(self) -> Any: + if self.report_error is not None: + raise self.report_error + return self.active_report + def _scope_push(self, name: str, scope_type: Any, **kwargs: Any) -> Any: handle = ("scope", name, len(self.events)) self.events.append(("scope.push", name, scope_type, kwargs)) @@ -142,6 +152,63 @@ def test_relay_initializes_explicit_plugins_before_first_session_scope( host.shutdown() +def test_foreign_active_plugin_configuration_is_left_unchanged( + explicit_static_config, + caplog, +): + foreign_report = {"diagnostics": [], "source": "embedding-host"} + relay = _FakeRelay(active_report=foreign_report) + + with caplog.at_level("WARNING"): + host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + + try: + assert not host.managed_execution_enabled() + assert relay.active_report is foreign_report + assert relay.events == [] + assert "already active outside Hermes native ownership" in caplog.text + assert "leaving it unchanged" in caplog.text + finally: + host.shutdown() + + +def test_unreadable_foreign_plugin_state_fails_safe( + explicit_static_config, + caplog, +): + relay = _FakeRelay(report_error=RuntimeError("report unavailable")) + + with caplog.at_level("WARNING"): + host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + + try: + assert not host.managed_execution_enabled() + assert relay.events == [] + assert "refusing to replace it" in caplog.text + finally: + host.shutdown() + + +def test_legacy_exporter_env_without_plugins_toml_warns_and_stays_disabled( + monkeypatch, + caplog, +): + monkeypatch.delenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, raising=False) + monkeypatch.setenv("HERMES_NEMO_RELAY_ATOF_ENABLED", "1") + relay = _FakeRelay() + + with caplog.at_level("WARNING"): + host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + + try: + assert not host.managed_execution_enabled() + assert relay.events == [] + assert "no HERMES_NEMO_RELAY_PLUGINS_TOML was provided" in caplog.text + assert "HERMES_NEMO_RELAY_ATOF_ENABLED" in caplog.text + finally: + host.shutdown() + + def test_initialization_failure_is_fail_open(explicit_static_config, caplog): relay = _FakeRelay(initialize_error=RuntimeError("rejected config")) diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index c86fd73ed5..b533cd7d4d 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -25,6 +25,7 @@ from hermes_cli.plugins import ( resolve_plugin_command_result, _portable_skill_namespace, ) +from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV from hermes_cli.middleware import ( VALID_MIDDLEWARE, apply_llm_request_middleware, @@ -120,6 +121,43 @@ def _make_plugin_dir(base: Path, name: str, *, register_body: str = "pass", class TestPluginDiscovery: """Tests for plugin discovery from directories and entry points.""" + def test_removed_relay_plugin_identity_cannot_be_reloaded( + self, monkeypatch, caplog + ): + from hermes_cli import plugins as plugins_mod + + manifest = PluginManifest( + name="nemo_relay", + key="observability/nemo_relay", + source="user", + ) + manager = PluginManager() + monkeypatch.setattr( + manager, + "_collect_directory_manifests", + lambda: [manifest], + ) + monkeypatch.setattr(manager, "_scan_entry_points", lambda: []) + monkeypatch.setattr( + plugins_mod, + "_get_enabled_plugins", + lambda: {"observability/nemo_relay"}, + ) + monkeypatch.setattr(plugins_mod, "_get_disabled_plugins", lambda: set()) + loaded: list[PluginManifest] = [] + monkeypatch.setattr(manager, "_load_plugin", loaded.append) + + with caplog.at_level(logging.WARNING): + manager.discover_and_load() + + state = manager._plugins["observability/nemo_relay"] + assert loaded == [] + assert not state.enabled + assert state.error is not None + assert "Relay lifecycle is owned by Hermes core" in state.error + assert RELAY_PLUGINS_CONFIG_ENV in state.error + assert "Refusing to load removed Hermes Relay plugin" in caplog.text + def test_enabled_portable_plugin_registers_components( self, tmp_path, monkeypatch ): diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py new file mode 100644 index 0000000000..e53cf1700c --- /dev/null +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -0,0 +1,85 @@ +"""Regression tests for migration from the removed Hermes Relay plugin.""" + +from __future__ import annotations + +import os +from unittest.mock import patch + +import yaml + +from hermes_cli.config import migrate_config +from hermes_cli.doctor import collect_relay_plugin_cutover_findings +from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV + + +def test_v38_migration_removes_only_legacy_relay_plugin_keys(tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump( + { + "_config_version": 37, + "plugins": { + "enabled": [ + "keep-me", + "observability/nemo_relay", + "nemo_relay", + ] + }, + }, + sort_keys=False, + ), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + results = migrate_config(interactive=False, quiet=True) + + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + assert raw["_config_version"] == 38 + assert raw["plugins"]["enabled"] == ["keep-me"] + assert any( + "observability/nemo_relay" in warning + and RELAY_PLUGINS_CONFIG_ENV in warning + for warning in results["warnings"] + ) + + +def test_doctor_reports_stale_relay_plugin_key(): + findings = dict( + collect_relay_plugin_cutover_findings( + {"plugins": {"enabled": ["observability/nemo_relay"]}}, + {}, + ) + ) + + replacement = findings["plugins.enabled: observability/nemo_relay"] + assert "remove it" in replacement + assert RELAY_PLUGINS_CONFIG_ENV in replacement + + +def test_doctor_reports_legacy_exporter_env_without_new_config(monkeypatch): + monkeypatch.delenv(RELAY_PLUGINS_CONFIG_ENV, raising=False) + findings = dict( + collect_relay_plugin_cutover_findings( + {}, + {"HERMES_NEMO_RELAY_ATIF_ENABLED": "true"}, + ) + ) + + assert "now ignored" in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"] + assert RELAY_PLUGINS_CONFIG_ENV in findings["HERMES_NEMO_RELAY_ATIF_ENABLED"] + + +def test_doctor_does_not_warn_for_legacy_env_after_new_config_is_selected( + monkeypatch, +): + monkeypatch.delenv(RELAY_PLUGINS_CONFIG_ENV, raising=False) + findings = collect_relay_plugin_cutover_findings( + {}, + { + RELAY_PLUGINS_CONFIG_ENV: "/tmp/plugins.toml", + "HERMES_NEMO_RELAY_ATIF_ENABLED": "true", + }, + ) + + assert findings == []