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 ///
251 /// # Returns
252 /// A scope-end [`Event`] derived from the provided handle.
253 pub fn end_scope_handle(&self, handle: &ScopeHandle, data: Option<Json>) -> Event {
254 self.build_scope_end_event(
255 EndScopeHandleParams::builder()
256 .handle(handle)
257 .data_opt(data)
258 .build(),
259 )
260 }
261
262 /// Build a scope-end event from builder parameters.
263 ///
264 /// # Parameters
265 /// - `params`: Scope end-event builder parameters.
266 ///
267 /// # Returns
268 /// A scope-end [`Event`] derived from the provided parameters.
269 pub fn build_scope_end_event(&self, params: EndScopeHandleParams<'_>) -> Event {
270 let handle = params.handle;
271 Event::Scope(ScopeEvent::new(
272 BaseEvent::builder()
273 .parent_uuid_opt(handle.parent_uuid)
274 .uuid(handle.uuid)
275 .timestamp(
276 params
277 .timestamp
278 .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
279 )
280 .name(handle.name.as_str())
281 .data_opt(params.data)
282 .metadata_opt(handle.metadata.clone())
283 .build(),
284 ScopeCategory::End,
285 scope_attributes_to_strings(handle.attributes),
286 EventCategory::from(handle.scope_type),
287 None,
288 ))
289 }
290
291 /// Create a new tool handle.
292 ///
293 /// # Parameters
294 /// - `name`: Tool name recorded on emitted events.
295 /// - `parent_uuid`: Optional parent scope UUID.
296 /// - `attributes`: Tool attribute bitflags.
297 /// - `data`: Optional application payload stored on the handle.
298 /// - `metadata`: Optional metadata stored on the handle.
299 /// - `tool_call_id`: Optional provider-specific correlation identifier.
300 /// - `timestamp`: Optional handle start time. When omitted, the current
301 /// UTC time is used.
302 ///
303 /// # Returns
304 /// A new [`ToolHandle`] with a fresh UUID.
305 pub fn create_tool_handle(&self, params: CreateToolHandleParams<'_>) -> ToolHandle {
306 ToolHandle::builder()
307 .name(params.name)
308 .started_at(params.timestamp.unwrap_or_else(Utc::now))
309 .attributes(params.attributes)
310 .parent_uuid_opt(params.parent_uuid)
311 .data_opt(params.data)
312 .metadata_opt(params.metadata)
313 .tool_call_id_opt(params.tool_call_id)
314 .build()
315 }
316
317 /// Build a tool-start event from a handle.
318 ///
319 /// # Parameters
320 /// - `handle`: Tool handle to serialize into an event.
321 /// - `data`: Optional tool input payload.
322 ///
323 /// # Returns
324 /// A tool-start [`Event`] derived from the provided handle.
325 pub fn build_tool_start_event(&self, handle: &ToolHandle, data: Option<Json>) -> Event {
326 Event::Scope(ScopeEvent::new(
327 BaseEvent::builder()
328 .parent_uuid_opt(handle.parent_uuid)
329 .uuid(handle.uuid)
330 .timestamp(handle.started_at)
331 .name(handle.name.as_str())
332 .data_opt(data)
333 .metadata_opt(handle.metadata.clone())
334 .build(),
335 ScopeCategory::Start,
336 tool_attributes_to_strings(handle.attributes),
337 EventCategory::tool(),
338 Some(
339 CategoryProfile::builder()
340 .tool_call_id_opt(handle.tool_call_id.clone())
341 .build(),
342 ),
343 ))
344 }
345
346 /// Build a tool-end event from a handle and optional overrides.
347 ///
348 /// # Parameters
349 /// - `handle`: Tool handle to serialize into an event.
350 /// - `data`: Optional end-event data payload.
351 /// - `metadata`: Optional metadata payload merged over `handle.metadata`.
352 ///
353 /// # Returns
354 /// A tool-end [`Event`] derived from the provided handle.
355 pub fn end_tool_handle(
356 &self,
357 handle: &ToolHandle,
358 data: Option<Json>,
359 metadata: Option<Json>,
360 ) -> Event {
361 self.build_tool_end_event(
362 EndToolHandleParams::builder()
363 .handle(handle)
364 .data_opt(data)
365 .metadata_opt(metadata)
366 .build(),
367 )
368 }
369
370 /// Build a tool-end event from builder parameters.
371 ///
372 /// The `metadata` payload is merged over the metadata already stored on
373 /// the handle.
374 ///
375 /// # Parameters
376 /// - `params`: Tool end-event builder parameters.
377 ///
378 /// # Returns
379 /// A tool-end [`Event`] derived from the provided parameters.
380 pub fn build_tool_end_event(&self, params: EndToolHandleParams<'_>) -> Event {
381 let handle = params.handle;
382 Event::Scope(ScopeEvent::new(
383 BaseEvent::builder()
384 .parent_uuid_opt(handle.parent_uuid)
385 .uuid(handle.uuid)
386 .timestamp(
387 params
388 .timestamp
389 .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
390 )
391 .name(handle.name.as_str())
392 .data_opt(params.data)
393 .metadata_opt(merge_json(handle.metadata.clone(), params.metadata))
394 .build(),
395 ScopeCategory::End,
396 tool_attributes_to_strings(handle.attributes),
397 EventCategory::tool(),
398 Some(
399 CategoryProfile::builder()
400 .tool_call_id_opt(handle.tool_call_id.clone())
401 .build(),
402 ),
403 ))
404 }
405
406 /// Create a new LLM handle.
407 ///
408 /// # Parameters
409 /// - `name`: Logical provider or model family name.
410 /// - `parent_uuid`: Optional parent scope UUID.
411 /// - `attributes`: LLM attribute bitflags.
412 /// - `data`: Optional application payload stored on the handle.
413 /// - `metadata`: Optional metadata stored on the handle.
414 /// - `model_name`: Optional normalized model name stored on the handle.
415 /// - `timestamp`: Optional handle start time. When omitted, the current
416 /// UTC time is used.
417 ///
418 /// # Returns
419 /// A new [`LlmHandle`] with a fresh UUID.
420 pub fn create_llm_handle(&self, params: CreateLlmHandleParams<'_>) -> LlmHandle {
421 LlmHandle::builder()
422 .name(params.name)
423 .started_at(params.timestamp.unwrap_or_else(Utc::now))
424 .attributes(params.attributes)
425 .parent_uuid_opt(params.parent_uuid)
426 .data_opt(params.data)
427 .metadata_opt(params.metadata)
428 .model_name_opt(params.model_name)
429 .build()
430 }
431
432 /// Build an LLM-start event from a handle.
433 ///
434 /// # Parameters
435 /// - `handle`: LLM handle to serialize into an event.
436 /// - `data`: Sanitized LLM request payload.
437 /// - `annotated_request`: Optional normalized request annotation.
438 ///
439 /// # Returns
440 /// An LLM-start [`Event`] derived from the provided handle.
441 pub fn build_llm_start_event(
442 &self,
443 handle: &LlmHandle,
444 data: Option<Json>,
445 annotated_request: Option<Arc<AnnotatedLlmRequest>>,
446 ) -> Event {
447 Event::Scope(ScopeEvent::new(
448 BaseEvent::builder()
449 .parent_uuid_opt(handle.parent_uuid)
450 .uuid(handle.uuid)
451 .timestamp(handle.started_at)
452 .name(handle.name.as_str())
453 .data_opt(data)
454 .metadata_opt(handle.metadata.clone())
455 .build(),
456 ScopeCategory::Start,
457 llm_attributes_to_strings(handle.attributes),
458 EventCategory::llm(),
459 Some(
460 CategoryProfile::builder()
461 .model_name_opt(handle.model_name.clone())
462 .annotated_request_opt(annotated_request)
463 .build(),
464 ),
465 ))
466 }
467
468 /// Build an LLM-end event from a handle and optional overrides.
469 ///
470 /// # Parameters
471 /// - `handle`: LLM handle to serialize into an event.
472 /// - `data`: Sanitized LLM response payload.
473 /// - `metadata`: Optional metadata payload merged over `handle.metadata`.
474 /// - `annotated_response`: Optional normalized response annotation.
475 ///
476 /// # Returns
477 /// An LLM-end [`Event`] derived from the provided handle.
478 pub fn end_llm_handle(
479 &self,
480 handle: &LlmHandle,
481 data: Option<Json>,
482 metadata: Option<Json>,
483 annotated_response: Option<Arc<AnnotatedLlmResponse>>,
484 ) -> Event {
485 self.build_llm_end_event(
486 EndLlmHandleParams::builder()
487 .handle(handle)
488 .data_opt(data)
489 .metadata_opt(metadata)
490 .annotated_response_opt(annotated_response)
491 .build(),
492 )
493 }
494
495 /// Build an LLM-end event from builder parameters.
496 ///
497 /// The `metadata` payload is merged over the metadata already stored on
498 /// the handle.
499 ///
500 /// # Parameters
501 /// - `params`: LLM end-event builder parameters.
502 ///
503 /// # Returns
504 /// An LLM-end [`Event`] derived from the provided parameters.
505 pub fn build_llm_end_event(&self, params: EndLlmHandleParams<'_>) -> Event {
506 let handle = params.handle;
507 Event::Scope(ScopeEvent::new(
508 BaseEvent::builder()
509 .parent_uuid_opt(handle.parent_uuid)
510 .uuid(handle.uuid)
511 .timestamp(
512 params
513 .timestamp
514 .unwrap_or_else(|| end_timestamp_after(handle.started_at)),
515 )
516 .name(handle.name.as_str())
517 .data_opt(params.data)
518 .metadata_opt(merge_json(handle.metadata.clone(), params.metadata))
519 .build(),
520 ScopeCategory::End,
521 llm_attributes_to_strings(handle.attributes),
522 EventCategory::llm(),
523 Some(
524 CategoryProfile::builder()
525 .model_name_opt(handle.model_name.clone())
526 .annotated_response_opt(params.annotated_response)
527 .build(),
528 ),
529 ))
530 }
531
532 fn emit_guardrail_scope_start(
533 name: &str,
534 parent_uuid: Option<Uuid>,
535 metadata: Option<Json>,
536 input: Json,
537 subscribers: &[EventSubscriberFn],
538 ) -> ScopeHandle {
539 let handle = ScopeHandle::builder()
540 .name(name)
541 .scope_type(ScopeType::Guardrail)
542 .parent_uuid_opt(parent_uuid)
543 .metadata_opt(metadata)
544 .build();
545 let event = Event::Scope(ScopeEvent::new(
546 BaseEvent::builder()
547 .parent_uuid_opt(handle.parent_uuid)
548 .uuid(handle.uuid)
549 .timestamp(handle.started_at)
550 .name(handle.name.as_str())
551 .data(input)
552 .metadata_opt(handle.metadata.clone())
553 .build(),
554 ScopeCategory::Start,
555 scope_attributes_to_strings(handle.attributes),
556 EventCategory::from(handle.scope_type),
557 None,
558 ));
559 Self::emit_event(&event, subscribers);
560 handle
561 }
562
563 fn emit_guardrail_scope_end(
564 handle: &ScopeHandle,
565 output: Json,
566 subscribers: &[EventSubscriberFn],
567 ) {
568 let event = Event::Scope(ScopeEvent::new(
569 BaseEvent::builder()
570 .parent_uuid_opt(handle.parent_uuid)
571 .uuid(handle.uuid)
572 .timestamp(end_timestamp_after(handle.started_at))
573 .name(handle.name.as_str())
574 .data(output)
575 .metadata_opt(handle.metadata.clone())
576 .build(),
577 ScopeCategory::End,
578 scope_attributes_to_strings(handle.attributes),
579 EventCategory::from(handle.scope_type),
580 None,
581 ));
582 Self::emit_event(&event, subscribers);
583 }
584
585 /// Run tool request sanitizers across global and scope-local registries.
586 ///
587 /// # Parameters
588 /// - `name`: Tool name associated with the request.
589 /// - `args`: Raw tool arguments to sanitize for observability.
590 /// - `scope_locals`: Scope-local sanitizer registries collected from the
591 /// active scope stack.
592 ///
593 /// # Returns
594 /// The sanitized JSON payload after every matching guardrail has run.
595 pub(crate) fn tool_sanitize_request_chain(
596 &self,
597 name: &str,
598 args: Json,
599 scope_locals: &[&SortedRegistry<Guardrail<ToolSanitizeFn>>],
600 ) -> Json {
601 let entries = merge_guardrail_entries(&self.tool_sanitize_request_guardrails, scope_locals);
602 let mut value = args;
603 for entry in entries {
604 value = (entry.payload)(name, value);
605 }
606 value
607 }
608
609 /// Run tool response sanitizers across global and scope-local registries.
610 ///
611 /// # Parameters
612 /// - `name`: Tool name associated with the response.
613 /// - `result`: Raw tool result to sanitize for observability.
614 /// - `scope_locals`: Scope-local sanitizer registries collected from the
615 /// active scope stack.
616 ///
617 /// # Returns
618 /// The sanitized JSON payload after every matching guardrail has run.
619 pub(crate) fn tool_sanitize_response_chain(
620 &self,
621 name: &str,
622 result: Json,
623 scope_locals: &[&SortedRegistry<Guardrail<ToolSanitizeFn>>],
624 ) -> Json {
625 let entries =
626 merge_guardrail_entries(&self.tool_sanitize_response_guardrails, scope_locals);
627 let mut value = result;
628 for entry in entries {
629 value = (entry.payload)(name, value);
630 }
631 value
632 }
633
634 /// Snapshot tool conditional-execution guardrails in priority order.
635 ///
636 /// # Parameters
637 /// - `scope_locals`: Scope-local conditional guardrail registries collected
638 /// from the active scope stack.
639 ///
640 /// # Returns
641 /// Named guardrail snapshots that can be evaluated after registry locks
642 /// are released.
643 pub(crate) fn tool_conditional_execution_entries(
644 &self,
645 scope_locals: &[&SortedRegistry<Guardrail<ToolConditionalFn>>],
646 ) -> Vec<Guardrail<ToolConditionalFn>> {
647 merge_guardrail_entries(&self.tool_conditional_execution_guardrails, scope_locals)
648 .into_iter()
649 .cloned()
650 .collect()
651 }
652
653 /// Evaluate a snapshot of tool conditional-execution guardrails in priority order.
654 ///
655 /// This function emits guardrail scope start/end events while evaluating
656 /// the provided entries. Callers should pass entries snapped from the
657 /// global and scope-local registries so subscriber callbacks run without
658 /// registry locks held. If `entries` is empty, no guardrail scopes are
659 /// emitted. Guardrail start events identify the guardrail and target but
660 /// intentionally omit raw tool arguments from their event data.
661 ///
662 /// # Parameters
663 /// - `name`: Tool name associated with the request.
664 /// - `args`: Tool arguments to validate.
665 /// - `entries`: Borrowed conditional guardrail snapshots to evaluate.
666 /// - `subscribers`: Event subscribers that should observe guardrail scope
667 /// start/end events.
668 /// - `parent_uuid`: Optional parent scope UUID for emitted guardrail
669 /// scopes.
670 /// - `metadata`: Optional metadata attached to emitted guardrail scopes.
671 ///
672 /// # Returns
673 /// A [`Result`](crate::error::Result) containing `Ok(None)` when execution
674 /// is allowed or `Ok(Some(reason))` when a guardrail rejects the call.
675 ///
676 /// # Errors
677 /// Propagates any error returned by a guardrail callback after emitting the
678 /// corresponding guardrail scope end event.
679 pub(crate) fn tool_conditional_execution_snapshot_chain(
680 name: &str,
681 args: &Json,
682 entries: &[Guardrail<ToolConditionalFn>],
683 subscribers: &[EventSubscriberFn],
684 parent_uuid: Option<Uuid>,
685 metadata: Option<Json>,
686 ) -> crate::error::Result<Option<String>> {
687 for entry in entries {
688 let handle = Self::emit_guardrail_scope_start(
689 &entry.name,
690 parent_uuid,
691 metadata.clone(),
692 json!({
693 "kind": "tool_conditional_execution",
694 "target_name": name,
695 }),
696 subscribers,
697 );
698 let result = (entry.payload)(name, args);
699 let output = match &result {
700 Ok(Some(reason)) => json!({
701 "allowed": false,
702 "rejected": true,
703 "rejection_reason": reason,
704 }),
705 Ok(None) => json!({
706 "allowed": true,
707 "rejected": false,
708 }),
709 Err(error) => json!({
710 "allowed": false,
711 "error": error.to_string(),
712 }),
713 };
714 Self::emit_guardrail_scope_end(&handle, output, subscribers);
715 if let Some(error) = result? {
716 return Ok(Some(error));
717 }
718 }
719 Ok(None)
720 }
721
722 /// Run tool request intercepts in priority order.
723 ///
724 /// # Parameters
725 /// - `name`: Tool name associated with the request.
726 /// - `args`: Tool arguments to pass through the intercept chain.
727 /// - `scope_locals`: Scope-local request intercept registries collected
728 /// from the active scope stack.
729 ///
730 /// # Returns
731 /// A [`Result`] containing the final JSON argument payload.
732 ///
733 /// # Errors
734 /// Propagates any error returned by an intercept callback.
735 ///
736 /// # Notes
737 /// If an intercept entry has `break_chain` enabled, later intercepts are
738 /// skipped after that entry runs.
739 pub(crate) fn tool_request_intercepts_chain(
740 &self,
741 name: &str,
742 args: Json,
743 scope_locals: &[&SortedRegistry<Intercept<ToolInterceptFn>>],
744 ) -> crate::error::Result<Json> {
745 let entries = merge_intercept_entries(&self.tool_request_intercepts, scope_locals);
746 let mut value = args;
747 for entry in entries {
748 value = (entry.payload.callable)(name, value)?;
749 if entry.payload.break_chain {
750 break;
751 }
752 }
753 Ok(value)
754 }
755
756 /// Build the composed tool execution continuation chain.
757 ///
758 /// # Parameters
759 /// - `name`: Tool name passed into each execution intercept.
760 /// - `default_fn`: Base tool callback that should run after all intercepts.
761 /// - `scope_locals`: Scope-local execution intercept registries collected
762 /// from the active scope stack.
763 ///
764 /// # Returns
765 /// A composed [`ToolExecutionNextFn`] that wraps `default_fn` in every
766 /// matching execution intercept.
767 pub(crate) fn tool_build_execution_chain(
768 &self,
769 name: &str,
770 default_fn: ToolExecutionNextFn,
771 scope_locals: &[&SortedRegistry<ExecutionIntercept<ToolExecutionFn>>],
772 ) -> ToolExecutionNextFn {
773 let matching =
774 merge_execution_intercept_callables(&self.tool_execution_intercepts, scope_locals);
775 let mut next = default_fn;
776 let name = name.to_string();
777 for (callable, _) in matching.into_iter().rev() {
778 let current_next = next.clone();
779 let current_name = name.clone();
780 next = Arc::new(move |args| callable(¤t_name, args, current_next.clone()));
781 }
782 next
783 }
784
785 /// Run LLM request sanitizers across global and scope-local registries.
786 ///
787 /// # Parameters
788 /// - `request`: Raw LLM request to sanitize for observability.
789 /// - `scope_locals`: Scope-local sanitizer registries collected from the
790 /// active scope stack.
791 ///
792 /// # Returns
793 /// The sanitized [`LlmRequest`] after every matching guardrail has run.
794 pub(crate) fn llm_sanitize_request_chain(
795 &self,
796 request: LlmRequest,
797 scope_locals: &[&SortedRegistry<Guardrail<LlmSanitizeRequestFn>>],
798 ) -> LlmRequest {
799 let entries = merge_guardrail_entries(&self.llm_sanitize_request_guardrails, scope_locals);
800 let mut value = request;
801 for entry in entries {
802 value = (entry.payload)(value);
803 }
804 value
805 }
806
807 /// Run LLM response sanitizers across global and scope-local registries.
808 ///
809 /// # Parameters
810 /// - `response`: Raw response payload to sanitize for observability.
811 /// - `scope_locals`: Scope-local sanitizer registries collected from the
812 /// active scope stack.
813 ///
814 /// # Returns
815 /// The sanitized response payload after every matching guardrail has run.
816 pub(crate) fn llm_sanitize_response_chain(
817 &self,
818 response: Json,
819 scope_locals: &[&SortedRegistry<Guardrail<LlmSanitizeResponseFn>>],
820 ) -> Json {
821 let entries = merge_guardrail_entries(&self.llm_sanitize_response_guardrails, scope_locals);
822 let mut value = response;
823 for entry in entries {
824 value = (entry.payload)(value);
825 }
826 value
827 }
828
829 /// Snapshot LLM conditional-execution guardrails in priority order.
830 ///
831 /// # Parameters
832 /// - `scope_locals`: Scope-local conditional guardrail registries collected
833 /// from the active scope stack.
834 ///
835 /// # Returns
836 /// Named guardrail snapshots that can be evaluated after registry locks
837 /// are released.
838 pub(crate) fn llm_conditional_execution_entries(
839 &self,
840 scope_locals: &[&SortedRegistry<Guardrail<LlmConditionalFn>>],
841 ) -> Vec<Guardrail<LlmConditionalFn>> {
842 merge_guardrail_entries(&self.llm_conditional_execution_guardrails, scope_locals)
843 .into_iter()
844 .cloned()
845 .collect()
846 }
847
848 /// Evaluate a snapshot of LLM conditional-execution guardrails in priority order.
849 ///
850 /// This function emits guardrail scope start/end events while evaluating
851 /// the provided entries. Callers should pass entries snapped from the
852 /// global and scope-local registries so subscriber callbacks run without
853 /// registry locks held. If `entries` is empty, no guardrail scopes are
854 /// emitted. Guardrail start events identify the guardrail but intentionally
855 /// omit raw LLM requests from their event data.
856 ///
857 /// # Parameters
858 /// - `request`: LLM request to validate.
859 /// - `entries`: Borrowed conditional guardrail snapshots to evaluate.
860 /// - `subscribers`: Event subscribers that should observe guardrail scope
861 /// start/end events.
862 /// - `parent_uuid`: Optional parent scope UUID for emitted guardrail
863 /// scopes.
864 /// - `metadata`: Optional metadata attached to emitted guardrail scopes.
865 ///
866 /// # Returns
867 /// A [`Result`](crate::error::Result) containing `Ok(None)` when execution
868 /// is allowed or `Ok(Some(reason))` when a guardrail rejects the call.
869 ///
870 /// # Errors
871 /// Propagates any error returned by a guardrail callback after emitting the
872 /// corresponding guardrail scope end event.
873 pub(crate) fn llm_conditional_execution_snapshot_chain(
874 request: &LlmRequest,
875 entries: &[Guardrail<LlmConditionalFn>],
876 subscribers: &[EventSubscriberFn],
877 parent_uuid: Option<Uuid>,
878 metadata: Option<Json>,
879 ) -> crate::error::Result<Option<String>> {
880 for entry in entries {
881 let handle = Self::emit_guardrail_scope_start(
882 &entry.name,
883 parent_uuid,
884 metadata.clone(),
885 json!({
886 "kind": "llm_conditional_execution",
887 }),
888 subscribers,
889 );
890 let result = (entry.payload)(request);
891 let output = match &result {
892 Ok(Some(reason)) => json!({
893 "allowed": false,
894 "rejected": true,
895 "rejection_reason": reason,
896 }),
897 Ok(None) => json!({
898 "allowed": true,
899 "rejected": false,
900 }),
901 Err(error) => json!({
902 "allowed": false,
903 "error": error.to_string(),
904 }),
905 };
906 Self::emit_guardrail_scope_end(&handle, output, subscribers);
907 if let Some(error) = result? {
908 return Ok(Some(error));
909 }
910 }
911 Ok(None)
912 }
913
914 /// Run LLM request intercepts in priority order.
915 ///
916 /// # Parameters
917 /// - `name`: Logical provider or model family name.
918 /// - `request`: LLM request to pass through the intercept chain.
919 /// - `annotated`: Optional normalized request annotation to carry through
920 /// the chain.
921 /// - `scope_locals`: Scope-local request intercept registries collected
922 /// from the active scope stack.
923 ///
924 /// # Returns
925 /// A [`Result`] containing the final request and annotation pair.
926 ///
927 /// # Errors
928 /// Propagates any error returned by an intercept callback.
929 ///
930 /// # Notes
931 /// If an intercept entry has `break_chain` enabled, later intercepts are
932 /// skipped after that entry runs.
933 pub(crate) fn llm_request_intercepts_chain(
934 &self,
935 name: &str,
936 request: LlmRequest,
937 annotated: Option<AnnotatedLlmRequest>,
938 scope_locals: &[&SortedRegistry<Intercept<LlmRequestInterceptFn>>],
939 ) -> crate::error::Result<(LlmRequest, Option<AnnotatedLlmRequest>)> {
940 let entries = merge_intercept_entries(&self.llm_request_intercepts, scope_locals);
941 let mut request_value = request;
942 let mut annotated_value = annotated;
943 for entry in entries {
944 let (new_request, new_annotated) =
945 (entry.payload.callable)(name, request_value, annotated_value)?;
946 request_value = new_request;
947 annotated_value = new_annotated;
948 if entry.payload.break_chain {
949 break;
950 }
951 }
952 Ok((request_value, annotated_value))
953 }
954
955 /// Build the composed non-streaming LLM execution continuation chain.
956 ///
957 /// # Parameters
958 /// - `name`: Logical provider or model family name passed into each
959 /// execution intercept.
960 /// - `default_fn`: Base provider callback that should run after all
961 /// intercepts.
962 /// - `scope_locals`: Scope-local execution intercept registries collected
963 /// from the active scope stack.
964 ///
965 /// # Returns
966 /// A composed [`LlmExecutionNextFn`] that wraps `default_fn` in every
967 /// matching execution intercept.
968 pub(crate) fn llm_build_execution_chain(
969 &self,
970 name: &str,
971 default_fn: LlmExecutionNextFn,
972 scope_locals: &[&SortedRegistry<ExecutionIntercept<LlmExecutionFn>>],
973 ) -> LlmExecutionNextFn {
974 let matching =
975 merge_execution_intercept_callables(&self.llm_execution_intercepts, scope_locals);
976 let mut next = default_fn;
977 let name = name.to_string();
978 for (callable, _) in matching.into_iter().rev() {
979 let current_next = next.clone();
980 let current_name = name.clone();
981 next = Arc::new(move |request| callable(¤t_name, request, current_next.clone()));
982 }
983 next
984 }
985
986 /// Build the composed streaming LLM execution continuation chain.
987 ///
988 /// # Parameters
989 /// - `name`: Logical provider or model family name passed into each
990 /// execution intercept.
991 /// - `default_fn`: Base stream-producing callback that should run after all
992 /// intercepts.
993 /// - `scope_locals`: Scope-local execution intercept registries collected
994 /// from the active scope stack.
995 ///
996 /// # Returns
997 /// A composed [`LlmStreamExecutionNextFn`] that wraps `default_fn` in every
998 /// matching execution intercept.
999 pub(crate) fn llm_stream_build_execution_chain(
1000 &self,
1001 name: &str,
1002 default_fn: LlmStreamExecutionNextFn,
1003 scope_locals: LlmStreamExecutionRegistryRefs<'_>,
1004 ) -> LlmStreamExecutionNextFn {
1005 let matching = merge_execution_intercept_callables(
1006 &self.llm_stream_execution_intercepts,
1007 scope_locals,
1008 );
1009 let mut next = default_fn;
1010 let name = name.to_string();
1011 for (callable, _) in matching.into_iter().rev() {
1012 let current_next = next.clone();
1013 let current_name = name.clone();
1014 next = Arc::new(move |request| callable(¤t_name, request, current_next.clone()));
1015 }
1016 next
1017 }
1018}
1019
1020fn end_timestamp_after(started_at: chrono::DateTime<Utc>) -> chrono::DateTime<Utc> {
1021 let now = Utc::now();
1022 if now > started_at {
1023 now
1024 } else {
1025 started_at + Duration::microseconds(1)
1026 }
1027}
1028
1029impl Default for NemoRelayContextState {
1030 fn default() -> Self {
1031 Self::new()
1032 }
1033}