fix(nemo-relay): align dynamic plugin configuration

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
This commit is contained in:
Bryan Bednarski 2026-07-13 17:01:08 -06:00
parent 2d2bed5891
commit 7e201fa1b6
No known key found for this signature in database
GPG key ID: CC5B6BE166579FEF
4 changed files with 171 additions and 33 deletions

View file

@ -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 <plugin-id> --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

View file

@ -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)

View file

@ -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):

View file

@ -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"]