Skip to main content

nemo_relay/api/
scope.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::api::event::{BaseEvent, MarkEvent};
5use crate::api::runtime::NemoRelayContextState;
6use crate::api::runtime::global_context;
7use crate::api::runtime::{
8    current_scope_stack, task_scope_push, task_scope_remove, task_scope_top,
9};
10use crate::api::shared::{ensure_runtime_owner, resolve_parent_uuid, snapshot_event_subscribers};
11use crate::error::{FlowError, Result};
12use crate::json::Json;
13use bitflags::bitflags;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use typed_builder::TypedBuilder;
17use uuid::Uuid;
18
19use crate::api::llm::LlmAttributes;
20use crate::api::tool::ToolAttributes;
21
22bitflags! {
23    /// Bitflags that modify scope behavior and observability.
24    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25    pub struct ScopeAttributes: u32 {
26        /// Marks the scope as running in parallel with sibling work.
27        const PARALLEL    = 0b01;
28        /// Marks the scope as safe to move across execution contexts.
29        const RELOCATABLE = 0b10;
30    }
31}
32
33/// Semantic category attached to a scope lifecycle span.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
35#[serde(rename_all = "lowercase")]
36pub enum ScopeType {
37    /// A top-level agent or workflow scope.
38    Agent,
39    /// A generic function or application step.
40    Function,
41    /// A tool lifecycle scope.
42    Tool,
43    /// An LLM lifecycle scope.
44    Llm,
45    /// A retrieval step such as document search.
46    Retriever,
47    /// An embedding generation step.
48    Embedder,
49    /// A reranking step.
50    Reranker,
51    /// A guardrail or validation step.
52    Guardrail,
53    /// An evaluation or scoring step.
54    Evaluator,
55    /// A caller-defined custom scope category.
56    Custom,
57    /// A fallback for unknown or unsupported scope categories.
58    Unknown,
59}
60
61impl ScopeType {
62    /// Return the stable lowercase string form used for encoded scope types.
63    pub const fn as_str(self) -> &'static str {
64        match self {
65            Self::Agent => "agent",
66            Self::Function => "function",
67            Self::Tool => "tool",
68            Self::Llm => "llm",
69            Self::Retriever => "retriever",
70            Self::Embedder => "embedder",
71            Self::Reranker => "reranker",
72            Self::Guardrail => "guardrail",
73            Self::Evaluator => "evaluator",
74            Self::Custom => "custom",
75            Self::Unknown => "unknown",
76        }
77    }
78}
79
80/// Attribute bitflags attached to a concrete handle kind.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
82pub enum HandleAttributes {
83    /// Scope-specific attributes.
84    Scope(ScopeAttributes),
85    /// Tool-specific attributes.
86    Tool(ToolAttributes),
87    /// LLM-specific attributes.
88    Llm(LlmAttributes),
89}
90
91/// Runtime-owned handle identifying an active or completed scope.
92#[derive(Debug, Clone, Serialize, Deserialize, TypedBuilder)]
93#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
94pub struct ScopeHandle {
95    /// Unique scope identifier.
96    #[builder(default = Uuid::now_v7())]
97    pub uuid: Uuid,
98    /// Timestamp captured when the scope handle was created.
99    #[builder(default = Utc::now())]
100    pub started_at: DateTime<Utc>,
101    /// Semantic category of the scope.
102    pub scope_type: ScopeType,
103    /// Human-readable scope name.
104    #[builder(setter(into))]
105    pub name: String,
106    /// Optional application payload stored on the handle.
107    #[builder(default)]
108    pub data: Option<Json>,
109    /// Optional metadata attached to the scope.
110    #[builder(default)]
111    pub metadata: Option<Json>,
112    /// Scope behavior flags.
113    #[builder(default = ScopeAttributes::empty())]
114    pub attributes: ScopeAttributes,
115    /// UUID of the parent scope, if any.
116    #[builder(default)]
117    pub parent_uuid: Option<Uuid>,
118}
119
120/// Builder parameters for [`push_scope`].
121#[derive(TypedBuilder)]
122#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
123pub struct PushScopeParams<'a> {
124    /// Human-readable scope name recorded on emitted lifecycle events.
125    pub name: &'a str,
126    /// Semantic category for the new scope.
127    pub scope_type: ScopeType,
128    /// Optional explicit parent scope.
129    #[builder(default)]
130    pub parent: Option<&'a ScopeHandle>,
131    /// Scope attribute bitflags applied to the new scope.
132    #[builder(default = ScopeAttributes::empty())]
133    pub attributes: ScopeAttributes,
134    /// Optional application payload stored on the scope handle.
135    #[builder(default)]
136    pub data: Option<Json>,
137    /// Optional JSON metadata recorded on the emitted start event.
138    #[builder(default)]
139    pub metadata: Option<Json>,
140    /// Optional JSON payload exported as the scope start event data.
141    #[builder(default)]
142    pub input: Option<Json>,
143    /// Optional timestamp recorded on the emitted start event.
144    #[builder(default)]
145    pub timestamp: Option<DateTime<Utc>>,
146}
147
148/// Builder parameters for [`NemoRelayContextState::create_scope_handle`].
149#[derive(Debug, Clone, TypedBuilder)]
150#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
151pub struct CreateScopeHandleParams<'a> {
152    /// Human-readable scope name.
153    pub name: &'a str,
154    /// Optional parent scope UUID.
155    #[builder(default)]
156    pub parent_uuid: Option<Uuid>,
157    /// Semantic category of the scope.
158    pub scope_type: ScopeType,
159    /// Scope attribute bitflags.
160    #[builder(default = ScopeAttributes::empty())]
161    pub attributes: ScopeAttributes,
162    /// Optional application payload stored on the handle.
163    #[builder(default)]
164    pub data: Option<Json>,
165    /// Optional metadata stored on the handle.
166    #[builder(default)]
167    pub metadata: Option<Json>,
168    /// Optional timestamp captured as the handle start time and reused by the
169    /// emitted start event. When omitted, the current UTC time is used.
170    #[builder(default)]
171    pub timestamp: Option<DateTime<Utc>>,
172}
173
174/// Builder parameters for [`NemoRelayContextState::build_scope_end_event`].
175#[derive(Debug, Clone, TypedBuilder)]
176#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
177pub struct EndScopeHandleParams<'a> {
178    /// Scope handle to serialize into the emitted end event.
179    pub handle: &'a ScopeHandle,
180    /// Optional JSON payload exported as the semantic scope output.
181    #[builder(default)]
182    pub data: Option<Json>,
183    /// Optional metadata to be appended to the metadata set when the scope was created.
184    #[builder(default)]
185    pub metadata: Option<Json>,
186    /// Optional timestamp recorded on the emitted end event. When omitted, the
187    /// runtime records the current UTC time, or one microsecond after the
188    /// handle start time if the current time is not later.
189    #[builder(default)]
190    pub timestamp: Option<DateTime<Utc>>,
191}
192
193/// Builder parameters for [`pop_scope`].
194#[derive(TypedBuilder)]
195#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
196pub struct PopScopeParams<'a> {
197    /// UUID of the scope that should be popped.
198    pub handle_uuid: &'a Uuid,
199    /// Optional JSON payload exported as the semantic scope output.
200    #[builder(default)]
201    pub output: Option<Json>,
202    /// Optional JSON payload metadata to be appended to the metadata set when the scope was created.
203    #[builder(default)]
204    pub metadata: Option<Json>,
205    /// Optional timestamp recorded on the emitted end event. When omitted, the
206    /// runtime records the current UTC time, or one microsecond after the
207    /// handle start time if the current time is not later.
208    #[builder(default)]
209    pub timestamp: Option<DateTime<Utc>>,
210}
211
212/// Builder parameters for [`event`].
213#[derive(TypedBuilder)]
214#[builder(field_defaults(setter(strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
215pub struct EmitMarkEventParams<'a> {
216    /// Event name to emit.
217    pub name: &'a str,
218    /// Optional explicit parent scope.
219    #[builder(default)]
220    pub parent: Option<&'a ScopeHandle>,
221    /// Optional JSON payload recorded as the mark data.
222    #[builder(default)]
223    pub data: Option<Json>,
224    /// Optional JSON metadata recorded on the emitted event.
225    #[builder(default)]
226    pub metadata: Option<Json>,
227    /// Optional timestamp recorded on the emitted mark event. When omitted, the
228    /// current UTC time is used.
229    #[builder(default)]
230    pub timestamp: Option<DateTime<Utc>>,
231}
232
233/// Return the current scope at the top of the active stack.
234///
235/// This reads the task-local or thread-local scope stack without mutating it
236/// and returns a clone of the current top-most [`ScopeHandle`].
237///
238/// # Returns
239/// A [`Result`] containing the current [`ScopeHandle`] when the runtime owner
240/// check succeeds.
241///
242/// # Errors
243/// Returns an error when the current binding has not initialized the shared
244/// runtime ownership correctly.
245pub fn get_handle() -> Result<ScopeHandle> {
246    ensure_runtime_owner()?;
247    Ok(task_scope_top())
248}
249
250/// Push a new scope onto the active scope stack.
251///
252/// This creates a new [`ScopeHandle`], emits a scope-start event to global and
253/// scope-local subscribers, and makes the new scope the current top of stack.
254///
255/// # Parameters
256/// - `name`: Human-readable scope name recorded on emitted lifecycle events.
257/// - `scope_type`: Semantic category for the new scope.
258/// - `parent`: Optional explicit parent scope. When `None`, the current top of
259///   stack is used as the parent.
260/// - `attributes`: Bitflags that modify scope behavior and observability.
261/// - `data`: Optional application payload stored on the returned handle.
262/// - `metadata`: Optional JSON metadata recorded on the emitted start event.
263/// - `input`: Optional JSON payload exported as the Agent Trajectory
264///   Observability Format (ATOF) data payload.
265/// - `timestamp`: Optional timestamp recorded as the handle start time and on
266///   the emitted start event. When `None`, the current UTC time is used.
267///
268/// # Returns
269/// A [`Result`] containing the newly created [`ScopeHandle`].
270///
271/// # Errors
272/// Returns an error when the runtime owner check fails or when internal state
273/// cannot be read safely.
274///
275/// # Notes
276/// Scope-local subscribers attached to ancestor scopes observe the emitted
277/// start event before the function returns.
278pub fn push_scope(params: PushScopeParams<'_>) -> Result<ScopeHandle> {
279    ensure_runtime_owner()?;
280    let parent_uuid = resolve_parent_uuid(params.parent);
281    let (handle, event, subscribers) = {
282        let scope_stack = current_scope_stack();
283        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
284        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
285        let subscribers = snapshot_event_subscribers(scope_subscribers)?;
286        let context = global_context();
287        let state = context
288            .read()
289            .map_err(|error| FlowError::Internal(error.to_string()))?;
290        let handle_params = CreateScopeHandleParams::builder()
291            .name(params.name)
292            .parent_uuid_opt(parent_uuid)
293            .scope_type(params.scope_type)
294            .attributes(params.attributes)
295            .data_opt(params.data)
296            .metadata_opt(params.metadata)
297            .timestamp_opt(params.timestamp)
298            .build();
299        let handle = state.create_scope_handle(handle_params);
300        let event = state.build_scope_start_event(&handle, params.input);
301        (handle, event, subscribers)
302    };
303    task_scope_push(handle.clone());
304    NemoRelayContextState::emit_event(&event, &subscribers);
305    Ok(handle)
306}
307
308/// Pop the current scope from the active scope stack.
309///
310/// This emits a scope-end event for the target scope and removes any
311/// scope-local registrations owned by that scope.
312///
313/// # Parameters
314/// - `handle_uuid`: UUID of the scope that should be popped.
315/// - `output`: Optional JSON payload exported as the semantic scope output.
316/// - `timestamp`: Optional timestamp recorded on the emitted end event. When
317///   `None`, the runtime uses the current UTC time, or one microsecond after
318///   the handle start time if the current time is not later.
319///
320/// # Returns
321/// A [`Result`] that is `Ok(())` when the scope was popped successfully.
322///
323/// # Errors
324/// Returns [`FlowError::InvalidArgument`] when the target scope exists but is
325/// not the current top of stack, and [`FlowError::NotFound`] when the UUID is
326/// unknown to the active stack.
327///
328/// # Notes
329/// The implicit root scope cannot be removed.
330pub fn pop_scope(params: PopScopeParams<'_>) -> Result<()> {
331    ensure_runtime_owner()?;
332    let scope_stack = current_scope_stack();
333    let (scope, event, subscribers) = {
334        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
335        let top = scope_guard.top();
336        if top.uuid != *params.handle_uuid {
337            if scope_guard.find(params.handle_uuid).is_some() {
338                return Err(FlowError::InvalidArgument(
339                    "scope handle is not at the top of the stack".into(),
340                ));
341            }
342            return Err(FlowError::NotFound("scope handle not found".into()));
343        }
344        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
345        let subscribers = snapshot_event_subscribers(scope_subscribers)?;
346        let scope = top.clone();
347        let context = global_context();
348        let state = context
349            .read()
350            .map_err(|error| FlowError::Internal(error.to_string()))?;
351        let event = state.build_scope_end_event(
352            EndScopeHandleParams::builder()
353                .handle(&scope)
354                .data_opt(params.output)
355                .timestamp_opt(params.timestamp)
356                .metadata_opt(params.metadata)
357                .build(),
358        );
359        (scope, event, subscribers)
360    };
361    let removed = task_scope_remove(params.handle_uuid)?;
362    debug_assert_eq!(removed.uuid, scope.uuid);
363    NemoRelayContextState::emit_event(&event, &subscribers);
364    Ok(())
365}
366
367/// Emit a standalone mark event under the current or provided scope.
368///
369/// This creates a point-in-time lifecycle event without pushing or popping a
370/// new scope.
371///
372/// # Parameters
373/// - `name`: Event name to emit.
374/// - `parent`: Optional explicit parent scope. When `None`, the current top of
375///   stack is used.
376/// - `data`: Optional JSON payload recorded on the emitted event.
377/// - `metadata`: Optional JSON metadata recorded on the emitted event.
378/// - `timestamp`: Optional timestamp recorded on the emitted mark event. When
379///   `None`, the current UTC time is used.
380///
381/// # Returns
382/// A [`Result`] that is `Ok(())` after the event has been emitted.
383///
384/// # Errors
385/// Returns an error when the runtime owner check fails or when internal state
386/// cannot be read safely.
387///
388/// # Notes
389/// Scope-local subscribers attached to ancestor scopes observe the emitted
390/// mark event just like scope, tool, and LLM lifecycle events.
391pub fn event(params: EmitMarkEventParams<'_>) -> Result<()> {
392    ensure_runtime_owner()?;
393    let parent_uuid = resolve_parent_uuid(params.parent);
394    let (event, subscribers) = {
395        let scope_stack = current_scope_stack();
396        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
397        let scope_subscribers = scope_guard.collect_scope_local_subscribers();
398        let subscribers = snapshot_event_subscribers(scope_subscribers)?;
399        let context = global_context();
400        let state = context
401            .read()
402            .map_err(|error| FlowError::Internal(error.to_string()))?;
403        let event = state.create_event(MarkEvent::new(
404            BaseEvent::builder()
405                .name(params.name)
406                .parent_uuid_opt(parent_uuid)
407                .timestamp(params.timestamp.unwrap_or_else(Utc::now))
408                .data_opt(params.data)
409                .metadata_opt(params.metadata)
410                .build(),
411            None,
412            None,
413        ));
414        (event, subscribers)
415    };
416    NemoRelayContextState::emit_event(&event, &subscribers);
417    Ok(())
418}