Skip to content

Commit e5876f1

Browse files
authored
fix(python): load dynamic plugin specs from TOML (#694)
#### Overview Adds a minimal Python compatibility API that converts standard `[[plugins.dynamic]]` records from one explicit `plugins.toml` into the existing `DynamicPluginActivationSpec` objects accepted by `initialize_with_dynamic_plugins()`. This unblocks Python applications that embed Relay without introducing the larger file-backed activation, lifecycle reconciliation, dynamic layering, or initialization redesign proposed for a later release. The new API is intentionally a temporary 0.7 surface: ```python plugin_config_path = os.environ["NEMO_RELAY_PLUGINS_TOML"] dynamic_plugins = plugin.load_dynamic_plugin_activation_specs(plugin_config_path) activation = await plugin.initialize_with_dynamic_plugins({}, dynamic_plugins) ``` `NEMO_RELAY_PLUGINS_TOML` is an optional host-side convention in this example. Relay does not read the environment variable automatically; the embedding application resolves a path through its environment, command-line, or configuration system and passes that path to the helper. - [X] I confirm this contribution is my own work, or I have the right to submit it under this project's license. - [X] I searched existing issues and open pull requests, and this does not duplicate existing work. A broader implementation exists in #684. This PR is a deliberately scoped 0.7 alternative that reuses the existing activation owner instead of introducing shared lifecycle and host-configuration infrastructure. #### Details ##### Public API Adds: ```python def load_dynamic_plugin_activation_specs( plugin_config_path: str | os.PathLike[str], ) -> list[DynamicPluginActivationSpec]: ... ``` The helper: * Reads one explicitly selected `plugins.toml`. * Parses every `[[plugins.dynamic]]` record in declaration order. * Resolves relative manifest paths against the selected file. * Reads `plugin.id` and `plugin.kind` from each manifest. * Preserves the record's JSON-compatible `config`. * Rejects malformed TOML, invalid record shapes, unsupported fields, invalid plugin identities, duplicate plugin IDs, and non-JSON configuration. * Returns the existing activation-spec type without loading code. The existing dynamic initializer now accepts a `Sequence` rather than only a `list`. This reflects its existing behavior and allows parser results, lists, and tuples to compose without casts. ##### Developer flow ```mermaid flowchart LR User["User selects a plugins.toml"] --> Host["Embedding host resolves the path"] Env["Optional NEMO_RELAY_PLUGINS_TOML"] --> Host Host --> Helper["load_dynamic_plugin_activation_specs(path)"] Helper --> Config["Read one explicit plugins.toml"] Config --> Records["Parse [[plugins.dynamic]] records"] Records --> Manifests["Resolve and read relay-plugin.toml manifests"] Manifests --> Specs["Build DynamicPluginActivationSpec list"] Specs --> Initialize["initialize_with_dynamic_plugins(config, specs)"] Initialize --> Activation["Owned PluginHostActivation"] Activation --> Runtime["Host retains activation while work is admitted"] Runtime --> Close["await activation.close() during shutdown"] ``` ##### Configuration behavior The temporary dynamic path and existing static configuration path remain separate: ```mermaid flowchart TB subgraph Static["Existing static component resolution"] UserConfig["User plugins.toml"] --> StaticLayering["User → project → system → programmatic overlay"] ProjectConfig["Project .nemo-relay/plugins.toml"] --> StaticLayering SystemConfig["System /etc/nemo-relay/plugins.toml"] --> StaticLayering end subgraph Dynamic["New 0.7 compatibility path"] ExplicitPath["One explicit plugins.toml path"] --> DynamicParser["Parse [[plugins.dynamic]] only"] DynamicParser --> DynamicSpecs["Explicit activation specs"] end StaticLayering --> HostInitializer["Existing dynamic host initializer"] DynamicSpecs --> HostInitializer HostInitializer --> OwnedHost["PluginHostActivation"] ``` The helper does not perform dynamic-plugin layering. It reads only the explicitly supplied file. Static `[[components]]` from that file are inherited only when the same file is also selected by Relay's normal static discovery. Every dynamic declaration in the selected file becomes an activation spec. Passing those specs to `initialize_with_dynamic_plugins()` is explicit consent to load the referenced trusted native libraries or worker processes. Python workers that require a lifecycle-managed `environment_ref` still require the existing explicit activation or CLI lifecycle path. ##### Intentional non-goals This PR does not: * Consolidate `initialize()` and `initialize_with_dynamic_plugins()`. * Add a unified `initialize_from_plugins_toml()` API. * Discover or merge dynamic records across user, project, and system layers. * Read or reconcile `.dynamic-plugins.json`. * Consult CLI enablement or tombstone state. * Provision or attest Python worker environments. * Change plugin enablement, install plugins, or execute package managers. * Change Rust, Node.js, Go, FFI, or CLI behavior. The helper is documented as a 0.7 compatibility surface and is expected to be deprecated after the unified file-backed initializer lands. Keeping the conversion behind one Relay API lets embedded hosts remove their TOML and manifest parsing now while keeping the future migration localized to one call site. ##### Documentation and validation Updates the Python type stub, plugin-configuration guide, and 0.7 release notes. Tests cover relative and absolute manifest resolution, native and worker spec construction, config preservation, malformed records and TOML, missing manifests, duplicate IDs, and end-to-end native activation from a real `[[plugins.dynamic]]` record. Validation completed: * Focused parser and native-activation tests: `16 passed`. * Ruff formatting and linting. * `ty` type checking. * Changed-file and repository-wide pre-commit suites. * Cargo formatting, clippy, check, and dependency-policy checks. * Python worker protobuf compatibility. * Go formatting and vet. * Node formatting and public docstring checks. * Fern structure and strict broken-link validation. The complete dynamic-host Python module was also attempted locally. Pre-existing tests inherited an invalid machine-level `/etc/nemo-relay/plugins.toml`, and sandboxed worker tests could not bind Unix sockets. The tests directly covering this change passed independently. Breaking changes: none. #### Where should the reviewer start? Start with `python/nemo_relay/plugin.py`, specifically `load_dynamic_plugin_activation_specs()`. The central design decision is that this helper performs only the missing file-to-activation-spec conversion. It deliberately reuses the existing dynamic initializer and owned activation lifetime rather than introducing another activation owner or pulling CLI lifecycle behavior into the Python binding. Then review `python/tests/test_dynamic_plugin_host.py` for the standard TOML parsing, failure behavior, and end-to-end native activation coverage. #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) * Relates to #673 * Relates to #684 * Relates to [NousResearch/hermes-agent#77915](<NousResearch/hermes-agent#77915>) ## Summary by CodeRabbit * **New Features** * Added a Python compatibility helper for loading dynamic plugin activation specifications from a selected `plugins.toml` file. * Supports manifest path resolution, ordered activation specifications, nested configuration, duplicate detection, and validation of plugin records and JSON values. * Dynamic plugin initialization now accepts any ordered collection of activation specifications. * **Documentation** * Added guidance covering configuration resolution, explicit loading consent, supported behavior, limitations, and planned deprecation. * **Tests** * Expanded coverage for valid configurations, absolute and nested manifest paths, malformed files, duplicate IDs, and missing manifests. ## Summary by CodeRabbit * **New Features** * Added support for loading dynamic plugin activation settings from a selected `plugins.toml` file. * Added validation for manifests, duplicate identifiers, malformed configuration, and invalid JSON values. * Dynamic plugin initialization now accepts any ordered collection of activation specifications. * **Documentation** * Added configuration guidance, behavior details, limitations, compatibility notes, and planned deprecation information. * **Tests** * Added coverage for valid configurations, path resolution, nested settings, and common loading errors. Authors: - Bryan Bednarski (https://github.com/bbednarski9) Approvers: - Will Killian (https://github.com/willkill07) - Maryam Najafian (https://github.com/mnajafian-nv) URL: #694
1 parent 05700e1 commit e5876f1

5 files changed

Lines changed: 345 additions & 4 deletions

File tree

docs/about-nemo-relay/release-notes/index.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ their values cannot be isolated between endpoints.
102102

103103
### Fixed Known Issues in 0.7
104104

105+
- Embedded Python hosts can use
106+
`plugin.load_dynamic_plugin_activation_specs(path)` to convert the standard
107+
`[[plugins.dynamic]]` records in one explicitly selected `plugins.toml` into
108+
the activation specs accepted by `initialize_with_dynamic_plugins()`. This
109+
scoped 0.7 compatibility helper removes host-side TOML and manifest parsing;
110+
a future unified file-backed initializer is expected to replace it.
105111
- Programmatically declared plugin components now apply their `enabled` value
106112
over discovered file configuration. When code re-enables a component that a
107113
discovered file disabled, initialization reports a warning that names the

docs/configure-plugins/plugin-configuration-files.mdx

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,50 @@ manifest’s optional static JSON Schema before you enable or run the plugin. Us
163163
dynamic-plugin lifecycle. Refer to [Configure Discoverable Plugins](/configure-plugins/discoverable-plugins)
164164
for manifest, trust, and policy requirements.
165165

166+
### Embedded Python Compatibility Helper
167+
168+
Python hosts that already own plugin activation can convert the dynamic records
169+
from one explicitly selected file into the activation specs accepted by the
170+
0.7 host API:
171+
172+
```python
173+
import asyncio
174+
175+
from nemo_relay import plugin
176+
177+
178+
async def main() -> None:
179+
dynamic_plugins = plugin.load_dynamic_plugin_activation_specs(
180+
"path/to/plugins.toml"
181+
)
182+
activation = await plugin.initialize_with_dynamic_plugins({}, dynamic_plugins)
183+
async with activation:
184+
# Run your host application while dynamic plugins are active.
185+
...
186+
187+
188+
asyncio.run(main())
189+
```
190+
191+
The helper resolves each manifest relative to `plugins.toml` and reads the
192+
plugin ID and execution lane from the manifest. It does not perform discovery,
193+
consult CLI lifecycle state, provision a Python worker environment, or change
194+
enablement.
195+
196+
On success, the helper returns every `[[plugins.dynamic]]` declaration in file
197+
order; it does not skip invalid entries. A missing `plugins.toml` or referenced
198+
manifest raises `FileNotFoundError`. Malformed TOML, invalid records or required
199+
manifest fields, duplicate plugin IDs, and non-JSON configuration raise
200+
`ValueError`. The helper does not apply an optional manifest-declared static
201+
JSON Schema.
202+
203+
Passing the result to `initialize_with_dynamic_plugins()` is explicit consent
204+
to load those trusted native libraries or worker processes.
205+
206+
This helper is a 0.7 compatibility surface for embedded integrations and is
207+
planned for deprecation after Relay provides a unified file-backed
208+
initializer. Keep its use localized so migration is straightforward.
209+
166210
The runtime reads only files named `plugins.toml` during default discovery.
167211

168212
## Runtime Discovery

python/nemo_relay/plugin.py

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,13 @@
1111
from __future__ import annotations
1212

1313
import asyncio
14+
import json
15+
import os
16+
import tomllib
17+
from collections.abc import Sequence
1418
from contextlib import asynccontextmanager
1519
from dataclasses import dataclass, field, fields, is_dataclass
20+
from pathlib import Path
1621
from typing import TYPE_CHECKING, AsyncIterator, Callable, Literal, Protocol, Self, TypedDict, cast
1722

1823
from nemo_relay import (
@@ -417,6 +422,114 @@ async def __aexit__(
417422
await self.close()
418423

419424

425+
def load_dynamic_plugin_activation_specs(
426+
plugin_config_path: str | os.PathLike[str],
427+
) -> list[DynamicPluginActivationSpec]:
428+
"""Load dynamic activation specs from one standard ``plugins.toml``.
429+
430+
Args:
431+
plugin_config_path: Explicit path to the ``plugins.toml`` file.
432+
433+
Returns:
434+
Activation specs for every ``[[plugins.dynamic]]`` record, in file
435+
order. Manifest paths are resolved relative to ``plugins.toml`` and
436+
each plugin's identifier and execution lane come from its manifest.
437+
438+
Behavior:
439+
This 0.7 compatibility helper parses one explicit file only. It does
440+
not perform standard discovery, inspect CLI lifecycle state, provision
441+
worker environments, change enablement, or activate plugins. It is
442+
planned for deprecation after a unified file-backed initializer is
443+
available. Keep its use localized and pass the result to
444+
:func:`initialize_with_dynamic_plugins`.
445+
"""
446+
source = Path(os.fspath(plugin_config_path)).resolve()
447+
document = _load_plugin_toml(source, "plugin TOML")
448+
version = document.get("version", 1)
449+
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
450+
raise ValueError(f"plugin config version {version!r} in {source} is unsupported; expected 1")
451+
plugins = document.get("plugins", {})
452+
if not isinstance(plugins, dict):
453+
raise ValueError(f"invalid dynamic plugin config in {source}: 'plugins' must be a table")
454+
plugins = cast(dict[str, object], plugins)
455+
dynamic_plugins = plugins.get("dynamic", [])
456+
if not isinstance(dynamic_plugins, list):
457+
raise ValueError(f"invalid dynamic plugin config in {source}: 'plugins.dynamic' must be an array of tables")
458+
459+
specs: list[DynamicPluginActivationSpec] = []
460+
seen_plugin_ids: set[str] = set()
461+
for index, entry in enumerate(dynamic_plugins):
462+
if not isinstance(entry, dict):
463+
raise ValueError(f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}] must be a table")
464+
entry = cast(dict[str, object], entry)
465+
unknown_fields = sorted(set(entry) - {"manifest", "config"})
466+
if unknown_fields:
467+
raise ValueError(
468+
f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}] has unknown fields: "
469+
+ ", ".join(unknown_fields)
470+
)
471+
manifest_ref = entry.get("manifest")
472+
if not isinstance(manifest_ref, str) or not manifest_ref.strip():
473+
raise ValueError(
474+
f"invalid dynamic plugin config in {source}: "
475+
f"plugins.dynamic[{index}].manifest must be a non-empty string"
476+
)
477+
manifest_path = Path(manifest_ref)
478+
if not manifest_path.is_absolute():
479+
manifest_path = source.parent / manifest_path
480+
manifest_path = manifest_path.resolve()
481+
482+
manifest = _load_plugin_toml(manifest_path, "dynamic plugin manifest")
483+
identity = manifest.get("plugin")
484+
if not isinstance(identity, dict):
485+
raise ValueError(f"invalid dynamic plugin manifest in {manifest_path}: 'plugin' must be a table")
486+
identity = cast(dict[str, object], identity)
487+
plugin_id = identity.get("id")
488+
if not isinstance(plugin_id, str) or not plugin_id.strip():
489+
raise ValueError(
490+
f"invalid dynamic plugin manifest in {manifest_path}: 'plugin.id' must be a non-empty string"
491+
)
492+
plugin_id = plugin_id.strip()
493+
kind = identity.get("kind")
494+
if kind not in ("rust_dynamic", "worker"):
495+
raise ValueError(
496+
f"invalid dynamic plugin manifest in {manifest_path}: 'plugin.kind' must be 'rust_dynamic' or 'worker'"
497+
)
498+
if plugin_id in seen_plugin_ids:
499+
raise ValueError(f"duplicate dynamic plugin id {plugin_id!r} in {source}")
500+
seen_plugin_ids.add(plugin_id)
501+
502+
config = entry.get("config", {})
503+
if not isinstance(config, dict):
504+
raise ValueError(
505+
f"invalid dynamic plugin config in {source}: plugins.dynamic[{index}].config must be a table"
506+
)
507+
try:
508+
normalized_config = cast(JsonObject, json.loads(json.dumps(config, allow_nan=False)))
509+
except (TypeError, ValueError) as error:
510+
raise ValueError(
511+
f"invalid dynamic plugin config in {source}: "
512+
f"plugins.dynamic[{index}].config must contain JSON values: {error}"
513+
) from error
514+
specs.append(
515+
DynamicPluginActivationSpec(
516+
plugin_id=plugin_id,
517+
kind=cast(DynamicPluginKind, kind),
518+
manifest_ref=str(manifest_path),
519+
config=normalized_config,
520+
)
521+
)
522+
return specs
523+
524+
525+
def _load_plugin_toml(path: Path, description: str) -> dict[str, object]:
526+
try:
527+
with path.open("rb") as file:
528+
return cast(dict[str, object], tomllib.load(file))
529+
except tomllib.TOMLDecodeError as error:
530+
raise ValueError(f"invalid {description} in {path}: {error}") from error
531+
532+
420533
def validate(config: PluginConfig | JsonObject) -> ConfigReport:
421534
"""Validate a plugin configuration without changing runtime state.
422535
@@ -452,7 +565,7 @@ async def initialize(config: PluginConfig | JsonObject) -> ConfigReport:
452565

453566
async def initialize_with_dynamic_plugins(
454567
config: PluginConfig | JsonObject,
455-
dynamic_plugins: list[DynamicPluginActivationSpec | JsonObject],
568+
dynamic_plugins: Sequence[DynamicPluginActivationSpec | JsonObject],
456569
) -> PluginHostActivation:
457570
"""Initialize registered components with dynamic plugins as one owned host.
458571
@@ -607,6 +720,7 @@ def deregister(plugin_kind: str) -> bool:
607720
"PluginContext",
608721
"PluginHostActivation",
609722
"Plugin",
723+
"load_dynamic_plugin_activation_specs",
610724
"initialize_with_dynamic_plugins",
611725
"clear",
612726
"clear_async",

python/nemo_relay/plugin.pyi

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from collections.abc import Callable
4+
import os
5+
from collections.abc import Callable, Sequence
56
from types import TracebackType
67
from typing import AsyncContextManager, Literal, Protocol, Self, TypedDict
78

@@ -160,11 +161,14 @@ class PluginHostActivation:
160161
traceback: TracebackType | None,
161162
) -> None: ...
162163

164+
def load_dynamic_plugin_activation_specs(
165+
plugin_config_path: str | os.PathLike[str],
166+
) -> list[DynamicPluginActivationSpec]: ...
163167
def validate(config: PluginConfig | JsonObject) -> ConfigReport: ...
164168
async def initialize(config: PluginConfig | JsonObject) -> ConfigReport: ...
165169
async def initialize_with_dynamic_plugins(
166170
config: PluginConfig | JsonObject,
167-
dynamic_plugins: list[DynamicPluginActivationSpec | JsonObject],
171+
dynamic_plugins: Sequence[DynamicPluginActivationSpec | JsonObject],
168172
) -> PluginHostActivation: ...
169173
def clear() -> None: ...
170174
async def clear_async() -> None: ...

0 commit comments

Comments
 (0)