Skip to content

Commit 2243ec3

Browse files
authored
feat: implement nemo_guardrails remote backend (#144)
#### Overview Adds the first real built-in `nemo_guardrails` remote backend for NeMo Relay core. This PR moves beyond the contract-only surface and implements the first shippable remote slice: - built-in plugin auto-registration in core - remote backend activation for `mode = remote` - non-streaming and streaming LLM execution through the Guardrails server - managed `tool_output` execution checks through the same remote backend - request-default pass-through for remote Guardrails request semantics - explicit validation that stock remote mode does not currently support managed `tool_input` - focused runtime validation and coverage for transport, malformed responses, tool rewrites, and error handling - [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. #### Details - auto-register the built-in `nemo_guardrails` plugin through `ensure_builtin_plugins_registered()` - add a dedicated default core feature `guardrails-remote` for the remote backend dependency path - implement the remote Guardrails runtime across: - `crates/core/src/plugins/nemo_guardrails/component.rs` - `crates/core/src/plugins/nemo_guardrails/remote.rs` - support the native remote server contract for `codec = "openai_chat"` - support both non-streaming and streaming LLM execution - support remote request defaults pass-through: - `context` - `thread_id` - `state` - `rails` - `llm_params` - `llm_output` - `output_vars` - `log` - support managed `tool_output` execution checks via the remote backend - reject managed `tool_input` in stock remote mode with a focused validation error instead of silently leaving it non-enforcing - emit coarse backend-level `nemo_guardrails.remote.*` marks for both LLM and managed tool remote checks - precompute stable remote Guardrails payloads during runtime initialization instead of rebuilding them per request - split remote runtime coverage into `component_tests.rs` and `remote_tests.rs` to keep contract/config tests separate from transport/runtime behavior - add focused unit coverage for: - built-in registration - remote config validation - transport and malformed-response lanes - non-streaming and streaming remote execution - managed `tool_output` blocking and rewrite behavior - tool-only config isolation from LLM execution - context/state/thread propagation on managed tool checks Rail and observability boundary in this PR: - Native NeMo Relay-managed surfaces: - `input` - `output` - `tool_output` - These are real runtime interception surfaces in NeMo Relay. The built-in plugin installs managed LLM and tool execution behavior around them, and the remote backend can block or rewrite those executions directly. - Managed `tool_input` remains a known NeMo Relay surface, but stock remote mode now rejects it explicitly because the Guardrails remote contract does not currently activate pre-execution tool-call rails from externally submitted chat-completions history. - Remote request-time rail pass-through: - `request_defaults.rails.input` - `request_defaults.rails.output` - `request_defaults.rails.retrieval` - `request_defaults.rails.dialog` - `request_defaults.rails.tool_input` - `request_defaults.rails.tool_output` - These are forwarded to the Guardrails server as remote request semantics. In this PR, `retrieval` and `dialog` are supported this way only: NeMo Relay does not currently expose separate managed retrieval or dialog execution surfaces to intercept locally. - The remote backend emits coarse backend-level marks: - `nemo_guardrails.remote.start` - `nemo_guardrails.remote.end` - `nemo_guardrails.remote.error` - These marks cover managed LLM remote execution and managed `tool_output` remote checks. They provide backend-level visibility into remote Guardrails activity without introducing separate NeMo Relay-native retrieval or dialog scopes in this slice. Intentional PR2 boundary: - native remote support is `openai_chat` only - local backend is still not implemented - stock remote mode supports managed `tool_output` but not managed `tool_input` - this stays core-only; binding surface enablement is deferred Validation: - `cargo fmt --all` - `cargo test -p nemo-relay nemo_guardrails --lib --tests` - `cargo clippy --workspace --all-targets -- -D warnings` - `uv run pre-commit run --files crates/core/src/plugins/nemo_guardrails/component.rs crates/core/tests/unit/plugins/nemo_guardrails/component_tests.rs crates/core/tests/unit/plugins/nemo_guardrails/remote_tests.rs` Targeted live validation covered: - allowed and blocked non-streaming LLM requests - streaming requests - trusted HTTPS transport - managed `tool_output` blocked path - direct stock Guardrails repro confirming externally supplied pre-exec tool-call history does not activate remote managed `tool_input` Notes: - `uv run pre-commit run --all-files` still reports an unrelated repo-environment failure in `ty` for unresolved `deepagents` integration imports - unrelated `package-lock.json` churn was restored and is not part of this PR #### Where should the reviewer start? Start in `crates/core/src/plugins/nemo_guardrails/component.rs`, then `crates/core/src/plugins/nemo_guardrails/remote.rs`. The most important design decision in this PR is that the built-in plugin now implements a real remote execution backend while keeping the remote contract honest: - native remote support is OpenAI chat-completions shaped - LLM and managed `tool_output` execution are supported through the core runtime - stock remote managed `tool_input` is explicitly rejected instead of being left as a silent best-effort path - broader non-chat codec parity and the local Python runtime remain out of scope for this slice #### Related Issues: (use one of the action keywords Closes / Fixes / Resolves / Relates to) - Relates to #NMF-131 - Relates to #NMF-148 Authors: - https://github.com/afourniernv Approvers: - Will Killian (https://github.com/willkill07) URL: #144
1 parent fb75181 commit 2243ec3

7 files changed

Lines changed: 3014 additions & 35 deletions

File tree

crates/core/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ readme = "README.md"
1414
workspace = true
1515

1616
[features]
17-
default = ["otel", "openinference"]
17+
default = ["otel", "openinference", "guardrails-remote"]
1818
schema = ["dep:schemars"]
19+
guardrails-remote = [
20+
"dep:reqwest",
21+
"dep:rustls",
22+
]
1923
otel = [
2024
"dep:async-trait",
2125
"dep:getrandom",

crates/core/src/plugin.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -762,9 +762,11 @@ pub fn register_plugin(plugin: Arc<dyn Plugin>) -> Result<()> {
762762
/// Built-in plugins are available to validation and initialization without a
763763
/// binding or application-specific registration call.
764764
pub fn ensure_builtin_plugins_registered() -> Result<()> {
765-
match BUILTIN_PLUGIN_REGISTRATION
766-
.get_or_init(crate::observability::plugin_component::register_observability_component)
767-
{
765+
let register_builtins = || {
766+
crate::observability::plugin_component::register_observability_component()?;
767+
crate::plugins::nemo_guardrails::component::register_nemo_guardrails_component()
768+
};
769+
match BUILTIN_PLUGIN_REGISTRATION.get_or_init(register_builtins) {
768770
Ok(()) => Ok(()),
769771
Err(err) => Err(clone_cached_plugin_error(err)),
770772
}

crates/core/src/plugins/nemo_guardrails/plugin_component.rs renamed to crates/core/src/plugins/nemo_guardrails/component.rs

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,25 @@ use crate::plugin::{
1717
lookup_plugin, register_plugin,
1818
};
1919

20+
#[cfg(all(feature = "guardrails-remote", not(target_arch = "wasm32")))]
21+
#[path = "remote.rs"]
22+
mod remote;
23+
#[cfg(all(feature = "guardrails-remote", not(target_arch = "wasm32")))]
24+
use remote::register_remote_backend;
25+
2026
/// The plugin kind reserved for the planned first-party component.
2127
pub const NEMO_GUARDRAILS_PLUGIN_KIND: &str = "nemo_guardrails";
2228

29+
#[cfg(any(target_arch = "wasm32", not(feature = "guardrails-remote")))]
30+
fn register_remote_backend(
31+
_config: NeMoGuardrailsConfig,
32+
_ctx: &mut PluginRegistrationContext,
33+
) -> PluginResult<()> {
34+
Err(PluginError::RegistrationFailed(
35+
"built-in NeMo Guardrails remote backend is unavailable in this build".to_string(),
36+
))
37+
}
38+
2339
/// Top-level NeMo Guardrails component wrapper.
2440
#[derive(Debug, Clone)]
2541
pub struct ComponentSpec {
@@ -182,6 +198,12 @@ pub struct RequestDefaultsConfig {
182198
/// Default context object passed into Guardrails requests.
183199
#[serde(default, skip_serializing_if = "Option::is_none")]
184200
pub context: Option<Json>,
201+
/// Default remote thread identifier for continuation-aware requests.
202+
#[serde(default, skip_serializing_if = "Option::is_none")]
203+
pub thread_id: Option<String>,
204+
/// Default remote Guardrails state payload for continuation-aware requests.
205+
#[serde(default, skip_serializing_if = "Option::is_none")]
206+
pub state: Option<Json>,
185207
/// Default request-time rail selection.
186208
#[serde(default, skip_serializing_if = "Option::is_none")]
187209
pub rails: Option<RequestRailsConfig>,
@@ -307,6 +329,8 @@ crate::editor_config! {
307329
crate::editor_config! {
308330
impl RequestDefaultsConfig {
309331
context => { label: "context", kind: Json, optional: true },
332+
thread_id => { label: "thread_id", kind: String, optional: true },
333+
state => { label: "state", kind: Json, optional: true },
310334
rails => {
311335
label: "rails",
312336
kind: Section,
@@ -349,13 +373,13 @@ impl Plugin for NeMoGuardrailsPlugin {
349373

350374
fn register<'a>(
351375
&'a self,
352-
_plugin_config: &Map<String, Json>,
353-
_ctx: &'a mut PluginRegistrationContext,
376+
plugin_config: &Map<String, Json>,
377+
ctx: &'a mut PluginRegistrationContext,
354378
) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
355-
Box::pin(async {
356-
Err(PluginError::RegistrationFailed(
357-
"built-in NeMo Guardrails plugin backend is not implemented yet".to_string(),
358-
))
379+
let parsed = parse_nemo_guardrails_config(plugin_config);
380+
Box::pin(async move {
381+
let config = parsed?;
382+
register_nemo_guardrails_backend(config, ctx)
359383
})
360384
}
361385
}
@@ -419,6 +443,21 @@ fn string_enum_schema(
419443
schema.into()
420444
}
421445

446+
fn register_nemo_guardrails_backend(
447+
config: NeMoGuardrailsConfig,
448+
ctx: &mut PluginRegistrationContext,
449+
) -> PluginResult<()> {
450+
match config.mode.as_str() {
451+
"remote" => register_remote_backend(config, ctx),
452+
"local" => Err(PluginError::RegistrationFailed(
453+
"built-in NeMo Guardrails local backend is not implemented yet".to_string(),
454+
)),
455+
other => Err(PluginError::InvalidConfig(format!(
456+
"unsupported NeMo Guardrails mode '{other}'"
457+
))),
458+
}
459+
}
460+
422461
fn parse_nemo_guardrails_config(
423462
plugin_config: &Map<String, Json>,
424463
) -> PluginResult<NeMoGuardrailsConfig> {
@@ -497,6 +536,8 @@ fn validate_nemo_guardrails_plugin_config(
497536
"request_defaults",
498537
&[
499538
"context",
539+
"thread_id",
540+
"state",
500541
"rails",
501542
"llm_params",
502543
"llm_output",
@@ -526,6 +567,7 @@ fn validate_nemo_guardrails_plugin_config(
526567
validate_config_shape(&mut diagnostics, &config.policy, &config);
527568
validate_codec_requirements(&mut diagnostics, &config.policy, &config);
528569
validate_surface_selection(&mut diagnostics, &config.policy, &config);
570+
validate_remote_backend_support(&mut diagnostics, &config.policy, &config);
529571
validate_request_defaults(&mut diagnostics, &config.policy, &config);
530572

531573
diagnostics
@@ -869,6 +911,43 @@ fn validate_surface_selection(
869911
);
870912
}
871913

914+
fn validate_remote_backend_support(
915+
diagnostics: &mut Vec<ConfigDiagnostic>,
916+
policy: &ConfigPolicy,
917+
config: &NeMoGuardrailsConfig,
918+
) {
919+
if config.mode != "remote" {
920+
return;
921+
}
922+
923+
if (config.input || config.output)
924+
&& config
925+
.codec
926+
.as_deref()
927+
.is_some_and(|codec| codec != "openai_chat")
928+
{
929+
push_policy_diag(
930+
diagnostics,
931+
policy.unsupported_value,
932+
"nemo_guardrails.unsupported_value",
933+
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
934+
Some("codec".to_string()),
935+
"remote mode currently supports only codec = 'openai_chat'".to_string(),
936+
);
937+
}
938+
939+
if config.tool_input {
940+
push_policy_diag(
941+
diagnostics,
942+
policy.unsupported_value,
943+
"nemo_guardrails.unsupported_value",
944+
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
945+
Some("tool_input".to_string()),
946+
"remote mode does not currently support managed tool_input against the stock Guardrails remote contract".to_string(),
947+
);
948+
}
949+
}
950+
872951
fn validate_request_defaults(
873952
diagnostics: &mut Vec<ConfigDiagnostic>,
874953
policy: &ConfigPolicy,
@@ -885,6 +964,54 @@ fn validate_request_defaults(
885964
"request_defaults.context",
886965
"request_defaults.context must be a JSON object",
887966
);
967+
if let Some(thread_id) = &request_defaults.thread_id {
968+
let trimmed_thread_id = thread_id.trim();
969+
if trimmed_thread_id.is_empty() {
970+
push_policy_diag(
971+
diagnostics,
972+
policy.unsupported_value,
973+
"nemo_guardrails.unsupported_value",
974+
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
975+
Some("request_defaults.thread_id".to_string()),
976+
"request_defaults.thread_id must not be empty".to_string(),
977+
);
978+
} else if trimmed_thread_id.len() < 16 {
979+
push_policy_diag(
980+
diagnostics,
981+
policy.unsupported_value,
982+
"nemo_guardrails.unsupported_value",
983+
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
984+
Some("request_defaults.thread_id".to_string()),
985+
"request_defaults.thread_id must be at least 16 characters long".to_string(),
986+
);
987+
}
988+
}
989+
validate_json_object_field(
990+
diagnostics,
991+
policy,
992+
request_defaults.state.as_ref(),
993+
"request_defaults.state",
994+
"request_defaults.state must be a JSON object",
995+
);
996+
if let Some(state) = request_defaults
997+
.state
998+
.as_ref()
999+
.and_then(|value| value.as_object())
1000+
{
1001+
let contains_supported_key = state.contains_key("events") || state.contains_key("state");
1002+
let contains_unsupported_key = state.keys().any(|key| key != "events" && key != "state");
1003+
if (!state.is_empty() && !contains_supported_key) || contains_unsupported_key {
1004+
push_policy_diag(
1005+
diagnostics,
1006+
policy.unsupported_value,
1007+
"nemo_guardrails.unsupported_value",
1008+
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
1009+
Some("request_defaults.state".to_string()),
1010+
"request_defaults.state must be empty or contain only 'events' or 'state'"
1011+
.to_string(),
1012+
);
1013+
}
1014+
}
8881015
validate_json_object_field(
8891016
diagnostics,
8901017
policy,
@@ -1138,5 +1265,5 @@ fn default_timeout_millis() -> u64 {
11381265
}
11391266

11401267
#[cfg(test)]
1141-
#[path = "../../../tests/unit/plugins/nemo_guardrails/plugin_component_tests.rs"]
1268+
#[path = "../../../tests/unit/plugins/nemo_guardrails/component_tests.rs"]
11421269
mod tests;

crates/core/src/plugins/nemo_guardrails/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ pub(crate) fn test_mutex() -> &'static Mutex<()> {
1111
crate::shared_runtime::runtime_owner_test_mutex()
1212
}
1313

14-
pub mod plugin_component;
14+
pub mod component;

0 commit comments

Comments
 (0)