diff --git a/agent/relay_runtime.py b/agent/relay_runtime.py index 4f3f0ba5a5..73df097acc 100644 --- a/agent/relay_runtime.py +++ b/agent/relay_runtime.py @@ -267,7 +267,7 @@ class _ProcessRelayPluginConfiguration: try: plugin_mod = getattr(relay, "plugin", None) - plugin_config, dynamic_plugins = _configured_plugin_inputs() + plugin_config, dynamic_plugins = _configured_plugin_inputs(relay) if dynamic_plugins: initialize_dynamic = getattr( plugin_mod, @@ -1905,8 +1905,8 @@ def _load_nemo_relay() -> Any: return importlib.import_module("nemo_relay") -def _configured_plugin_inputs() -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Load one explicit config overlay and its Hermes dynamic host specs.""" +def _configured_plugin_inputs(relay: Any) -> tuple[dict[str, Any], list[Any]]: + """Load one explicit static overlay and dynamic host specs.""" configured = os.environ.get(RELAY_PLUGINS_CONFIG_ENV, "").strip() if not configured: return {}, [] @@ -1915,7 +1915,28 @@ def _configured_plugin_inputs() -> tuple[dict[str, Any], list[dict[str, Any]]]: try: with config_path.open("rb") as config_file: config = tomllib.load(config_file) - dynamic_plugins = _dynamic_plugin_specs(config, config_path) + legacy_dynamic_plugins = _dynamic_plugin_specs(config, config_path) + plugins_section = config.get("plugins") + if plugins_section is not None and legacy_dynamic_plugins: + raise ValueError( + "configure either Relay [[plugins.dynamic]] records or Hermes " + "[[dynamic_plugins]] activation specs, not both" + ) + + dynamic_plugins = legacy_dynamic_plugins + if plugins_section: + plugin_mod = getattr(relay, "plugin", None) + load_dynamic_plugins = getattr( + plugin_mod, + "load_dynamic_plugin_activation_specs", + None, + ) + if not callable(load_dynamic_plugins): + raise RuntimeError( + "installed NeMo Relay binding does not expose " + "plugin.load_dynamic_plugin_activation_specs" + ) + dynamic_plugins = load_dynamic_plugins(config_path) plugin_config = dict(config) plugin_config.pop("dynamic_plugins", None) plugin_config.pop("plugins", None) @@ -1934,16 +1955,10 @@ def _dynamic_plugin_specs( config: dict[str, Any], config_path: Path, ) -> list[dict[str, Any]]: - """Validate Hermes-owned specs for Relay's public dynamic host API.""" + """Validate Hermes worker activation specs pending Relay lifecycle loading.""" plugins_section = config.get("plugins") - if plugins_section is not None: - if not isinstance(plugins_section, dict): - raise ValueError("[plugins] must be a table") - if plugins_section: - raise ValueError( - "Relay CLI [[plugins.dynamic]] records require lifecycle state; " - "use Hermes [[dynamic_plugins]] activation specs" - ) + if plugins_section is not None and not isinstance(plugins_section, dict): + raise ValueError("[plugins] must be a table") raw_specs = config.get("dynamic_plugins") if raw_specs is None: diff --git a/tests/agent/test_relay_runtime_plugins.py b/tests/agent/test_relay_runtime_plugins.py index b486c8efe6..6f6061fecd 100644 --- a/tests/agent/test_relay_runtime_plugins.py +++ b/tests/agent/test_relay_runtime_plugins.py @@ -25,10 +25,12 @@ class _FakeRelay: self.initialize_error = initialize_error self.dynamic_initialize_error = dynamic_initialize_error self.activation_close_error = activation_close_error + self.dynamic_plugin_specs: list[dict[str, Any]] = [] self.ScopeType = SimpleNamespace(Agent="agent") self.plugin = SimpleNamespace( initialize=self._initialize_plugins, initialize_with_dynamic_plugins=self._initialize_dynamic_plugins, + load_dynamic_plugin_activation_specs=self._load_dynamic_plugin_specs, clear=self._clear_plugins, ) self.scope = SimpleNamespace( @@ -65,6 +67,10 @@ class _FakeRelay: return _Activation() + def _load_dynamic_plugin_specs(self, config_path: Any) -> list[dict[str, Any]]: + self.events.append(("plugin.load_dynamic_specs", str(config_path))) + return self.dynamic_plugin_specs + def _clear_plugins(self) -> None: self.events.append(("plugin.clear",)) @@ -570,10 +576,9 @@ manifest_ref = "relay-plugin.toml" host.shutdown() -def test_gateway_dynamic_records_are_not_activated_without_cli_lifecycle_state( +def test_standard_dynamic_records_use_relay_toml_loader( tmp_path, monkeypatch, - caplog, ): config = tmp_path / "plugins.toml" config.write_text( @@ -585,13 +590,22 @@ manifest = "relay-plugin.toml" ) monkeypatch.setenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, str(config)) relay = _FakeRelay() + relay.dynamic_plugin_specs = [ + { + "plugin_id": "native.policy", + "kind": "rust_dynamic", + "manifest_ref": str(tmp_path / "relay-plugin.toml"), + "config": {}, + } + ] - with caplog.at_level("WARNING"): - host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") + host = relay_runtime.RelayRuntime(relay=relay, profile_key="profile") try: assert host.managed_execution_enabled() - assert relay.events == [("plugin.initialize", {})] - assert "require lifecycle state" in caplog.text + assert relay.events == [ + ("plugin.load_dynamic_specs", str(config)), + ("plugin.initialize_dynamic", {}, relay.dynamic_plugin_specs), + ] finally: host.shutdown() @@ -633,6 +647,51 @@ manifest_ref = "worker/relay-plugin.toml" host.shutdown() +def test_real_binding_loads_standard_dynamic_specs_from_explicit_toml( + tmp_path, + monkeypatch, +): + relay = pytest.importorskip("nemo_relay") + manifest = tmp_path / "plugins" / "relay-plugin.toml" + manifest.parent.mkdir() + manifest.write_text( + """ +manifest_version = 1 + +[plugin] +id = "fixture.native" +kind = "rust_dynamic" +""".strip(), + encoding="utf-8", + ) + config = tmp_path / "plugins.toml" + config.write_text( + """ +version = 1 + +[[plugins.dynamic]] +manifest = "plugins/relay-plugin.toml" + +[plugins.dynamic.config] +mode = "strict" +""".strip(), + encoding="utf-8", + ) + monkeypatch.setenv(relay_runtime.RELAY_PLUGINS_CONFIG_ENV, str(config)) + + plugin_config, specs = relay_runtime._configured_plugin_inputs(relay) + + assert plugin_config == {"version": 1} + assert [spec.to_dict() for spec in specs] == [ + { + "plugin_id": "fixture.native", + "kind": "rust_dynamic", + "manifest_ref": str(manifest.resolve()), + "config": {"mode": "strict"}, + } + ] + + def test_real_binding_discovers_project_config_and_exports_native_activity( tmp_path, monkeypatch, @@ -658,7 +717,7 @@ kind = "observability" enabled = true [components.config] -version = 2 +version = 3 [components.config.atof] enabled = true