mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
fix(nemo-relay): align dynamic plugin configuration
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
This commit is contained in:
parent
2d2bed5891
commit
7e201fa1b6
4 changed files with 171 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue