diff --git a/plugins/observability/nemo_relay/README.md b/plugins/observability/nemo_relay/README.md index e3bd2dd441bc..a1b90c4da4fa 100644 --- a/plugins/observability/nemo_relay/README.md +++ b/plugins/observability/nemo_relay/README.md @@ -84,15 +84,15 @@ wheel from this checkout, then install the official NeMo Relay runtime extra: ```bash uv build --wheel python -m pip install --force-reinstall dist/hermes_agent-*.whl -python -m pip install "nemo-relay>=0.5,<0.7" +python -m pip install "nemo-relay>=0.5,<1.0" hermes plugins enable observability/nemo_relay ``` The plugin fails open when `nemo-relay` is not installed. Install a supported -NeMo Relay 0.5 or 0.6 distribution: +NeMo Relay 0.x distribution beginning with 0.5: ```bash -pip install "nemo-relay>=0.5,<0.7" +pip install "nemo-relay>=0.5,<1.0" ``` ## Export Configuration @@ -190,23 +190,52 @@ session, turn, approval, and subagent marks; the plugin skips its manual NeMo Relay. `tool_parallelism.mode = "observe_only"` keeps tool scheduling observational while still wrapping the real execution boundary. -### Dynamic Plugins (NeMo Relay 0.6) +### Dynamic Plugins -Hermes can activate explicit native or worker plugins through the NeMo Relay -0.6 binding. Add `dynamic_plugins` entries to the same file; each entry uses -the binding's `plugin_id`, `kind`, `manifest_ref`, optional `environment_ref`, -and component `config` fields: +Hermes feature-detects the dynamic-plugin activation API available in NeMo Relay +0.6 and later. Configure native or worker plugins with Hermes-owned +`[[dynamic_plugins]]` entries that match the Python binding's activation-spec +fields: ```toml [[dynamic_plugins]] plugin_id = "example-plugin" kind = "rust_dynamic" -manifest_ref = "/absolute/path/to/example-plugin/relay-plugin.toml" +manifest_ref = "./example-plugin/relay-plugin.toml" [dynamic_plugins.config] mode = "enabled" ``` +For a worker plugin, also provide the lifecycle-managed `environment_ref`: + +```toml +[[dynamic_plugins]] +plugin_id = "example-worker" +kind = "worker" +manifest_ref = "./example-worker/relay-plugin.toml" +environment_ref = "/absolute/path/from-nemo-relay-plugins-inspect" + +[dynamic_plugins.config] +mode = "enabled" +``` + +Provision the worker first with `nemo-relay plugins add`, then copy +`data.source.environment_ref` from the JSON output of +`nemo-relay plugins inspect --json`. Relay rejects arbitrary Python +environments at activation time. + +Relative `manifest_ref` and `environment_ref` values resolve relative to the +physical `plugins.toml` file. + +Relay's canonical gateway `[[plugins.dynamic]]` records are not interchangeable +with this Hermes-owned section. The gateway combines those records with +separate lifecycle state for enablement, trust policy, and worker environments; +the Python binding does not yet expose that resolver. Hermes rejects +`[[plugins.dynamic]]` with an actionable diagnostic instead of silently +ignoring it or bypassing lifecycle policy. Use `[[dynamic_plugins]]` until Relay +exposes shared file-and-lifecycle resolution to embedding hosts. + Hermes activates these plugins before registering its managed LLM and tool execution middleware and retains the activation for the runtime lifetime. During shutdown it closes session exporters, flushes Relay subscribers, and @@ -214,22 +243,22 @@ then closes the activation so callbacks are removed before plugin code is unloaded. NeMo Relay 0.5 does not expose dynamic activation through its Python binding. -When `dynamic_plugins` is present with a 0.5 runtime, Hermes logs an actionable -warning and continues with the ordinary static component configuration, so -ATOF and ATIF observability remain available. No dynamic plugin is loaded in -that degraded mode. +When dynamic plugin configuration is present with a binding that lacks the +activation API, Hermes logs an actionable warning and continues with the +ordinary static component configuration, so ATOF and ATIF observability remain +available. No dynamic plugin is loaded in that degraded mode. For the full generic Hermes middleware contract, see [`docs/middleware/README.md`](../../../docs/middleware/README.md). ## Canonical Local Examples -The observe-only examples in this section use a supported NeMo Relay 0.5 or -0.6 distribution and a local Ollama model served through the OpenAI-compatible -API. +The observe-only examples in this section use a supported NeMo Relay 0.x +distribution beginning with 0.5 and a local Ollama model served through the +OpenAI-compatible API. ```bash -pip install "nemo-relay>=0.5,<0.7" +pip install "nemo-relay>=0.5,<1.0" export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home mkdir -p "$HERMES_HOME" @@ -476,7 +505,7 @@ for the same execution. This example enables both NeMo Relay observability export and adaptive execution middleware for a local Hermes run. This path requires a NeMo Relay runtime that supports `[components.config.tool_parallelism]`, as provided by the supported -0.5 and 0.6 releases. +0.x release range beginning with 0.5. ```bash export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home diff --git a/plugins/observability/nemo_relay/__init__.py b/plugins/observability/nemo_relay/__init__.py index 92ead8341931..56c6140ca58c 100644 --- a/plugins/observability/nemo_relay/__init__.py +++ b/plugins/observability/nemo_relay/__init__.py @@ -106,9 +106,9 @@ class _Runtime: ) else: logger.warning( - "NeMo Relay dynamic plugins require nemo-relay>=0.6,<0.7; this binding does " - "not expose plugin.activate_dynamic_plugins. Continuing with static " - "observability only." + "NeMo Relay dynamic plugins require a binding that exposes " + "plugin.activate_dynamic_plugins (available in NeMo Relay 0.6+). " + "Continuing with static observability only." ) initialize = getattr(plugin_mod, "initialize", None) if not callable(initialize): @@ -774,7 +774,7 @@ def _load_settings() -> _Settings: return _Settings( plugins_toml_path=plugins_toml_path, plugins_config=plugins_config, - dynamic_plugins=_dynamic_plugin_specs(plugins_config), + dynamic_plugins=_dynamic_plugin_specs(plugins_config, plugins_toml_path), adaptive_enabled=adaptive_config is not None, adaptive_mode=_adaptive_mode(adaptive_config), atof_enabled=_env_bool("HERMES_NEMO_RELAY_ATOF_ENABLED"), @@ -792,14 +792,40 @@ def _load_settings() -> _Settings: def _static_plugin_config(plugins_config: dict[str, Any]) -> dict[str, Any]: - """Return Relay's base plugin config without Hermes-owned host fields.""" - return {key: value for key, value in plugins_config.items() if key != "dynamic_plugins"} + """Return Relay's base config without embedding- or gateway-host fields.""" + return { + key: value + for key, value in plugins_config.items() + if key not in {"dynamic_plugins", "plugins"} + } -def _dynamic_plugin_specs(plugins_config: dict[str, Any] | None) -> list[dict[str, Any]]: +def _dynamic_plugin_specs( + plugins_config: dict[str, Any] | None, + plugins_toml_path: str = "", +) -> list[dict[str, Any]]: if not isinstance(plugins_config, dict): return [] + raw_specs = plugins_config.get("dynamic_plugins") + plugins_section = plugins_config.get("plugins") + if plugins_section is not None: + if not isinstance(plugins_section, dict): + logger.error( + "Invalid NeMo Relay plugins config: expected [plugins] to be an object; " + "no dynamic plugins will be activated. Continuing with static " + "observability only." + ) + return [] + if plugins_section: + logger.error( + "Hermes cannot activate Relay gateway [[plugins.dynamic]] records because " + "the Python binding does not expose the CLI lifecycle resolver for " + "enablement, trust policy, and worker environments. Use Hermes-owned " + "[[dynamic_plugins]] activation specs instead; no dynamic plugins will be " + "activated. Continuing with static observability only." + ) + return [] if raw_specs is None: return [] if not isinstance(raw_specs, list): @@ -847,21 +873,26 @@ def _dynamic_plugin_specs(plugins_config: dict[str, Any] | None) -> list[dict[st ) invalid = True continue - if environment_ref is not None and not isinstance(environment_ref, str): + if environment_ref is not None and ( + not isinstance(environment_ref, str) or not environment_ref.strip() + ): logger.warning( - "Invalid NeMo Relay dynamic_plugins[%d]: environment_ref must be a string", + "Invalid NeMo Relay dynamic_plugins[%d]: environment_ref must be a " + "non-empty string", index, ) invalid = True continue spec: dict[str, Any] = { - "plugin_id": plugin_id, + "plugin_id": plugin_id.strip(), "kind": kind, - "manifest_ref": manifest_ref, + "manifest_ref": _config_relative_path(manifest_ref.strip(), plugins_toml_path), "config": config, } if environment_ref is not None: - spec["environment_ref"] = environment_ref + spec["environment_ref"] = _config_relative_path( + environment_ref.strip(), plugins_toml_path + ) specs.append(spec) if invalid: logger.error( @@ -872,6 +903,17 @@ def _dynamic_plugin_specs(plugins_config: dict[str, Any] | None) -> list[dict[st return specs +def _config_relative_path(value: str, plugins_toml_path: str) -> str: + """Resolve a plugin path relative to its physical ``plugins.toml`` file.""" + path = Path(value) + if path.is_absolute(): + return str(path) + config_path = Path(plugins_toml_path) if plugins_toml_path else Path.cwd() / "plugins.toml" + if not config_path.is_absolute(): + config_path = Path.cwd() / config_path + return os.path.abspath(config_path.parent / path) + + def _flush_relay_subscribers(nemo_relay: Any) -> None: subscribers = getattr(nemo_relay, "subscribers", None) flush = getattr(subscribers, "flush", None) diff --git a/tests/plugins/test_nemo_relay_plugin.py b/tests/plugins/test_nemo_relay_plugin.py index 4bc31f0ae3be..61958194e585 100644 --- a/tests/plugins/test_nemo_relay_plugin.py +++ b/tests/plugins/test_nemo_relay_plugin.py @@ -603,6 +603,73 @@ def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypa assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close") +def test_nemo_relay_rejects_gateway_dynamic_config_with_actionable_diagnostic( + tmp_path, monkeypatch, caplog +): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + plugins_toml = tmp_path / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[plugins.dynamic]] +manifest = "plugins/fixture/relay-plugin.toml" + +[plugins.dynamic.config] +mode = "test" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + with caplog.at_level("ERROR"): + plugin.on_session_start(session_id="s1") + + assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) + initialize = next(event for event in fake.events if event[0] == "plugin.initialize") + assert initialize[1] == {"version": 1} + assert "does not expose the CLI lifecycle resolver" in caplog.text + assert "Use Hermes-owned [[dynamic_plugins]]" in caplog.text + + +def test_nemo_relay_explicit_dynamic_paths_resolve_from_plugins_toml(tmp_path, monkeypatch): + fake = _FakeNemoRelay() + plugin = _fresh_plugin(monkeypatch, fake) + config_dir = tmp_path / "config" + config_dir.mkdir() + plugins_toml = config_dir / "plugins.toml" + plugins_toml.write_text( + """ +version = 1 + +[[dynamic_plugins]] +plugin_id = "worker-fixture" +kind = "worker" +manifest_ref = "../plugins/worker/relay-plugin.toml" +environment_ref = "../environments/worker-fixture" +""", + encoding="utf-8", + ) + monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml)) + + plugin.on_session_start(session_id="s1") + + activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic") + assert activation[2] == [ + { + "plugin_id": "worker-fixture", + "kind": "worker", + "manifest_ref": str(tmp_path / "plugins" / "worker" / "relay-plugin.toml"), + "environment_ref": str(tmp_path / "environments" / "worker-fixture"), + "config": {}, + } + ] + runtime = plugin._get_runtime() + assert runtime is not None + runtime.shutdown() + + @pytest.mark.parametrize( ("provider", "api_mode", "expected_surface", "should_rewrite"), [ @@ -763,7 +830,7 @@ def test_nemo_relay_plugin_degrades_to_static_config_on_relay_0_5( initialize = next(event for event in fake.events if event[0] == "plugin.initialize") assert "dynamic_plugins" not in initialize[1] assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events) - assert "require nemo-relay>=0.6,<0.7" in caplog.text + assert "available in NeMo Relay 0.6+" in caplog.text def test_nemo_relay_plugin_rejects_invalid_dynamic_specs(tmp_path, monkeypatch, caplog): diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index f2f4887d6091..8c0836e9059e 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -222,10 +222,10 @@ def test_feishu_extra_includes_qrcode_for_qr_login(): assert any(dep.startswith("qrcode") for dep in feishu_extra) -def test_nemo_relay_extra_uses_official_0_3_distribution(): +def test_nemo_relay_extra_uses_supported_official_distribution_range(): optional_dependencies = _load_optional_dependencies() - assert optional_dependencies["nemo-relay"] == ["nemo-relay==0.3"] + assert optional_dependencies["nemo-relay"] == ["nemo-relay>=0.5,<1.0"] assert not any( spec == "hermes-agent[nemo-relay]" for spec in optional_dependencies["all"]