Skip to content

Commit 2f3de9c

Browse files
committed
Add guardrail scope emission
1 parent efd933b commit 2f3de9c

6 files changed

Lines changed: 365 additions & 14 deletions

File tree

crates/core/src/api/llm.rs

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -523,11 +523,19 @@ pub async fn llm_call_execute(params: LlmCallExecuteParams) -> Result<Json> {
523523
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
524524
&registries.llm_conditional_execution_guardrails
525525
});
526+
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
526527
let context = global_context();
527528
let state = context
528529
.read()
529530
.map_err(|error| FlowError::Internal(error.to_string()))?;
530-
if let Some(error) = state.llm_conditional_execution_chain(&request, &scope_locals)? {
531+
let subscribers = state.collect_event_subscribers(&scope_subscribers);
532+
if let Some(error) = state.llm_conditional_execution_chain(
533+
&request,
534+
&scope_locals,
535+
&subscribers,
536+
resolve_parent_uuid(parent.as_ref()),
537+
metadata.clone(),
538+
)? {
531539
drop(state);
532540
drop(scope_guard);
533541
let mut rejection_data = json!({});
@@ -680,11 +688,19 @@ pub async fn llm_stream_call_execute(params: LlmStreamCallExecuteParams) -> Resu
680688
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
681689
&registries.llm_conditional_execution_guardrails
682690
});
691+
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
683692
let context = global_context();
684693
let state = context
685694
.read()
686695
.map_err(|error| FlowError::Internal(error.to_string()))?;
687-
if let Some(error) = state.llm_conditional_execution_chain(&request, &scope_locals)? {
696+
let subscribers = state.collect_event_subscribers(&scope_subscribers);
697+
if let Some(error) = state.llm_conditional_execution_chain(
698+
&request,
699+
&scope_locals,
700+
&subscribers,
701+
resolve_parent_uuid(parent.as_ref()),
702+
metadata.clone(),
703+
)? {
688704
drop(state);
689705
drop(scope_guard);
690706
let mut rejection_data = json!({});
@@ -814,7 +830,8 @@ pub fn llm_request_intercepts(name: &str, request: LlmRequest) -> Result<LlmRequ
814830
/// Run only the LLM conditional-execution guardrail chain.
815831
///
816832
/// This evaluates whether an LLM call should be allowed to proceed without
817-
/// emitting lifecycle events or invoking request intercepts or execution.
833+
/// invoking request intercepts or execution. Each evaluated guardrail emits an
834+
/// automatic guardrail scope start/end pair for observability.
818835
///
819836
/// # Parameters
820837
/// - `request`: Raw [`LlmRequest`] to validate.
@@ -828,19 +845,28 @@ pub fn llm_request_intercepts(name: &str, request: LlmRequest) -> Result<LlmRequ
828845
///
829846
/// # Notes
830847
/// This helper is useful for preflight checks when the caller needs the
831-
/// rejection result without starting an LLM span.
848+
/// rejection result without starting an LLM span. Guardrail scopes are still
849+
/// emitted for the conditional checks themselves.
832850
pub fn llm_conditional_execution(request: &LlmRequest) -> Result<()> {
833851
ensure_runtime_owner()?;
834852
let scope_stack = current_scope_stack();
835853
let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
836854
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
837855
&registries.llm_conditional_execution_guardrails
838856
});
857+
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
839858
let context = global_context();
840859
let state = context
841860
.read()
842861
.map_err(|error| FlowError::Internal(error.to_string()))?;
843-
if let Some(error) = state.llm_conditional_execution_chain(request, &scope_locals)? {
862+
let subscribers = state.collect_event_subscribers(&scope_subscribers);
863+
if let Some(error) = state.llm_conditional_execution_chain(
864+
request,
865+
&scope_locals,
866+
&subscribers,
867+
resolve_parent_uuid(None),
868+
None,
869+
)? {
844870
return Err(FlowError::GuardrailRejected(error));
845871
}
846872
Ok(())

crates/core/src/api/registry.rs

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub struct ExecutionIntercept<F> {
3333

3434
/// A priority-ordered guardrail registration entry.
3535
pub struct GuardrailEntry<F> {
36+
/// Caller-provided guardrail registration name.
37+
pub name: String,
3638
/// Lower values run earlier in the chain.
3739
pub priority: i32,
3840
/// The caller-provided guardrail callback.
@@ -69,7 +71,14 @@ macro_rules! global_guardrail_registry_api {
6971
.map_err(|error| FlowError::Internal(error.to_string()))?;
7072
state
7173
.$field
72-
.register(name.to_string(), GuardrailEntry { priority, guardrail })
74+
.register(
75+
name.to_string(),
76+
GuardrailEntry {
77+
name: name.to_string(),
78+
priority,
79+
guardrail,
80+
},
81+
)
7382
.map_err(FlowError::AlreadyExists)
7483
}
7584

@@ -259,7 +268,14 @@ macro_rules! scope_guardrail_registry_api {
259268
.ok_or_else(|| FlowError::NotFound(format!("scope {scope_uuid} not found")))?;
260269
registries
261270
.$field
262-
.register(name.to_string(), GuardrailEntry { priority, guardrail })
271+
.register(
272+
name.to_string(),
273+
GuardrailEntry {
274+
name: name.to_string(),
275+
priority,
276+
guardrail,
277+
},
278+
)
263279
.map_err(FlowError::AlreadyExists)
264280
}
265281

crates/core/src/api/runtime/state.rs

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use crate::api::runtime::callbacks::{
2525
LlmStreamExecutionRegistryRefs, ToolConditionalFn, ToolExecutionFn, ToolExecutionNextFn,
2626
ToolInterceptFn, ToolSanitizeFn,
2727
};
28-
use crate::api::scope::{CreateScopeHandleParams, EndScopeHandleParams, ScopeHandle};
28+
use crate::api::scope::{CreateScopeHandleParams, EndScopeHandleParams, ScopeHandle, ScopeType};
2929
use crate::api::tool::ToolHandle;
3030
use crate::api::tool::{CreateToolHandleParams, EndToolHandleParams};
3131
use crate::codec::request::AnnotatedLlmRequest;
@@ -36,6 +36,8 @@ use crate::context::registries::{
3636
use crate::json::{Json, merge_json};
3737
use crate::registry::SortedRegistry;
3838
use chrono::{Duration, Utc};
39+
use serde_json::json;
40+
use uuid::Uuid;
3941

4042
/// Process-global runtime state backing middleware and event emission.
4143
///
@@ -527,6 +529,42 @@ impl NemoFlowContextState {
527529
))
528530
}
529531

532+
fn emit_guardrail_scope_start(
533+
&self,
534+
name: &str,
535+
parent_uuid: Option<Uuid>,
536+
metadata: Option<Json>,
537+
input: Json,
538+
subscribers: &[EventSubscriberFn],
539+
) -> ScopeHandle {
540+
let handle = self.create_scope_handle(
541+
CreateScopeHandleParams::builder()
542+
.name(name)
543+
.parent_uuid_opt(parent_uuid)
544+
.scope_type(ScopeType::Guardrail)
545+
.metadata_opt(metadata)
546+
.build(),
547+
);
548+
let event = self.build_scope_start_event(&handle, Some(input));
549+
Self::emit_event(&event, subscribers);
550+
handle
551+
}
552+
553+
fn emit_guardrail_scope_end(
554+
&self,
555+
handle: &ScopeHandle,
556+
output: Json,
557+
subscribers: &[EventSubscriberFn],
558+
) {
559+
let event = self.build_scope_end_event(
560+
EndScopeHandleParams::builder()
561+
.handle(handle)
562+
.data(output)
563+
.build(),
564+
);
565+
Self::emit_event(&event, subscribers);
566+
}
567+
530568
/// Run tool request sanitizers across global and scope-local registries.
531569
///
532570
/// # Parameters
@@ -595,11 +633,42 @@ impl NemoFlowContextState {
595633
name: &str,
596634
args: &Json,
597635
scope_locals: &[&SortedRegistry<GuardrailEntry<ToolConditionalFn>>],
636+
subscribers: &[EventSubscriberFn],
637+
parent_uuid: Option<Uuid>,
638+
metadata: Option<Json>,
598639
) -> crate::error::Result<Option<String>> {
599640
let entries =
600641
merge_guardrail_entries(&self.tool_conditional_execution_guardrails, scope_locals);
601642
for entry in entries {
602-
if let Some(error) = (entry.guardrail)(name, args)? {
643+
let handle = self.emit_guardrail_scope_start(
644+
&entry.name,
645+
parent_uuid,
646+
metadata.clone(),
647+
json!({
648+
"kind": "tool_conditional_execution",
649+
"target_name": name,
650+
"input": args,
651+
}),
652+
subscribers,
653+
);
654+
let result = (entry.guardrail)(name, args);
655+
let output = match &result {
656+
Ok(Some(reason)) => json!({
657+
"allowed": false,
658+
"rejected": true,
659+
"rejection_reason": reason,
660+
}),
661+
Ok(None) => json!({
662+
"allowed": true,
663+
"rejected": false,
664+
}),
665+
Err(error) => json!({
666+
"allowed": false,
667+
"error": error.to_string(),
668+
}),
669+
};
670+
self.emit_guardrail_scope_end(&handle, output, subscribers);
671+
if let Some(error) = result? {
603672
return Ok(Some(error));
604673
}
605674
}
@@ -730,11 +799,41 @@ impl NemoFlowContextState {
730799
&self,
731800
request: &LlmRequest,
732801
scope_locals: &[&SortedRegistry<GuardrailEntry<LlmConditionalFn>>],
802+
subscribers: &[EventSubscriberFn],
803+
parent_uuid: Option<Uuid>,
804+
metadata: Option<Json>,
733805
) -> crate::error::Result<Option<String>> {
734806
let entries =
735807
merge_guardrail_entries(&self.llm_conditional_execution_guardrails, scope_locals);
736808
for entry in entries {
737-
if let Some(error) = (entry.guardrail)(request)? {
809+
let handle = self.emit_guardrail_scope_start(
810+
&entry.name,
811+
parent_uuid,
812+
metadata.clone(),
813+
json!({
814+
"kind": "llm_conditional_execution",
815+
"input": request,
816+
}),
817+
subscribers,
818+
);
819+
let result = (entry.guardrail)(request);
820+
let output = match &result {
821+
Ok(Some(reason)) => json!({
822+
"allowed": false,
823+
"rejected": true,
824+
"rejection_reason": reason,
825+
}),
826+
Ok(None) => json!({
827+
"allowed": true,
828+
"rejected": false,
829+
}),
830+
Err(error) => json!({
831+
"allowed": false,
832+
"error": error.to_string(),
833+
}),
834+
};
835+
self.emit_guardrail_scope_end(&handle, output, subscribers);
836+
if let Some(error) = result? {
738837
return Ok(Some(error));
739838
}
740839
}

crates/core/src/api/tool.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -368,11 +368,20 @@ pub async fn tool_call_execute(params: ToolCallExecuteParams) -> Result<Json> {
368368
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
369369
&registries.tool_conditional_execution_guardrails
370370
});
371+
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
371372
let context = global_context();
372373
let state = context
373374
.read()
374375
.map_err(|error| FlowError::Internal(error.to_string()))?;
375-
if let Some(error) = state.tool_conditional_execution_chain(&name, &args, &scope_locals)? {
376+
let subscribers = state.collect_event_subscribers(&scope_subscribers);
377+
if let Some(error) = state.tool_conditional_execution_chain(
378+
&name,
379+
&args,
380+
&scope_locals,
381+
&subscribers,
382+
resolve_parent_uuid(parent.as_ref()),
383+
metadata.clone(),
384+
)? {
376385
drop(state);
377386
drop(scope_guard);
378387
let mut rejection_data = json!({});
@@ -479,7 +488,8 @@ pub fn tool_request_intercepts(name: &str, args: Json) -> Result<Json> {
479488
/// Run only the tool conditional-execution guardrail chain.
480489
///
481490
/// This evaluates whether a tool call should be allowed to proceed without
482-
/// emitting lifecycle events or invoking request intercepts or execution.
491+
/// invoking request intercepts or execution. Each evaluated guardrail emits an
492+
/// automatic guardrail scope start/end pair for observability.
483493
///
484494
/// # Parameters
485495
/// - `name`: Tool name used when resolving the guardrail chain.
@@ -494,19 +504,29 @@ pub fn tool_request_intercepts(name: &str, args: Json) -> Result<Json> {
494504
///
495505
/// # Notes
496506
/// This helper is useful for preflight checks when the caller needs the
497-
/// rejection result without starting a tool span.
507+
/// rejection result without starting a tool span. Guardrail scopes are still
508+
/// emitted for the conditional checks themselves.
498509
pub fn tool_conditional_execution(name: &str, args: &Json) -> Result<()> {
499510
ensure_runtime_owner()?;
500511
let scope_stack = current_scope_stack();
501512
let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
502513
let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
503514
&registries.tool_conditional_execution_guardrails
504515
});
516+
let scope_subscribers = scope_guard.collect_scope_local_subscribers();
505517
let context = global_context();
506518
let state = context
507519
.read()
508520
.map_err(|error| FlowError::Internal(error.to_string()))?;
509-
if let Some(error) = state.tool_conditional_execution_chain(name, args, &scope_locals)? {
521+
let subscribers = state.collect_event_subscribers(&scope_subscribers);
522+
if let Some(error) = state.tool_conditional_execution_chain(
523+
name,
524+
args,
525+
&scope_locals,
526+
&subscribers,
527+
resolve_parent_uuid(None),
528+
None,
529+
)? {
510530
return Err(FlowError::GuardrailRejected(error));
511531
}
512532
Ok(())

0 commit comments

Comments
 (0)