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
16 changes: 7 additions & 9 deletions crates/cli/src/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -492,15 +492,13 @@ impl PreparedRun {
})?;
let _ = std::fs::remove_file(backup);
}
(_, false) => {
if cursor.path.exists() {
std::fs::remove_file(&cursor.path).map_err(|error| {
CliError::Launch(format!(
"failed to remove temporary Cursor hooks {}: {error}",
cursor.path.display()
))
})?;
}
(_, false) if cursor.path.exists() => {
std::fs::remove_file(&cursor.path).map_err(|error| {
CliError::Launch(format!(
"failed to remove temporary Cursor hooks {}: {error}",
cursor.path.display()
))
})?;
}
_ => {}
}
Expand Down
25 changes: 22 additions & 3 deletions crates/core/src/api/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,8 @@ pub enum ScopeCategory {

/// Category-specific profile data.
///
/// Unknown wire keys are preserved in `extra`. LLM annotations are runtime-only
/// enrichment used by internal adaptive and Agent Trajectory Interchange Format
/// (ATIF) logic and are never serialized.
/// Unknown wire keys are preserved in `extra`. LLM annotations are serialized
/// under `category_profile` when a codec captures them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, TypedBuilder)]
#[builder(field_defaults(setter(into, strip_option(ignore_invalid, fallback_suffix = "_opt"))))]
pub struct CategoryProfile {
Expand Down Expand Up @@ -215,6 +214,8 @@ impl CategoryProfile {
self.model_name.is_none()
&& self.tool_call_id.is_none()
&& self.subtype.is_none()
&& self.annotated_request.is_none()
&& self.annotated_response.is_none()
&& self.extra.is_empty()
}
}
Expand Down Expand Up @@ -338,6 +339,24 @@ impl Event {
}
}

/// Try to return this event as the canonical JSON object delivered by
/// language bindings to subscriber callbacks and ATOF exporters.
pub fn try_to_json_value(&self) -> serde_json::Result<Json> {
serde_json::to_value(self)
}

/// Return this event as the canonical JSON object delivered by language
/// bindings to subscriber callbacks.
pub fn to_json_value(&self) -> Json {
self.try_to_json_value()
.expect("serializing an ATOF event to JSON should not fail")
}

/// Return this event as canonical JSON.
pub fn to_json_string(&self) -> serde_json::Result<String> {
serde_json::to_string(&self.try_to_json_value()?)
}

/// Return the lifecycle phase for scope events.
pub fn scope_category(&self) -> Option<ScopeCategory> {
match self {
Expand Down
11 changes: 7 additions & 4 deletions crates/core/src/observability/atof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
//! Agent Trajectory Observability Format (ATOF) JSONL exporter support for NeMo
//! Flow.
//!
//! The [`AtofExporter`] registers as an event subscriber and writes each raw
//! NeMo Flow Agent Trajectory Observability Format (ATOF) event as one JSON
//! object per JSONL line.
//! The [`AtofExporter`] registers as an event subscriber and writes each
//! canonical NeMo Flow Agent Trajectory Observability Format (ATOF) event as
//! one JSON object per JSONL line.

use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
Expand Down Expand Up @@ -252,7 +252,10 @@ fn open_file(path: &Path, mode: AtofExporterMode) -> Result<File> {
}

fn write_event(writer: &mut BufWriter<File>, event: &Event) -> std::result::Result<(), String> {
serde_json::to_writer(&mut *writer, event).map_err(|error| error.to_string())?;
let value = event
.try_to_json_value()
.map_err(|error| error.to_string())?;
serde_json::to_writer(&mut *writer, &value).map_err(|error| error.to_string())?;
writer.write_all(b"\n").map_err(|error| error.to_string())?;
writer.flush().map_err(|error| error.to_string())
}
Expand Down
76 changes: 74 additions & 2 deletions crates/core/tests/unit/observability/atof_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,16 @@
//! Unit tests for the ATOF JSONL exporter.

use super::*;
use crate::api::event::{BaseEvent, Event, EventCategory, MarkEvent, ScopeCategory, ScopeEvent};
use crate::api::event::{
BaseEvent, CategoryProfile, Event, EventCategory, MarkEvent, ScopeCategory, ScopeEvent,
};
use crate::api::runtime::NemoFlowContextState;
use crate::api::runtime::global_context;
use crate::api::scope::{EmitMarkEventParams, PopScopeParams, PushScopeParams, ScopeType};
use serde_json::json;
use crate::codec::request::{AnnotatedLlmRequest, Message, MessageContent};
use serde_json::{Map, json};
use std::fs;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use uuid::Uuid;

Expand Down Expand Up @@ -55,6 +59,50 @@ fn make_scope_start_event(name: &str) -> Event {
))
}

