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}