Skip to content

feat(python): initialize plugins from TOML - #684

Draft
bbednarski9 wants to merge 10 commits into
release/0.7from
feat/python-plugin-file-activation
Draft

feat(python): initialize plugins from TOML#684
bbednarski9 wants to merge 10 commits into
release/0.7from
feat/python-plugin-file-activation

Conversation

@bbednarski9

@bbednarski9 bbednarski9 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Overview

Adds an owned Python initialization path for ordinary plugins.toml files, including lifecycle-enabled dynamic plugins, without requiring an embedded host to parse [[plugins.dynamic]] or construct activation specs itself.

This is the first PR in a two-PR stack. It introduces the binding-neutral resolver and activation foundation and exposes it in Python. A stacked follow-up will migrate the CLI runtime owner to the same activation plan while preserving the CLI control plane and bootstrap behavior.

  • I confirm this contribution is my own work, or I have the right to submit it under this project's license.
  • I searched existing issues and open pull requests, and this does not duplicate existing work.

Details

  • Add the versioned nemo-relay-plugin-host-config workspace crate. It shares Relay's user/project/system source selection, component merge rules, dynamic host policy, lifecycle state, trust checks, managed-environment attestation, and immutable activation snapshots with embedded hosts.
  • Add nemo_relay.plugin.initialize_from_plugins_toml(...) and the owned PluginFileActivation async lifetime. The API supports default discovery or a selected plugins.toml, a highest-precedence static overlay, inactive no-input handles, owned static-only handles, deterministic/idempotent close, async context management, and best-effort finalization.
  • Treat [[plugins.dynamic]] as declarations only. Each declaration is validated and reconciled against the same source file's sibling .dynamic-plugins.json; only live records with spec.enabled == true enter the activation plan. Initialization never installs packages, creates environments, changes enablement, or edits plugins.toml.
  • Persist reconciliation atomically before any code load. Source ownership is tracked in a hidden lifecycle-state sidecar without repurposing public config_ref; duplicate IDs and ambiguous live source moves fail closed.
  • Pin configuration and lifecycle sources to one physical path while keeping ordinary Windows paths portable for child runtimes. The resolver captures the winning selected spelling and physical identity in one pass, preserving highest-precedence alias semantics without re-resolving a consumed source. Reserved, overlong, UNC, and otherwise ambiguous paths retain their verbatim representation instead of being rewritten unsafely.
  • Add a hidden binding-neutral PluginHostActivationPlan that carries resolved static config, ordered dynamic specs, redacted diagnostics, and Arc-owned snapshot resources into Relay's cancellation-resistant mutation executor.
  • Harden native and worker ownership so callbacks, registrations, runtimes, libraries, managed environments, and snapshots are released in dependency order. Unsafe rollback or teardown retains the process lease and resources fail closed.
  • Keep relocatable macOS CPython workers self-contained by snapshotting the exact libpython<major>.<minor>.dylib selected by the attestation-verified pyvenv.cfg; the copied runtime library is then covered by the immutable snapshot digest and protection.
  • Preserve the existing initialize(), initialize_with_dynamic_plugins(), explicit activation specs, generic clear APIs, and non-Python bindings unchanged.
  • Add Python API documentation, discovery/lifecycle/security guidance, 0.7 release notes, workspace packaging/publishing metadata, and CI coverage for the new crate.

Security model: calling the new initializer is explicit consent to load lifecycle-enabled trusted native libraries or worker processes from the selected configuration scope. Enabled Python workers must already have a valid Relay-managed environment and attestation; this API is not an installer or control plane. Diagnostics include source paths and plugin IDs but do not include component values or credentials.

Validation completed locally:

  • cargo test -p nemo-relay-plugin-host-config — 84 unit tests and 8 integration tests passed.
  • cargo test -p nemo-relay-cli (1,204 library tests, 12 architecture tests, and 98 CLI integration tests)
  • cargo test -p nemo-relay-cli plugins::lifecycle::tests (79 lifecycle/control-plane tests)
  • cargo test -p nemo-relay --features worker-grpc --test native_plugin_integration --test worker_plugin_integration
  • just test-rust
  • just test-python-plugin
  • just test-python-plugin-e2e under the CI-pinned uv CPython 3.11.14 runtime (managed gRPC worker add, enable, activation, middleware execution, and teardown)
  • just test-python
  • just test-go
  • just test-node
  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • just package-rust
  • just package-python
  • just package-python-sdist (verified the shared crate is present in the sdist)
  • just docs
  • just docs-linkcheck
  • uv run pre-commit run --all-files
  • GitHub Actions: 75 checks passed across functional, packaging, smoke, docs, and policy jobs; Codecov passed at 94.57% for Dynamic Plugin SDKs and 90.26% for the patch.

Breaking changes: none. This is an additive Python API and a document-hidden Rust host contract. Node, FFI, and Go retain their existing explicit-spec APIs.

Where should the reviewer start?

Start with crates/plugin-host-config/src/activation.rs and crates/plugin-host-config/src/resolver.rs for the file-backed contract, then review crates/core/src/plugin/dynamic/host.rs for atomic ownership and teardown. The Python surface is wired in crates/python/src/py_plugin.rs and python/nemo_relay/plugin.py.