fn make_annotated_llm_event(name: &str) -> Event {
let request = AnnotatedLlmRequest {
messages: vec![Message::User {
content: MessageContent::Text("hello".into()),
name: None,
}],
model: Some("demo-model".into()),
params: None,
tools: None,
tool_choice: None,
store: None,
previous_response_id: None,
truncation: None,
reasoning: None,
include: None,
user: None,
metadata: None,
service_tier: None,
parallel_tool_calls: None,
max_output_tokens: None,
max_tool_calls: None,
top_logprobs: None,
stream: None,
extra: Map::new(),
};

Event::Scope(ScopeEvent::new(
BaseEvent::builder()
.uuid(Uuid::now_v7())
.name(name)
.data(json!({"input": true}))
.build(),
ScopeCategory::Start,
Vec::new(),
EventCategory::llm(),
Some(
CategoryProfile::builder()
.model_name("demo-model")
.annotated_request(Arc::new(request))
.build(),
),
))
}

fn read_jsonl(path: &Path) -> Vec<serde_json::Value> {
fs::read_to_string(path)
.unwrap()
Expand Down Expand Up @@ -144,6 +192,30 @@ fn subscriber_writes_scope_and_mark_events_as_raw_jsonl() {
assert_eq!(lines[1]["data"], json!({"step": 1}));
}

#[test]
fn subscriber_writes_canonical_event_jsonl() {
let dir = temp_dir("atof-canonical");
let exporter = AtofExporter::new(
AtofExporterConfig::new()
.with_output_directory(&dir)
.with_filename("events.jsonl"),
)
.unwrap();
let event = make_annotated_llm_event("llm-start");

(exporter.subscriber())(&event);
exporter.force_flush().unwrap();

let lines = read_jsonl(exporter.path());
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], event.try_to_json_value().unwrap());
assert!(lines[0].get("annotated_request").is_none());
assert_eq!(
lines[0]["category_profile"]["annotated_request"]["model"],
"demo-model"
);
}

