diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index e934efe5cc..8781ee6799 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -1905,9 +1905,8 @@ def _configured_plugin_inputs( "Hermes [[dynamic_plugins]] records are unsupported; use Relay " "[[plugins.dynamic]] records" ) - plugins_section = config.get("plugins") dynamic_plugins: list[Any] = [] - if plugins_section: + if "plugins" in config: dynamic_plugins = relay.plugin.load_dynamic_plugin_activation_specs( config_path ) @@ -1923,6 +1922,7 @@ def _configured_plugin_inputs( ) return None + def _flush_relay_subscribers(relay: Any) -> None: """Flush Relay without blocking an asyncio event-loop thread.""" _resolve_plugin_awaitable(relay.subscribers.flush_async()) diff --git a/docs/observability/relay-shared-metrics.md b/docs/observability/relay-shared-metrics.md index 3ae597b5d6..2d053cfbe6 100644 --- a/docs/observability/relay-shared-metrics.md +++ b/docs/observability/relay-shared-metrics.md @@ -43,10 +43,15 @@ behalf. Relay plugin activation is owned by the native runtime and remains explicitly opt-in. Set `HERMES_NEMO_RELAY_PLUGINS_TOML` to a selected `plugins.toml` to -activate configured middleware, exporters, or dynamic plugins. When it is -unset, Hermes does not invoke Relay's plugin initializer or trigger Relay -plugin configuration discovery. Invalid explicit configuration is reported -and Hermes continues without native plugin activation. +activate configured middleware, exporters, or dynamic plugins. When the +variable is unset, Hermes does not invoke Relay's plugin initializer, so Relay +does not perform plugin configuration discovery or layering. When it is set +and the selected file loads successfully, Relay performs its normal static +`plugins.toml` discovery and layers the selected static configuration over the +discovered configuration. Dynamic `[[plugins.dynamic]]` records are loaded +from the selected file only. If the selected file cannot be loaded, Hermes +reports the error and does not invoke Relay initialization or fall back to +ambient discovery. Hermes core owns one Relay host and one isolated Relay session scope per Hermes session. Core lifecycle producers use diff --git a/tests/agent/test_relay_runtime_plugins.py b/tests/agent/test_relay_runtime_plugins.py index 663359740c..79b1fae451 100644 --- a/tests/agent/test_relay_runtime_plugins.py +++ b/tests/agent/test_relay_runtime_plugins.py @@ -215,6 +215,32 @@ def test_malformed_explicit_config_does_not_fall_back_to_discovery( host.shutdown() +def test_present_plugins_section_is_validated_even_when_falsey( + tmp_path, + monkeypatch, + caplog, +): + config = tmp_path / "plugins.toml" + config.write_text("plugins = []", encoding="utf-8") + monkeypatch.setenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, str(config)) + relay = _FakeRelay() + + def reject_invalid_plugins(_config_path): + raise ValueError("'plugins' must be a table") + + relay.plugin.load_dynamic_plugin_activation_specs = reject_invalid_plugins + + 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 "'plugins' must be a table" in caplog.text + assert "continuing without Relay plugins" in caplog.text + finally: + host.shutdown() + + def test_two_profile_hosts_initialize_once_and_clear_after_final_shutdown( explicit_static_config, ): @@ -767,6 +793,67 @@ mode = "overwrite" assert not (atof_dir / "events.jsonl").exists() +def test_real_binding_layers_project_config_after_explicit_opt_in( + tmp_path, + monkeypatch, +): + relay = pytest.importorskip("nemo_relay") + if getattr(relay, "_native", None) is None: + pytest.skip("NeMo Relay native binding is unavailable on this platform") + + project_root = tmp_path / "project" + working_directory = project_root / "workspace" + config_directory = project_root / ".nemo-relay" + selected_directory = tmp_path / "selected-config" + atof_dir = tmp_path / "atof" + working_directory.mkdir(parents=True) + config_directory.mkdir() + selected_directory.mkdir() + (config_directory / "plugins.toml").write_text( + f""" +version = 1 + +[[components]] +kind = "observability" +enabled = true + +[components.config] +version = 3 + +[components.config.atof] +enabled = true + +[[components.config.atof.sinks]] +type = "file" +output_directory = "{atof_dir}" +filename = "events.jsonl" +mode = "overwrite" +""".strip(), + encoding="utf-8", + ) + selected_config = selected_directory / "plugins.toml" + selected_config.write_text("version = 1", encoding="utf-8") + xdg_config_home = tmp_path / "xdg" + xdg_config_home.mkdir() + monkeypatch.chdir(working_directory) + monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg_config_home)) + monkeypatch.setenv( + relay_runtime.RELAY_PLUGINS_CONFIG_ENV, + str(selected_config), + ) + relay.plugin.clear() + + host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + try: + assert host.managed_execution_enabled() + host.ensure_session({"session_id": "native-layered-plugins"}) + finally: + host.shutdown() + relay_runtime._reset_for_tests() + + assert (atof_dir / "events.jsonl").is_file() + + def test_real_binding_loads_explicit_config_and_exports_native_activity( tmp_path, monkeypatch,