About Native Dynamic Plugins

View as Markdown

A native dynamic plugin is a trusted Rust shared library that Relay loads into its own process. It follows the same validation and PluginContext contract as other plugins, but typed middleware crosses a stable C boundary without a worker process or gRPC JSON envelope. This model is appropriate when reusable callback behavior is sensitive to latency or throughput and the deployment can carry a platform-specific binary.

Native plugins are not sandboxed. They share the host address space, allocator boundary, and process fate. Load only reviewed artifacts, keep ABI ownership rules intact, and assume a native crash can terminate the Relay host.

Version Contracts

Three version values answer different questions:

ContractCurrent ValueMeaning
Package manifestmanifest_version = 1Shape of the authored relay-plugin.toml file.
Manifest native APIcompat.native_api = "1"Native plugin package contract accepted by discovery and trust validation.
C host-table ABIv4Function table negotiated by the current nemo-relay-plugin SDK. The host also exposes frozen v3 and v2 tables for compatible older binaries.

Typed async middleware and SDK executor configuration require the 0.8 contract, so the checked example declares compat.relay = ">=0.8.0,<1.0" and depends on nemo-relay-plugin 0.8.0. A manifest that admits an older host cannot promise those surfaces.

Relay 0.8 changes the native API 1 tool-result JSON contract without changing the v4 host-table layout. A tool callback and ToolNext continuation now return ToolExecutionResult, which carries result and optional opaque annotation; an execution intercept returns that pair plus pending marks. Rebuild native plugins for this contract even though the negotiated host-table ABI remains v4.

The v4 SDK also lets a plugin add a data schema and log severity to a mark, or emit validated metric measurements. PluginRuntime::runtime_diagnostics() returns the current host-level diagnostic snapshot. The snapshot is ordered by code and is not attributed to a particular plugin.

What the SDK Owns

The Rust SDK exports the stable entry symbol, converts host-owned JSON handles into typed DTOs, registers all 16 plugin surfaces, and drives async middleware on one SDK-owned multi-thread Tokio runtime per configured component. A plugin can set a default executor size and accept a positive executor.worker_threads component override. The default is two workers; change it only after measuring queued async work and account for the number of native components in the host.

Subscribers remain synchronous and run on Relay’s subscriber dispatcher. Typed middleware returns futures and runs on the SDK executor. A callback has no stable OS thread affinity, separate invocations can overlap, and blocking an executor thread can delay unrelated calls from the same component.

The checked-in examples/rust-native-plugin project is the end-to-end implementation. Its configuration, observation, request policy, execution wrappers, and runtime helpers are separated into modules so each following page can explain one responsibility without presenting a monolithic sample.

Implement the Native Plugin Entrypoint

The root module is intentionally small. validate checks both the example settings and the SDK-owned executor override. register parses the same settings once, obtains the component runtime handle, and delegates each feature group. The export macro produces the nemo_relay_register_plugin symbol named in the manifest.

1mod config;
2mod execution;
3mod observe;
4mod requests;
5mod runtime;
6
7use nemo_relay_plugin::{
8 ConfigDiagnostic, DiagnosticLevel, Json, NativeExecutorConfig,
9 NativePlugin, PluginContext,
10};
11use serde_json::Map;
12
13struct ExampleNativePlugin;
14
15impl NativePlugin for ExampleNativePlugin {
16 fn plugin_kind(&self) -> &str {
17 "examples.rust_native_policy"
18 }
19
20 fn executor_config(&self) -> NativeExecutorConfig {
21 NativeExecutorConfig { worker_threads: 2 }
22 }
23
24 fn allows_multiple_components(&self) -> bool {
25 false
26 }
27
28 fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
29 let mut diagnostics = config::validate(plugin_config);
30 if let Err(message) = self.executor_config_for_component(plugin_config) {
31 diagnostics.push(ConfigDiagnostic {
32 level: DiagnosticLevel::Error,
33 code: "examples.rust_native_policy.invalid_executor".into(),
34 component: Some("examples.rust_native_policy".into()),
35 field: Some("executor.worker_threads".into()),
36 message,
37 });
38 }
39 diagnostics
40 }
41
42 fn register(
43 &mut self,
44 plugin_config: &Map<String, Json>,
45 context: &mut PluginContext<'_>,
46 ) -> nemo_relay_plugin::Result<()> {
47 let config = config::ExampleConfig::parse(plugin_config)?;
48 let runtime = context.runtime();
49 observe::register(context, &config, &runtime)?;
50 requests::register(context, &config)?;
51 execution::register(context, &config, &runtime)?;
52 Ok(())
53 }
54}
55
56nemo_relay_plugin::nemo_relay_plugin!(
57 nemo_relay_register_plugin,
58 || ExampleNativePlugin
59);

The kind in plugin_kind() must match [plugin].id in relay-plugin.toml. Returning false from allows_multiple_components tells Relay to reject a document that tries to activate two configurations of this native implementation. The SDK owns registrations created through context; the plugin does not keep raw registry handles or unload them itself.

Complete Path

Follow these pages in order to build, activate, exercise, and remove the native plugin:

  1. Follow Build and Package to build the cdylib, validate its schema and manifest, calculate integrity, and register it.
  2. Add observability behavior with Observe and Sanitize, including subscribers and all three event sanitizer surfaces.
  3. Add policy and request rewriting with Control Requests, preserving annotations and making priority and break_chain explicit.
  4. Add tool, unary model, and lazy stream wrappers with Wrap Execution.
  5. Verify marks, scopes, isolated stacks, cleanup, and executor control with Runtime Events and Scopes.
  6. Consult Native ABI Reference only when implementing or auditing the raw boundary.

Success is not merely a library that loads. The atomic lifecycle test builds the cdylib in an isolated target directory, materializes and integrity-checks its manifest, activates a valid component, executes a managed tool call, observes the runtime mark, and clears registrations before the library unloads. The focused configuration tests cover rejected input and schema shape; the scenario pages show the remaining callback contracts.