Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions crates/core/src/api/runtime/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1785,19 +1785,38 @@ fn validate_event_metadata_attributes(attributes: &BTreeMap<String, Json>) -> Re
Ok(())
}

fn is_otel_compatible_attribute_number(value: &serde_json::Number) -> bool {
#[derive(Clone, Copy, PartialEq, Eq)]
enum OtelAttributePrimitiveKind {
Boolean,
Integer,
Double,
String,
}

fn otel_compatible_attribute_number_kind(
value: &serde_json::Number,
) -> Option<OtelAttributePrimitiveKind> {
if let Some(value) = value.as_u64() {
return i64::try_from(value).is_ok();
return i64::try_from(value)
.is_ok()
.then_some(OtelAttributePrimitiveKind::Integer);
}
if value.as_i64().is_some() {
return Some(OtelAttributePrimitiveKind::Integer);
}
value.as_i64().is_some() || value.as_f64().is_some()
value.as_f64().map(|_| OtelAttributePrimitiveKind::Double)
}

fn is_otel_compatible_attribute_number(value: &serde_json::Number) -> bool {
otel_compatible_attribute_number_kind(value).is_some()
}

fn is_otel_compatible_attribute_value(value: &Json) -> bool {
fn primitive_kind(value: &Json) -> Option<u8> {
fn primitive_kind(value: &Json) -> Option<OtelAttributePrimitiveKind> {
match value {
Json::Bool(_) => Some(0),
Json::Number(value) if is_otel_compatible_attribute_number(value) => Some(1),
Json::String(_) => Some(2),
Json::Bool(_) => Some(OtelAttributePrimitiveKind::Boolean),
Json::Number(value) => otel_compatible_attribute_number_kind(value),
Json::String(_) => Some(OtelAttributePrimitiveKind::String),
_ => None,
}
}
Expand Down
7 changes: 5 additions & 2 deletions crates/core/tests/unit/runtime_state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async fn event_metadata_injection_accepts_flat_otel_values_and_empty_output() {
),
("nv.test.strings".into(), json!(["a", "b"])),
("nv.test.booleans".into(), json!([true, false])),
("nv.test.numbers".into(), json!([1, 2])),
("nv.test.integers".into(), json!([1, 2])),
("nv.test.doubles".into(), json!([1.0, 2.5])),
("nv.test.empty".into(), json!([])),
]))
})
Expand Down Expand Up @@ -68,7 +69,8 @@ async fn event_metadata_injection_accepts_flat_otel_values_and_empty_output() {
);
assert_eq!(metadata["nv.test.strings"], json!(["a", "b"]));
assert_eq!(metadata["nv.test.booleans"], json!([true, false]));
assert_eq!(metadata["nv.test.numbers"], json!([1, 2]));
assert_eq!(metadata["nv.test.integers"], json!([1, 2]));
assert_eq!(metadata["nv.test.doubles"], json!([1.0, 2.5]));
assert_eq!(metadata["nv.test.empty"], json!([]));
}

Expand All @@ -92,6 +94,7 @@ async fn event_metadata_injection_rejects_invalid_output_atomically() {
BTreeMap::from([("nv.test.object".into(), json!({"nested": true}))]),
BTreeMap::from([("nv.test.nested_list".into(), json!([[1]]))]),
BTreeMap::from([("nv.test.mixed_list".into(), json!([1, "two"]))]),
BTreeMap::from([("nv.test.mixed_numbers".into(), json!([1, 2.5]))]),
BTreeMap::from([("nv.test.oversized_number".into(), json!(u64::MAX))]),
BTreeMap::from([("nv.test.oversized_list".into(), json!([u64::MAX]))]),
];
Expand Down
18 changes: 18 additions & 0 deletions crates/node/plugin.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,31 @@ export interface ToolExecutionInterceptOutcome {
pendingMarks?: PendingMarkSpec[];
}

/** Scalar value accepted in event metadata additions. */
export type EventMetadataScalar = string | number | boolean;

/**
* Flat value accepted in event metadata additions. After JSON conversion,
* numeric arrays must contain only integer values or only floating-point values.
*/
export type EventMetadataValue = EventMetadataScalar | string[] | number[] | boolean[];

/** Metadata additions returned by an event metadata injector. */
export type EventMetadata = Record<string, EventMetadataValue>;

