nemo-relay 0.5.0

Core Rust SDK for NeMo Relay observability, scope management, and runtime instrumentation.
Documentation
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::sync::Arc;

use uuid::Uuid;

use crate::api::llm::LlmRequest;
use crate::api::runtime::EventSubscriberFn;
use crate::api::runtime::global_context;
use crate::api::runtime::{current_scope_stack, task_scope_top};
use crate::api::scope::ScopeHandle;
use crate::api::scope::ScopeType;
use crate::codec::request::AnnotatedLlmRequest;
use crate::codec::traits::LlmCodec;
use crate::error::{FlowError, Result};
use crate::json::{Json, merge_json};
use crate::shared_runtime::ensure_process_runtime_owner;

/// Header carrying the current Dynamo agent session ID.
pub const DYNAMO_SESSION_ID_HEADER_KEY: &str = "x-dynamo-session-id";
/// Header carrying the parent Dynamo agent session ID.
pub const DYNAMO_PARENT_SESSION_ID_HEADER_KEY: &str = "x-dynamo-parent-session-id";

pub(crate) fn resolve_parent_uuid(parent: Option<&ScopeHandle>) -> Option<Uuid> {
    Some(
        parent
            .map(|handle| handle.uuid)
            .unwrap_or_else(|| task_scope_top().uuid),
    )
}

pub(crate) fn snapshot_event_subscribers(
    scope_local_subscribers: Vec<EventSubscriberFn>,
) -> Result<Vec<EventSubscriberFn>> {
    let context = global_context();
    let state = context
        .read()
        .map_err(|error| FlowError::Internal(error.to_string()))?;
    Ok(state.collect_event_subscribers(&scope_local_subscribers))
}

pub(crate) fn ensure_runtime_owner() -> Result<()> {
    ensure_process_runtime_owner()
}

/// Resolve the current and parent agent session IDs from the active scope stack.
///
/// The most recent two explicit Agent scopes are used.
/// Harness-specific session metadata takes precedence over the scope name, while
/// names keep application-created scopes useful when no metadata is attached.
pub(crate) fn resolve_agent_session_ids() -> Option<(String, Option<String>)> {
    let stack = current_scope_stack();
    let stack = stack.read().ok()?;
    let mut agent_scopes = stack
        .scopes()
        .iter()
        .skip(1)
        .filter(|scope| matches!(scope.scope_type, ScopeType::Agent))
        .rev();
    let current = agent_scopes.next().map(agent_scope_id)?;
    let parent = agent_scopes.next().map(agent_scope_id);
    Some((current, parent))
}

fn agent_scope_id(scope: &ScopeHandle) -> String {
    scope
        .metadata
        .as_ref()
        .and_then(|metadata| {
            [
                "codex_subagent_session_id",
                "subagent_session_id",
                "subagent_id",
                "session_id",
            ]
            .into_iter()
            .find_map(|key| metadata.get(key).and_then(|value| value.as_str()))
        })
        .unwrap_or(&scope.name)
        .to_string()
}

pub(crate) fn inject_dynamo_session_ids(request: &mut LlmRequest) {
    let Some((current, parent)) = resolve_agent_session_ids() else {
        return;
    };

    request.headers.insert(
        DYNAMO_SESSION_ID_HEADER_KEY.to_string(),
        Json::String(current),
    );
    match parent {
        Some(parent) => {
            request.headers.insert(
                DYNAMO_PARENT_SESSION_ID_HEADER_KEY.to_string(),
                Json::String(parent),
            );
        }
        None => {
            request.headers.remove(DYNAMO_PARENT_SESSION_ID_HEADER_KEY);
        }
    }
}

pub(crate) fn metadata_with_otel_status(
    metadata: Option<Json>,
    status_code: &'static str,
    status_message: Option<String>,
) -> Option<Json> {
    let mut status = serde_json::Map::new();
    status.insert(
        "otel.status_code".to_string(),
        Json::String(status_code.to_string()),
    );

    // In the OTel spec, the status description should only be set if the status code is ERROR.
    // https://opentelemetry.io/docs/specs/otel/trace/api/#set-status
    if status_code == "ERROR"
        && let Some(status_message) = status_message
    {
        status.insert(
            "otel.status_description".to_string(),
            Json::String(status_message),
        );
    }
    let mut metadata = merge_json(metadata, Some(Json::Object(status)));

    // Explicitly remove any existing otel.status_description if the status code is not ERROR.
    if status_code != "ERROR"
        && let Some(Json::Object(metadata)) = metadata.as_mut()
    {
        metadata.remove("otel.status_description");
    }
    metadata
}

pub(crate) fn run_request_intercepts_with_codec(
    name: &str,
    request: LlmRequest,
    codec: Option<Arc<dyn LlmCodec>>,
) -> Result<(
    LlmRequest,
    Option<Arc<AnnotatedLlmRequest>>,
    Vec<crate::api::event::PendingMarkSpec>,
)> {
    let annotated = match &codec {
        Some(codec) => Some(codec.decode(&request)?),
        None => None,
    };

    let entries = {
        let scope_stack = current_scope_stack();
        let scope_guard = scope_stack.read().expect("scope stack lock poisoned");
        let scope_locals = scope_guard
            .collect_scope_local_registries(|registries| &registries.llm_request_intercepts);

        let context = global_context();
        let state = context
            .read()
            .map_err(|error| FlowError::Internal(error.to_string()))?;
        state.llm_request_intercept_entries(&scope_locals)
    };

    let outcome =
        crate::api::runtime::NemoRelayContextState::llm_request_intercepts_snapshot_chain(
            name,
            request,
            annotated,
            &entries,
            codec.is_some(),
        )?;
    let mut request = outcome.request;
    inject_dynamo_session_ids(&mut request);
    let pending_marks = outcome.pending_marks;

    match (codec, outcome.annotated_request) {
        (Some(codec), Some(annotated)) => {
            let mut encoded = codec.encode(&annotated, &request)?;
            encoded.headers.extend(request.headers);
            Ok((encoded, Some(Arc::new(annotated)), pending_marks))
        }
        (_, annotated) => Ok((request, annotated.map(Arc::new), pending_marks)),
    }
}

#[cfg(test)]
#[path = "../../tests/unit/shared_tests.rs"]
mod tests;