Merge pull request #59846 from bbednarski9/bbednarski/nemo-relay-upgrade

feat(nemo-relay): nemo-relay observability version upgrade to support dynamic plugin activation
This commit is contained in:
Jeffrey Quesnelle 2026-07-14 13:15:49 -04:00 committed by GitHub
commit 77d5b2d573
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 859 additions and 54 deletions

View file

@ -84,14 +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.3"
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 and test it against the official NeMo Relay 0.3 PyPI distribution:
The plugin fails open when `nemo-relay` is not installed. Install a supported
NeMo Relay 0.x distribution beginning with 0.5:
```bash
pip install "nemo-relay==0.3"
pip install "nemo-relay>=0.5,<1.0"
```
## Export Configuration
@ -189,16 +190,75 @@ 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
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 = "./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
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 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 the official `nemo-relay==0.3`
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.3"
pip install "nemo-relay>=0.5,<1.0"
export HERMES_HOME=/tmp/hermes-nemo-relay-docs/hermes-home
mkdir -p "$HERMES_HOME"
@ -444,9 +504,8 @@ 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]`; the `nemo-relay==0.3`
install used by the earlier observability-only examples does not support this
adaptive config.
supports `[components.config.tool_parallelism]`, as provided by the supported
0.x release range beginning with 0.5.
```bash
export HERMES_HOME=/tmp/hermes-middleware-test/hermes-home

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import atexit
import asyncio
import inspect
import json
@ -19,6 +20,11 @@ logger = logging.getLogger(__name__)
_INIT_FAILED = object()
_LOCK = threading.RLock()
_RUNTIME: "_Runtime | object | None" = None
_RELAY_LLM_SURFACE_BY_API_MODE = {
"anthropic_messages": "anthropic.messages",
"chat_completions": "openai.chat_completions",
"codex_responses": "openai.responses",
}
@dataclass
@ -44,6 +50,7 @@ class _SubagentParent:
class _Settings:
plugins_toml_path: str = ""
plugins_config: dict[str, Any] | None = None
dynamic_plugins: list[dict[str, Any]] = field(default_factory=list)
adaptive_enabled: bool = False
adaptive_mode: str = "observe_only"
atof_enabled: bool = False
@ -67,6 +74,8 @@ class _Runtime:
self.subagent_parents: dict[str, _SubagentParent] = {}
self.atof_exporter: Any = None
self._atof_subscriber_name = "hermes.nemo_relay.atof"
self._plugin_activation: Any = None
self._shutdown_registered = False
self._plugin_config_initialized = self._configure_plugins_toml()
self._plugin_config_needs_reinit = False
if not self._plugin_config_initialized:
@ -76,29 +85,88 @@ class _Runtime:
if not self.settings.plugins_config:
return False
plugin_mod = getattr(self.nemo_relay, "plugin", None)
if plugin_mod is None:
return False
plugin_config = _static_plugin_config(self.settings.plugins_config)
if self.settings.dynamic_plugins:
activate_dynamic = getattr(plugin_mod, "activate_dynamic_plugins", None)
if callable(activate_dynamic):
try:
self._ensure_plugin_config_output_dirs(plugin_config)
self._plugin_activation = _resolve_awaitable(
activate_dynamic(plugin_config, self.settings.dynamic_plugins)
)
self._ensure_shutdown_registered()
return True
except Exception as exc:
logger.warning(
"NeMo Relay dynamic plugin activation failed; continuing with static "
"observability only: %s",
exc,
)
else:
logger.warning(
"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):
return False
try:
self._ensure_plugin_config_output_dirs(self.settings.plugins_config)
_resolve_awaitable(initialize(self.settings.plugins_config))
self._ensure_plugin_config_output_dirs(plugin_config)
_resolve_awaitable(initialize(plugin_config))
return True
except Exception as exc:
logger.debug("NeMo Relay plugins.toml init failed: %s", exc, exc_info=True)
return False
def _ensure_shutdown_registered(self) -> None:
if self._shutdown_registered:
return
atexit.register(self.shutdown)
self._shutdown_registered = True
def _clear_plugins_toml(self) -> None:
if not self._plugin_config_initialized:
return
plugin_mod = getattr(self.nemo_relay, "plugin", None)
clear = getattr(plugin_mod, "clear", None)
if not callable(clear):
return
try:
_resolve_awaitable(clear())
finally:
self._plugin_config_initialized = False
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
failures: list[str] = []
if self._plugin_activation is not None:
activation = self._plugin_activation
try:
_flush_relay_subscribers(self.nemo_relay)
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
close = getattr(activation, "close", None)
if callable(close):
try:
_resolve_awaitable(close())
except Exception as exc:
failures.append(f"dynamic plugin activation close failed: {exc}")
finally:
# Retain the owned activation through the complete close
# attempt. The binding transitions it to a terminal state
# before its awaitable resolves, including error results.
self._plugin_activation = None
self._plugin_config_initialized = False
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
else:
failures.append("dynamic plugin activation has no close method")
else:
try:
plugin_mod = getattr(self.nemo_relay, "plugin", None)
clear = getattr(plugin_mod, "clear", None)
if callable(clear):
_resolve_awaitable(clear())
except Exception as exc:
failures.append(f"static plugin configuration clear failed: {exc}")
finally:
self._plugin_config_initialized = False
self._plugin_config_needs_reinit = bool(self.settings.plugins_config)
if failures:
raise RuntimeError("; ".join(failures))
def _activate_direct_fallbacks(self) -> None:
self._plugin_config_needs_reinit = False
@ -221,24 +289,69 @@ class _Runtime:
state = self.sessions.pop(session_id, None)
if state is None:
return
failures: list[str] = []
if state.handle is not None:
try:
self.nemo_relay.scope.pop(state.handle, output=_jsonable(kwargs))
except Exception:
logger.debug("NeMo Relay session pop failed", exc_info=True)
self.export_atif(state)
except Exception as exc:
failures.append(f"session scope pop failed: {exc}")
try:
_flush_relay_subscribers(self.nemo_relay)
except Exception as exc:
failures.append(f"subscriber flush failed: {exc}")
try:
self.export_atif(state)
except Exception as exc:
failures.append(f"ATIF export failed: {exc}")
if state.atif_exporter is not None and state.atif_subscriber_name:
try:
state.atif_exporter.deregister(state.atif_subscriber_name)
except Exception:
logger.debug("NeMo Relay ATIF deregister failed", exc_info=True)
if self._plugin_config_initialized and not self.sessions:
except Exception as exc:
failures.append(f"ATIF deregister failed: {exc}")
if (
self._plugin_config_initialized
and self._plugin_activation is None
and not self.sessions
):
try:
self._clear_plugins_toml()
except Exception:
logger.debug("NeMo Relay plugins.toml clear failed", exc_info=True)
elif self.settings.plugins_config and not self.sessions:
except Exception as exc:
failures.append(f"plugin configuration clear failed: {exc}")
elif (
self.settings.plugins_config
and self._plugin_activation is None
and not self.sessions
):
self._plugin_config_needs_reinit = True
if failures:
logger.warning(
"NeMo Relay session %s teardown completed with errors: %s",
session_id,
"; ".join(failures),
)
def shutdown(self) -> None:
"""Close active sessions and the process-lifetime plugin activation."""
failures: list[str] = []
for session_id in list(self.sessions):
try:
self.close_session({"session_id": session_id, "reason": "runtime_shutdown"})
except Exception as exc:
failures.append(f"session {session_id} close failed: {exc}")
if self._plugin_config_initialized:
try:
self._clear_plugins_toml()
except Exception as exc:
failures.append(f"plugin runtime close failed: {exc}")
self._clear_atof()
if self._shutdown_registered and self._plugin_activation is None:
atexit.unregister(self.shutdown)
self._shutdown_registered = False
if failures:
logger.warning(
"NeMo Relay runtime shutdown completed with errors: %s",
"; ".join(failures),
)
def mark(self, name: str, kwargs: dict[str, Any]) -> None:
state = self.ensure_session(kwargs)
@ -274,14 +387,14 @@ class _Runtime:
def managed_llm_enabled(self) -> bool:
return (
self.settings.adaptive_enabled
(self.settings.adaptive_enabled or self._plugin_activation is not None)
and callable(getattr(getattr(self.nemo_relay, "llm", None), "execute", None))
and callable(getattr(self.nemo_relay, "LLMRequest", None))
)
def managed_tool_enabled(self) -> bool:
return (
self.settings.adaptive_enabled
(self.settings.adaptive_enabled or self._plugin_activation is not None)
and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None))
)
@ -291,6 +404,8 @@ class _Runtime:
normalize_payload: Callable[[Any], Any],
shape_response: Callable[[Any], Any],
make_managed_execute: Callable[[Callable[[Any], Any]], Any],
*,
preserve_raw_response: bool,
) -> Any:
# NeMo Relay's native managed execution may wrap a failing callback as an
# internal runtime error, hiding the real downstream provider/tool
@ -298,7 +413,7 @@ class _Runtime:
# execution so Hermes retry classification still sees it. The LLM and tool
# paths share this scaffolding; they differ only in payload normalization,
# response shaping, and the Relay call itself.
raw_response: dict[str, Any] = {"set": False, "value": None}
raw_response: dict[str, Any] = {"set": False, "value": None, "normalized": None}
callback_error: Exception | None = None
downstream_error: BaseException | None = None
@ -312,7 +427,8 @@ class _Runtime:
raise
raw_response["set"] = True
raw_response["value"] = raw
return shape_response(raw)
raw_response["normalized"] = shape_response(raw)
return raw_response["normalized"]
try:
managed_result = _resolve_awaitable(make_managed_execute(_impl))
@ -320,7 +436,13 @@ class _Runtime:
if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error):
raise downstream_error
raise
return raw_response["value"] if raw_response["set"] else managed_result
if (
preserve_raw_response
and raw_response["set"]
and _json_semantically_equal(managed_result, raw_response["normalized"])
):
return raw_response["value"]
return managed_result
def execute_llm(self, kwargs: dict[str, Any]) -> Any:
state = self.ensure_session(kwargs)
@ -337,7 +459,7 @@ class _Runtime:
def _make_managed(impl: Callable[[Any], Any]) -> Any:
async def _managed_execute() -> Any:
result = self.nemo_relay.llm.execute(
str(kwargs.get("provider") or "llm"),
_relay_llm_surface(kwargs),
request,
impl,
handle=state.handle,
@ -359,7 +481,7 @@ class _Runtime:
return _managed_execute()
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _llm_response_payload, _make_managed
next_call, _normalize, _llm_response_payload, _make_managed, preserve_raw_response=True
)
def execute_tool(self, kwargs: dict[str, Any]) -> Any:
@ -397,11 +519,15 @@ class _Runtime:
return _managed_execute()
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _jsonable, _make_managed
next_call, _normalize, _jsonable, _make_managed, preserve_raw_response=False
)
def register(ctx) -> None:
# Activate dynamic plugins before Hermes installs the managed execution
# boundaries that invoke their interceptors.
if _load_settings().dynamic_plugins:
_get_runtime()
ctx.register_hook("on_session_start", on_session_start)
ctx.register_hook("on_session_end", on_session_end)
ctx.register_hook("on_session_finalize", on_session_finalize)
@ -648,6 +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, 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"),
@ -664,6 +791,136 @@ def _load_settings() -> _Settings:
)
def _static_plugin_config(plugins_config: dict[str, Any]) -> dict[str, Any]:
"""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,
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):
logger.warning(
"Ignoring invalid NeMo Relay dynamic_plugins config: expected an array of plugin specs"
)
return []
specs: list[dict[str, Any]] = []
invalid = False
for index, raw_spec in enumerate(raw_specs):
if not isinstance(raw_spec, dict):
logger.warning(
"Invalid NeMo Relay dynamic_plugins[%d]: expected an object", index
)
invalid = True
continue
plugin_id = raw_spec.get("plugin_id")
kind = raw_spec.get("kind")
manifest_ref = raw_spec.get("manifest_ref")
config = raw_spec.get("config", {})
environment_ref = raw_spec.get("environment_ref")
if not isinstance(plugin_id, str) or not plugin_id.strip():
logger.warning(
"Invalid NeMo Relay dynamic_plugins[%d]: plugin_id is required", index
)
invalid = True
continue
if kind not in {"rust_dynamic", "worker"}:
logger.warning(
"Invalid NeMo Relay dynamic_plugins[%d]: kind must be rust_dynamic or worker",
index,
)
invalid = True
continue
if not isinstance(manifest_ref, str) or not manifest_ref.strip():
logger.warning(
"Invalid NeMo Relay dynamic_plugins[%d]: manifest_ref is required", index
)
invalid = True
continue
if not isinstance(config, dict):
logger.warning(
"Invalid NeMo Relay dynamic_plugins[%d]: config must be an object", index
)
invalid = True
continue
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 "
"non-empty string",
index,
)
invalid = True
continue
spec: dict[str, Any] = {
"plugin_id": plugin_id.strip(),
"kind": kind,
"manifest_ref": _config_relative_path(manifest_ref.strip(), plugins_toml_path),
"config": config,
}
if environment_ref is not None:
spec["environment_ref"] = _config_relative_path(
environment_ref.strip(), plugins_toml_path
)
specs.append(spec)
if invalid:
logger.error(
"NeMo Relay dynamic plugin configuration is invalid; no dynamic plugins "
"will be activated. Continuing with static observability only."
)
return []
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)
if callable(flush):
flush()
def _load_plugins_config(path: str) -> dict[str, Any] | None:
if not path:
return None
@ -776,6 +1033,14 @@ def _tool_key(kwargs: dict[str, Any]) -> str:
)
def _relay_llm_surface(kwargs: dict[str, Any]) -> str:
api_mode = str(kwargs.get("api_mode") or "").strip().lower()
return _RELAY_LLM_SURFACE_BY_API_MODE.get(
api_mode,
str(kwargs.get("provider") or "llm"),
)
def _metadata(kwargs: dict[str, Any]) -> dict[str, Any]:
keys = (
"telemetry_schema_version",
@ -835,6 +1100,15 @@ def _jsonable(value: Any) -> Any:
return str(value)
def _json_semantically_equal(left: Any, right: Any) -> bool:
"""Compare JSON-compatible values without conflating booleans and numbers."""
try:
options = {"ensure_ascii": False, "sort_keys": True, "separators": (",", ":")}
return json.dumps(_jsonable(left), **options) == json.dumps(_jsonable(right), **options)
except (TypeError, ValueError):
return False
def _value(obj: Any, key: str, default: Any = None) -> Any:
if isinstance(obj, dict):
return obj.get(key, default)
@ -959,4 +1233,6 @@ def _resolve_awaitable(value: Any) -> Any:
def reset_for_tests() -> None:
global _RUNTIME
with _LOCK:
if isinstance(_RUNTIME, _Runtime):
_RUNTIME.shutdown()
_RUNTIME = None

View file

@ -205,7 +205,7 @@ vision = []
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
nemo-relay = ["nemo-relay>=0.5,<1.0"]
homeassistant = ["aiohttp==3.14.1"]
sms = ["aiohttp==3.14.1"]
teams = ["microsoft-teams-apps==2.0.13.4", "aiohttp==3.14.1"] # aiohttp 3.14.1: CVE-2026-34993(RCE)/47265 + 34513/34518/34519/34520/34525

View file

@ -41,7 +41,12 @@ class _FakeNemoRelay:
call_end=self._tool_call_end,
execute=self._tool_execute,
)
self.plugin = SimpleNamespace(initialize=self._plugin_initialize, clear=self._plugin_clear)
self.plugin = SimpleNamespace(
initialize=self._plugin_initialize,
clear=self._plugin_clear,
activate_dynamic_plugins=self._plugin_activate_dynamic,
)
self.subscribers = SimpleNamespace(flush=self._flush_subscribers)
self.LLMRequest = _FakeLLMRequest
self.AtofExporterConfig = _FakeAtofExporterConfig
self.AtofExporterMode = SimpleNamespace(Append="append", Overwrite="overwrite")
@ -100,6 +105,22 @@ class _FakeNemoRelay:
async def _plugin_clear(self):
self.events.append(("plugin.clear",))
async def _plugin_activate_dynamic(self, config, dynamic_plugins):
self.events.append(("plugin.activate_dynamic", config, dynamic_plugins))
return _FakePluginActivation(self.events)
def _flush_subscribers(self):
self.events.append(("subscribers.flush",))
class _FakePluginActivation:
def __init__(self, events):
self.events = events
self.report = {"diagnostics": []}
async def close(self):
self.events.append(("plugin.activation.close",))
class _FakeLLMRequest:
def __init__(self, headers, content):
@ -143,6 +164,7 @@ class _FakeAtifExporter:
return True
def export_json(self):
self.events.append(("atif.export", self.session_id))
return json.dumps({"session_id": self.session_id, "agent_name": self.agent_name})
@ -181,6 +203,26 @@ mode = "observe_only"
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
def _enable_dynamic_plugin(tmp_path, monkeypatch) -> Path:
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
f"""
version = 1
[[dynamic_plugins]]
plugin_id = "fixture"
kind = "rust_dynamic"
manifest_ref = "{(tmp_path / "fixture" / "relay-plugin.toml").as_posix()}"
[dynamic_plugins.config]
mode = "test"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
return plugins_toml
def test_manifest_fields():
data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text())
assert data["name"] == "nemo_relay"
@ -508,6 +550,432 @@ enabled = true
assert event_names.count("plugin.clear") == 1
def test_nemo_relay_plugin_activates_and_owns_dynamic_plugins(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1")
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif"))
plugin.on_session_start(session_id="s1")
runtime = plugin._get_runtime()
assert runtime is not None
assert runtime._plugin_activation is not None
llm_result = plugin.on_llm_execution_middleware(
session_id="s1",
provider="openai",
model="fixture",
request={"messages": []},
next_call=lambda request: {"request": request},
)
tool_result = plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="fixture-tool",
args={"value": 1},
next_call=lambda args: {"args": args},
)
assert llm_result["request"]["intercepted"] is True
assert tool_result["args"]["intercepted"] is True
plugin.on_session_finalize(session_id="s1", reason="shutdown")
assert runtime._plugin_activation is not None
assert not any(event[0] == "plugin.activation.close" for event in fake.events)
plugin.on_session_start(session_id="s2")
plugin.on_session_finalize(session_id="s2", reason="shutdown")
assert sum(event[0] == "plugin.activate_dynamic" for event in fake.events) == 1
activation = next(event for event in fake.events if event[0] == "plugin.activate_dynamic")
assert "dynamic_plugins" not in activation[1]
assert activation[2] == [
{
"plugin_id": "fixture",
"kind": "rust_dynamic",
"manifest_ref": str(tmp_path / "fixture" / "relay-plugin.toml"),
"config": {"mode": "test"},
}
]
event_names = [event[0] for event in fake.events]
assert "plugin.clear" not in event_names
assert event_names.index("subscribers.flush") < event_names.index("atif.export")
assert event_names.index("atif.export") < event_names.index("atif.deregister")
runtime.shutdown()
event_names = [event[0] for event in fake.events]
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"),
[
("custom", "chat_completions", "openai.chat_completions", True),
("openai-codex", "codex_responses", "openai.responses", True),
("anthropic", "anthropic_messages", "anthropic.messages", True),
("custom", "anthropic_messages", "anthropic.messages", True),
("bedrock", "bedrock_converse", "bedrock", False),
],
)
def test_nemo_relay_managed_llm_uses_wire_protocol_for_interceptor_dispatch(
tmp_path,
monkeypatch,
provider,
api_mode,
expected_surface,
should_rewrite,
):
fake = _FakeNemoRelay()
supported_surfaces = {
"anthropic.messages",
"openai.chat_completions",
"openai.responses",
}
def execute(name, request, func, **kwargs):
fake.events.append(("llm.execute.start", name, request.content, kwargs))
content = dict(request.content)
if name in supported_surfaces:
content["rewritten_for"] = name
result = func(_FakeLLMRequest(request.headers, content))
fake.events.append(("llm.execute.end", name, result, kwargs))
return result
fake.llm.execute = execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
result = plugin.on_llm_execution_middleware(
session_id="s1",
provider=provider,
api_mode=api_mode,
model="fixture",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=lambda request: request,
)
execute_start = next(
event for event in fake.events if event[0] == "llm.execute.start"
)
assert execute_start[1] == expected_surface
assert execute_start[3]["metadata"]["provider"] == provider
assert execute_start[3]["metadata"]["api_mode"] == api_mode
if should_rewrite:
assert result["rewritten_for"] == expected_surface
else:
assert "rewritten_for" not in result
def test_nemo_relay_managed_llm_returns_post_next_interceptor_result(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
raw_response = SimpleNamespace(
model="fixture",
choices=[
SimpleNamespace(
message=SimpleNamespace(role="assistant", content="raw", tool_calls=[]),
finish_reason="stop",
)
],
usage=None,
)
def execute(name, request, func, **kwargs):
del name, kwargs
normalized = func(_FakeLLMRequest(request.headers, request.content))
return {**normalized, "post_next_interceptor": True}
fake.llm.execute = execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
result = plugin.on_llm_execution_middleware(
session_id="s1",
provider="openai",
api_mode="chat_completions",
model="fixture",
request={"messages": []},
next_call=lambda request: raw_response,
)
assert result["post_next_interceptor"] is True
assert result["assistant_message"]["content"] == "raw"
assert result is not raw_response
def test_nemo_relay_managed_tool_returns_post_interceptor_result(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
def execute(name, args, func, **kwargs):
fake.events.append(("tool.execute.start", name, args, kwargs))
raw = func({"intercepted": True, **args})
result = {"compressed": True, "raw": raw}
fake.events.append(("tool.execute.end", name, result, kwargs))
return result
fake.tools.execute = execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
result = plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="fixture-tool",
args={"value": 1},
next_call=lambda args: {"tool_output": args},
)
assert result == {
"compressed": True,
"raw": {"tool_output": {"intercepted": True, "value": 1}},
}
def test_nemo_relay_plugin_activates_before_registering_managed_middleware(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
class _Context:
def register_hook(self, name, callback):
del name, callback
def register_middleware(self, name, callback):
del callback
fake.events.append(("hermes.register_middleware", name))
plugin.register(_Context())
event_names = [event[0] for event in fake.events]
assert event_names.index("plugin.activate_dynamic") < event_names.index(
"hermes.register_middleware"
)
runtime = plugin._get_runtime()
assert runtime is not None
runtime.shutdown()
def test_nemo_relay_plugin_degrades_to_static_config_on_relay_0_5(
tmp_path, monkeypatch, caplog
):
fake = _FakeNemoRelay()
delattr(fake.plugin, "activate_dynamic_plugins")
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
with caplog.at_level("WARNING"):
plugin.on_session_start(session_id="s1")
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 "available in NeMo Relay 0.6+" in caplog.text
def test_nemo_relay_plugin_rejects_invalid_dynamic_specs(tmp_path, monkeypatch, caplog):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
dynamic_plugins = [{ kind = "rust_dynamic", manifest_ref = "missing-id" }]
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
with caplog.at_level("WARNING"):
plugin.on_session_start(session_id="s1")
assert not any(event[0] == "plugin.activate_dynamic" for event in fake.events)
assert "plugin_id is required" in caplog.text
def test_nemo_relay_plugin_rejects_entire_mixed_valid_invalid_dynamic_request(
tmp_path, monkeypatch, caplog
):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
f"""
version = 1
[[dynamic_plugins]]
plugin_id = "valid-fixture"
kind = "rust_dynamic"
manifest_ref = "{(tmp_path / "valid" / "relay-plugin.toml").as_posix()}"
[[dynamic_plugins]]
kind = "worker"
manifest_ref = "{(tmp_path / "invalid" / "relay-plugin.toml").as_posix()}"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
with caplog.at_level("WARNING"):
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 "dynamic_plugins" not in initialize[1]
assert "no dynamic plugins will be activated" in caplog.text
def test_nemo_relay_plugin_registers_shutdown_after_dynamic_retry(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
activation_attempts = 0
async def _flaky_activate(config, dynamic_plugins):
nonlocal activation_attempts
activation_attempts += 1
fake.events.append(
("plugin.activate_dynamic.attempt", activation_attempts, config, dynamic_plugins)
)
if activation_attempts == 1:
raise RuntimeError("temporary activation failure")
return _FakePluginActivation(fake.events)
fake.plugin.activate_dynamic_plugins = _flaky_activate
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
plugin.on_session_start(session_id="s1")
plugin.on_session_finalize(session_id="s1")
plugin.on_session_start(session_id="s2")
runtime = plugin._get_runtime()
assert runtime is not None
assert runtime._plugin_activation is not None
assert runtime._shutdown_registered is True
assert activation_attempts == 2
runtime.shutdown()
assert any(event[0] == "plugin.activation.close" for event in fake.events)
def test_nemo_relay_plugin_attempts_activation_close_after_subscriber_flush_failure(
tmp_path, monkeypatch, caplog
):
fake = _FakeNemoRelay()
def _failing_flush():
fake.events.append(("subscribers.flush.failed",))
raise RuntimeError("flush boom")
fake.subscribers.flush = _failing_flush
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
plugin.on_session_start(session_id="s1")
runtime = plugin._get_runtime()
assert runtime is not None
with caplog.at_level("WARNING"):
runtime.shutdown()
event_names = [event[0] for event in fake.events]
assert event_names.count("subscribers.flush.failed") == 2
flush_indices = [
index for index, name in enumerate(event_names) if name == "subscribers.flush.failed"
]
assert max(flush_indices) < event_names.index("plugin.activation.close")
assert runtime._plugin_activation is None
assert "subscriber flush failed: flush boom" in caplog.text
def test_nemo_relay_plugin_continues_shutdown_after_atif_export_failure(
tmp_path, monkeypatch, caplog
):
fake = _FakeNemoRelay()
class _FailingAtifExporter(_FakeAtifExporter):
def export_json(self):
self.events.append(("atif.export.failed", self.session_id))
raise OSError("disk full")
fake.AtifExporter = lambda session_id, agent_name, agent_version, **kwargs: (
_FailingAtifExporter(fake.events, session_id, agent_name, agent_version, kwargs)
)
plugin = _fresh_plugin(monkeypatch, fake)
_enable_dynamic_plugin(tmp_path, monkeypatch)
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_ENABLED", "1")
monkeypatch.setenv("HERMES_NEMO_RELAY_ATIF_OUTPUT_DIRECTORY", str(tmp_path / "atif"))
plugin.on_session_start(session_id="s1")
runtime = plugin._get_runtime()
assert runtime is not None
with caplog.at_level("WARNING"):
runtime.shutdown()
event_names = [event[0] for event in fake.events]
assert event_names.index("atif.export.failed") < event_names.index("atif.deregister")
assert event_names.index("atif.deregister") < event_names.index("plugin.activation.close")
assert runtime._plugin_activation is None
assert "ATIF export failed: disk full" in caplog.text
def test_nemo_relay_plugin_keeps_plugins_toml_active_while_other_sessions_remain(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
@ -764,15 +1232,16 @@ mode = "observe_only"
),
finish_reason="tool_calls",
)
raw_response = SimpleNamespace(
id="resp-1",
model="demo-model",
choices=[raw_choice],
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8),
)
def next_call(request):
seen_request.update(request)
return SimpleNamespace(
id="resp-1",
model="demo-model",
choices=[raw_choice],
usage=SimpleNamespace(prompt_tokens=3, completion_tokens=5, total_tokens=8),
)
return raw_response
response = plugin.on_llm_execution_middleware(
session_id="s1",
@ -786,6 +1255,7 @@ mode = "observe_only"
next_call=next_call,
)
assert response is raw_response
assert response.model == "demo-model"
assert response.choices == [raw_choice]
assert seen_request["intercepted"] is True

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

14
uv.lock generated
View file

@ -1804,7 +1804,7 @@ requires-dist = [
{ name = "microsoft-teams-apps", marker = "extra == 'teams'", specifier = "==2.0.13.4" },
{ name = "mistralai", marker = "extra == 'mistral'", specifier = "==2.4.8" },
{ name = "modal", marker = "extra == 'modal'", specifier = "==1.3.4" },
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" },
{ name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = ">=0.5,<1.0" },
{ name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
{ name = "openai", specifier = "==2.24.0" },
{ name = "packaging", specifier = "==26.0" },
@ -2608,14 +2608,14 @@ wheels = [
[[package]]
name = "nemo-relay"
version = "0.3.0"
version = "0.5.0"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/55/3b65643db2df02fb3838b3d251442e05b7306cbbc77e69884ae78743546f/nemo_relay-0.3.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a6e44abe38bf6dda8f6a8b149cd9069a548186f24263ae8469d5a37cefc95209", size = 5282297, upload-time = "2026-05-29T22:32:36.315Z" },
{ url = "https://files.pythonhosted.org/packages/c7/ef/e2f4bc02f99f38706fdde0cdda6b6d8fddea9e6975da1b66377c35958b85/nemo_relay-0.3.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99ba247121cd0d17b9c5e2a95beb42512243430773b93435848b8b4b9f9ca61f", size = 4722431, upload-time = "2026-05-29T22:32:38.088Z" },
{ url = "https://files.pythonhosted.org/packages/12/18/bedb5d57f206e2859cef11f12f568974a529acf382436523a37e095b2cc7/nemo_relay-0.3.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20bb1e4ed87f179befc4db3af2e35f08be711378b322f3672222d80294f57be7", size = 5002734, upload-time = "2026-05-29T22:32:40.191Z" },
{ url = "https://files.pythonhosted.org/packages/26/ec/4b2e758a0a25397f2e97be889a11b7787d108658e0b60ddff5048b25adb8/nemo_relay-0.3.0-cp311-abi3-win_amd64.whl", hash = "sha256:e659c2772b35c0ea4897a91f6bf86b551ed403f6f41d0b60fd8151f5c78b83d0", size = 4947785, upload-time = "2026-05-29T22:32:41.784Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/45a3397559679d846ae023a82527c43b14631786b3a7c95e69c05e8bb553/nemo_relay-0.3.0-cp311-abi3-win_arm64.whl", hash = "sha256:9655514adb518e19caf7b3f6a08bbe82c1b24759c0a8021c3df949fd265bcef6", size = 4662147, upload-time = "2026-05-29T22:32:43.554Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fc/6892859ffbfb60f8cc09a4181c8d407b574d49490e35cd563e7f0a61bf54/nemo_relay-0.5.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:203409e0f392fc4f40f52277347ed75ba9969b2af28ddb3b6ae53f789185a0ed", size = 7769721, upload-time = "2026-07-08T18:43:57.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/13/25c4fcf6fb979af07c9562238819183588a910c2ae09767603500d6948c2/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de61ad0644c5e8c300de814f6884c5de7ae51db14694f5057f938b4da54bfaaf", size = 7037083, upload-time = "2026-07-08T18:44:00.135Z" },
{ url = "https://files.pythonhosted.org/packages/49/97/bd4c77e975d50f7d09f8bfcb74d7e704e05a8b2205f555bf557f84edc5fe/nemo_relay-0.5.0-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb323569f104506f4f1a0e2104b22d74c9a0bbf3280ec4d58ad3d768de76fb15", size = 7430817, upload-time = "2026-07-08T18:44:02.165Z" },
{ url = "https://files.pythonhosted.org/packages/da/8b/5681f3430e6d25bf7419dbe576da14599e1cfadc083edb74e691a0c3a980/nemo_relay-0.5.0-cp311-abi3-win_amd64.whl", hash = "sha256:da1bd1e1dec79b7bda3984dd8c59b32ba97abf96c37e85cd1f38d208a29f42b9", size = 7359048, upload-time = "2026-07-08T18:44:04.169Z" },
{ url = "https://files.pythonhosted.org/packages/4e/f2/65c3ef0435e671c829063d4872897f9054f369f0aeacab12c5aefe486705/nemo_relay-0.5.0-cp311-abi3-win_arm64.whl", hash = "sha256:87462585f17b40ce82c54347acef5ace96ee11de3015f234e0b902f23b3793fb", size = 6934235, upload-time = "2026-07-08T18:44:06.078Z" },
]
[[package]]