/** Component-scoped registration context passed to plugin handlers. */
export interface PluginContext {
/**
* Register an event subscriber for this component. Callback failures are isolated and reported
* through the Node binding's callback-error channel; flushSubscribers waits for returned promises.
*/
registerSubscriber(name: string, callback: (event: Json) => void | Promise<void>): void;
/** Register an event metadata injector for this component. */
registerEventMetadataInjector(
name: string,
priority: number,
callback: (event: Json) => EventMetadata | Promise<EventMetadata>,
): void;
Comment thread
ericevans-nv marked this conversation as resolved.
/** Register a mark event sanitizer for this component. */
registerMarkSanitizeGuardrail(
name: string,
Expand Down
125 changes: 121 additions & 4 deletions crates/node/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ use nemo_relay::api::runtime::subscriber_dispatcher::{
PublicationBuffer, capture_nested_publication_buffer, with_nested_publication_buffer,
};
use nemo_relay::api::runtime::{
EventSanitizeFn, LlmExecutionNextFn, LlmJsonStream, LlmStreamExecutionNextFn, LlmStreamInner,
ScopeStackHandle as CoreScopeStackHandle, ToolExecutionNextFn,
EventMetadataInjectorFn, EventSanitizeFn, LlmExecutionNextFn, LlmJsonStream,
LlmStreamExecutionNextFn, LlmStreamInner, ScopeStackHandle as CoreScopeStackHandle,
ToolExecutionNextFn,
};
use nemo_relay::api::runtime::{
TASK_SCOPE_STACK, capture_propagation_context as capture_propagation_context_handle,
Expand Down Expand Up @@ -820,6 +821,45 @@ fn add_plugin_event_sanitizer(
context.set_named_property(property, function)
}

fn add_plugin_event_metadata_injector(
env: &Env,
context: &mut JsObject,
namespace_prefix: String,
registrations: Arc<StdMutex<Vec<PluginRegistration>>>,
) -> napi::Result<()> {
let function = env.create_function_from_closure(
"__nemo_relay_plugin_register_event_metadata_injector",
move |ctx| {
let name = format!("{}{}", namespace_prefix, ctx.get::<String>(0)?);
let priority = ctx.get::<i32>(1)?;
let callback = ctx.get::<JsFunction>(2)?;
core_registry_api::register_event_metadata_injector(
&name,
priority,
node_event_metadata_injector_fn(ctx.env, &callback)?,
)
.map_err(to_napi_err)?;

let name_clone = name.clone();
registrations.lock().unwrap().push(PluginRegistration::new(
"plugin",
name_clone.clone(),
Box::new(move || {
core_registry_api::deregister_event_metadata_injector(&name_clone)
.map(|_| ())
.map_err(|error| {
PluginError::RegistrationFailed(format!(
"event metadata injector deregistration failed: {error}"
))
})
}),
));
ctx.env.get_undefined()
},
)?;
context.set_named_property("registerEventMetadataInjector", function)
}

fn build_plugin_context(
env: &Env,
namespace_prefix: String,
Expand Down Expand Up @@ -862,6 +902,13 @@ fn build_plugin_context(
)?;
context.set_named_property("registerSubscriber", register_subscriber)?;

add_plugin_event_metadata_injector(
env,
&mut context,
namespace_prefix.clone(),
registrations.clone(),
)?;

add_plugin_event_sanitizer(
env,
&mut context,
Expand Down Expand Up @@ -1563,6 +1610,16 @@ fn node_event_sanitize_fn(env: &Env, func: &JsFunction) -> napi::Result<EventSan
Ok(callable::wrap_js_event_sanitize_promise_fn(callback))
}

fn node_event_metadata_injector_fn(
env: &Env,
func: &JsFunction,
) -> napi::Result<EventMetadataInjectorFn> {
let callback = Arc::new(crate::promise_call::PromiseAwareFn::new(env, func)?);
Ok(callable::wrap_js_event_metadata_injector_promise_fn(
callback,
))
}

type NodeLlmCodec = (
Arc<dyn nemo_relay::codec::traits::LlmCodec>,
Vec<Arc<PersistentJsFunction>>,
Expand Down Expand Up @@ -3083,9 +3140,38 @@ pub fn llm_stream_call_execute(
}

// ---------------------------------------------------------------------------
// Tool guardrail registrations
// Event metadata injector and event guardrail registrations
// ---------------------------------------------------------------------------

/// Register a global event metadata injector.
///
/// The callback receives an immutable event snapshot and may return additions
/// directly or in a Promise. Relay validates and merges accepted additions
/// before event sanitizers run.
#[napi]
pub fn register_event_metadata_injector(
env: Env,
name: String,
priority: i32,
#[napi(
ts_arg_type = "(event: Json) => import('./plugin').EventMetadata | Promise<import('./plugin').EventMetadata>"
)]
injector: JsFunction,
) -> Result<()> {
core_registry_api::register_event_metadata_injector(
&name,
priority,
node_event_metadata_injector_fn(&env, &injector)?,
)
.map_err(to_napi_err)
}

/// Deregister a global event metadata injector by name.
#[napi]
pub fn deregister_event_metadata_injector(name: String) -> Result<bool> {
core_registry_api::deregister_event_metadata_injector(&name).map_err(to_napi_err)
}

macro_rules! napi_event_guardrail_api {
($register_name:ident, $deregister_name:ident, $core_register:path, $core_deregister:path) => {
/// Register an event sanitize guardrail.
Expand Down Expand Up @@ -3628,9 +3714,40 @@ pub fn flush_subscribers(env: Env) -> Result<JsObject> {
}

// ---------------------------------------------------------------------------
// Scope-local guardrail registrations — Tool
// Scope-local event metadata injector and event guardrail registrations
// ---------------------------------------------------------------------------

/// Register an event metadata injector owned by an active scope.
#[napi]
pub fn scope_register_event_metadata_injector(
env: Env,
scope_uuid: String,
name: String,
priority: i32,
#[napi(
ts_arg_type = "(event: Json) => import('./plugin').EventMetadata | Promise<import('./plugin').EventMetadata>"
)]
injector: JsFunction,
) -> Result<()> {
let uuid = uuid::Uuid::parse_str(&scope_uuid)
.map_err(|error| napi::Error::from_reason(format!("invalid UUID: {error}")))?;
core_registry_api::scope_register_event_metadata_injector(
&uuid,
&name,
priority,
node_event_metadata_injector_fn(&env, &injector)?,
)
.map_err(to_napi_err)
}

/// Deregister a scope-local event metadata injector by name.
#[napi]
pub fn scope_deregister_event_metadata_injector(scope_uuid: String, name: String) -> Result<bool> {
let uuid = uuid::Uuid::parse_str(&scope_uuid)
.map_err(|error| napi::Error::from_reason(format!("invalid UUID: {error}")))?;
core_registry_api::scope_deregister_event_metadata_injector(&uuid, &name).map_err(to_napi_err)
}

macro_rules! napi_scope_event_guardrail_api {
($register_name:ident, $deregister_name:ident, $core_register:path, $core_deregister:path) => {
/// Register a scope-local event sanitize guardrail.
Expand Down
46 changes: 41 additions & 5 deletions crates/node/src/callable.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! handles serialization of arguments to/from JSON and manages cross-thread communication
//! between the Rust async runtime and the Node.js event loop.

use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
Expand All @@ -22,10 +22,11 @@ use napi::threadsafe_function::{
use napi::{Env, JsFunction, JsObject, JsUnknown, NapiRaw, NapiValue};
use napi_derive::napi;
use nemo_relay::api::runtime::{
EventSanitizeFn, EventSubscriberFn, LlmCodecIdentity, LlmConditionalFn, LlmExecutionNextFn,
LlmJsonStream, LlmRequestInterceptFn, LlmSanitizeRequestContext, LlmSanitizeRequestFn,
LlmSanitizeResponseContext, LlmSanitizeResponseFn, LlmStreamExecutionNextFn, ToolConditionalFn,
ToolExecutionNextFn, ToolInterceptFn, ToolSanitizeFn,
EventMetadataInjectorFn, EventSanitizeFn, EventSubscriberFn, LlmCodecIdentity,
LlmConditionalFn, LlmExecutionNextFn, LlmJsonStream, LlmRequestInterceptFn,
LlmSanitizeRequestContext, LlmSanitizeRequestFn, LlmSanitizeResponseContext,
LlmSanitizeResponseFn, LlmStreamExecutionNextFn, ToolConditionalFn, ToolExecutionNextFn,
ToolInterceptFn, ToolSanitizeFn,
};
use serde::{Deserialize, Serialize};
use serde_json::Value as Json;
Expand Down Expand Up @@ -619,6 +620,41 @@ pub fn wrap_js_event_sanitize_promise_fn(func: Arc<PromiseAwareFn>) -> EventSani
})
}

/// Wrap a Promise-aware JavaScript event metadata injector.
pub fn wrap_js_event_metadata_injector_promise_fn(
func: Arc<PromiseAwareFn>,
) -> EventMetadataInjectorFn {
Arc::new(move |event: Arc<Event>| {
let func = func.clone();
Box::pin(async move {
let event_json = JsEvent::try_from_event(&event)
.map(JsEvent::into_json)
.map_err(|error| {
let error = FlowError::Internal(format!(
"failed to serialize JavaScript event metadata injector context: {error}"
));
record_callback_error(error.to_string());
error
})?;
let publication =
nemo_relay::api::runtime::subscriber_dispatcher::in_dispatcher_callback();
let value = if publication {
func.call_spread_for_publication(vec![event_json]).await
} else {
func.call(event_json).await
}
.inspect_err(|error| record_callback_error(error.to_string()))?;
serde_json::from_value::<BTreeMap<String, Json>>(value).map_err(|error| {
let error = FlowError::Internal(format!(
"invalid JavaScript event metadata injector result: {error}"
));
record_callback_error(error.to_string());
error
})
})
})
}

fn recv_json_or_null(rx: std::sync::mpsc::Receiver<Json>, error_prefix: &str) -> Json {
rx.recv().unwrap_or_else(|e| {
record_callback_error(format!("{error_prefix}: {e}"));
Expand Down
Loading
Loading