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(¤t_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(¤t_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(¤t_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}