Skip to main content

nemo_relay/api/
tool.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use serde_json::json;
5
6use crate::api::event::{BaseEvent, Event, MarkEvent, PendingMarkSpec};
7use crate::api::runtime::NemoRelayContextState;
8use crate::api::runtime::current_scope_stack;
9use crate::api::runtime::global_context;
10use crate::api::runtime::subscriber_dispatcher::{
11    PendingPublication, dispatch_sanitized_event, dispatch_transformed_event,
12    register_pending_publication,
13};
14use crate::api::runtime::{
15    EventSubscriberFn, ScopeStackHandle, ToolExecutionNextFn, with_active_event_uuid,
16};
17use crate::api::scope::event;
18use crate::api::scope::{EmitMarkEventParams, ScopeHandle};
19use crate::api::shared::{
20    ensure_runtime_owner, metadata_with_otel_error, metadata_with_otel_status, resolve_parent_uuid,
21    snapshot_event_sanitizers, snapshot_event_subscribers,
22};
23use crate::api::skill_load;
24use crate::error::{FlowError, Result};
25use crate::json::Json;
26use chrono::{DateTime, TimeDelta, Utc};
27use serde::{Deserialize, Serialize};
28use typed_builder::TypedBuilder;
29use uuid::Uuid;
30
31pub use nemo_relay_types::api::tool::{ToolAttributes, ToolExecutionInterceptOutcome};
32
33fn queue_sanitized_event(event: Event, subscribers: &[EventSubscriberFn]) -> bool {
34    let scope_stack = current_scope_stack();
35    queue_sanitized_event_with_scope_stack(event, subscribers, scope_stack)
36}
37
38fn queue_sanitized_event_with_scope_stack(
39    event: Event,
40    subscribers: &[EventSubscriberFn],
41    scope_stack: ScopeStackHandle,
42) -> bool {
43    let sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
44    dispatch_sanitized_event(event, sanitizers, subscribers, scope_stack)
45}
46
47/// Runtime-owned handle identifying an active or completed tool call.
48#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
49#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
50pub struct ToolHandle {
51    /// Unique tool-call identifier.
52    #[builder(default = Uuid::now_v7())]
53    pub uuid: Uuid,
54    /// Timestamp captured when the tool handle was created.
55    #[builder(default = Utc::now())]
56    pub started_at: DateTime<Utc>,
57    /// Tool name recorded on lifecycle events.
58    #[builder(setter(into))]
59    pub name: String,
60    /// Optional application payload stored on the handle.
61    #[builder(default)]
62    pub data: Option<Json>,
63    /// Optional metadata attached to the tool span.
64    #[builder(default)]
65    pub metadata: Option<Json>,
66    /// Tool behavior flags.
67    #[builder(default = ToolAttributes::empty())]
68    pub attributes: ToolAttributes,
69    /// UUID of the parent scope, if any.
70    #[builder(default)]
71    pub parent_uuid: Option<Uuid>,
72    /// Optional provider-specific tool-call correlation identifier.
73    #[builder(default, setter(into))]
74    pub tool_call_id: Option<String>,
75}
76
77/// Builder parameters for [`NemoRelayContextState::create_tool_handle`].
78#[derive(Debug, Clone, TypedBuilder)]
79#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
80pub struct CreateToolHandleParams<'a> {
81    /// Tool name recorded on emitted events.
82    pub name: &'a str,
83    /// Optional parent scope UUID.
84    #[builder(default)]
85    pub parent_uuid: Option<uuid::Uuid>,
86    /// Tool attribute bitflags.
87    #[builder(default = ToolAttributes::empty())]
88    pub attributes: ToolAttributes,
89    /// Optional application payload stored on the handle.
90    #[builder(default)]
91    pub data: Option<Json>,
92    /// Optional metadata stored on the handle.
93    #[builder(default)]
94    pub metadata: Option<Json>,
95    /// Optional provider-specific correlation identifier.
96    #[builder(default, setter(into))]
97    pub tool_call_id: Option<String>,
98    /// Optional timestamp captured as the handle start time and reused by the
99    /// emitted start event. When omitted, the current UTC time is used.
100    #[builder(default)]
101    pub timestamp: Option<DateTime<Utc>>,
102}
103
104fn resolve_skill_loads(
105    name: &str,
106    args: &Json,
107    metadata: Option<&Json>,
108) -> Vec<skill_load::SkillLoad> {
109    let already_handled = metadata
110        .and_then(Json::as_object)
111        .and_then(|metadata| metadata.get(skill_load::HANDLED_METADATA_KEY))
112        .and_then(Json::as_bool)
113        .unwrap_or(false);
114    if already_handled {
115        Vec::new()
116    } else if let Some(skill_loads) = skill_load::precomputed(metadata) {
117        skill_loads
118    } else {
119        skill_load::detect(name, args)
120    }
121}
122
123/// Builder parameters for [`NemoRelayContextState::build_tool_end_event`].
124#[derive(Debug, Clone, TypedBuilder)]
125#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
126pub struct EndToolHandleParams<'a> {
127    /// Tool handle to serialize into the emitted end event.
128    pub handle: &'a ToolHandle,
129    /// Optional data payload merged over the handle data.
130    #[builder(default)]
131    pub data: Option<Json>,
132    /// Optional metadata payload merged over the handle metadata.
133    #[builder(default)]
134    pub metadata: Option<Json>,
135    /// Optional timestamp recorded on the emitted end event. When omitted, the
136    /// runtime records the current UTC time, or one microsecond after the
137    /// handle start time if the current time is not later.
138    #[builder(default)]
139    pub timestamp: Option<DateTime<Utc>>,
140}
141
142/// Builder parameters for [`tool_call`].
143#[derive(TypedBuilder)]
144#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
145pub struct ToolCallParams<'a> {
146    /// Tool name recorded on the emitted lifecycle event.
147    pub name: &'a str,
148    /// Raw tool arguments associated with the span.
149    pub args: Json,
150    /// Optional explicit parent scope.
151    #[builder(default)]
152    pub parent: Option<&'a ScopeHandle>,
153    /// Tool attribute bitflags applied to the span.
154    #[builder(default = ToolAttributes::empty())]
155    pub attributes: ToolAttributes,
156    /// Optional application payload stored on the handle but not emitted as
157    /// Agent Trajectory Observability Format (ATOF) data.
158    #[builder(default)]
159    pub data: Option<Json>,
160    /// Optional JSON metadata recorded on the start event.
161    #[builder(default)]
162    pub metadata: Option<Json>,
163    /// Optional provider-specific correlation identifier.
164    #[builder(default, setter(into))]
165    pub tool_call_id: Option<String>,
166    /// Optional timestamp captured as the handle start time and reused by the
167    /// emitted start event. When omitted, the current UTC time is used.
168    #[builder(default)]
169    pub timestamp: Option<DateTime<Utc>>,
170}
171
172/// Builder parameters for [`tool_call_execute`].
173#[derive(TypedBuilder)]
174#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
175pub struct ToolCallExecuteParams {
176    /// Tool name recorded on emitted lifecycle events.
177    #[builder(setter(into))]
178    pub name: String,
179    /// Raw tool arguments passed into the managed pipeline.
180    pub args: Json,
181    /// Tool callback or execution continuation.
182    pub func: ToolExecutionNextFn,
183    /// Optional explicit parent scope for the emitted tool span.
184    #[builder(default)]
185    pub parent: Option<ScopeHandle>,
186    /// Tool attribute bitflags applied to the managed span.
187    #[builder(default = ToolAttributes::empty())]
188    pub attributes: ToolAttributes,
189    /// Optional application payload stored on the handle but not emitted as
190    /// Agent Trajectory Observability Format (ATOF) data.
191    #[builder(default)]
192    pub data: Option<Json>,
193    /// Optional JSON metadata recorded on emitted events.
194    #[builder(default)]
195    pub metadata: Option<Json>,
196}
197
198/// Builder parameters for [`tool_call_end`].
199#[derive(TypedBuilder)]
200#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
201pub struct ToolCallEndParams<'a> {
202    /// Tool handle to close.
203    pub handle: &'a ToolHandle,
204    /// Raw tool result associated with the end event.
205    pub result: Json,
206    /// Optional application payload retained for compatibility; Agent
207    /// Trajectory Observability Format (ATOF) data is the result.
208    #[builder(default)]
209    pub data: Option<Json>,
210    /// Optional JSON metadata recorded on the end event.
211    #[builder(default)]
212    pub metadata: Option<Json>,
213    /// Optional timestamp recorded on the emitted end event. When omitted, the
214    /// runtime records the current UTC time, or one microsecond after the
215    /// handle start time if the current time is not later.
216    #[builder(default)]
217    pub timestamp: Option<DateTime<Utc>>,
218}
219
220/// Start a manual tool lifecycle span.
221///
222/// This submits a tool-start event for queued sanitize-request guardrails and
223/// publication without waiting for that work.
224///
225/// # Parameters
226/// - `name`: Tool name recorded on the emitted lifecycle event.
227/// - `args`: Raw tool arguments associated with the span.
228/// - `parent`: Optional explicit parent scope.
229/// - `attributes`: Tool attribute bitflags applied to the span.
230/// - `data`: Optional application payload stored on the returned handle. The
231///   emitted start event data is the sanitized `args` payload.
232/// - `metadata`: Optional JSON metadata recorded on the start event.
233/// - `tool_call_id`: Optional provider-specific correlation identifier.
234/// - `timestamp`: Optional timestamp recorded as the handle start time and on
235///   the emitted start event. When `None`, the current UTC time is used.
236///
237/// # Returns
238/// A [`Result`] containing the created [`ToolHandle`] after its start-event
239/// snapshot has been submitted for queued publication.
240///
241/// # Errors
242/// Returns an error when the runtime owner check fails or when internal state
243/// cannot be read safely. Dispatcher submission failures are logged because
244/// observability publication is best effort.
245///
246/// # Notes
247/// Sanitize-request guardrails affect only the emitted start-event payload, not
248/// the caller-owned `args` value. If a sanitizer errors or panics, Relay omits
249/// that observability payload and does not run remaining sanitizers.
250pub fn tool_call(params: ToolCallParams<'_>) -> Result<ToolHandle> {
251    ensure_runtime_owner()?;
252    let scope_stack = current_scope_stack();
253    let (entries, subscribers) = {
254        let scope_guard = scope_stack
255            .read()
256            .map_err(|error| FlowError::Internal(error.to_string()))?;
257        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
258            &registries.tool_sanitize_request_guardrails
259        });
260        let subscribers =
261            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
262        let context = global_context();
263        let state = context
264            .read()
265            .map_err(|error| FlowError::Internal(error.to_string()))?;
266        (
267            state.tool_sanitize_request_entries(&scope_locals),
268            subscribers,
269        )
270    };
271    let skill_loads = resolve_skill_loads(params.name, &params.args, params.metadata.as_ref());
272    let raw_args = params.args;
273    let (handle, event, marks) = {
274        let context = global_context();
275        let state = context
276            .read()
277            .map_err(|error| FlowError::Internal(error.to_string()))?;
278        let handle = state.create_tool_handle(
279            CreateToolHandleParams::builder()
280                .name(params.name)
281                .parent_uuid_opt(resolve_parent_uuid(params.parent))
282                .attributes(params.attributes)
283                .data_opt(params.data)
284                .metadata_opt(params.metadata)
285                .tool_call_id_opt(params.tool_call_id)
286                .timestamp_opt(params.timestamp)
287                .build(),
288        );
289        let event = state.build_tool_start_event(&handle, None);
290        let marks = skill_loads
291            .into_iter()
292            .map(|skill_load| {
293                state.create_event(MarkEvent::new(
294                    BaseEvent::builder()
295                        .name("skill.load")
296                        .parent_uuid(handle.uuid)
297                        .timestamp(handle.started_at)
298                        .data(json!({"skill_name": skill_load.name}))
299                        .metadata(json!({
300                            "skill_load_source": <&str>::from(skill_load.source),
301                            "tool_name": handle.name,
302                        }))
303                        .build(),
304                    None,
305                    None,
306                ))
307            })
308            .collect::<Vec<_>>();
309        (handle, event, marks)
310    };
311    let tool_name = handle.name.clone();
312    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
313    dispatch_transformed_event(
314        event,
315        Box::new(move |mut event| {
316            Box::pin(async move {
317                let sanitized = NemoRelayContextState::tool_sanitize_request_snapshot_chain(
318                    &tool_name, raw_args, &entries,
319                )
320                .await;
321                let mut fields = event.sanitize_fields();
322                fields.data = sanitized;
323                event.apply_sanitize_fields(fields);
324                event
325            })
326        }),
327        event_sanitizers,
328        &subscribers,
329        scope_stack.clone(),
330    );
331    for mark in marks {
332        let sanitizers = snapshot_event_sanitizers(&mark, &scope_stack).unwrap_or_default();
333        dispatch_sanitized_event(mark, sanitizers, &subscribers, scope_stack.clone());
334    }
335    Ok(handle)
336}
337
338async fn tool_call_with_subscriber_snapshot(
339    params: ToolCallParams<'_>,
340) -> Result<(ToolHandle, Vec<EventSubscriberFn>)> {
341    ensure_runtime_owner()?;
342    let parent_uuid = resolve_parent_uuid(params.parent);
343    let (entries, subscribers) = {
344        let scope_stack = current_scope_stack();
345        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
346        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
347            &registries.tool_sanitize_request_guardrails
348        });
349        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
350        let subscribers = snapshot_event_subscribers(scope_subscribers)?;
351        let context = global_context();
352        let state = context
353            .read()
354            .map_err(|error| FlowError::Internal(error.to_string()))?;
355        let entries = state.tool_sanitize_request_entries(&scope_locals);
356        (entries, subscribers)
357    };
358    let skill_loads = resolve_skill_loads(params.name, &params.args, params.metadata.as_ref());
359    let sanitized_args = NemoRelayContextState::tool_sanitize_request_snapshot_chain(
360        params.name,
361        params.args,
362        &entries,
363    )
364    .await;
365    let (handle, event, marks) = {
366        let context = global_context();
367        let state = context
368            .read()
369            .map_err(|error| FlowError::Internal(error.to_string()))?;
370        let handle_params = CreateToolHandleParams::builder()
371            .name(params.name)
372            .parent_uuid_opt(parent_uuid)
373            .attributes(params.attributes)
374            .data_opt(params.data)
375            .metadata_opt(params.metadata)
376            .tool_call_id_opt(params.tool_call_id)
377            .timestamp_opt(params.timestamp)
378            .build();
379        let handle = state.create_tool_handle(handle_params);
380        let event = state.build_tool_start_event(&handle, sanitized_args);
381        let marks = skill_loads
382            .into_iter()
383            .map(|skill_load| {
384                state.create_event(MarkEvent::new(
385                    BaseEvent::builder()
386                        .name("skill.load")
387                        .parent_uuid(handle.uuid)
388                        .timestamp(handle.started_at)
389                        .data(json!({"skill_name": skill_load.name}))
390                        .metadata(json!({
391                            "skill_load_source": <&str>::from(skill_load.source),
392                            "tool_name": handle.name,
393                        }))
394                        .build(),
395                    None,
396                    None,
397                ))
398            })
399            .collect::<Vec<_>>();
400        (handle, event, marks)
401    };
402    queue_sanitized_event(event, &subscribers);
403    for mark in marks {
404        queue_sanitized_event(mark, &subscribers);
405    }
406    Ok((handle, subscribers))
407}
408
409/// Finish a manual tool lifecycle span.
410///
411/// This submits a tool-end event for queued sanitization and publication for a
412/// handle previously returned by [`tool_call`].
413///
414/// # Parameters
415/// - `handle`: Tool handle to close.
416/// - `result`: Raw tool result associated with the end event.
417/// - `data`: Optional application payload retained for compatibility. The
418///   emitted end event data is the sanitized `result` unless it sanitizes to
419///   JSON null, in which case this payload is used.
420/// - `metadata`: Optional JSON metadata recorded on the end event.
421/// - `timestamp`: Optional timestamp recorded on the emitted end event. When
422///   `None`, the runtime uses the current UTC time, or one microsecond after
423///   the handle start time if the current time is not later.
424///
425/// # Returns
426/// A [`Result`] that is `Ok(())` when the end-event snapshot has been submitted
427/// for queued publication.
428///
429/// # Errors
430/// Returns an error when the runtime owner check fails or when internal state
431/// cannot be read safely. Dispatcher submission failures are logged because
432/// observability publication is best effort.
433///
434/// # Notes
435/// Sanitize-response guardrails affect only the emitted end-event payload, not
436/// the caller-owned `result` value. If a sanitizer errors or panics, Relay omits
437/// that observability payload and does not run remaining sanitizers.
438pub fn tool_call_end(params: ToolCallEndParams<'_>) -> Result<()> {
439    ensure_runtime_owner()?;
440    let scope_stack = current_scope_stack();
441    let (entries, subscribers) = {
442        let scope_guard = scope_stack
443            .read()
444            .map_err(|error| FlowError::Internal(error.to_string()))?;
445        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
446            &registries.tool_sanitize_response_guardrails
447        });
448        let subscribers =
449            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?;
450        let context = global_context();
451        let state = context
452            .read()
453            .map_err(|error| FlowError::Internal(error.to_string()))?;
454        (
455            state.tool_sanitize_response_entries(&scope_locals),
456            subscribers,
457        )
458    };
459    let result = params.result;
460    let fallback = params.data;
461    let event = {
462        let context = global_context();
463        let state = context
464            .read()
465            .map_err(|error| FlowError::Internal(error.to_string()))?;
466        state.build_tool_end_event(
467            EndToolHandleParams::builder()
468                .handle(params.handle)
469                .data(Json::Null)
470                .metadata_opt(params.metadata)
471                .timestamp_opt(params.timestamp)
472                .build(),
473        )
474    };
475    let tool_name = params.handle.name.clone();
476    let event_sanitizers = snapshot_event_sanitizers(&event, &scope_stack).unwrap_or_default();
477    dispatch_transformed_event(
478        event,
479        Box::new(move |mut event| {
480            Box::pin(async move {
481                let sanitized = NemoRelayContextState::tool_sanitize_response_snapshot_chain(
482                    &tool_name, result, &entries,
483                )
484                .await;
485                let mut fields = event.sanitize_fields();
486                fields.data = sanitized.and_then(|value| {
487                    if value.is_null() {
488                        fallback
489                    } else {
490                        Some(value)
491                    }
492                });
493                event.apply_sanitize_fields(fields);
494                event
495            })
496        }),
497        event_sanitizers,
498        &subscribers,
499        scope_stack,
500    );
501    Ok(())
502}
503
504async fn tool_call_end_with_pending_marks(
505    params: ToolCallEndParams<'_>,
506    pending_marks: Vec<PendingMarkSpec>,
507    lifecycle_subscribers: Option<&[EventSubscriberFn]>,
508) -> Result<()> {
509    ensure_runtime_owner()?;
510    let (entries, subscribers) = {
511        let scope_stack = current_scope_stack();
512        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
513        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
514            &registries.tool_sanitize_response_guardrails
515        });
516        let subscribers = if lifecycle_subscribers.is_some() {
517            Vec::new()
518        } else {
519            snapshot_event_subscribers(scope_guard.collect_scope_local_subscribers())?
520        };
521        let context = global_context();
522        let state = context
523            .read()
524            .map_err(|error| FlowError::Internal(error.to_string()))?;
525        let entries = state.tool_sanitize_response_entries(&scope_locals);
526        (entries, subscribers)
527    };
528    let subscribers = lifecycle_subscribers.unwrap_or(&subscribers);
529    let sanitized_result = NemoRelayContextState::tool_sanitize_response_snapshot_chain(
530        &params.handle.name,
531        params.result,
532        &entries,
533    )
534    .await;
535    let data = sanitized_result.and_then(|value| {
536        if value.is_null() {
537            params.data
538        } else {
539            Some(value)
540        }
541    });
542    let event = {
543        let context = global_context();
544        let state = context
545            .read()
546            .map_err(|error| FlowError::Internal(error.to_string()))?;
547        state.build_tool_end_event(
548            EndToolHandleParams::builder()
549                .handle(params.handle)
550                .data_opt(data)
551                .metadata_opt(params.metadata)
552                .timestamp_opt(params.timestamp)
553                .build(),
554        )
555    };
556    let marks = pending_marks
557        .into_iter()
558        .enumerate()
559        .map(|(index, mark)| {
560            let timestamp = *event.timestamp()
561                + TimeDelta::microseconds(i64::try_from(index).unwrap_or_default() + 1);
562            Event::Mark(MarkEvent::new(
563                BaseEvent::builder()
564                    .name(mark.name)
565                    .parent_uuid(params.handle.uuid)
566                    .timestamp(timestamp)
567                    .data_opt(mark.data)
568                    .metadata_opt(mark.metadata)
569                    .build(),
570                mark.category,
571                mark.category_profile,
572            ))
573        })
574        .collect::<Vec<_>>();
575    queue_sanitized_event(event, subscribers);
576    for mark in marks {
577        queue_sanitized_event(mark, subscribers);
578    }
579    Ok(())
580}
581
582fn emit_tool_end_without_output(
583    handle: &ToolHandle,
584    metadata: Option<Json>,
585    lifecycle_subscribers: &[EventSubscriberFn],
586    scope_stack: ScopeStackHandle,
587) -> Result<()> {
588    ensure_runtime_owner()?;
589    let event = {
590        let context = global_context();
591        let state = context
592            .read()
593            .map_err(|error| FlowError::Internal(error.to_string()))?;
594        state.end_tool_handle(handle, handle.data.clone(), metadata)
595    };
596    queue_sanitized_event_with_scope_stack(event, lifecycle_subscribers, scope_stack);
597    Ok(())
598}
599
600struct ManagedToolCompletion {
601    handle: Option<ToolHandle>,
602    metadata: Option<Json>,
603    subscribers: Vec<EventSubscriberFn>,
604    scope_stack: ScopeStackHandle,
605    pending_publication: Option<PendingPublication>,
606}
607
608impl ManagedToolCompletion {
609    fn new(
610        handle: &ToolHandle,
611        metadata: Option<Json>,
612        subscribers: &[EventSubscriberFn],
613        scope_stack: ScopeStackHandle,
614    ) -> Self {
615        Self {
616            handle: Some(handle.clone()),
617            metadata,
618            subscribers: subscribers.to_vec(),
619            scope_stack,
620            pending_publication: (!subscribers.is_empty())
621                .then(register_pending_publication)
622                .flatten(),
623        }
624    }
625
626    fn disarm(&mut self) {
627        self.handle = None;
628        drop(self.pending_publication.take());
629    }
630}
631
632impl Drop for ManagedToolCompletion {
633    fn drop(&mut self) {
634        let pending_publication = self.pending_publication.take();
635        let Some(handle) = self.handle.take() else {
636            return;
637        };
638        let metadata = metadata_with_otel_status(
639            self.metadata.take(),
640            "ERROR",
641            Some("tool execution cancelled".into()),
642        );
643        let _ = emit_tool_end_without_output(
644            &handle,
645            metadata,
646            &self.subscribers,
647            self.scope_stack.clone(),
648        );
649        drop(pending_publication);
650    }
651}
652
653/// Execute a tool call through the managed middleware pipeline.
654///
655/// This runs conditional-execution guardrails, request intercepts,
656/// sanitize-request guardrails, execution intercepts, the tool callback, and
657/// sanitize-response guardrails in the runtime-defined order.
658///
659/// # Parameters
660/// - `name`: Tool name recorded on emitted lifecycle events.
661/// - `args`: Raw tool arguments passed into the managed pipeline.
662/// - `func`: Tool callback or execution continuation.
663/// - `parent`: Optional explicit parent scope for the emitted tool span.
664/// - `attributes`: Tool attribute bitflags applied to the managed span.
665/// - `data`: Optional application payload stored on the managed tool handle.
666///   It may be used on failure end events that have no output payload.
667/// - `metadata`: Optional JSON metadata recorded on emitted events.
668///
669/// # Returns
670/// A [`Result`] containing the raw tool result returned by the callback or an
671/// execution intercept.
672///
673/// # Errors
674/// Returns [`FlowError::GuardrailRejected`] when conditional-execution
675/// guardrails block the call, or any error raised by request intercepts,
676/// execution intercepts, or the callback itself.
677///
678/// # Notes
679/// When execution fails after the start event has been emitted, the runtime
680/// still emits a tool-end event without an output payload.
681pub async fn tool_call_execute(params: ToolCallExecuteParams) -> Result<Json> {
682    let ToolCallExecuteParams {
683        name,
684        args,
685        func,
686        parent,
687        attributes,
688        data,
689        metadata,
690    } = params;
691    ensure_runtime_owner()?;
692    {
693        let (entries, subscribers, parent_uuid, guardrail_metadata) = {
694            let scope_stack = current_scope_stack();
695            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
696            let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
697                &registries.tool_conditional_execution_guardrails
698            });
699            let scope_subscribers = scope_guard.collect_scope_local_subscribers();
700            let context = global_context();
701            let state = context
702                .read()
703                .map_err(|error| FlowError::Internal(error.to_string()))?;
704            let entries = state.tool_conditional_execution_entries(&scope_locals);
705            let subscribers = state.collect_event_subscribers(&scope_subscribers);
706            (
707                entries,
708                subscribers,
709                resolve_parent_uuid(parent.as_ref()),
710                metadata.clone(),
711            )
712        };
713        if let Some(error) = NemoRelayContextState::tool_conditional_execution_snapshot_chain(
714            &name,
715            &args,
716            &entries,
717            &subscribers,
718            parent_uuid,
719            guardrail_metadata,
720        )
721        .await?
722        {
723            let mut rejection_data = json!({});
724            if let Some(object) = rejection_data.as_object_mut() {
725                object.insert("rejected".into(), json!(true));
726                object.insert("rejection_reason".into(), json!(&error));
727            }
728            let _ = event(
729                EmitMarkEventParams::builder()
730                    .name(&name)
731                    .parent_opt(parent.as_ref())
732                    .data(rejection_data)
733                    .metadata_opt(metadata.clone())
734                    .build(),
735            );
736            return Err(FlowError::GuardrailRejected(error));
737        }
738    }
739
740    let intercept_entries = {
741        let scope_stack = current_scope_stack();
742        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
743        let scope_locals = scope_guard
744            .collect_scope_local_registries(|registries| &registries.tool_request_intercepts);
745        let context = global_context();
746        let state = context
747            .read()
748            .map_err(|error| FlowError::Internal(error.to_string()))?;
749        state.tool_request_intercept_entries(&scope_locals)
750    };
751    let intercepted_args = NemoRelayContextState::tool_request_intercepts_snapshot_chain(
752        &name,
753        args,
754        &intercept_entries,
755    )
756    .await?;
757
758    let (handle, lifecycle_subscribers) = tool_call_with_subscriber_snapshot(
759        ToolCallParams::builder()
760            .name(name.as_str())
761            .args(intercepted_args.clone())
762            .parent_opt(parent.as_ref())
763            .attributes(attributes)
764            .data_opt(data.clone())
765            .metadata_opt(metadata.clone())
766            .build(),
767    )
768    .await?;
769
770    let lifecycle_scope_stack = current_scope_stack();
771    let mut completion = ManagedToolCompletion::new(
772        &handle,
773        metadata.clone(),
774        &lifecycle_subscribers,
775        lifecycle_scope_stack.clone(),
776    );
777    let execution_name = name.clone();
778    let execution = with_active_event_uuid(handle.uuid, async move {
779        let execution = {
780            let scope_stack = current_scope_stack();
781            let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
782            let scope_locals = scope_guard
783                .collect_scope_local_registries(|registries| &registries.tool_execution_intercepts);
784            let context = global_context();
785            let state = context
786                .read()
787                .map_err(|error| FlowError::Internal(error.to_string()))?;
788            state.tool_build_execution_chain(&execution_name, func, &scope_locals)
789        };
790        execution(intercepted_args).await
791    })
792    .await;
793    match execution {
794        Ok(outcome) => {
795            let ToolExecutionInterceptOutcome {
796                result,
797                pending_marks,
798            } = outcome;
799            let end_metadata = metadata_with_otel_status(metadata, "OK", None);
800            tool_call_end_with_pending_marks(
801                ToolCallEndParams::builder()
802                    .handle(&handle)
803                    .result(result.clone())
804                    .data_opt(data)
805                    .metadata_opt(end_metadata)
806                    .build(),
807                pending_marks,
808                Some(&lifecycle_subscribers),
809            )
810            .await?;
811            completion.disarm();
812            Ok(result)
813        }
814        Err(error) => {
815            let end_metadata = metadata_with_otel_error(metadata, &error);
816            let _ = emit_tool_end_without_output(
817                &handle,
818                end_metadata,
819                &lifecycle_subscribers,
820                lifecycle_scope_stack,
821            );
822            completion.disarm();
823            Err(error)
824        }
825    }
826}
827
828/// Run only the tool request-intercept chain.
829///
830/// This applies the currently active global and scope-local request intercepts
831/// without emitting lifecycle events or invoking tool execution.
832///
833/// # Parameters
834/// - `name`: Tool name used when resolving the intercept chain.
835/// - `args`: Raw tool arguments to transform.
836///
837/// # Returns
838/// A [`Result`] containing the transformed JSON arguments.
839///
840/// # Errors
841/// Returns any error raised by the request-intercept chain.
842///
843/// # Notes
844/// Conditional guardrails and execution intercepts are not run by this helper.
845pub async fn tool_request_intercepts(name: &str, args: Json) -> Result<Json> {
846    ensure_runtime_owner()?;
847    let entries = {
848        let scope_stack = current_scope_stack();
849        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
850        let scope_locals = scope_guard
851            .collect_scope_local_registries(|registries| &registries.tool_request_intercepts);
852        let context = global_context();
853        let state = context
854            .read()
855            .map_err(|error| FlowError::Internal(error.to_string()))?;
856        state.tool_request_intercept_entries(&scope_locals)
857    };
858    NemoRelayContextState::tool_request_intercepts_snapshot_chain(name, args, &entries).await
859}
860
861/// Run only the tool conditional-execution guardrail chain.
862///
863/// This evaluates whether a tool call should be allowed to proceed without
864/// invoking request intercepts or execution. Each evaluated guardrail emits an
865/// automatic guardrail scope start/end pair for observability.
866///
867/// # Parameters
868/// - `name`: Tool name used when resolving the guardrail chain.
869/// - `args`: Raw tool arguments to validate.
870///
871/// # Returns
872/// A [`Result`] that is `Ok(())` when all guardrails allow execution.
873///
874/// # Errors
875/// Returns [`FlowError::GuardrailRejected`] when a guardrail blocks execution,
876/// or any error raised by the guardrail chain itself.
877///
878/// # Notes
879/// This helper is useful for preflight checks when the caller needs the
880/// rejection result without starting a tool span. Guardrail scopes are still
881/// emitted for the conditional checks themselves.
882pub async fn tool_conditional_execution(name: &str, args: &Json) -> Result<()> {
883    ensure_runtime_owner()?;
884    let (entries, subscribers, parent_uuid) = {
885        let scope_stack = current_scope_stack();
886        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
887        let scope_locals = scope_guard.collect_scope_local_registries(|registries| {
888            &registries.tool_conditional_execution_guardrails
889        });
890        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
891        let context = global_context();
892        let state = context
893            .read()
894            .map_err(|error| FlowError::Internal(error.to_string()))?;
895        let entries = state.tool_conditional_execution_entries(&scope_locals);
896        let subscribers = state.collect_event_subscribers(&scope_subscribers);
897        (entries, subscribers, resolve_parent_uuid(None))
898    };
899    if let Some(error) = NemoRelayContextState::tool_conditional_execution_snapshot_chain(
900        name,
901        args,
902        &entries,
903        &subscribers,
904        parent_uuid,
905        None,
906    )
907    .await?
908    {
909        return Err(FlowError::GuardrailRejected(error));
910    }
911    Ok(())
912}
913
914#[cfg(test)]
915#[path = "../../tests/unit/tool_api_tests.rs"]
916mod tests;