Skip to main content

nemo_relay/api/runtime/
state.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Process-global runtime state and middleware-chain builders.
5//!
6//! [`NemoRelayContextState`] owns the registries and helper methods that power
7//! the public scope, tool, and LLM APIs. Advanced integrations can use this
8//! type directly to register middleware, attach runtime extensions, and build
9//! the resolved callback chains that the higher-level API layer executes.
10
11use std::any::Any;
12use std::collections::HashMap;
13use std::sync::Arc;
14
15use crate::api::event::{
16    BaseEvent, CategoryProfile, Event, EventCategory, MarkEvent, ScopeCategory, ScopeEvent,
17    llm_attributes_to_strings, scope_attributes_to_strings, tool_attributes_to_strings,
18};
19use crate::api::llm::{CreateLlmHandleParams, EndLlmHandleParams};
20use crate::api::llm::{LlmHandle, LlmRequest};
21use crate::api::registry::{ExecutionIntercept, Guardrail, Intercept};
22use crate::api::runtime::callbacks::{
23    EventSubscriberFn, LlmConditionalFn, LlmExecutionFn, LlmExecutionNextFn, LlmRequestInterceptFn,
24    LlmSanitizeRequestFn, LlmSanitizeResponseFn, LlmStreamExecutionFn, LlmStreamExecutionNextFn,
25    LlmStreamExecutionRegistryRefs, ToolConditionalFn, ToolExecutionFn, ToolExecutionNextFn,
26    ToolInterceptFn, ToolSanitizeFn,
27};
28use crate::api::runtime::subscriber_dispatcher;
29use crate::api::scope::{CreateScopeHandleParams, EndScopeHandleParams, ScopeHandle, ScopeType};
30use crate::api::tool::ToolHandle;
31use crate::api::tool::{CreateToolHandleParams, EndToolHandleParams};
32use crate::codec::request::AnnotatedLlmRequest;
33use crate::codec::response::AnnotatedLlmResponse;
34use crate::context::registries::{
35    merge_execution_intercept_callables, merge_guardrail_entries, merge_intercept_entries,
36};
37use crate::json::{Json, merge_json};
38use crate::registry::SortedRegistry;
39use chrono::{Duration, Utc};
40use serde_json::json;
41use uuid::Uuid;
42
43/// Process-global runtime state backing middleware and event emission.
44///
45/// The public API layer stores one shared instance of this type for the
46/// process. It contains global middleware registries, lifecycle subscribers,
47/// and arbitrary extension slots used by bindings or integrations.
48pub struct NemoRelayContextState {
49    /// Global tool request sanitizers applied to emitted tool-start payloads.
50    pub(crate) tool_sanitize_request_guardrails: SortedRegistry<Guardrail<ToolSanitizeFn>>,
51    /// Global tool response sanitizers applied to emitted tool-end payloads.
52    pub(crate) tool_sanitize_response_guardrails: SortedRegistry<Guardrail<ToolSanitizeFn>>,
53    /// Global tool guardrails that can reject execution before the callback runs.
54    pub(crate) tool_conditional_execution_guardrails: SortedRegistry<Guardrail<ToolConditionalFn>>,
55    /// Global tool request intercepts that can rewrite arguments before execution.
56    pub(crate) tool_request_intercepts: SortedRegistry<Intercept<ToolInterceptFn>>,
57    /// Global tool execution intercepts that wrap or replace callback execution.
58    pub(crate) tool_execution_intercepts: SortedRegistry<ExecutionIntercept<ToolExecutionFn>>,
59    /// Global LLM request sanitizers applied to emitted LLM-start payloads.
60    pub(crate) llm_sanitize_request_guardrails: SortedRegistry<Guardrail<LlmSanitizeRequestFn>>,
61    /// Global LLM response sanitizers applied to emitted LLM-end payloads.
62    pub(crate) llm_sanitize_response_guardrails: SortedRegistry<Guardrail<LlmSanitizeResponseFn>>,
63    /// Global LLM guardrails that can reject execution before the provider callback runs.
64    pub(crate) llm_conditional_execution_guardrails: SortedRegistry<Guardrail<LlmConditionalFn>>,
65    /// Global LLM request intercepts that can rewrite or annotate requests.
66    pub(crate) llm_request_intercepts: SortedRegistry<Intercept<LlmRequestInterceptFn>>,
67    /// Global non-streaming LLM execution intercepts that wrap callback execution.
68    pub(crate) llm_execution_intercepts: SortedRegistry<ExecutionIntercept<LlmExecutionFn>>,
69    /// Global streaming LLM execution intercepts that wrap stream-producing callbacks.
70    pub(crate) llm_stream_execution_intercepts:
71        SortedRegistry<ExecutionIntercept<LlmStreamExecutionFn>>,
72    /// Global lifecycle subscribers notified after runtime events are emitted.
73    pub(crate) event_subscribers: HashMap<String, EventSubscriberFn>,
74    /// Arbitrary binding- or integration-specific runtime extensions.
75    pub(crate) extensions: HashMap<String, Box<dyn Any + Send + Sync>>,
76}
77
78impl NemoRelayContextState {
79    /// Create an empty runtime state with no registered middleware.
80    ///
81    /// # Returns
82    /// A [`NemoRelayContextState`] with empty registries, no subscribers, and no
83    /// extensions.
84    pub fn new() -> Self {
85        Self {
86            tool_sanitize_request_guardrails: SortedRegistry::new(),
87            tool_sanitize_response_guardrails: SortedRegistry::new(),
88            tool_conditional_execution_guardrails: SortedRegistry::new(),
89            tool_request_intercepts: SortedRegistry::new(),
90            tool_execution_intercepts: SortedRegistry::new(),
91            llm_sanitize_request_guardrails: SortedRegistry::new(),
92            llm_sanitize_response_guardrails: SortedRegistry::new(),
93            llm_conditional_execution_guardrails: SortedRegistry::new(),
94            llm_request_intercepts: SortedRegistry::new(),
95            llm_execution_intercepts: SortedRegistry::new(),
96            llm_stream_execution_intercepts: SortedRegistry::new(),
97            event_subscribers: HashMap::new(),
98            extensions: HashMap::new(),
99        }
100    }
101
102    /// Store an arbitrary runtime extension under `key`.
103    ///
104    /// Extensions let bindings or integrations attach shared state to the
105    /// process-global runtime without adding new first-class fields.
106    ///
107    /// # Parameters
108    /// - `key`: Stable identifier for the extension slot.
109    /// - `value`: Typed extension value to store.
110    pub fn set_extension<T: Any + Send + Sync>(&mut self, key: impl Into<String>, value: T) {
111        self.extensions.insert(key.into(), Box::new(value));
112    }
113
114    /// Borrow a typed runtime extension by key.
115    ///
116    /// # Parameters
117    /// - `key`: Extension slot name.
118    ///
119    /// # Returns
120    /// `Some(&T)` when an extension exists under `key` with the requested type
121    /// and `None` otherwise.
122    pub fn get_extension<T: Any + Send + Sync>(&self, key: &str) -> Option<&T> {
123        self.extensions
124            .get(key)
125            .and_then(|value| value.downcast_ref::<T>())
126    }
127
128    /// Mutably borrow a typed runtime extension by key.
129    ///
130    /// # Parameters
131    /// - `key`: Extension slot name.
132    ///
133    /// # Returns
134    /// `Some(&mut T)` when an extension exists under `key` with the requested
135    /// type and `None` otherwise.
136    pub fn get_extension_mut<T: Any + Send + Sync>(&mut self, key: &str) -> Option<&mut T> {
137        self.extensions
138            .get_mut(key)
139            .and_then(|value| value.downcast_mut::<T>())
140    }
141
142    /// Remove a runtime extension by key.
143    ///
144    /// # Parameters
145    /// - `key`: Extension slot name.
146    ///
147    /// # Returns
148    /// `true` when an extension was removed and `false` when no extension was
149    /// stored under `key`.
150    pub fn remove_extension(&mut self, key: &str) -> bool {
151        self.extensions.remove(key).is_some()
152    }
153
154    /// Combine global and scope-local subscribers into one delivery list.
155    ///
156    /// # Parameters
157    /// - `scope_local_subscribers`: Subscribers collected from the active scope
158    ///   stack.
159    ///
160    /// # Returns
161    /// A vector containing all global subscribers followed by the provided
162    /// scope-local subscribers.
163    pub(crate) fn collect_event_subscribers(
164        &self,
165        scope_local_subscribers: &[EventSubscriberFn],
166    ) -> Vec<EventSubscriberFn> {
167        let mut subscribers =
168            Vec::with_capacity(self.event_subscribers.len() + scope_local_subscribers.len());
169        subscribers.extend(self.event_subscribers.values().cloned());
170        subscribers.extend(scope_local_subscribers.iter().cloned());
171        subscribers
172    }
173
174    /// Deliver an event to every subscriber in order.
175    ///
176    /// # Parameters
177    /// - `event`: Fully constructed lifecycle event to deliver.
178    /// - `subscribers`: Subscribers that should observe the event.
179    pub(crate) fn emit_event(event: &Event, subscribers: &[EventSubscriberFn]) {
180        subscriber_dispatcher::dispatch_event(event, subscribers);
181    }
182
183    /// Build a standalone mark event.
184    ///
185    /// # Parameters
186    /// - `params`: A pre-built [`MarkEvent`] to wrap in an [`Event`].
187    ///
188    /// # Returns
189    /// A mark [`Event`] containing the provided [`MarkEvent`].
190    pub fn create_event(&self, params: MarkEvent) -> Event {
191        Event::Mark(params)
192    }
193
194    /// Create a new scope handle.
195    ///
196    /// # Parameters
197    /// - `name`: Human-readable scope name.
198    /// - `parent_uuid`: Optional parent scope UUID.
199    /// - `scope_type`: Semantic category of the scope.
200    /// - `attributes`: Scope attribute bitflags.
201    /// - `data`: Optional application payload stored on the handle.
202    /// - `metadata`: Optional metadata stored on the handle.
203    /// - `timestamp`: Optional handle start time. When omitted, the current
204    ///   UTC time is used.
205    ///
206    /// # Returns
207    /// A new [`ScopeHandle`] with a fresh UUID.
208    pub fn create_scope_handle(&self, params: CreateScopeHandleParams<'_>) -> ScopeHandle {
209        ScopeHandle::builder()
210            .name(params.name)
211            .scope_type(params.scope_type)
212            .started_at(params.timestamp.unwrap_or_else(Utc::now))
213            .attributes(params.attributes)
214            .parent_uuid_opt(params.parent_uuid)
215            .data_opt(params.data)
216            .metadata_opt(params.metadata)
217            .build()
218    }
219
220    /// Build a scope-start event from a handle.
221    ///
222    /// # Parameters
223    /// - `handle`: Scope handle to serialize into an event.
224    /// - `data`: Optional semantic input payload exported on the start event.
225    ///
226    /// # Returns
227    /// A scope-start [`Event`] derived from the provided handle.
228    pub fn build_scope_start_event(&self, handle: &ScopeHandle, data: Option<Json>) -> Event {
229        Event::Scope(ScopeEvent::new(
230            BaseEvent::builder()
231                .parent_uuid_opt(handle.parent_uuid)
232                .uuid(handle.uuid)
233                .timestamp(handle.started_at)
234                .name(handle.name.as_str())
235                .data_opt(data)
236                .metadata_opt(handle.metadata.clone())
237                .build(),
238            ScopeCategory::Start,
239            scope_attributes_to_strings(handle.attributes),
240            EventCategory::from(handle.scope_type),
241            None,
242        ))
243    }
244
245    /// Build a scope-end event from a handle.
246    ///
247    /// # Parameters
248    /// - `handle`: Scope handle to serialize into an event.
249    /// - `data`: Optional data payload returned from the scope.
250    /// - `metadata`: Optional metadata payload merged over `handle.metadata`.
251    ///
252    /// # Returns
253    /// A scope-end [`Event`] derived from the provided handle.
254    pub fn end_scope_handle(
255        &self,
256        handle: &ScopeHandle,
257        data: Option<Json>,
258        metadata: Option<Json>,
259    ) -> Event {
260        self.build_scope_end_event(
261            EndScopeHandleParams::builder()
262                .handle(handle)
263                .data_opt(data)
264                .metadata_opt(metadata)
265                .build(),
266        )
267    }
268
269    /// Build a scope-end event from builder parameters.
270    ///
271    /// The `metadata` payload is merged over the metadata already stored on
272    /// the handle.
273    ///
274    /// # Parameters
275    /// - `params`: Scope end-event builder parameters.
276    ///
277    /// # Returns
278    /// A scope-end [`Event`] derived from the provided parameters.
279    pub fn build_scope_end_event(&self, params: EndScopeHandleParams<'_>) -> Event {
280        let handle = params.handle;
281        Event::Scope(ScopeEvent::new(
282            BaseEvent::builder()
283                .parent_uuid_opt(handle.parent_uuid)
284                .uuid(handle.uuid)
285                .timestamp(
286                    params
287                        .timestamp
288                        .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
289                )
290                .name(handle.name.as_str())
291                .data_opt(params.data)
292                .metadata_opt(merge_json(handle.metadata.clone(), params.metadata))
293                .build(),
294            ScopeCategory::End,
295            scope_attributes_to_strings(handle.attributes),
296            EventCategory::from(handle.scope_type),
297            None,
298        ))
299    }
300
301    /// Create a new tool handle.
302    ///
303    /// # Parameters
304    /// - `name`: Tool name recorded on emitted events.
305    /// - `parent_uuid`: Optional parent scope UUID.
306    /// - `attributes`: Tool attribute bitflags.
307    /// - `data`: Optional application payload stored on the handle.
308    /// - `metadata`: Optional metadata stored on the handle.
309    /// - `tool_call_id`: Optional provider-specific correlation identifier.
310    /// - `timestamp`: Optional handle start time. When omitted, the current
311    ///   UTC time is used.
312    ///
313    /// # Returns
314    /// A new [`ToolHandle`] with a fresh UUID.
315    pub fn create_tool_handle(&self, params: CreateToolHandleParams<'_>) -> ToolHandle {
316        ToolHandle::builder()
317            .name(params.name)
318            .started_at(params.timestamp.unwrap_or_else(Utc::now))
319            .attributes(params.attributes)
320            .parent_uuid_opt(params.parent_uuid)
321            .data_opt(params.data)
322            .metadata_opt(params.metadata)
323            .tool_call_id_opt(params.tool_call_id)
324            .build()
325    }
326
327    /// Build a tool-start event from a handle.
328    ///
329    /// # Parameters
330    /// - `handle`: Tool handle to serialize into an event.
331    /// - `data`: Optional tool input payload.
332    ///
333    /// # Returns
334    /// A tool-start [`Event`] derived from the provided handle.
335    pub fn build_tool_start_event(&self, handle: &ToolHandle, data: Option<Json>) -> Event {
336        Event::Scope(ScopeEvent::new(
337            BaseEvent::builder()
338                .parent_uuid_opt(handle.parent_uuid)
339                .uuid(handle.uuid)
340                .timestamp(handle.started_at)
341                .name(handle.name.as_str())
342                .data_opt(data)
343                .metadata_opt(handle.metadata.clone())
344                .build(),
345            ScopeCategory::Start,
346            tool_attributes_to_strings(handle.attributes),
347            EventCategory::tool(),
348            Some(
349                CategoryProfile::builder()
350                    .tool_call_id_opt(handle.tool_call_id.clone())
351                    .build(),
352            ),
353        ))
354    }
355
356    /// Build a tool-end event from a handle and optional overrides.
357    ///
358    /// # Parameters
359    /// - `handle`: Tool handle to serialize into an event.
360    /// - `data`: Optional end-event data payload.
361    /// - `metadata`: Optional metadata payload merged over `handle.metadata`.
362    ///
363    /// # Returns
364    /// A tool-end [`Event`] derived from the provided handle.
365    pub fn end_tool_handle(
366        &self,
367        handle: &ToolHandle,
368        data: Option<Json>,
369        metadata: Option<Json>,
370    ) -> Event {
371        self.build_tool_end_event(
372            EndToolHandleParams::builder()
373                .handle(handle)
374                .data_opt(data)
375                .metadata_opt(metadata)
376                .build(),
377        )
378    }
379
380    /// Build a tool-end event from builder parameters.
381    ///
382    /// The `metadata` payload is merged over the metadata already stored on
383    /// the handle.
384    ///
385    /// # Parameters
386    /// - `params`: Tool end-event builder parameters.
387    ///
388    /// # Returns
389    /// A tool-end [`Event`] derived from the provided parameters.
390    pub fn build_tool_end_event(&self, params: EndToolHandleParams<'_>) -> Event {
391        let handle = params.handle;
392        Event::Scope(ScopeEvent::new(
393            BaseEvent::builder()
394                .parent_uuid_opt(handle.parent_uuid)
395                .uuid(handle.uuid)
396                .timestamp(
397                    params
398                        .timestamp
399                        .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
400                )
401                .name(handle.name.as_str())
402                .data_opt(params.data)
403                .metadata_opt(merge_json(handle.metadata.clone(), params.metadata))
404                .build(),
405            ScopeCategory::End,
406            tool_attributes_to_strings(handle.attributes),
407            EventCategory::tool(),
408            Some(
409                CategoryProfile::builder()
410                    .tool_call_id_opt(handle.tool_call_id.clone())
411                    .build(),
412            ),
413        ))
414    }
415
416    /// Create a new LLM handle.
417    ///
418    /// # Parameters
419    /// - `name`: Logical provider or model family name.
420    /// - `parent_uuid`: Optional parent scope UUID.
421    /// - `attributes`: LLM attribute bitflags.
422    /// - `data`: Optional application payload stored on the handle.
423    /// - `metadata`: Optional metadata stored on the handle.
424    /// - `model_name`: Optional normalized model name stored on the handle.
425    /// - `timestamp`: Optional handle start time. When omitted, the current
426    ///   UTC time is used.
427    ///
428    /// # Returns
429    /// A new [`LlmHandle`] with a fresh UUID.
430    pub fn create_llm_handle(&self, params: CreateLlmHandleParams<'_>) -> LlmHandle {
431        LlmHandle::builder()
432            .name(params.name)
433            .started_at(params.timestamp.unwrap_or_else(Utc::now))
434            .attributes(params.attributes)
435            .parent_uuid_opt(params.parent_uuid)
436            .data_opt(params.data)
437            .metadata_opt(params.metadata)
438            .model_name_opt(params.model_name)
439            .build()
440    }
441
442    /// Build an LLM-start event from a handle.
443    ///
444    /// # Parameters
445    /// - `handle`: LLM handle to serialize into an event.
446    /// - `data`: Sanitized LLM request payload.
447    /// - `annotated_request`: Optional normalized request annotation.
448    ///
449    /// # Returns
450    /// An LLM-start [`Event`] derived from the provided handle.
451    pub fn build_llm_start_event(
452        &self,
453        handle: &LlmHandle,
454        data: Option<Json>,
455        annotated_request: Option<Arc<AnnotatedLlmRequest>>,
456    ) -> Event {
457        Event::Scope(ScopeEvent::new(
458            BaseEvent::builder()
459                .parent_uuid_opt(handle.parent_uuid)
460                .uuid(handle.uuid)
461                .timestamp(handle.started_at)
462                .name(handle.name.as_str())
463                .data_opt(data)
464                .metadata_opt(handle.metadata.clone())
465                .build(),
466            ScopeCategory::Start,
467            llm_attributes_to_strings(handle.attributes),
468            EventCategory::llm(),
469            Some(
470                CategoryProfile::builder()
471                    .model_name_opt(handle.model_name.clone())
472                    .annotated_request_opt(annotated_request)
473                    .build(),
474            ),
475        ))
476    }
477
478    /// Build an LLM-end event from a handle and optional overrides.
479    ///
480    /// # Parameters
481    /// - `handle`: LLM handle to serialize into an event.
482    /// - `data`: Sanitized LLM response payload.
483    /// - `metadata`: Optional metadata payload merged over `handle.metadata`.
484    /// - `annotated_response`: Optional normalized response annotation.
485    ///
486    /// # Returns
487    /// An LLM-end [`Event`] derived from the provided handle.
488    pub fn end_llm_handle(
489        &self,
490        handle: &LlmHandle,
491        data: Option<Json>,
492        metadata: Option<Json>,
493        annotated_response: Option<Arc<AnnotatedLlmResponse>>,
494    ) -> Event {
495        self.build_llm_end_event(
496            EndLlmHandleParams::builder()
497                .handle(handle)
498                .data_opt(data)
499                .metadata_opt(metadata)
500                .annotated_response_opt(annotated_response)
501                .build(),
502        )
503    }
504
505    /// Build an LLM-end event from builder parameters.
506    ///
507    /// The `metadata` payload is merged over the metadata already stored on
508    /// the handle.
509    ///
510    /// # Parameters
511    /// - `params`: LLM end-event builder parameters.
512    ///
513    /// # Returns
514    /// An LLM-end [`Event`] derived from the provided parameters.
515    pub fn build_llm_end_event(&self, params: EndLlmHandleParams<'_>) -> Event {
516        let handle = params.handle;
517        Event::Scope(ScopeEvent::new(
518            BaseEvent::builder()
519                .parent_uuid_opt(handle.parent_uuid)
520                .uuid(handle.uuid)
521                .timestamp(
522                    params
523                        .timestamp
524                        .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
525                )
526                .name(handle.name.as_str())
527                .data_opt(params.data)
528                .metadata_opt(merge_json(handle.metadata.clone(), params.metadata))
529                .build(),
530            ScopeCategory::End,
531            llm_attributes_to_strings(handle.attributes),
532            EventCategory::llm(),
533            Some(
534                CategoryProfile::builder()
535                    .model_name_opt(handle.model_name.clone())
536                    .annotated_response_opt(params.annotated_response)
537                    .build(),
538            ),
539        ))
540    }
541
542    fn emit_guardrail_scope_start(
543        name: &str,
544        parent_uuid: Option<Uuid>,
545        metadata: Option<Json>,
546        input: Json,
547        subscribers: &[EventSubscriberFn],
548    ) -> ScopeHandle {
549        let handle = ScopeHandle::builder()
550            .name(name)
551            .scope_type(ScopeType::Guardrail)
552            .parent_uuid_opt(parent_uuid)
553            .metadata_opt(metadata)
554            .build();
555        let event = Event::Scope(ScopeEvent::new(
556            BaseEvent::builder()
557                .parent_uuid_opt(handle.parent_uuid)
558                .uuid(handle.uuid)
559                .timestamp(handle.started_at)
560                .name(handle.name.as_str())
561                .data(input)
562                .metadata_opt(handle.metadata.clone())
563                .build(),
564            ScopeCategory::Start,
565            scope_attributes_to_strings(handle.attributes),
566            EventCategory::from(handle.scope_type),
567            None,
568        ));
569        Self::emit_event(&event, subscribers);
570        handle
571    }
572
573    fn emit_guardrail_scope_end(
574        handle: &ScopeHandle,
575        output: Json,
576        subscribers: &[EventSubscriberFn],
577    ) {
578        let event = Event::Scope(ScopeEvent::new(
579            BaseEvent::builder()
580                .parent_uuid_opt(handle.parent_uuid)
581                .uuid(handle.uuid)
582                .timestamp(end_timestamp_after(handle.started_at))
583                .name(handle.name.as_str())
584                .data(output)
585                .metadata_opt(handle.metadata.clone())
586                .build(),
587            ScopeCategory::End,
588            scope_attributes_to_strings(handle.attributes),
589            EventCategory::from(handle.scope_type),
590            None,
591        ));
592        Self::emit_event(&event, subscribers);
593    }
594
595    /// Run tool request sanitizers across global and scope-local registries.
596    ///
597    /// # Parameters
598    /// - `name`: Tool name associated with the request.
599    /// - `args`: Raw tool arguments to sanitize for observability.
600    /// - `scope_locals`: Scope-local sanitizer registries collected from the
601    ///   active scope stack.
602    ///
603    /// # Returns
604    /// The sanitized JSON payload after every matching guardrail has run.
605    pub(crate) fn tool_sanitize_request_chain(
606        &self,
607        name: &str,
608        args: Json,
609        scope_locals: &[&SortedRegistry<Guardrail<ToolSanitizeFn>>],
610    ) -> Json {
611        let entries = merge_guardrail_entries(&self.tool_sanitize_request_guardrails, scope_locals);
612        let mut value = args;
613        for entry in entries {
614            value = (entry.payload)(name, value);
615        }
616        value
617    }
618
619    /// Run tool response sanitizers across global and scope-local registries.
620    ///
621    /// # Parameters
622    /// - `name`: Tool name associated with the response.
623    /// - `result`: Raw tool result to sanitize for observability.
624    /// - `scope_locals`: Scope-local sanitizer registries collected from the
625    ///   active scope stack.
626    ///
627    /// # Returns
628    /// The sanitized JSON payload after every matching guardrail has run.
629    pub(crate) fn tool_sanitize_response_chain(
630        &self,
631        name: &str,
632        result: Json,
633        scope_locals: &[&SortedRegistry<Guardrail<ToolSanitizeFn>>],
634    ) -> Json {
635        let entries =
636            merge_guardrail_entries(&self.tool_sanitize_response_guardrails, scope_locals);
637        let mut value = result;
638        for entry in entries {
639            value = (entry.payload)(name, value);
640        }
641        value
642    }
643
644    /// Snapshot tool conditional-execution guardrails in priority order.
645    ///
646    /// # Parameters
647    /// - `scope_locals`: Scope-local conditional guardrail registries collected
648    ///   from the active scope stack.
649    ///
650    /// # Returns
651    /// Named guardrail snapshots that can be evaluated after registry locks
652    /// are released.
653    pub(crate) fn tool_conditional_execution_entries(
654        &self,
655        scope_locals: &[&SortedRegistry<Guardrail<ToolConditionalFn>>],
656    ) -> Vec<Guardrail<ToolConditionalFn>> {
657        merge_guardrail_entries(&self.tool_conditional_execution_guardrails, scope_locals)
658            .into_iter()
659            .cloned()
660            .collect()
661    }
662
663    /// Evaluate a snapshot of tool conditional-execution guardrails in priority order.
664    ///
665    /// This function emits guardrail scope start/end events while evaluating
666    /// the provided entries. Callers should pass entries snapped from the
667    /// global and scope-local registries so subscriber callbacks run without
668    /// registry locks held. If `entries` is empty, no guardrail scopes are
669    /// emitted. Guardrail start events identify the guardrail and target but
670    /// intentionally omit raw tool arguments from their event data.
671    ///
672    /// # Parameters
673    /// - `name`: Tool name associated with the request.
674    /// - `args`: Tool arguments to validate.
675    /// - `entries`: Borrowed conditional guardrail snapshots to evaluate.
676    /// - `subscribers`: Event subscribers that should observe guardrail scope
677    ///   start/end events.
678    /// - `parent_uuid`: Optional parent scope UUID for emitted guardrail
679    ///   scopes.
680    /// - `metadata`: Optional metadata attached to emitted guardrail scopes.
681    ///
682    /// # Returns
683    /// A [`Result`](crate::error::Result) containing `Ok(None)` when execution
684    /// is allowed or `Ok(Some(reason))` when a guardrail rejects the call.
685    ///
686    /// # Errors
687    /// Propagates any error returned by a guardrail callback after emitting the
688    /// corresponding guardrail scope end event.
689    pub(crate) fn tool_conditional_execution_snapshot_chain(
690        name: &str,
691        args: &Json,
692        entries: &[Guardrail<ToolConditionalFn>],
693        subscribers: &[EventSubscriberFn],
694        parent_uuid: Option<Uuid>,
695        metadata: Option<Json>,
696    ) -> crate::error::Result<Option<String>> {
697        for entry in entries {
698            let handle = Self::emit_guardrail_scope_start(
699                &entry.name,
700                parent_uuid,
701                metadata.clone(),
702                json!({
703                    "kind": "tool_conditional_execution",
704                    "target_name": name,
705                }),
706                subscribers,
707            );
708            let result = (entry.payload)(name, args);
709            let output = match &result {
710                Ok(Some(reason)) => json!({
711                    "allowed": false,
712                    "rejected": true,
713                    "rejection_reason": reason,
714                }),
715                Ok(None) => json!({
716                    "allowed": true,
717                    "rejected": false,
718                }),
719                Err(error) => json!({
720                    "allowed": false,
721                    "error": error.to_string(),
722                }),
723            };
724            Self::emit_guardrail_scope_end(&handle, output, subscribers);
725            if let Some(error) = result? {
726                return Ok(Some(error));
727            }
728        }
729        Ok(None)
730    }
731
732    /// Run tool request intercepts in priority order.
733    ///
734    /// # Parameters
735    /// - `name`: Tool name associated with the request.
736    /// - `args`: Tool arguments to pass through the intercept chain.
737    /// - `scope_locals`: Scope-local request intercept registries collected
738    ///   from the active scope stack.
739    ///
740    /// # Returns
741    /// A [`Result`] containing the final JSON argument payload.
742    ///
743    /// # Errors
744    /// Propagates any error returned by an intercept callback.
745    ///
746    /// # Notes
747    /// If an intercept entry has `break_chain` enabled, later intercepts are
748    /// skipped after that entry runs.
749    pub(crate) fn tool_request_intercepts_chain(
750        &self,
751        name: &str,
752        args: Json,
753        scope_locals: &[&SortedRegistry<Intercept<ToolInterceptFn>>],
754    ) -> crate::error::Result<Json> {
755        let entries = merge_intercept_entries(&self.tool_request_intercepts, scope_locals);
756        let mut value = args;
757        for entry in entries {
758            value = (entry.payload.callable)(name, value)?;
759            if entry.payload.break_chain {
760                break;
761            }
762        }
763        Ok(value)
764    }
765
766    /// Build the composed tool execution continuation chain.
767    ///
768    /// # Parameters
769    /// - `name`: Tool name passed into each execution intercept.
770    /// - `default_fn`: Base tool callback that should run after all intercepts.
771    /// - `scope_locals`: Scope-local execution intercept registries collected
772    ///   from the active scope stack.
773    ///
774    /// # Returns
775    /// A composed [`ToolExecutionNextFn`] that wraps `default_fn` in every
776    /// matching execution intercept.
777    pub(crate) fn tool_build_execution_chain(
778        &self,
779        name: &str,
780        default_fn: ToolExecutionNextFn,
781        scope_locals: &[&SortedRegistry<ExecutionIntercept<ToolExecutionFn>>],
782    ) -> ToolExecutionNextFn {
783        let matching =
784            merge_execution_intercept_callables(&self.tool_execution_intercepts, scope_locals);
785        let mut next = default_fn;
786        let name = name.to_string();
787        for (callable, _) in matching.into_iter().rev() {
788            let current_next = next.clone();
789            let current_name = name.clone();
790            next = Arc::new(move |args| callable(&current_name, args, current_next.clone()));
791        }
792        next
793    }
794
795    /// Run LLM request sanitizers across global and scope-local registries.
796    ///
797    /// # Parameters
798    /// - `request`: Raw LLM request to sanitize for observability.
799    /// - `scope_locals`: Scope-local sanitizer registries collected from the
800    ///   active scope stack.
801    ///
802    /// # Returns
803    /// The sanitized [`LlmRequest`] after every matching guardrail has run.
804    pub(crate) fn llm_sanitize_request_chain(
805        &self,
806        request: LlmRequest,
807        scope_locals: &[&SortedRegistry<Guardrail<LlmSanitizeRequestFn>>],
808    ) -> LlmRequest {
809        let entries = merge_guardrail_entries(&self.llm_sanitize_request_guardrails, scope_locals);
810        let mut value = request;
811        for entry in entries {
812            value = (entry.payload)(value);
813        }
814        value
815    }
816
817    /// Run LLM response sanitizers across global and scope-local registries.
818    ///
819    /// # Parameters
820    /// - `response`: Raw response payload to sanitize for observability.
821    /// - `scope_locals`: Scope-local sanitizer registries collected from the
822    ///   active scope stack.
823    ///
824    /// # Returns
825    /// The sanitized response payload after every matching guardrail has run.
826    pub(crate) fn llm_sanitize_response_chain(
827        &self,
828        response: Json,
829        scope_locals: &[&SortedRegistry<Guardrail<LlmSanitizeResponseFn>>],
830    ) -> Json {
831        let entries = merge_guardrail_entries(&self.llm_sanitize_response_guardrails, scope_locals);
832        let mut value = response;
833        for entry in entries {
834            value = (entry.payload)(value);
835        }
836        value
837    }
838
839    /// Snapshot LLM conditional-execution guardrails in priority order.
840    ///
841    /// # Parameters
842    /// - `scope_locals`: Scope-local conditional guardrail registries collected
843    ///   from the active scope stack.
844    ///
845    /// # Returns
846    /// Named guardrail snapshots that can be evaluated after registry locks
847    /// are released.
848    pub(crate) fn llm_conditional_execution_entries(
849        &self,
850        scope_locals: &[&SortedRegistry<Guardrail<LlmConditionalFn>>],
851    ) -> Vec<Guardrail<LlmConditionalFn>> {
852        merge_guardrail_entries(&self.llm_conditional_execution_guardrails, scope_locals)
853            .into_iter()
854            .cloned()
855            .collect()
856    }
857
858    /// Evaluate a snapshot of LLM conditional-execution guardrails in priority order.
859    ///
860    /// This function emits guardrail scope start/end events while evaluating
861    /// the provided entries. Callers should pass entries snapped from the
862    /// global and scope-local registries so subscriber callbacks run without
863    /// registry locks held. If `entries` is empty, no guardrail scopes are
864    /// emitted. Guardrail start events identify the guardrail but intentionally
865    /// omit raw LLM requests from their event data.
866    ///
867    /// # Parameters
868    /// - `request`: LLM request to validate.
869    /// - `entries`: Borrowed conditional guardrail snapshots to evaluate.
870    /// - `subscribers`: Event subscribers that should observe guardrail scope
871    ///   start/end events.
872    /// - `parent_uuid`: Optional parent scope UUID for emitted guardrail
873    ///   scopes.
874    /// - `metadata`: Optional metadata attached to emitted guardrail scopes.
875    ///
876    /// # Returns
877    /// A [`Result`](crate::error::Result) containing `Ok(None)` when execution
878    /// is allowed or `Ok(Some(reason))` when a guardrail rejects the call.
879    ///
880    /// # Errors
881    /// Propagates any error returned by a guardrail callback after emitting the
882    /// corresponding guardrail scope end event.
883    pub(crate) fn llm_conditional_execution_snapshot_chain(
884        request: &LlmRequest,
885        entries: &[Guardrail<LlmConditionalFn>],
886        subscribers: &[EventSubscriberFn],
887        parent_uuid: Option<Uuid>,
888        metadata: Option<Json>,
889    ) -> crate::error::Result<Option<String>> {
890        for entry in entries {
891            let handle = Self::emit_guardrail_scope_start(
892                &entry.name,
893                parent_uuid,
894                metadata.clone(),
895                json!({
896                    "kind": "llm_conditional_execution",
897                }),
898                subscribers,
899            );
900            let result = (entry.payload)(request);
901            let output = match &result {
902                Ok(Some(reason)) => json!({
903                    "allowed": false,
904                    "rejected": true,
905                    "rejection_reason": reason,
906                }),
907                Ok(None) => json!({
908                    "allowed": true,
909                    "rejected": false,
910                }),
911                Err(error) => json!({
912                    "allowed": false,
913                    "error": error.to_string(),
914                }),
915            };
916            Self::emit_guardrail_scope_end(&handle, output, subscribers);
917            if let Some(error) = result? {
918                return Ok(Some(error));
919            }
920        }
921        Ok(None)
922    }
923
924    /// Run LLM request intercepts in priority order.
925    ///
926    /// # Parameters
927    /// - `name`: Logical provider or model family name.
928    /// - `request`: LLM request to pass through the intercept chain.
929    /// - `annotated`: Optional normalized request annotation to carry through
930    ///   the chain.
931    /// - `scope_locals`: Scope-local request intercept registries collected
932    ///   from the active scope stack.
933    ///
934    /// # Returns
935    /// A [`Result`] containing the final request and annotation pair.
936    ///
937    /// # Errors
938    /// Propagates any error returned by an intercept callback.
939    ///
940    /// # Notes
941    /// If an intercept entry has `break_chain` enabled, later intercepts are
942    /// skipped after that entry runs.
943    pub(crate) fn llm_request_intercepts_chain(
944        &self,
945        name: &str,
946        request: LlmRequest,
947        annotated: Option<AnnotatedLlmRequest>,
948        scope_locals: &[&SortedRegistry<Intercept<LlmRequestInterceptFn>>],
949    ) -> crate::error::Result<(LlmRequest, Option<AnnotatedLlmRequest>)> {
950        let entries = merge_intercept_entries(&self.llm_request_intercepts, scope_locals);
951        let mut request_value = request;
952        let mut annotated_value = annotated;
953        for entry in entries {
954            let (new_request, new_annotated) =
955                (entry.payload.callable)(name, request_value, annotated_value)?;
956            request_value = new_request;
957            annotated_value = new_annotated;
958            if entry.payload.break_chain {
959                break;
960            }
961        }
962        Ok((request_value, annotated_value))
963    }
964
965    /// Build the composed non-streaming LLM execution continuation chain.
966    ///
967    /// # Parameters
968    /// - `name`: Logical provider or model family name passed into each
969    ///   execution intercept.
970    /// - `default_fn`: Base provider callback that should run after all
971    ///   intercepts.
972    /// - `scope_locals`: Scope-local execution intercept registries collected
973    ///   from the active scope stack.
974    ///
975    /// # Returns
976    /// A composed [`LlmExecutionNextFn`] that wraps `default_fn` in every
977    /// matching execution intercept.
978    pub(crate) fn llm_build_execution_chain(
979        &self,
980        name: &str,
981        default_fn: LlmExecutionNextFn,
982        scope_locals: &[&SortedRegistry<ExecutionIntercept<LlmExecutionFn>>],
983    ) -> LlmExecutionNextFn {
984        let matching =
985            merge_execution_intercept_callables(&self.llm_execution_intercepts, scope_locals);
986        let mut next = default_fn;
987        let name = name.to_string();
988        for (callable, _) in matching.into_iter().rev() {
989            let current_next = next.clone();
990            let current_name = name.clone();
991            next = Arc::new(move |request| callable(&current_name, request, current_next.clone()));
992        }
993        next
994    }
995
996    /// Build the composed streaming LLM execution continuation chain.
997    ///
998    /// # Parameters
999    /// - `name`: Logical provider or model family name passed into each
1000    ///   execution intercept.
1001    /// - `default_fn`: Base stream-producing callback that should run after all
1002    ///   intercepts.
1003    /// - `scope_locals`: Scope-local execution intercept registries collected
1004    ///   from the active scope stack.
1005    ///
1006    /// # Returns
1007    /// A composed [`LlmStreamExecutionNextFn`] that wraps `default_fn` in every
1008    /// matching execution intercept.
1009    pub(crate) fn llm_stream_build_execution_chain(
1010        &self,
1011        name: &str,
1012        default_fn: LlmStreamExecutionNextFn,
1013        scope_locals: LlmStreamExecutionRegistryRefs<'_>,
1014    ) -> LlmStreamExecutionNextFn {
1015        let matching = merge_execution_intercept_callables(
1016            &self.llm_stream_execution_intercepts,
1017            scope_locals,
1018        );
1019        let mut next = default_fn;
1020        let name = name.to_string();
1021        for (callable, _) in matching.into_iter().rev() {
1022            let current_next = next.clone();
1023            let current_name = name.clone();
1024            next = Arc::new(move |request| callable(&current_name, request, current_next.clone()));
1025        }
1026        next
1027    }
1028}
1029
1030fn end_timestamp_after(started_at: chrono::DateTime<Utc>) -> chrono::DateTime<Utc> {
1031    let now = Utc::now();
1032    if now > started_at {
1033        now
1034    } else {
1035        started_at + Duration::microseconds(1)
1036    }
1037}
1038
1039impl Default for NemoRelayContextState {
1040    fn default() -> Self {
1041        Self::new()
1042    }
1043}