The most important design invariant is that the same immutable snapshot verified immediately before load remains owned by the native or worker runtime through the last in-flight callback. Lifecycle reconciliation is durable configuration state, not part of the runtime activation transaction.

Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to)

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: ac76ad65-b782-4a08-9a7c-6ca80d2b2909

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added size:XXL PR is very large Feature a new feature lang:python PR changes/introduces Python code lang:rust PR changes/introduces Rust code labels Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

License Diff

Compared against origin/main.

Lockfile license changes

Lockfile License Changes

Rust

Added

  • None

Removed

  • None

Updated/Changed

  • None

Node

Added

  • None

Removed

  • None

Updated/Changed

  • None

Python

Added

  • None

Removed

  • None

Updated/Changed

  • None
Status output
[license-diff] selected languages: rust, node, python
[license-diff] generating current inventory
[license-diff] current: generating Rust inventory
[license-diff] current: Rust inventory complete (449 packages)
[license-diff] current: generating Node inventory
[license-diff] current: Node inventory complete (367 packages)
[license-diff] current: generating Python inventory
[license-diff] current: Python inventory complete (105 packages)
[license-diff] current inventory complete
[license-diff] checking out base ref origin/main into a temporary worktree
[license-diff] base: generating Rust inventory
[license-diff] base: Rust inventory complete (449 packages)
[license-diff] base: generating Node inventory
[license-diff] base: Node inventory complete (367 packages)
[license-diff] base: generating Python inventory
[license-diff] base: Python inventory complete (105 packages)
[license-diff] base inventory complete
[license-diff] removing temporary base worktree
[license-diff] comparing inventories
[license-diff] rendering Markdown output
[license-diff] done

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.26154% with 458 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.21%. Comparing base (0ef068f) to head (883cc68).

Files with missing lines Patch % Lines
crates/plugin-host-config/src/snapshot.rs 86.88% 100 Missing ⚠️
crates/plugin-host-config/src/environment.rs 90.67% 64 Missing ⚠️
crates/core/src/plugin/dynamic/worker.rs 85.07% 60 Missing ⚠️
crates/core/src/plugin/dynamic/host.rs 82.30% 57 Missing ⚠️
crates/plugin-host-config/src/lifecycle.rs 88.10% 45 Missing ⚠️
crates/cli/src/plugins/lifecycle/state.rs 82.72% 28 Missing ⚠️
crates/plugin-host-config/src/state.rs 90.07% 28 Missing ⚠️
crates/cli/src/plugins/lifecycle/mod.rs 89.72% 26 Missing ⚠️
crates/plugin-host-config/src/io.rs 87.25% 13 Missing ⚠️
crates/plugin-host-config/src/resolver.rs 97.83% 10 Missing ⚠️
... and 8 more
Additional details and impacted files
@@               Coverage Diff               @@
##           release/0.7     #684      +/-   ##
===============================================
- Coverage        94.40%   94.21%   -0.19%     
===============================================
  Files              330      338       +8     
  Lines            96829    99893    +3064     
  Branches           113      113              
===============================================
+ Hits             91404    94110    +2706     
- Misses            5425     5782     +357     
- Partials             0        1       +1     
Components Coverage Δ
Rust Runtime 96.26% <86.68%> (-0.14%) ⬇️
Shared DTO Types 99.05% <ø> (ø)
CLI 91.83% <86.87%> (-0.23%) ⬇️
Go Binding 95.02% <ø> (-0.01%) ⬇️
Python Binding 95.15% <96.43%> (-0.03%) ⬇️
Node Binding 98.01% <ø> (-0.35%) ⬇️
Dynamic Plugin SDKs 94.57% <91.57%> (-1.88%) ⬇️
Files with missing lines Coverage Δ
crates/cli/src/configuration/mod.rs 86.70% <100.00%> (-4.85%) ⬇️
crates/cli/src/configuration/types.rs 100.00% <ø> (ø)
crates/cli/src/plugins/lifecycle/environment.rs 82.16% <100.00%> (-3.53%) ⬇️
crates/plugin-host-config/src/error.rs 100.00% <100.00%> (ø)
crates/plugin-host-config/src/policy.rs 100.00% <100.00%> (ø)
python/nemo_relay/plugin.py 99.44% <100.00%> (+0.09%) ⬆️
crates/core/src/plugin/dynamic.rs 96.23% <98.33%> (+1.28%) ⬆️
crates/core/src/plugin.rs 95.45% <92.00%> (+0.23%) ⬆️
crates/core/src/plugin/dynamic/native.rs 95.69% <97.85%> (+0.05%) ⬆️
crates/core/src/plugin/dynamic/registry.rs 99.27% <93.10%> (-0.73%) ⬇️
... and 14 more

... and 7 files with indirect coverage changes


Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 0ef068f...883cc68. Read the comment docs.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 changed the base branch from release/0.7 to main August 4, 2026 15:25
@bbednarski9 bbednarski9 added this to the 0.8 milestone Aug 4, 2026
Signed-off-by: Bryan Bednarski <bbednarski@nvidia.com>
@bbednarski9
bbednarski9 changed the base branch from main to release/0.7 August 4, 2026 16:07
rapids-bot Bot pushed a commit that referenced this pull request Aug 4, 2026
#### 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Feature a new feature lang:python PR changes/introduces Python code lang:rust PR changes/introduces Rust code size:XXL PR is very large

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enhancement]: Initialize discovered [[plugins.dynamic]] without caller-built specs in Python

1 participant