#[test]
fn register_deregister_flush_and_shutdown_work_with_runtime_events() {
let _guard = crate::observability::test_mutex().lock().unwrap();
Expand Down
108 changes: 108 additions & 0 deletions crates/core/tests/unit/types_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

//! Unit tests for types in the NeMo Flow core crate.

use std::sync::Arc;

use serde_json::{Map, json};
use uuid::{Uuid, Version};

Expand All @@ -13,6 +15,48 @@ use crate::api::event::{
use crate::api::llm::{LlmAttributes, LlmHandle, LlmRequest};
use crate::api::scope::{ScopeAttributes, ScopeHandle, ScopeType};
use crate::api::tool::{ToolAttributes, ToolHandle};
use crate::codec::request::{AnnotatedLlmRequest, Message, MessageContent};
use crate::codec::response::AnnotatedLlmResponse;

fn annotated_request(model: &str, text: &str) -> AnnotatedLlmRequest {
AnnotatedLlmRequest {
messages: vec![Message::User {
content: MessageContent::Text(text.into()),
name: None,
}],
model: Some(model.into()),
params: None,
tools: None,
tool_choice: None,
store: None,
previous_response_id: None,
truncation: None,
reasoning: None,
include: None,
user: None,
metadata: None,
service_tier: None,
parallel_tool_calls: None,
max_output_tokens: None,
max_tool_calls: None,
top_logprobs: None,
stream: None,
extra: Map::new(),
}
}

fn annotated_response(id: &str, model: &str, text: &str) -> AnnotatedLlmResponse {
AnnotatedLlmResponse {
id: Some(id.into()),
model: Some(model.into()),
message: Some(MessageContent::Text(text.into())),
tool_calls: None,
finish_reason: None,
usage: None,
api_specific: None,
extra: Map::new(),
}
}

#[test]
fn handle_constructors_preserve_supplied_metadata() {
Expand Down Expand Up @@ -189,6 +233,70 @@ fn event_accessors_cover_scope_tool_llm_and_mark_variants() {
assert_eq!(mark_event.tool_call_id(), None);
}

#[test]
fn event_json_value_uses_canonical_subscriber_shape() {
let request = annotated_request("demo-model", "hi");
let response = annotated_response("resp-1", "demo-model", "hello");
let event = Event::Scope(ScopeEvent::new(
BaseEvent::builder()
.name("llm")
.data(json!({"input": true}))
.metadata(json!({"trace": "abc"}))
.build(),
ScopeCategory::End,
llm_attributes_to_strings(LlmAttributes::STATEFUL),
EventCategory::llm(),
Some(
CategoryProfile::builder()
.model_name("demo-model")
.annotated_request(Arc::new(request))
.annotated_response(Arc::new(response))
.build(),
),
));

let value = event.try_to_json_value().unwrap();
assert_eq!(event.to_json_value(), value);
assert_eq!(value["kind"], json!("scope"));
assert_eq!(value["scope_category"], json!("end"));
assert_eq!(value["category"], json!("llm"));
assert_eq!(value["data"], json!({"input": true}));
assert_eq!(value["metadata"], json!({"trace": "abc"}));
assert!(value.get("annotated_request").is_none());
assert!(value.get("annotated_response").is_none());
assert_eq!(
value["category_profile"]["annotated_request"]["model"],
json!("demo-model")
);
assert_eq!(
value["category_profile"]["annotated_response"]["id"],
json!("resp-1")
);

let encoded = event.to_json_string().unwrap();
let decoded: serde_json::Value = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded, value);
}

#[test]
fn category_profile_wire_empty_accounts_for_annotations() {
assert!(CategoryProfile::default().is_wire_empty());

let request_profile = CategoryProfile::builder()
.annotated_request(Arc::new(annotated_request("demo-model", "hi")))
.build();
assert!(!request_profile.is_wire_empty());

let response_profile = CategoryProfile::builder()
.annotated_response(Arc::new(annotated_response(
"resp-1",
"demo-model",
"hello",
)))
.build();
assert!(!response_profile.is_wire_empty());
}

#[test]
fn atof_event_builders_construct_concrete_events() {
let parent_uuid = Some(Uuid::now_v7());
Expand Down
9 changes: 9 additions & 0 deletions crates/ffi/nemo_flow.h
Original file line number Diff line number Diff line change
Expand Up @@ -2295,6 +2295,15 @@ char *nemo_flow_event_name(const struct FfiEvent *ptr);
*/
char *nemo_flow_event_kind(const struct FfiEvent *ptr);

/**
* Return the canonical subscriber event JSON as a C string.
* Caller must free the result with `nemo_flow_string_free`.
*
* # Safety
* `ptr` must be a valid `FfiEvent` pointer or null.
*/
char *nemo_flow_event_json(const struct FfiEvent *ptr);

/**
* Return the ATOF version as a C string.
*
Expand Down
20 changes: 20 additions & 0 deletions crates/ffi/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use nemo_flow::api::tool::ToolHandle;
use nemo_flow::codec::traits::{LlmCodec, LlmResponseCodec};

use crate::convert::{json_to_c_string, str_to_c_string};
use crate::error::set_last_error;
#[cfg(test)]
use crate::{api, convert};

Expand Down Expand Up @@ -600,6 +601,25 @@ pub unsafe extern "C" fn nemo_flow_event_kind(ptr: *const FfiEvent) -> *mut c_ch
str_to_c_string(unsafe { &*ptr }.0.kind())
}

/// Return the canonical subscriber event JSON as a C string.
/// Caller must free the result with `nemo_flow_string_free`.
///
/// # Safety
/// `ptr` must be a valid `FfiEvent` pointer or null.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn nemo_flow_event_json(ptr: *const FfiEvent) -> *mut c_char {
if ptr.is_null() {
return std::ptr::null_mut();
}
match unsafe { &*ptr }.0.try_to_json_value() {
Ok(value) => json_to_c_string(&value),
Err(error) => {
set_last_error(&error.to_string());
std::ptr::null_mut()
}
}
}

/// Return the ATOF version as a C string.
///
/// # Safety
Expand Down
2 changes: 2 additions & 0 deletions crates/ffi/tests/integration/api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ unsafe extern "C" fn subscriber_cb(_user_data: *mut libc::c_void, event: *const
"uuid": unsafe { take_string(nemo_flow_event_uuid(event)) }.unwrap_or_default(),
"name": unsafe { take_string(nemo_flow_event_name(event)) }.unwrap_or_default(),
"kind": unsafe { take_string(nemo_flow_ffi::types::nemo_flow_event_kind(event)) }.unwrap_or_default(),
"json": unsafe { take_string(nemo_flow_ffi::types::nemo_flow_event_json(event)) }
.map(|s| serde_json::from_str::<Json>(&s).unwrap()),
"data": unsafe { take_string(nemo_flow_event_data(event)) }
.map(|s| serde_json::from_str::<Json>(&s).unwrap()),
"metadata": unsafe { take_string(nemo_flow_event_metadata(event)) }
Expand Down
Loading
Loading