1use std::collections::{HashMap, HashSet};
32use std::sync::{Arc, Mutex};
33
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36use uuid::Uuid;
37
38use crate::api::event::{Event, EventNormalizationExt};
39use crate::api::runtime::EventSubscriberFn;
40use crate::api::subscriber::flush_subscribers;
41use crate::codec::request::{AnnotatedLlmRequest, ContentPart, Message, MessageContent};
42use crate::codec::response::AnnotatedLlmResponse;
43use crate::error::Result;
44use crate::json::Json;
45
46use super::{estimate_cost_for_response_or_model, manual, merge_usage, model_name_for_llm_event};
47
48pub const ATIF_SCHEMA_VERSION: &str = "ATIF-v1.7";
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct AtifAgentInfo {
61 pub name: String,
63 pub version: String,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub model_name: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub tool_definitions: Option<Vec<Json>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub extra: Option<Json>,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AtifStep {
79 pub step_id: usize,
81 pub source: String,
83 pub message: Json,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub timestamp: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub model_name: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub reasoning_effort: Option<Json>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub reasoning_content: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub tool_calls: Option<Vec<AtifToolCall>>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub observation: Option<AtifObservation>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub metrics: Option<AtifMetrics>,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub llm_call_count: Option<u64>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub is_copied_context: Option<bool>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub extra: Option<Json>,
115}
116
117#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct AtifMetrics {
120 #[serde(skip_serializing_if = "Option::is_none")]
122 pub prompt_tokens: Option<u64>,
123 #[serde(skip_serializing_if = "Option::is_none")]
125 pub completion_tokens: Option<u64>,
126 #[serde(skip_serializing_if = "Option::is_none")]
128 pub cached_tokens: Option<u64>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub cost_usd: Option<f64>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub prompt_token_ids: Option<Vec<u64>>,
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub completion_token_ids: Option<Vec<u64>>,
138 #[serde(skip_serializing_if = "Option::is_none")]
140 pub logprobs: Option<Vec<f64>>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub extra: Option<Json>,
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148pub struct AtifFinalMetrics {
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub total_prompt_tokens: Option<u64>,
152 #[serde(skip_serializing_if = "Option::is_none")]
154 pub total_completion_tokens: Option<u64>,
155 #[serde(skip_serializing_if = "Option::is_none")]
157 pub total_cached_tokens: Option<u64>,
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub total_cost_usd: Option<f64>,
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub total_steps: Option<u64>,
164 #[serde(skip_serializing_if = "Option::is_none")]
166 pub extra: Option<Json>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct AtifToolCall {
172 pub tool_call_id: String,
174 pub function_name: String,
176 pub arguments: Json,
178 #[serde(skip_serializing_if = "Option::is_none")]
180 pub extra: Option<Json>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct AtifObservation {
186 pub results: Vec<AtifObservationResult>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AtifObservationResult {
193 #[serde(skip_serializing_if = "Option::is_none")]
195 pub source_call_id: Option<String>,
196 #[serde(skip_serializing_if = "Option::is_none")]
198 pub content: Option<Json>,
199 #[serde(skip_serializing_if = "Option::is_none")]
201 pub subagent_trajectory_ref: Option<Vec<AtifSubagentTrajectoryRef>>,
202 #[serde(skip_serializing_if = "Option::is_none")]
204 pub extra: Option<Json>,
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AtifSubagentTrajectoryRef {
210 #[serde(skip_serializing_if = "Option::is_none")]
212 pub trajectory_id: Option<String>,
213 #[serde(skip_serializing_if = "Option::is_none")]
215 pub session_id: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub extra: Option<Json>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct AtifAncestry {
224 pub function_id: String,
226 pub function_name: String,
228 #[serde(skip_serializing_if = "Option::is_none")]
230 pub parent_id: Option<String>,
231 #[serde(skip_serializing_if = "Option::is_none")]
233 pub parent_name: Option<String>,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AtifInvocationInfo {
242 #[serde(skip_serializing_if = "Option::is_none")]
244 pub start_timestamp: Option<f64>,
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub end_timestamp: Option<f64>,
248 #[serde(skip_serializing_if = "Option::is_none")]
250 pub invocation_id: Option<String>,
251 #[serde(skip_serializing_if = "Option::is_none")]
253 pub status: Option<String>,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub framework: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct AtifStepExtra {
265 pub ancestry: AtifAncestry,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub invocation: Option<AtifInvocationInfo>,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 pub llm_request: Option<Json>,
273 #[serde(skip_serializing_if = "Option::is_none")]
275 pub llm_response: Option<Json>,
276 #[serde(skip_serializing_if = "Option::is_none")]
281 pub event_payload: Option<Json>,
282 #[serde(default, skip_serializing_if = "Vec::is_empty")]
284 pub tool_ancestry: Vec<AtifAncestry>,
285 #[serde(skip_serializing_if = "Option::is_none")]
287 pub tool_invocations: Option<Vec<AtifInvocationInfo>>,
288}
289
290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
291enum RequestTurnState {
292 FreshUser,
293 Continuation,
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct AtifTrajectory {
299 pub schema_version: String,
301 pub session_id: String,
303 #[serde(skip_serializing_if = "Option::is_none")]
305 pub trajectory_id: Option<String>,
306 pub agent: AtifAgentInfo,
308 pub steps: Vec<AtifStep>,
310 #[serde(skip_serializing_if = "Option::is_none")]
312 pub notes: Option<String>,
313 #[serde(skip_serializing_if = "Option::is_none")]
315 pub final_metrics: Option<AtifFinalMetrics>,
316 #[serde(skip_serializing_if = "Option::is_none")]
318 pub continued_trajectory_ref: Option<String>,
319 #[serde(skip_serializing_if = "Option::is_none")]
321 pub subagent_trajectories: Option<Vec<AtifTrajectory>>,
322 #[serde(skip_serializing_if = "Option::is_none")]
324 pub extra: Option<Json>,
325}
326
327struct AtifExporterState {
332 session_id: String,
333 agent_info: AtifAgentInfo,
334 events: Vec<Event>,
335}
336
337#[derive(Clone)]
342pub struct AtifExporter {
343 state: Arc<Mutex<AtifExporterState>>,
344}
345
346impl AtifExporter {
347 pub fn new(session_id: String, agent_info: AtifAgentInfo) -> Self {
356 Self {
357 state: Arc::new(Mutex::new(AtifExporterState {
358 session_id,
359 agent_info,
360 events: Vec::new(),
361 })),
362 }
363 }
364
365 pub fn subscriber(&self) -> EventSubscriberFn {
374 let state = self.state.clone();
375 Arc::new(move |event: &Event| {
376 if matches!(event, Event::Mark(_)) {
377 return;
378 }
379 if let Ok(mut s) = state.lock() {
380 s.events.push(event.clone());
381 }
382 })
383 }
384
385 pub fn export(&self) -> Result<AtifTrajectory> {
398 self.try_export()
399 }
400
401 pub fn try_export(&self) -> Result<AtifTrajectory> {
406 flush_subscribers()?;
407 let (session_id, agent_info, events) = {
408 let state = self.state.lock().unwrap();
409 (
410 state.session_id.clone(),
411 state.agent_info.clone(),
412 state.events.clone(),
413 )
414 };
415 let collected_events: Vec<&Event> = events.iter().collect();
416 Ok(events_to_trajectory(
417 &session_id,
418 agent_info,
419 &collected_events,
420 ))
421 }
422
423 pub fn clear(&self) {
428 let mut state = self.state.lock().unwrap();
429 state.events.clear();
430 }
431}
432
433fn unwrap_llm_request(input: &Json) -> Json {
442 if let Some(obj) = input.as_object()
443 && obj.contains_key("content")
444 && obj.contains_key("headers")
445 {
446 return obj.get("content").cloned().unwrap_or_else(|| input.clone());
447 }
448 input.clone()
449}
450
451fn extract_llm_response_message(output: &Json) -> Json {
458 let Some(obj) = output.as_object() else {
459 return atif_content_value(output);
460 };
461
462 if let Some(message) = extract_object_llm_response_message(output, obj) {
463 return message;
464 }
465
466 atif_content_value(output)
467}
468
469fn extract_object_llm_response_message(
470 output: &Json,
471 obj: &serde_json::Map<String, Json>,
472) -> Option<Json> {
473 if let Some(content) = non_null_object_field(obj, "content") {
474 return Some(extract_content_message(output, &content));
475 }
476 if let Some(content) = assistant_message_content(obj) {
477 return Some(atif_content_value(&content));
478 }
479 if let Some(content) = raw_response_message_field(output, "content")
480 && !content.is_null()
481 {
482 return Some(atif_content_value(content));
483 }
484 if let Some(answer) = non_null_object_field(obj, "answer") {
485 return Some(atif_content_value(&answer));
486 }
487 if let Some(content) = openai_responses_output_message(output) {
488 return Some(content);
489 }
490 tool_call_array(output).map(|_| empty_message())
491}
492
493fn extract_content_message(output: &Json, content: &Json) -> Json {
494 anthropic_messages_content_message(output, content)
495 .unwrap_or_else(|| atif_content_value(content))
496}
497
498fn assistant_message_content(obj: &serde_json::Map<String, Json>) -> Option<Json> {
499 obj.get("assistant_message")
500 .and_then(Json::as_object)
501 .and_then(|assistant| non_null_object_field(assistant, "content"))
502}
503
504fn non_null_object_field(obj: &serde_json::Map<String, Json>, key: &str) -> Option<Json> {
505 obj.get(key).filter(|value| !value.is_null()).cloned()
506}
507
508fn empty_message() -> Json {
509 Json::String(String::new())
510}
511
512fn atif_content_value(value: &Json) -> Json {
513 match value {
514 Json::String(_) => value.clone(),
515 Json::Array(_) if is_atif_content_parts(value) => value.clone(),
516 Json::Null => empty_message(),
517 _ => Json::String(json_to_string(value)),
518 }
519}
520
521fn anthropic_messages_content_message(output: &Json, content: &Json) -> Option<Json> {
522 let object = output.as_object()?;
523 if object.get("type").and_then(Json::as_str) != Some("message") {
524 return None;
525 }
526 let blocks = content.as_array()?;
527 let mut text_parts = Vec::new();
528 let mut has_tool_use = false;
529 for block in blocks {
530 let Some(block_object) = block.as_object() else {
531 continue;
532 };
533 match block_object.get("type").and_then(Json::as_str) {
534 Some("text") => {
535 if let Some(text) = block_object.get("text").and_then(Json::as_str)
536 && !text.trim().is_empty()
537 {
538 text_parts.push(text.to_string());
539 }
540 }
541 Some("tool_use") => has_tool_use = true,
542 _ => {}
543 }
544 }
545 match text_parts.as_slice() {
546 [] if has_tool_use => Some(empty_message()),
547 [] => None,
548 [text] => Some(Json::String(text.clone())),
549 _ => Some(Json::String(text_parts.join("\n"))),
550 }
551}
552
553fn observation_content_value(value: &Json) -> Option<Json> {
554 match value {
555 Json::String(_) => Some(value.clone()),
556 Json::Array(_) if is_atif_content_parts(value) => Some(value.clone()),
557 _ => None,
558 }
559}
560
561fn observation_extra(event: &Event, output: &Json) -> Json {
562 let mut extra = event_extra(event);
563 if let Some(tool_result) = observation_tool_result_extra(output)
564 && let Json::Object(extra_object) = &mut extra
565 {
566 extra_object.insert("tool_result".to_string(), tool_result);
567 }
568 extra
569}
570
571fn observation_tool_result_extra(value: &Json) -> Option<Json> {
572 match value {
573 Json::Null | Json::String(_) => None,
574 Json::Array(_) if is_atif_content_parts(value) => None,
575 _ => Some(value.clone()),
576 }
577}
578
579fn is_atif_content_parts(value: &Json) -> bool {
580 let Some(parts) = value.as_array() else {
581 return false;
582 };
583 parts.iter().all(|part| {
584 let Some(object) = part.as_object() else {
585 return false;
586 };
587 match object.get("type").and_then(Json::as_str) {
588 Some("text") => object.get("text").and_then(Json::as_str).is_some(),
589 Some("image") => is_atif_image_source(object.get("source")),
590 _ => false,
591 }
592 })
593}
594
595fn is_atif_image_source(value: Option<&Json>) -> bool {
596 let Some(source) = value.and_then(Json::as_object) else {
597 return false;
598 };
599 matches!(
600 source.get("media_type").and_then(Json::as_str),
601 Some("image/jpeg" | "image/png" | "image/gif" | "image/webp")
602 ) && source.get("path").and_then(Json::as_str).is_some()
603}
604
605fn json_to_string(value: &Json) -> String {
606 serde_json::to_string(value).unwrap_or_else(|_| value.to_string())
607}
608
609fn raw_response_message_field<'a>(output: &'a Json, field: &str) -> Option<&'a Json> {
610 let object = output.as_object()?;
611 object
612 .get("raw_response")
613 .or(Some(output))
614 .and_then(|raw_response| raw_response.as_object())
615 .and_then(|raw_response| raw_response.get("choices"))
616 .and_then(Json::as_array)
617 .and_then(|choices| choices.first())
618 .and_then(Json::as_object)
619 .and_then(|choice| choice.get("message"))
620 .and_then(Json::as_object)
621 .and_then(|message| message.get(field))
622}
623
624fn openai_responses_output_message(output: &Json) -> Option<Json> {
625 let object = output.as_object()?;
626 if let Some(output_text) = object.get("output_text").and_then(Json::as_str) {
627 return Some(Json::String(output_text.to_string()));
628 }
629
630 let output_items = object.get("output").and_then(Json::as_array)?;
631 let mut text_parts = Vec::new();
632 for item in output_items {
633 collect_openai_responses_output_text(item, &mut text_parts);
634 }
635
636 match text_parts.as_slice() {
637 [] => None,
638 [text] => Some(Json::String(text.clone())),
639 _ => Some(Json::String(text_parts.join("\n"))),
640 }
641}
642
643fn collect_openai_responses_output_text(item: &Json, text_parts: &mut Vec<String>) {
644 let Some(item_obj) = item.as_object() else {
645 return;
646 };
647 match item_obj.get("type").and_then(Json::as_str) {
648 Some("message") => {
649 if let Some(content) = item_obj.get("content").and_then(Json::as_array) {
650 collect_openai_responses_content_text(content, "output_text", text_parts);
651 }
652 }
653 Some("output_text") => {
654 if let Some(text) = item_obj.get("text").and_then(Json::as_str) {
655 text_parts.push(text.to_string());
656 }
657 }
658 _ => {}
659 }
660}
661
662fn collect_openai_responses_content_text(
663 content: &[Json],
664 block_type: &str,
665 text_parts: &mut Vec<String>,
666) {
667 for block in content {
668 let Some(block_obj) = block.as_object() else {
669 continue;
670 };
671 if block_obj.get("type").and_then(Json::as_str) == Some(block_type)
672 && let Some(text) = block_obj.get("text").and_then(Json::as_str)
673 {
674 text_parts.push(text.to_string());
675 }
676 }
677}
678
679const TOKEN_USAGE_KNOWN_KEYS: &[&str] = &[
681 "prompt_tokens",
682 "input_tokens",
683 "inputTokens",
684 "input",
685 "completion_tokens",
686 "output_tokens",
687 "completionTokens",
688 "outputTokens",
689 "output",
690 "cached_tokens",
691 "cachedTokens",
692 "cache_read_tokens",
693 "cacheReadTokens",
694 "cache_read_input_tokens",
695 "cacheReadInputTokens",
696 "cacheRead",
697 "cache_creation_input_tokens",
698 "cacheCreationInputTokens",
699 "cache_write_tokens",
700 "cacheWriteTokens",
701 "cacheWrite",
702 "cost_usd",
703 "cost",
704 "prompt_tokens_details",
705 "input_tokens_details",
706 "prompt_token_ids",
707 "completion_token_ids",
708 "logprobs",
709];
710
711fn extract_metrics(
717 output: &Json,
718 provider: Option<&str>,
719 model_name: Option<&str>,
720 normalized_response: Option<&AnnotatedLlmResponse>,
721) -> Option<AtifMetrics> {
722 let raw_usage = token_usage_object(output);
723 let fallback_usage = manual::usage_from_manual_llm_output(Some(output));
724 let normalized_usage = normalized_response.and_then(|response| response.usage.as_ref());
725 let merged_usage = merge_usage(normalized_usage, fallback_usage.as_ref());
726 let prompt = merged_usage.as_ref().and_then(|usage| usage.prompt_tokens);
727 let completion = merged_usage
728 .as_ref()
729 .and_then(|usage| usage.completion_tokens);
730 let cache_read = merged_usage
731 .as_ref()
732 .and_then(|usage| usage.cache_read_tokens);
733 let cache_write = merged_usage
734 .as_ref()
735 .and_then(|usage| usage.cache_write_tokens);
736 let cached = sum_options(cache_read, cache_write);
737 let normalized_cost_source = normalized_usage.and_then(|usage| usage.cost.as_ref());
738 let normalized_cost =
739 normalized_cost_source.and_then(|cost| cost.total_or_component_sum_for_currency("USD"));
740 let manual_cost =
741 manual::cost_from_manual_llm_output(Some(output), manual::ManualCostPolicy::AtifUsdOnly)
742 .map(|(total, _)| total);
743 let explicit_cost = if normalized_cost_source.is_some() {
744 normalized_cost
745 } else {
746 manual_cost
747 };
748 let has_reported_cost = normalized_cost_source.is_some()
749 || raw_usage.is_some_and(|usage| usage.get("cost").is_some());
750 let cost = if has_reported_cost {
751 explicit_cost
752 } else {
753 explicit_cost.or_else(|| {
754 let usage = merged_usage.as_ref()?;
755 estimate_cost_for_response_or_model(
756 provider,
757 model_name,
758 normalized_response
759 .and_then(|response| response.model.as_deref())
760 .or_else(|| response_model_name(output)),
761 usage,
762 )
763 .and_then(|cost| cost.total_for_currency("USD"))
764 })
765 };
766 let prompt_ids = raw_usage
767 .and_then(|usage| usage.get("prompt_token_ids"))
768 .and_then(Json::as_array)
769 .map(|a| a.iter().filter_map(Json::as_u64).collect());
770 let completion_ids = raw_usage
771 .and_then(|usage| usage.get("completion_token_ids"))
772 .and_then(Json::as_array)
773 .map(|a| a.iter().filter_map(Json::as_u64).collect());
774 let logprobs = raw_usage
775 .and_then(|usage| usage.get("logprobs"))
776 .and_then(Json::as_array)
777 .map(|a| a.iter().filter_map(Json::as_f64).collect());
778 let known: std::collections::HashSet<&str> = TOKEN_USAGE_KNOWN_KEYS.iter().copied().collect();
779 let mut extra_map: serde_json::Map<String, Json> = output
780 .as_object()
781 .into_iter()
782 .flat_map(|output| {
783 ["usage", "token_usage"]
784 .into_iter()
785 .filter_map(|key| output.get(key).and_then(Json::as_object))
786 })
787 .flat_map(|usage| usage.iter())
788 .filter(|(k, _)| !known.contains(k.as_str()))
789 .map(|(k, v)| (k.clone(), v.clone()))
790 .collect();
791 if let Some(summary) =
792 normalized_response.and_then(|response| response.optimization_summary.as_ref())
793 && let Ok(summary) = serde_json::to_value(summary)
794 {
795 let relay = extra_map
796 .entry("nemo_relay".to_string())
797 .or_insert_with(|| Json::Object(serde_json::Map::new()));
798 if !relay.is_object() {
799 *relay = Json::Object(serde_json::Map::new());
800 }
801 if let Some(relay) = relay.as_object_mut() {
802 relay.insert("optimization".to_string(), summary);
803 }
804 }
805 let extra = if extra_map.is_empty() {
806 None
807 } else {
808 Some(Json::Object(extra_map))
809 };
810 if prompt.is_none()
811 && completion.is_none()
812 && cached.is_none()
813 && cost.is_none()
814 && extra.is_none()
815 {
816 return None;
817 }
818 Some(AtifMetrics {
819 prompt_tokens: prompt,
820 completion_tokens: completion,
821 cached_tokens: cached,
822 cost_usd: cost,
823 prompt_token_ids: prompt_ids,
824 completion_token_ids: completion_ids,
825 logprobs,
826 extra,
827 })
828}
829
830fn merge_metrics(
831 primary: Option<AtifMetrics>,
832 supplemental: Option<&AtifMetrics>,
833) -> Option<AtifMetrics> {
834 match (primary, supplemental) {
835 (None, None) => None,
836 (Some(metrics), None) => Some(metrics),
837 (None, Some(supplemental)) => Some(supplemental.clone()),
838 (Some(mut metrics), Some(supplemental)) => {
839 merge_metrics_fields(&mut metrics, supplemental);
840 Some(metrics)
841 }
842 }
843}
844
845fn merge_metrics_fields(target: &mut AtifMetrics, supplemental: &AtifMetrics) {
846 if target.prompt_tokens.is_none() {
847 target.prompt_tokens = supplemental.prompt_tokens;
848 }
849 if target.completion_tokens.is_none() {
850 target.completion_tokens = supplemental.completion_tokens;
851 }
852 if target.cached_tokens.is_none() {
853 target.cached_tokens = supplemental.cached_tokens;
854 }
855 if target.cost_usd.is_none() {
856 target.cost_usd = supplemental.cost_usd;
857 }
858 if target.prompt_token_ids.is_none() {
859 target.prompt_token_ids = supplemental.prompt_token_ids.clone();
860 }
861 if target.completion_token_ids.is_none() {
862 target.completion_token_ids = supplemental.completion_token_ids.clone();
863 }
864 if target.logprobs.is_none() {
865 target.logprobs = supplemental.logprobs.clone();
866 }
867 merge_metrics_extra(&mut target.extra, &supplemental.extra);
868}
869
870fn merge_metrics_extra(target: &mut Option<Json>, supplemental: &Option<Json>) {
871 let Some(supplemental) = supplemental else {
872 return;
873 };
874 match (target.as_mut(), supplemental) {
875 (Some(Json::Object(target_object)), Json::Object(supplemental_object)) => {
876 for (key, value) in supplemental_object {
877 target_object
878 .entry(key.clone())
879 .or_insert_with(|| value.clone());
880 }
881 }
882 (None, _) => *target = Some(supplemental.clone()),
883 _ => {}
884 }
885}
886
887fn token_usage_object(output: &Json) -> Option<&serde_json::Map<String, Json>> {
888 let output = output.as_object()?;
889 output
890 .get("token_usage")
891 .or_else(|| output.get("usage"))
892 .and_then(Json::as_object)
893}
894
895fn response_model_name(output: &Json) -> Option<&str> {
896 output
897 .as_object()
898 .and_then(|object| object.get("model").and_then(Json::as_str))
899}
900
901fn sum_options(left: Option<u64>, right: Option<u64>) -> Option<u64> {
902 match (left, right) {
903 (Some(left), Some(right)) => Some(left + right),
904 (Some(value), None) | (None, Some(value)) => Some(value),
905 (None, None) => None,
906 }
907}
908
909fn extract_reasoning_effort(input: &Json) -> Option<Json> {
914 if let Some(obj) = input.as_object()
915 && let Some(v) = obj.get("reasoning_effort")
916 && !v.is_null()
917 {
918 return Some(v.clone());
919 }
920 None
921}
922
923fn extract_reasoning_content(output: &Json) -> Option<String> {
928 if let Some(obj) = output.as_object()
929 && let Some(r) = obj.get("reasoning")
930 {
931 return r.as_str().map(String::from);
932 }
933 None
934}
935
936fn extract_user_messages(input: &Json) -> Json {
947 if let Some(obj) = input.as_object()
948 && let Some(messages) = obj.get("messages").and_then(Json::as_array)
949 && let Some(message) = messages
950 .iter()
951 .rev()
952 .filter_map(Json::as_object)
953 .find(|message| match message.get("role").and_then(Json::as_str) {
954 Some(role) => role == "user",
955 None => true,
956 })
957 .and_then(|message| message.get("content"))
958 {
959 return atif_content_value(message);
960 }
961 if let Some(obj) = input.as_object()
962 && let Some(message) = obj.get("input").and_then(openai_responses_input_message)
963 {
964 return message;
965 }
966 if let Some(obj) = input.as_object()
967 && let Some(prompt) = obj.get("prompt")
968 {
969 return atif_content_value(prompt);
970 }
971 atif_content_value(input)
972}
973
974fn llm_start_user_step_message(event: &Event, input: &Json, has_paired_end: bool) -> Option<Json> {
975 let turn_state = event
976 .annotated_request()
977 .and_then(|request| request_turn_state_from_annotation(request.as_ref()))
978 .or_else(|| request_turn_state_from_raw(input));
979
980 if has_paired_end && matches!(turn_state, Some(RequestTurnState::Continuation)) {
981 return None;
982 }
983
984 event
985 .annotated_request()
986 .and_then(|request| atif_message_from_annotated_request(request.as_ref()))
987 .or_else(|| Some(extract_user_messages(input)))
988}
989
990fn request_turn_state_from_raw(input: &Json) -> Option<RequestTurnState> {
991 let object = input.as_object()?;
992 if let Some(messages) = object.get("messages").and_then(Json::as_array)
993 && let Some(state) = chat_messages_turn_state(messages)
994 {
995 return Some(state);
996 }
997 if let Some(input_items) = object.get("input")
998 && let Some(state) = openai_responses_turn_state(input_items)
999 {
1000 return Some(state);
1001 }
1002 object.get("prompt").map(|_| RequestTurnState::FreshUser)
1003}
1004
1005fn request_turn_state_from_annotation(request: &AnnotatedLlmRequest) -> Option<RequestTurnState> {
1006 request
1007 .messages
1008 .iter()
1009 .rev()
1010 .find_map(annotated_message_turn_state)
1011}
1012
1013fn chat_messages_turn_state(messages: &[Json]) -> Option<RequestTurnState> {
1014 messages
1015 .iter()
1016 .rev()
1017 .filter_map(Json::as_object)
1018 .find_map(raw_chat_message_turn_state)
1019}
1020
1021fn raw_chat_message_turn_state(
1022 message: &serde_json::Map<String, Json>,
1023) -> Option<RequestTurnState> {
1024 match message.get("role").and_then(Json::as_str) {
1025 Some("system" | "developer") => None,
1026 Some("user") | None => Some(match message.get("content") {
1027 Some(content) if raw_content_starts_new_turn(content) => RequestTurnState::FreshUser,
1028 Some(_) => RequestTurnState::Continuation,
1029 None => RequestTurnState::FreshUser,
1030 }),
1031 Some(_) => Some(RequestTurnState::Continuation),
1032 }
1033}
1034
1035fn raw_content_starts_new_turn(content: &Json) -> bool {
1036 match content {
1037 Json::String(_) => true,
1038 Json::Array(parts) => {
1039 if parts.iter().any(raw_content_part_is_user_input) {
1040 return true;
1041 }
1042 !parts.iter().any(raw_content_part_is_tool_continuation)
1043 }
1044 Json::Object(_) => {
1045 raw_content_part_is_user_input(content)
1046 || !raw_content_part_is_tool_continuation(content)
1047 }
1048 _ => true,
1049 }
1050}
1051
1052fn raw_content_part_is_user_input(part: &Json) -> bool {
1053 matches!(
1054 part.get("type").and_then(Json::as_str),
1055 Some(
1056 "text"
1057 | "input_text"
1058 | "image_url"
1059 | "image"
1060 | "input_image"
1061 | "audio"
1062 | "input_audio"
1063 | "file"
1064 | "document"
1065 )
1066 )
1067}
1068
1069fn raw_content_part_is_tool_continuation(part: &Json) -> bool {
1070 matches!(
1071 part.get("type").and_then(Json::as_str),
1072 Some("tool_result" | "tool_use")
1073 )
1074}
1075
1076fn openai_responses_turn_state(input: &Json) -> Option<RequestTurnState> {
1077 if input.is_string() {
1078 return Some(RequestTurnState::FreshUser);
1079 }
1080
1081 input
1082 .as_array()?
1083 .iter()
1084 .rev()
1085 .filter_map(Json::as_object)
1086 .find_map(openai_responses_item_turn_state)
1087}
1088
1089fn openai_responses_item_turn_state(
1090 item: &serde_json::Map<String, Json>,
1091) -> Option<RequestTurnState> {
1092 match item.get("type").and_then(Json::as_str) {
1093 Some("message") => match item.get("role").and_then(Json::as_str) {
1094 Some("system" | "developer") => None,
1095 Some("user") | None => Some(RequestTurnState::FreshUser),
1096 Some(_) => Some(RequestTurnState::Continuation),
1097 },
1098 Some(_) => Some(RequestTurnState::Continuation),
1099 _ => None,
1100 }
1101}
1102
1103fn openai_responses_input_message(input: &Json) -> Option<Json> {
1104 if input.is_string() {
1105 return Some(atif_content_value(input));
1106 }
1107
1108 let items = input.as_array()?;
1109 items
1110 .iter()
1111 .rev()
1112 .find_map(openai_responses_input_item_message)
1113}
1114
1115fn openai_responses_input_item_message(item: &Json) -> Option<Json> {
1116 let item_obj = item.as_object()?;
1117 if item_obj.get("role").and_then(Json::as_str) != Some("user") {
1118 return None;
1119 }
1120 let content = item_obj.get("content")?;
1121 openai_responses_input_content_message(content)
1122}
1123
1124fn openai_responses_input_content_message(content: &Json) -> Option<Json> {
1125 if content.is_string() {
1126 return Some(atif_content_value(content));
1127 }
1128
1129 if let Some(content_parts) = content.as_array() {
1130 let mut text_parts = Vec::new();
1131 collect_openai_responses_content_text(content_parts, "input_text", &mut text_parts);
1132 if text_parts.is_empty() {
1133 collect_openai_responses_content_text(content_parts, "text", &mut text_parts);
1134 }
1135 return match text_parts.as_slice() {
1136 [] => is_atif_content_parts(content).then(|| content.clone()),
1137 [text] => Some(Json::String(text.clone())),
1138 _ => Some(Json::String(text_parts.join("\n"))),
1139 };
1140 }
1141
1142 None
1143}
1144
1145fn extract_tool_calls(output: &Json) -> Option<Vec<AtifToolCall>> {
1157 let arr = tool_call_array(output)
1158 .filter(|arr| !arr.is_empty())
1159 .map(|arr| arr.iter().collect::<Vec<_>>())
1160 .or_else(|| openai_responses_function_call_items(output))
1161 .or_else(|| anthropic_messages_tool_use_items(output))?;
1162 let mut calls = Vec::with_capacity(arr.len());
1163 for (index, tc) in arr.iter().enumerate() {
1164 let tc_obj = tc.as_object()?;
1165 let mut id = tc_obj
1166 .get("id")
1167 .or_else(|| tc_obj.get("tool_call_id"))
1168 .or_else(|| tc_obj.get("call_id"))
1169 .and_then(Json::as_str)
1170 .unwrap_or("")
1171 .to_string();
1172 let func = tc_obj.get("function").and_then(Json::as_object);
1173 let name = func
1174 .and_then(|f| f.get("name"))
1175 .or_else(|| tc_obj.get("name"))
1176 .or_else(|| tc_obj.get("toolName"))
1177 .or_else(|| tc_obj.get("tool_name"))
1178 .or_else(|| tc_obj.get("function_name"))
1179 .and_then(Json::as_str)
1180 .unwrap_or("")
1181 .to_string();
1182 if id.is_empty() && !name.is_empty() {
1183 id = format!("{name}:{}", index + 1);
1184 }
1185 let raw_arguments = func
1186 .and_then(|f| f.get("arguments"))
1187 .or_else(|| tc_obj.get("arguments"))
1188 .or_else(|| tc_obj.get("args"))
1189 .or_else(|| tc_obj.get("input"));
1190 let arguments = normalize_tool_arguments(raw_arguments);
1191 if id.is_empty() && name.is_empty() {
1193 continue;
1194 }
1195 calls.push(AtifToolCall {
1196 tool_call_id: id,
1197 function_name: name,
1198 arguments,
1199 extra: tool_call_extra(tc),
1200 });
1201 }
1202 if calls.is_empty() { None } else { Some(calls) }
1203}
1204
1205fn atif_message_from_annotated_request(request: &AnnotatedLlmRequest) -> Option<Json> {
1209 let content = request
1210 .messages
1211 .iter()
1212 .rev()
1213 .find_map(|message| match message {
1214 Message::User { content, .. } => Some(content),
1215 _ => None,
1216 })?;
1217 match content {
1218 MessageContent::Text(text) => Some(Json::String(text.clone())),
1219 MessageContent::Parts(_) => None,
1220 }
1221}
1222
1223fn annotated_message_turn_state(message: &Message) -> Option<RequestTurnState> {
1224 match message {
1225 Message::System { .. } | Message::Developer { .. } => None,
1226 Message::User { content, .. } => Some(if annotated_content_starts_new_turn(content) {
1227 RequestTurnState::FreshUser
1228 } else {
1229 RequestTurnState::Continuation
1230 }),
1231 Message::Assistant { .. }
1232 | Message::Tool { .. }
1233 | Message::Function { .. }
1234 | Message::ToolCallItem { .. }
1235 | Message::ToolResultItem { .. } => Some(RequestTurnState::Continuation),
1236 Message::ProviderNative { value, .. } => provider_native_turn_state(value),
1237 }
1238}
1239
1240fn annotated_content_starts_new_turn(content: &MessageContent) -> bool {
1241 match content {
1242 MessageContent::Text(_) => true,
1243 MessageContent::Parts(parts) => {
1244 let has_user_input = parts.iter().any(|part| {
1245 matches!(
1246 part,
1247 ContentPart::Text { .. }
1248 | ContentPart::ImageUrl { .. }
1249 | ContentPart::Image { .. }
1250 | ContentPart::Audio { .. }
1251 | ContentPart::File { .. }
1252 )
1253 });
1254 if has_user_input {
1255 return true;
1256 }
1257 let has_tool_continuation = parts.iter().any(|part| {
1258 matches!(
1259 part,
1260 ContentPart::ToolUse { .. } | ContentPart::ToolResult { .. }
1261 )
1262 });
1263 !has_tool_continuation
1264 }
1265 }
1266}
1267
1268fn provider_native_turn_state(value: &Json) -> Option<RequestTurnState> {
1269 let object = value.as_object()?;
1270 if object.get("type").is_some() {
1271 return openai_responses_item_turn_state(object);
1272 }
1273 match object.get("role").and_then(Json::as_str) {
1274 Some("system" | "developer") => None,
1275 Some("user") => Some(RequestTurnState::FreshUser),
1276 Some(_) => Some(RequestTurnState::Continuation),
1277 None => None,
1278 }
1279}
1280
1281fn atif_message_from_annotated_response(response: &AnnotatedLlmResponse) -> Option<Json> {
1282 match &response.message {
1283 Some(MessageContent::Text(text)) => Some(Json::String(text.clone())),
1284 Some(MessageContent::Parts(_)) => None,
1286 None => Some(empty_message()),
1290 }
1291}
1292
1293fn tool_call_array(output: &Json) -> Option<&Vec<Json>> {
1294 output
1295 .as_object()
1296 .and_then(|object| object.get("tool_calls"))
1297 .and_then(Json::as_array)
1298 .or_else(|| {
1299 output
1300 .as_object()
1301 .and_then(|object| object.get("assistant_message"))
1302 .and_then(Json::as_object)
1303 .and_then(|assistant| assistant.get("tool_calls"))
1304 .and_then(Json::as_array)
1305 })
1306 .or_else(|| raw_response_message_field(output, "tool_calls").and_then(Json::as_array))
1307}
1308
1309fn openai_responses_function_call_items(output: &Json) -> Option<Vec<&Json>> {
1310 let items = output
1311 .as_object()
1312 .and_then(|object| object.get("output"))
1313 .and_then(Json::as_array)?;
1314 let function_call_items = items
1315 .iter()
1316 .filter(|item| item.get("type").and_then(Json::as_str) == Some("function_call"))
1317 .collect::<Vec<_>>();
1318 (!function_call_items.is_empty()).then_some(function_call_items)
1319}
1320
1321fn anthropic_messages_tool_use_items(output: &Json) -> Option<Vec<&Json>> {
1322 let object = output.as_object()?;
1323 if object.get("type").and_then(Json::as_str) != Some("message") {
1324 return None;
1325 }
1326 let content_blocks = object.get("content").and_then(Json::as_array)?;
1327 let tool_use_items = content_blocks
1328 .iter()
1329 .filter(|item| item.get("type").and_then(Json::as_str) == Some("tool_use"))
1330 .collect::<Vec<_>>();
1331 (!tool_use_items.is_empty()).then_some(tool_use_items)
1332}
1333
1334fn normalize_tool_arguments(raw_arguments: Option<&Json>) -> Json {
1335 let Some(raw_arguments) = raw_arguments else {
1336 return serde_json::json!({});
1337 };
1338 match raw_arguments {
1339 Json::Object(_) => raw_arguments.clone(),
1340 Json::String(arguments) => match serde_json::from_str::<Json>(arguments) {
1341 Ok(Json::Object(object)) => Json::Object(object),
1342 Ok(value) => serde_json::json!({ "value": value }),
1343 Err(_) => serde_json::json!({ "raw": arguments }),
1344 },
1345 Json::Null => serde_json::json!({}),
1346 value => serde_json::json!({ "value": value }),
1347 }
1348}
1349
1350fn tool_call_extra(tool_call: &Json) -> Option<Json> {
1351 let object = tool_call.as_object()?;
1352 let mut extra = serde_json::Map::new();
1353
1354 for (key, value) in object {
1355 if !matches!(
1356 key.as_str(),
1357 "id" | "tool_call_id"
1358 | "call_id"
1359 | "type"
1360 | "function"
1361 | "name"
1362 | "tool_name"
1363 | "function_name"
1364 | "arguments"
1365 | "args"
1366 | "input"
1367 ) {
1368 extra.insert(key.clone(), value.clone());
1369 }
1370 }
1371
1372 if let Some(function) = object.get("function").and_then(Json::as_object) {
1373 let mut function_extra = serde_json::Map::new();
1374 for (key, value) in function {
1375 if key != "name" && key != "arguments" {
1376 function_extra.insert(key.clone(), value.clone());
1377 }
1378 }
1379 if !function_extra.is_empty() {
1380 extra.insert("function".to_string(), Json::Object(function_extra));
1381 }
1382 }
1383
1384 (!extra.is_empty()).then_some(Json::Object(extra))
1385}
1386
1387fn event_extra(event: &Event) -> Json {
1388 let mut extra = serde_json::Map::new();
1389 extra.insert(
1390 "event_uuid".to_string(),
1391 Json::String(event.uuid().to_string()),
1392 );
1393 extra.insert(
1394 "event_name".to_string(),
1395 Json::String(event.name().to_string()),
1396 );
1397 if let Some(parent_uuid) = event.parent_uuid() {
1398 extra.insert(
1399 "parent_event_uuid".to_string(),
1400 Json::String(parent_uuid.to_string()),
1401 );
1402 }
1403 if let Some(metadata) = event.metadata()
1404 && !metadata.is_null()
1405 {
1406 extra.insert("metadata".to_string(), metadata.clone());
1407 }
1408 Json::Object(extra)
1409}
1410
1411fn compute_final_metrics(steps: &[AtifStep]) -> Option<AtifFinalMetrics> {
1416 let mut totals = FinalMetricsTotals::default();
1417 for metrics in steps.iter().filter_map(|step| step.metrics.as_ref()) {
1418 totals.add(metrics);
1419 }
1420 Some(totals.into_final_metrics(steps.len()))
1421}
1422
1423#[derive(Default)]
1424struct FinalMetricsTotals {
1425 prompt_tokens: Option<u64>,
1426 completion_tokens: Option<u64>,
1427 cached_tokens: Option<u64>,
1428 cost_usd: Option<f64>,
1429 optimization_prompt_tokens_saved: Option<u64>,
1430 optimization_total_tokens_saved: Option<u64>,
1431 optimization_estimated_cost_saved_usd: Option<f64>,
1432 optimization_calls: u64,
1433}
1434
1435impl FinalMetricsTotals {
1436 fn add(&mut self, metrics: &AtifMetrics) {
1437 add_u64_total(&mut self.prompt_tokens, metrics.prompt_tokens);
1438 add_u64_total(&mut self.completion_tokens, metrics.completion_tokens);
1439 add_u64_total(&mut self.cached_tokens, metrics.cached_tokens);
1440 add_f64_total(&mut self.cost_usd, metrics.cost_usd);
1441 let optimization = metrics
1442 .extra
1443 .as_ref()
1444 .and_then(|extra| extra.pointer("/nemo_relay/optimization"));
1445 if let Some(optimization) = optimization {
1446 self.optimization_calls = self.optimization_calls.saturating_add(1);
1447 add_u64_total(
1448 &mut self.optimization_prompt_tokens_saved,
1449 optimization
1450 .pointer("/tokens_saved/prompt_tokens")
1451 .and_then(Json::as_u64),
1452 );
1453 add_u64_total(
1454 &mut self.optimization_total_tokens_saved,
1455 optimization
1456 .pointer("/tokens_saved/total_tokens")
1457 .and_then(Json::as_u64),
1458 );
1459 let saved_usd = optimization
1460 .get("currency")
1461 .and_then(Json::as_str)
1462 .filter(|currency| currency.eq_ignore_ascii_case("USD"))
1463 .and_then(|_| optimization.get("estimated_cost_saved"))
1464 .and_then(Json::as_f64);
1465 add_f64_total(&mut self.optimization_estimated_cost_saved_usd, saved_usd);
1466 }
1467 }
1468
1469 fn into_final_metrics(self, step_count: usize) -> AtifFinalMetrics {
1470 let optimization_extra = (self.optimization_calls > 0).then(|| {
1471 serde_json::json!({
1472 "nemo_relay": {
1473 "optimization": {
1474 "llm_call_count": self.optimization_calls,
1475 "prompt_tokens_saved": self.optimization_prompt_tokens_saved,
1476 "total_tokens_saved": self.optimization_total_tokens_saved,
1477 "estimated_cost_saved_usd": self.optimization_estimated_cost_saved_usd,
1478 }
1479 }
1480 })
1481 });
1482 AtifFinalMetrics {
1483 total_prompt_tokens: self.prompt_tokens,
1484 total_completion_tokens: self.completion_tokens,
1485 total_cached_tokens: self.cached_tokens,
1486 total_cost_usd: self.cost_usd,
1487 total_steps: Some(step_count as u64),
1488 extra: optimization_extra,
1489 }
1490 }
1491}
1492
1493fn add_u64_total(total: &mut Option<u64>, value: Option<u64>) {
1494 if let Some(value) = value {
1495 *total = Some(total.unwrap_or(0) + value);
1496 }
1497}
1498
1499fn add_f64_total(total: &mut Option<f64>, value: Option<f64>) {
1500 if let Some(value) = value {
1501 *total = Some(total.unwrap_or(0.0) + value);
1502 }
1503}
1504
1505fn build_ancestry(
1513 event: &Event,
1514 name_map: &std::collections::HashMap<Uuid, String>,
1515) -> AtifAncestry {
1516 AtifAncestry {
1517 function_id: event.uuid().to_string(),
1518 function_name: event.name().to_string(),
1519 parent_id: event.parent_uuid().map(|u| u.to_string()),
1520 parent_name: event.parent_uuid().and_then(|u| name_map.get(&u)).cloned(),
1521 }
1522}
1523
1524fn build_invocation_info(
1529 start_ts: Option<DateTime<Utc>>,
1530 end_ts: DateTime<Utc>,
1531 invocation_id: Option<String>,
1532 framework: &str,
1533) -> AtifInvocationInfo {
1534 AtifInvocationInfo {
1535 start_timestamp: start_ts.map(|s| s.timestamp_millis() as f64 / 1000.0),
1536 end_timestamp: start_ts.map(|_| end_ts.timestamp_millis() as f64 / 1000.0),
1537 invocation_id,
1538 status: Some("completed".to_string()),
1539 framework: Some(framework.to_string()),
1540 }
1541}
1542
1543fn delegation_tool_call_id(value: &Json) -> Option<String> {
1544 [
1545 &["tool_call_id"][..],
1546 &["toolCallId"],
1547 &["source_call_id"],
1548 &["sourceCallId"],
1549 &["delegation_tool_call_id"],
1550 &["delegationToolCallId"],
1551 &["parent_tool_call_id"],
1552 &["parentToolCallId"],
1553 &["extra", "tool_call_id"],
1554 &["extra", "toolCallId"],
1555 &["extra", "source_call_id"],
1556 &["extra", "sourceCallId"],
1557 &["extra", "delegation_tool_call_id"],
1558 &["extra", "delegationToolCallId"],
1559 &["extra", "parent_tool_call_id"],
1560 &["extra", "parentToolCallId"],
1561 ]
1562 .into_iter()
1563 .find_map(|path| json_string_at(value, path))
1564}
1565
1566fn json_string_at(value: &Json, path: &[&str]) -> Option<String> {
1567 let mut current = value;
1568 for key in path {
1569 current = current.get(*key)?;
1570 }
1571 current.as_str().map(ToString::to_string)
1572}
1573
1574struct EventLookupMaps {
1575 name_map: std::collections::HashMap<Uuid, String>,
1576 start_ts_map: std::collections::HashMap<Uuid, DateTime<Utc>>,
1577 llm_end_uuids: HashSet<Uuid>,
1578 llm_start_model_names: HashMap<Uuid, String>,
1579 tool_call_ids: std::collections::HashMap<Uuid, String>,
1580 suppressed_llm_events: HashSet<Uuid>,
1581 supplemental_llm_metrics: HashMap<Uuid, AtifMetrics>,
1582}
1583
1584impl EventLookupMaps {
1585 fn from_events(events: &[&Event]) -> Self {
1586 Self::from_events_with_correlation_events(events, events, events)
1587 }
1588
1589 fn from_events_for_agent(events: &[&Event], tree: &AgentScopeTree, agent_uuid: Uuid) -> Self {
1590 let tool_correlation_events = events
1591 .iter()
1592 .copied()
1593 .filter(|event| tree.owner_agent(event) == Some(agent_uuid))
1594 .collect::<Vec<_>>();
1595 Self::from_events_with_correlation_events(events, events, &tool_correlation_events)
1596 }
1597
1598 fn from_events_with_correlation_events(
1599 events: &[&Event],
1600 llm_dedupe_events: &[&Event],
1601 tool_correlation_events: &[&Event],
1602 ) -> Self {
1603 let mut name_map = std::collections::HashMap::new();
1604 let mut start_ts_map = std::collections::HashMap::new();
1605 let mut llm_end_uuids = HashSet::new();
1606 let mut llm_start_model_names = HashMap::new();
1607 for event in events {
1608 if is_start_event(event) {
1609 name_map.insert(event.uuid(), event.name().to_string());
1610 start_ts_map.insert(event.uuid(), *event.timestamp());
1611 if event.category().map(|category| category.as_str()) == Some("llm")
1612 && let Some(model_name) = model_name_for_llm_event(event)
1613 {
1614 llm_start_model_names.insert(event.uuid(), model_name);
1615 }
1616 }
1617 if event.scope_category() == Some(crate::api::event::ScopeCategory::End)
1618 && event.category().map(|category| category.as_str()) == Some("llm")
1619 && event.data().is_some()
1620 {
1621 llm_end_uuids.insert(event.uuid());
1622 }
1623 }
1624 let llm_dedupe = build_llm_dedupe(llm_dedupe_events);
1625 Self {
1626 name_map,
1627 start_ts_map,
1628 llm_end_uuids,
1629 llm_start_model_names,
1630 tool_call_ids: build_tool_call_correlations(tool_correlation_events),
1631 suppressed_llm_events: llm_dedupe.suppressed_events,
1632 supplemental_llm_metrics: llm_dedupe.supplemental_metrics,
1633 }
1634 }
1635
1636 fn should_suppress_llm_event(&self, event: &Event) -> bool {
1637 event.category().map(|category| category.as_str()) == Some("llm")
1638 && self.suppressed_llm_events.contains(&event.uuid())
1639 }
1640}
1641
1642#[derive(Default)]
1643struct LlmDedupeLookups {
1644 suppressed_events: HashSet<Uuid>,
1645 supplemental_metrics: HashMap<Uuid, AtifMetrics>,
1646}
1647
1648#[derive(Default)]
1649struct LlmSpanParts<'a> {
1650 start: Option<&'a Event>,
1651 end: Option<&'a Event>,
1652}
1653
1654#[derive(Debug, Clone)]
1655struct LlmSpanCandidate {
1656 uuid: Uuid,
1657 parent_uuid: Option<Uuid>,
1658 start_ts: DateTime<Utc>,
1659 end_ts: DateTime<Utc>,
1660 request_signature: String,
1661 request_correlation_keys: HashSet<String>,
1662 response_signature: String,
1663 model_name: Option<String>,
1664 fidelity_score: u8,
1665 end_metrics: Option<AtifMetrics>,
1666 hook_instrumentation: bool,
1667 gateway_instrumentation: bool,
1668 non_exact_provider_payload: bool,
1669}
1670
1671fn build_llm_dedupe(events: &[&Event]) -> LlmDedupeLookups {
1672 let candidates = collect_llm_span_candidates(events);
1673 let mut lookups = LlmDedupeLookups::default();
1674
1675 for (left_idx, left) in candidates.iter().enumerate() {
1676 for right in candidates.iter().skip(left_idx + 1) {
1677 if same_physical_llm_request(left, right) {
1678 suppress_lower_fidelity_llm_span(left, right, &mut lookups);
1679 }
1680 }
1681 }
1682
1683 lookups
1684}
1685
1686fn collect_llm_span_candidates(events: &[&Event]) -> Vec<LlmSpanCandidate> {
1687 let mut spans: HashMap<Uuid, LlmSpanParts<'_>> = HashMap::new();
1688 for event in events {
1689 if event.category().map(|category| category.as_str()) != Some("llm") {
1690 continue;
1691 }
1692 let parts = spans.entry(event.uuid()).or_default();
1693 match event.scope_category() {
1694 Some(crate::api::event::ScopeCategory::Start) => parts.start = Some(event),
1695 Some(crate::api::event::ScopeCategory::End) => parts.end = Some(event),
1696 None => {}
1697 }
1698 }
1699
1700 spans
1701 .into_iter()
1702 .filter_map(|(uuid, parts)| LlmSpanCandidate::from_events(uuid, parts.start?, parts.end?))
1703 .collect()
1704}
1705
1706impl LlmSpanCandidate {
1707 fn from_events(uuid: Uuid, start: &Event, end: &Event) -> Option<Self> {
1708 let request_signature = start.data().map(llm_request_signature)?;
1709 let response_signature = end.data().map(llm_response_signature)?;
1710 Some(Self {
1711 uuid,
1712 parent_uuid: start.parent_uuid().or_else(|| end.parent_uuid()),
1713 start_ts: *start.timestamp(),
1714 end_ts: *end.timestamp(),
1715 request_signature,
1716 request_correlation_keys: llm_request_correlation_keys(start, end),
1717 response_signature,
1718 model_name: effective_model_for_pair(start, end),
1719 fidelity_score: llm_event_fidelity_score(start).max(llm_event_fidelity_score(end)),
1720 end_metrics: end.data().and_then(|output| {
1721 let normalized_response = end.normalized_llm_response();
1722 let requested_model = start
1723 .model_name()
1724 .map(ToOwned::to_owned)
1725 .or_else(|| model_name_for_llm_event(start))
1726 .or_else(|| end.model_name().map(ToOwned::to_owned));
1727 extract_metrics(
1728 output,
1729 Some(end.name()),
1730 requested_model.as_deref(),
1731 normalized_response.as_deref(),
1732 )
1733 }),
1734 hook_instrumentation: is_hook_instrumented_llm_event(start)
1735 || is_hook_instrumented_llm_event(end),
1736 gateway_instrumentation: is_gateway_instrumented_llm_event(start)
1737 || is_gateway_instrumented_llm_event(end),
1738 non_exact_provider_payload: has_non_exact_provider_payload(start)
1739 || has_non_exact_provider_payload(end),
1740 })
1741 }
1742}
1743
1744fn llm_request_signature(input: &Json) -> String {
1745 let content = unwrap_llm_request(input);
1746 json_to_string(&extract_user_messages(&content))
1747}
1748
1749fn llm_response_signature(output: &Json) -> String {
1750 json_to_string(&serde_json::json!({
1751 "message": extract_llm_response_message(output),
1752 "tool_calls": extract_tool_calls(output),
1753 }))
1754}
1755
1756fn llm_request_correlation_keys(start: &Event, end: &Event) -> HashSet<String> {
1757 let mut keys = HashSet::new();
1758 collect_llm_request_correlation_keys(start, &mut keys);
1759 collect_llm_request_correlation_keys(end, &mut keys);
1760 keys
1761}
1762
1763fn collect_llm_request_correlation_keys(event: &Event, keys: &mut HashSet<String>) {
1764 if let Some(metadata) = event.metadata() {
1765 collect_request_correlation_values(metadata, keys);
1766 }
1767 if let Some(data) = event.data() {
1768 collect_request_correlation_values(data, keys);
1769 collect_request_correlation_values(&unwrap_llm_request(data), keys);
1770 }
1771}
1772
1773fn collect_request_correlation_values(value: &Json, keys: &mut HashSet<String>) {
1774 for path in [
1775 &["api_call_id"][..],
1776 &["apiCallId"],
1777 &["request_id"],
1778 &["requestId"],
1779 &["request", "id"],
1780 &["metadata", "request_id"],
1781 &["metadata", "requestId"],
1782 &["extra", "api_call_id"],
1783 &["extra", "apiCallId"],
1784 &["extra", "request_id"],
1785 &["extra", "requestId"],
1786 &["llm_correlation_request_id"],
1787 ] {
1788 insert_correlation_key(keys, "request", json_string_at(value, path));
1789 }
1790
1791 for path in [
1792 &["generation_id"][..],
1793 &["generationId"],
1794 &["generation", "id"],
1795 &["metadata", "generation_id"],
1796 &["metadata", "generationId"],
1797 &["extra", "generation_id"],
1798 &["extra", "generationId"],
1799 &["llm_correlation_generation_id"],
1800 ] {
1801 insert_correlation_key(keys, "generation", json_string_at(value, path));
1802 }
1803}
1804
1805fn insert_correlation_key(keys: &mut HashSet<String>, kind: &str, value: Option<String>) {
1806 if let Some(value) = value.filter(|value| !value.is_empty()) {
1807 keys.insert(format!("{kind}:{value}"));
1808 }
1809}
1810
1811fn same_physical_llm_request(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1812 same_parent(left, right)
1813 && compatible_model_names(left, right)
1814 && llm_spans_overlap(left, right)
1815 && (same_llm_payload_signatures(left, right)
1816 || complementary_hook_and_gateway_spans(left, right))
1817}
1818
1819fn same_llm_payload_signatures(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1820 left.request_signature == right.request_signature
1821 && left.response_signature == right.response_signature
1822}
1823
1824fn complementary_hook_and_gateway_spans(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1825 let complementary_polarity = (left.non_exact_provider_payload
1826 && left.hook_instrumentation
1827 && right.gateway_instrumentation)
1828 || (right.non_exact_provider_payload
1829 && right.hook_instrumentation
1830 && left.gateway_instrumentation);
1831
1832 complementary_polarity
1833 && (left.request_signature == right.request_signature
1834 || shared_llm_request_correlation_key(left, right))
1835}
1836
1837fn shared_llm_request_correlation_key(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1838 !left
1839 .request_correlation_keys
1840 .is_disjoint(&right.request_correlation_keys)
1841}
1842
1843fn same_parent(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1844 left.parent_uuid.is_some() && left.parent_uuid == right.parent_uuid
1845}
1846
1847fn compatible_model_names(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1848 match (&left.model_name, &right.model_name) {
1849 (Some(left_model), Some(right_model)) => left_model == right_model,
1850 _ => true,
1851 }
1852}
1853
1854fn llm_spans_overlap(left: &LlmSpanCandidate, right: &LlmSpanCandidate) -> bool {
1855 left.start_ts <= right.end_ts && right.start_ts <= left.end_ts
1856}
1857
1858fn suppress_lower_fidelity_llm_span(
1859 left: &LlmSpanCandidate,
1860 right: &LlmSpanCandidate,
1861 lookups: &mut LlmDedupeLookups,
1862) {
1863 match left.fidelity_score.cmp(&right.fidelity_score) {
1864 std::cmp::Ordering::Greater => suppress_llm_span(right, left, lookups),
1865 std::cmp::Ordering::Less => suppress_llm_span(left, right, lookups),
1866 std::cmp::Ordering::Equal => {}
1867 }
1868}
1869
1870fn suppress_llm_span(
1871 suppressed: &LlmSpanCandidate,
1872 canonical: &LlmSpanCandidate,
1873 lookups: &mut LlmDedupeLookups,
1874) {
1875 lookups.suppressed_events.insert(suppressed.uuid);
1876 if let Some(metrics) = &suppressed.end_metrics {
1877 let entry = lookups
1878 .supplemental_metrics
1879 .entry(canonical.uuid)
1880 .or_default();
1881 merge_metrics_fields(entry, metrics);
1882 }
1883}
1884
1885fn llm_event_fidelity_score(event: &Event) -> u8 {
1886 let Some(metadata) = event.metadata().and_then(Json::as_object) else {
1887 return 50;
1888 };
1889 if metadata
1890 .get("projection")
1891 .and_then(Json::as_bool)
1892 .unwrap_or(false)
1893 {
1894 return 10;
1895 }
1896 if has_non_exact_provider_payload(event) {
1897 return 30;
1898 }
1899 if metadata
1900 .get("provider_payload_exact")
1901 .and_then(Json::as_bool)
1902 .unwrap_or(false)
1903 {
1904 return 100;
1905 }
1906 if metadata.contains_key("fidelity_source") || metadata.contains_key("api_call_id") {
1907 return 95;
1908 }
1909 if metadata.contains_key("hook_event_name") {
1910 return 90;
1911 }
1912 if is_gateway_instrumented_llm_event(event) {
1913 return 50;
1914 }
1915 50
1916}
1917
1918fn is_hook_instrumented_llm_event(event: &Event) -> bool {
1919 event
1920 .metadata()
1921 .and_then(Json::as_object)
1922 .is_some_and(|metadata| metadata.contains_key("hook_event_name"))
1923}
1924
1925fn is_gateway_instrumented_llm_event(event: &Event) -> bool {
1926 event
1927 .metadata()
1928 .and_then(Json::as_object)
1929 .is_some_and(|metadata| {
1930 metadata.contains_key("gateway_path") || metadata.contains_key("llm_correlation_source")
1931 })
1932}
1933
1934fn has_non_exact_provider_payload(event: &Event) -> bool {
1935 event
1936 .metadata()
1937 .and_then(Json::as_object)
1938 .and_then(|metadata| metadata.get("provider_payload_exact"))
1939 .and_then(Json::as_bool)
1940 == Some(false)
1941}
1942
1943#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1944struct ToolCallMatchKey {
1945 name: String,
1946 arguments: String,
1947}
1948
1949#[derive(Debug, Clone)]
1950struct ToolExecutionRecord {
1951 uuid: Uuid,
1952 explicit_call_id: Option<String>,
1953 key: Option<ToolCallMatchKey>,
1954}
1955
1956#[derive(Debug, Clone)]
1957struct LlmToolCallRecord {
1958 tool_call_id: String,
1959 key: Option<ToolCallMatchKey>,
1960}
1961
1962fn build_tool_call_correlations(events: &[&Event]) -> HashMap<Uuid, String> {
1963 let (mut correlations, executions, tool_calls) = collect_tool_correlation_inputs(events);
1964 let consumed_tool_call_ids = consumed_tool_call_ids(&correlations);
1965 let executions_by_key = group_unmatched_executions_by_key(executions, &correlations);
1966 let tool_calls_by_key = group_tool_calls_by_key(tool_calls, &consumed_tool_call_ids);
1967 apply_keyed_tool_correlations(&mut correlations, executions_by_key, &tool_calls_by_key);
1968 correlations
1969}
1970
1971fn consumed_tool_call_ids(correlations: &HashMap<Uuid, String>) -> HashSet<String> {
1972 correlations.values().cloned().collect()
1973}
1974
1975fn collect_tool_correlation_inputs(
1976 events: &[&Event],
1977) -> (
1978 HashMap<Uuid, String>,
1979 Vec<ToolExecutionRecord>,
1980 Vec<LlmToolCallRecord>,
1981) {
1982 let mut explicit = HashMap::new();
1983 let mut executions = Vec::new();
1984 let mut tool_calls = Vec::new();
1985
1986 for event in events {
1987 collect_tool_correlation_event(event, &mut explicit, &mut executions, &mut tool_calls);
1988 }
1989
1990 (explicit, executions, tool_calls)
1991}
1992
1993fn collect_tool_correlation_event(
1994 event: &Event,
1995 explicit: &mut HashMap<Uuid, String>,
1996 executions: &mut Vec<ToolExecutionRecord>,
1997 tool_calls: &mut Vec<LlmToolCallRecord>,
1998) {
1999 match event_signature(event) {
2000 ("scope", Some(crate::api::event::ScopeCategory::Start), Some("tool")) => {
2001 collect_tool_execution_start(event, explicit, executions)
2002 }
2003 ("scope", Some(crate::api::event::ScopeCategory::End), Some("tool")) => {
2004 collect_explicit_tool_call_id(event, explicit)
2005 }
2006 ("scope", Some(crate::api::event::ScopeCategory::End), Some("llm")) => {
2007 collect_llm_tool_calls(event, tool_calls)
2008 }
2009 _ => {}
2010 }
2011}
2012
2013fn event_signature(
2014 event: &Event,
2015) -> (&str, Option<crate::api::event::ScopeCategory>, Option<&str>) {
2016 (
2017 event.kind(),
2018 event.scope_category(),
2019 event.category().map(|category| category.as_str()),
2020 )
2021}
2022
2023fn collect_tool_execution_start(
2024 event: &Event,
2025 explicit: &mut HashMap<Uuid, String>,
2026 executions: &mut Vec<ToolExecutionRecord>,
2027) {
2028 let record = ToolExecutionRecord {
2029 uuid: event.uuid(),
2030 explicit_call_id: event.tool_call_id().map(ToOwned::to_owned),
2031 key: tool_execution_match_key(event),
2032 };
2033 if let Some(tool_call_id) = &record.explicit_call_id {
2034 explicit.insert(record.uuid, tool_call_id.clone());
2035 }
2036 executions.push(record);
2037}
2038
2039fn collect_explicit_tool_call_id(event: &Event, explicit: &mut HashMap<Uuid, String>) {
2040 if let Some(tool_call_id) = event.tool_call_id() {
2041 explicit.insert(event.uuid(), tool_call_id.to_string());
2042 }
2043}
2044
2045fn collect_llm_tool_calls(event: &Event, tool_calls: &mut Vec<LlmToolCallRecord>) {
2046 let Some(calls) = event.data().and_then(extract_tool_calls) else {
2047 return;
2048 };
2049 tool_calls.extend(calls.into_iter().map(|tool_call| LlmToolCallRecord {
2050 key: tool_call_match_key(&tool_call.function_name, &tool_call.arguments),
2051 tool_call_id: tool_call.tool_call_id,
2052 }));
2053}
2054
2055fn group_unmatched_executions_by_key(
2056 executions: Vec<ToolExecutionRecord>,
2057 correlations: &HashMap<Uuid, String>,
2058) -> HashMap<ToolCallMatchKey, Vec<Uuid>> {
2059 let mut grouped: HashMap<ToolCallMatchKey, Vec<Uuid>> = HashMap::new();
2060 for execution in executions {
2061 if correlations.contains_key(&execution.uuid) {
2062 continue;
2063 }
2064 if let Some(key) = execution.key {
2065 grouped.entry(key).or_default().push(execution.uuid);
2066 }
2067 }
2068 grouped
2069}
2070
2071fn group_tool_calls_by_key(
2072 tool_calls: Vec<LlmToolCallRecord>,
2073 consumed_tool_call_ids: &HashSet<String>,
2074) -> HashMap<ToolCallMatchKey, Vec<String>> {
2075 let mut grouped: HashMap<ToolCallMatchKey, Vec<String>> = HashMap::new();
2076 for tool_call in tool_calls {
2077 if consumed_tool_call_ids.contains(&tool_call.tool_call_id) {
2078 continue;
2079 }
2080 if let Some(key) = tool_call.key {
2081 grouped.entry(key).or_default().push(tool_call.tool_call_id);
2082 }
2083 }
2084 grouped
2085}
2086
2087fn apply_keyed_tool_correlations(
2088 correlations: &mut HashMap<Uuid, String>,
2089 executions_by_key: HashMap<ToolCallMatchKey, Vec<Uuid>>,
2090 tool_calls_by_key: &HashMap<ToolCallMatchKey, Vec<String>>,
2091) {
2092 for (key, execution_uuids) in executions_by_key {
2093 let Some(tool_call_ids) = tool_calls_by_key.get(&key) else {
2094 continue;
2095 };
2096 if execution_uuids.len() == tool_call_ids.len() {
2097 insert_keyed_tool_correlations(correlations, execution_uuids, tool_call_ids);
2098 }
2099 }
2100}
2101
2102fn insert_keyed_tool_correlations(
2103 correlations: &mut HashMap<Uuid, String>,
2104 execution_uuids: Vec<Uuid>,
2105 tool_call_ids: &[String],
2106) {
2107 for (uuid, tool_call_id) in execution_uuids.into_iter().zip(tool_call_ids) {
2108 correlations.insert(uuid, tool_call_id.clone());
2109 }
2110}
2111
2112fn tool_execution_match_key(event: &Event) -> Option<ToolCallMatchKey> {
2113 let arguments = event
2114 .data()
2115 .map(|data| normalize_tool_arguments(Some(data)))?;
2116 tool_call_match_key(event.name(), &arguments)
2117}
2118
2119fn tool_call_match_key(name: &str, arguments: &Json) -> Option<ToolCallMatchKey> {
2120 if name.is_empty() {
2121 return None;
2122 }
2123 Some(ToolCallMatchKey {
2124 name: name.to_string(),
2125 arguments: json_to_string(arguments),
2126 })
2127}
2128
2129#[derive(Default)]
2130struct PendingAgentStep {
2131 step_idx: Option<usize>,
2132 ancestry: Option<AtifAncestry>,
2133 invocation: Option<AtifInvocationInfo>,
2134 llm_request: Option<Json>,
2135 llm_response: Option<Json>,
2136 tool_ancestry: Vec<AtifAncestry>,
2137 tool_invocations: Vec<AtifInvocationInfo>,
2138 tool_call_order: Vec<String>,
2139}
2140
2141impl PendingAgentStep {
2142 fn finalize_into(&mut self, steps: &mut [AtifStep]) {
2143 let (Some(step_idx), Some(ancestry)) = (self.step_idx.take(), self.ancestry.take()) else {
2144 return;
2145 };
2146 let Some(step) = steps.get_mut(step_idx) else {
2147 return;
2148 };
2149
2150 self.sort_tool_metadata();
2151 let extra = AtifStepExtra {
2152 ancestry,
2153 invocation: self.invocation.take(),
2154 llm_request: self.llm_request.take(),
2155 llm_response: self.llm_response.take(),
2156 event_payload: None,
2157 tool_ancestry: std::mem::take(&mut self.tool_ancestry),
2158 tool_invocations: if self.tool_invocations.is_empty() {
2159 None
2160 } else {
2161 Some(std::mem::take(&mut self.tool_invocations))
2162 },
2163 };
2164 step.extra = serde_json::to_value(&extra).ok();
2165 }
2166
2167 fn set_current_agent(
2168 &mut self,
2169 step_idx: usize,
2170 ancestry: AtifAncestry,
2171 invocation: AtifInvocationInfo,
2172 tool_call_order: Vec<String>,
2173 llm_response: Json,
2174 ) {
2175 self.step_idx = Some(step_idx);
2176 self.ancestry = Some(ancestry);
2177 self.invocation = Some(invocation);
2178 self.llm_response = Some(llm_response);
2179 self.tool_ancestry.clear();
2180 self.tool_invocations.clear();
2181 self.tool_call_order = tool_call_order;
2182 }
2183
2184 fn stash_llm_request(&mut self, llm_request: Json) {
2185 self.llm_request = Some(llm_request);
2186 }
2187
2188 fn push_tool_metadata(&mut self, ancestry: AtifAncestry, invocation: AtifInvocationInfo) {
2189 self.tool_ancestry.push(ancestry);
2190 self.tool_invocations.push(invocation);
2191 }
2192
2193 fn push_tool_call_id(&mut self, tool_call_id: String) {
2194 if !self
2195 .tool_call_order
2196 .iter()
2197 .any(|known_id| known_id == &tool_call_id)
2198 {
2199 self.tool_call_order.push(tool_call_id);
2200 }
2201 }
2202
2203 fn has_active_step(&self) -> bool {
2204 self.step_idx.is_some()
2205 }
2206
2207 fn has_tool_call_id(&self, tool_call_id: &str) -> bool {
2208 self.tool_call_order
2209 .iter()
2210 .any(|known_id| known_id == tool_call_id)
2211 }
2212
2213 fn sort_tool_metadata(&mut self) {
2214 if self.tool_call_order.is_empty() || self.tool_ancestry.is_empty() {
2215 return;
2216 }
2217
2218 let mut pairs: Vec<(AtifAncestry, AtifInvocationInfo)> =
2219 std::mem::take(&mut self.tool_ancestry)
2220 .into_iter()
2221 .zip(std::mem::take(&mut self.tool_invocations))
2222 .collect();
2223 pairs.sort_by_key(|(_, invocation)| {
2224 invocation
2225 .invocation_id
2226 .as_deref()
2227 .and_then(|id| self.tool_call_order.iter().position(|entry| entry == id))
2228 .unwrap_or(usize::MAX)
2229 });
2230 let (sorted_ancestry, sorted_invocations): (Vec<_>, Vec<_>) = pairs.into_iter().unzip();
2231 self.tool_ancestry = sorted_ancestry;
2232 self.tool_invocations = sorted_invocations;
2233 }
2234}
2235
2236#[derive(Default)]
2237struct StepConversionState {
2238 steps: Vec<AtifStep>,
2239 last_tool_call_map: std::collections::HashMap<String, String>,
2240 tool_scope_call_ids: std::collections::HashMap<Uuid, String>,
2241 active_tool_call_id: Option<String>,
2242 pending_observations: Vec<AtifObservationResult>,
2243 pending_obs_timestamp: Option<String>,
2244 deferred_observations: HashMap<String, Vec<DeferredToolObservation>>,
2245 deferred_tool_metadata: HashMap<String, Vec<(AtifAncestry, AtifInvocationInfo)>>,
2246 current_reasoning_effort: Option<Json>,
2247 current_agent: PendingAgentStep,
2248}
2249
2250struct DeferredToolObservation {
2251 result: AtifObservationResult,
2252 timestamp: Option<String>,
2253}
2254
2255impl StepConversionState {
2256 fn handle_event(&mut self, event: &Event, lookups: &EventLookupMaps) {
2257 if lookups.should_suppress_llm_event(event) {
2258 return;
2259 }
2260 match (
2261 event.kind(),
2262 event.scope_category(),
2263 event.category().map(|category| category.as_str()),
2264 ) {
2265 ("scope", Some(crate::api::event::ScopeCategory::Start), Some("llm")) => {
2266 self.handle_llm_start(event, lookups)
2267 }
2268 ("scope", Some(crate::api::event::ScopeCategory::End), Some("llm")) => {
2269 self.handle_llm_end(event, lookups)
2270 }
2271 ("scope", Some(crate::api::event::ScopeCategory::Start), Some("tool")) => {
2272 self.handle_tool_start(event, lookups)
2273 }
2274 ("scope", Some(crate::api::event::ScopeCategory::End), Some("tool")) => {
2275 self.handle_tool_end(event, lookups)
2276 }
2277 _ => {}
2278 }
2279 }
2280
2281 fn flush_observations(&mut self) {
2282 if self.pending_observations.is_empty() {
2283 return;
2284 }
2285
2286 let timestamp = self.pending_obs_timestamp.take();
2287 let observations = std::mem::take(&mut self.pending_observations);
2288 let (attached, standalone) = self.route_observations(observations, timestamp.clone());
2289 self.attach_observations_to_current_step(attached);
2290 self.push_standalone_observation_step(standalone, timestamp);
2291 }
2292
2293 fn route_observations(
2294 &mut self,
2295 observations: Vec<AtifObservationResult>,
2296 timestamp: Option<String>,
2297 ) -> (Vec<AtifObservationResult>, Vec<AtifObservationResult>) {
2298 let mut attached = Vec::new();
2299 let mut standalone = Vec::new();
2300 for mut result in observations {
2301 match result.source_call_id.clone() {
2302 Some(source_call_id) => {
2303 self.route_correlated_observation(
2304 source_call_id,
2305 result,
2306 timestamp.clone(),
2307 &mut attached,
2308 );
2309 }
2310 None => {
2311 result.source_call_id = None;
2312 standalone.push(result);
2313 }
2314 }
2315 }
2316 (attached, standalone)
2317 }
2318
2319 fn route_correlated_observation(
2320 &mut self,
2321 source_call_id: String,
2322 result: AtifObservationResult,
2323 timestamp: Option<String>,
2324 attached: &mut Vec<AtifObservationResult>,
2325 ) {
2326 if self.current_step_has_tool_call(&source_call_id) {
2327 attached.push(result);
2328 } else {
2329 self.defer_observation(source_call_id, result, timestamp);
2330 }
2331 }
2332
2333 fn attach_observations_to_current_step(&mut self, attached: Vec<AtifObservationResult>) {
2334 if attached.is_empty() {
2335 return;
2336 }
2337 let Some(step_idx) = self.current_agent.step_idx else {
2338 return;
2339 };
2340 let Some(step) = self.steps.get_mut(step_idx) else {
2341 return;
2342 };
2343 let observation = step.observation.get_or_insert_with(|| AtifObservation {
2344 results: Vec::new(),
2345 });
2346 for result in attached {
2347 merge_observation_result(observation, result);
2348 }
2349 }
2350
2351 fn push_standalone_observation_step(
2352 &mut self,
2353 mut observations: Vec<AtifObservationResult>,
2354 timestamp: Option<String>,
2355 ) {
2356 if observations.is_empty() {
2357 return;
2358 }
2359
2360 for result in &mut observations {
2361 if result.source_call_id.is_some() {
2362 result.source_call_id = None;
2363 }
2364 }
2365
2366 self.steps.push(AtifStep {
2367 step_id: 0,
2368 source: "system".to_string(),
2369 message: empty_message(),
2370 timestamp,
2371 model_name: None,
2372 reasoning_effort: None,
2373 reasoning_content: None,
2374 tool_calls: None,
2375 observation: Some(AtifObservation {
2376 results: observations,
2377 }),
2378 metrics: None,
2379 llm_call_count: None,
2380 is_copied_context: None,
2381 extra: None,
2382 });
2383 }
2384
2385 fn finalize_agent_extra(&mut self) {
2386 self.current_agent.finalize_into(&mut self.steps);
2387 }
2388
2389 fn current_step_has_tool_call(&self, source_call_id: &str) -> bool {
2390 let Some(step_idx) = self.current_agent.step_idx else {
2391 return false;
2392 };
2393 self.steps
2394 .get(step_idx)
2395 .and_then(|step| step.tool_calls.as_deref())
2396 .unwrap_or_default()
2397 .iter()
2398 .any(|tool_call| tool_call.tool_call_id == source_call_id)
2399 }
2400
2401 fn defer_observation(
2402 &mut self,
2403 source_call_id: String,
2404 result: AtifObservationResult,
2405 timestamp: Option<String>,
2406 ) {
2407 self.deferred_observations
2408 .entry(source_call_id)
2409 .or_default()
2410 .push(DeferredToolObservation { result, timestamp });
2411 }
2412
2413 fn attach_deferred_to_current_agent(&mut self) {
2414 let Some(step_idx) = self.current_agent.step_idx else {
2415 return;
2416 };
2417 let tool_call_ids = self.tool_call_ids_for_step(step_idx);
2418
2419 for source_call_id in tool_call_ids {
2420 self.attach_deferred_observations(step_idx, &source_call_id);
2421 self.attach_deferred_tool_metadata(&source_call_id);
2422 }
2423 }
2424
2425 fn tool_call_ids_for_step(&self, step_idx: usize) -> Vec<String> {
2426 self.steps
2427 .get(step_idx)
2428 .and_then(|step| step.tool_calls.as_deref())
2429 .unwrap_or_default()
2430 .iter()
2431 .map(|tool_call| tool_call.tool_call_id.clone())
2432 .collect()
2433 }
2434
2435 fn attach_deferred_observations(&mut self, step_idx: usize, source_call_id: &str) {
2436 let Some(observations) = self.deferred_observations.remove(source_call_id) else {
2437 return;
2438 };
2439 let Some(step) = self.steps.get_mut(step_idx) else {
2440 return;
2441 };
2442 let observation = step.observation.get_or_insert_with(|| AtifObservation {
2443 results: Vec::new(),
2444 });
2445 for deferred in observations {
2446 merge_observation_result(observation, deferred.result);
2447 }
2448 }
2449
2450 fn attach_deferred_tool_metadata(&mut self, source_call_id: &str) {
2451 let Some(metadata) = self.deferred_tool_metadata.remove(source_call_id) else {
2452 return;
2453 };
2454 for (ancestry, invocation) in metadata {
2455 self.current_agent.push_tool_metadata(ancestry, invocation);
2456 }
2457 }
2458
2459 fn flush_deferred_observations_as_standalone(&mut self) {
2460 if self.deferred_observations.is_empty() {
2461 return;
2462 }
2463 let mut deferred = std::mem::take(&mut self.deferred_observations)
2464 .into_values()
2465 .flatten()
2466 .collect::<Vec<_>>();
2467 deferred.sort_by_key(|entry| entry.timestamp.clone());
2468 let timestamp = deferred.iter().find_map(|entry| entry.timestamp.clone());
2469 let mut observations = deferred
2470 .into_iter()
2471 .map(|mut entry| {
2472 entry.result.source_call_id = None;
2473 entry.result
2474 })
2475 .collect::<Vec<_>>();
2476 if observations.is_empty() {
2477 return;
2478 }
2479 self.deferred_tool_metadata.clear();
2480 self.steps.push(AtifStep {
2481 step_id: 0,
2482 source: "system".to_string(),
2483 message: empty_message(),
2484 timestamp,
2485 model_name: None,
2486 reasoning_effort: None,
2487 reasoning_content: None,
2488 tool_calls: None,
2489 observation: Some(AtifObservation {
2490 results: std::mem::take(&mut observations),
2491 }),
2492 metrics: None,
2493 llm_call_count: None,
2494 is_copied_context: None,
2495 extra: None,
2496 });
2497 }
2498
2499 fn handle_llm_start(&mut self, event: &Event, lookups: &EventLookupMaps) {
2500 self.flush_observations();
2501 self.finalize_agent_extra();
2502 self.tool_scope_call_ids.clear();
2503 self.active_tool_call_id = None;
2504
2505 let Some(input) = event.data() else {
2506 return;
2507 };
2508 let content = unwrap_llm_request(input);
2509 self.current_reasoning_effort = extract_reasoning_effort(&content);
2510 let has_paired_end = lookups.llm_end_uuids.contains(&event.uuid());
2511 let Some(message) = llm_start_user_step_message(event, &content, has_paired_end) else {
2512 self.current_agent.stash_llm_request(content);
2513 return;
2514 };
2515 let extra = AtifStepExtra {
2516 ancestry: build_ancestry(event, &lookups.name_map),
2517 invocation: None,
2518 llm_request: Some(content.clone()),
2519 llm_response: None,
2520 event_payload: None,
2521 tool_ancestry: Vec::new(),
2522 tool_invocations: None,
2523 };
2524 self.steps.push(AtifStep {
2525 step_id: 0,
2526 source: "user".to_string(),
2527 message,
2528 timestamp: Some(event.timestamp().to_rfc3339()),
2529 model_name: None,
2530 reasoning_effort: None,
2531 reasoning_content: None,
2532 tool_calls: None,
2533 observation: None,
2534 metrics: None,
2535 llm_call_count: None,
2536 is_copied_context: None,
2537 extra: serde_json::to_value(&extra).ok(),
2538 });
2539 }
2540
2541 fn handle_llm_end(&mut self, event: &Event, lookups: &EventLookupMaps) {
2542 self.flush_observations();
2543
2544 let Some(output) = event.data() else {
2545 return;
2546 };
2547 let tool_calls = extract_tool_calls(output);
2548 let tool_call_order = refresh_tool_call_lookup(&mut self.last_tool_call_map, &tool_calls);
2549 let reasoning_effort = self.current_reasoning_effort.take();
2550 let reasoning_content = extract_reasoning_content(output);
2551 let start_ts = lookups.start_ts_map.get(&event.uuid()).cloned();
2552 let paired_start_model = lookups
2553 .llm_start_model_names
2554 .get(&event.uuid())
2555 .map(String::as_str);
2556 let step_model_name =
2557 effective_response_model_name(event).or_else(|| paired_start_model.map(str::to_owned));
2558 let ancestry = build_ancestry(event, &lookups.name_map);
2559 let invocation = build_invocation_info(
2560 start_ts,
2561 *event.timestamp(),
2562 Some(event.uuid().to_string()),
2563 "nemo_relay",
2564 );
2565
2566 let metrics = merge_metrics(
2567 {
2568 let normalized_response = event.normalized_llm_response();
2569 extract_metrics(
2570 output,
2571 Some(event.name()),
2572 paired_start_model.or_else(|| event.model_name()),
2573 normalized_response.as_deref(),
2574 )
2575 },
2576 lookups.supplemental_llm_metrics.get(&event.uuid()),
2577 );
2578
2579 self.steps.push(AtifStep {
2580 step_id: 0,
2581 source: "agent".to_string(),
2582 message: event
2583 .annotated_response()
2584 .and_then(|response| atif_message_from_annotated_response(response))
2585 .unwrap_or_else(|| extract_llm_response_message(output)),
2586 timestamp: Some(event.timestamp().to_rfc3339()),
2587 model_name: step_model_name,
2588 reasoning_effort,
2589 reasoning_content,
2590 tool_calls,
2591 observation: None,
2592 metrics,
2593 llm_call_count: Some(1),
2594 is_copied_context: None,
2595 extra: None,
2596 });
2597 self.current_agent.set_current_agent(
2598 self.steps.len() - 1,
2599 ancestry,
2600 invocation,
2601 tool_call_order,
2602 output.clone(),
2603 );
2604 self.attach_deferred_to_current_agent();
2605 }
2606
2607 fn handle_tool_start(&mut self, event: &Event, lookups: &EventLookupMaps) {
2608 let Some(source_call_id) = self.source_call_id_for_tool_start(event, lookups) else {
2609 return;
2610 };
2611 self.tool_scope_call_ids
2612 .insert(event.uuid(), source_call_id.clone());
2613 if !self.current_agent.has_active_step() {
2614 return;
2615 }
2616 if !self.ensure_tool_call_on_current_agent(event, &source_call_id) {
2617 return;
2618 }
2619 self.active_tool_call_id = Some(source_call_id);
2620 }
2621
2622 fn source_call_id_for_tool_start(
2623 &self,
2624 event: &Event,
2625 lookups: &EventLookupMaps,
2626 ) -> Option<String> {
2627 event
2628 .tool_call_id()
2629 .map(ToOwned::to_owned)
2630 .or_else(|| self.tool_scope_call_ids.get(&event.uuid()).cloned())
2631 .or_else(|| lookups.tool_call_ids.get(&event.uuid()).cloned())
2632 .or_else(|| self.current_step_tool_call_id_by_name(event.name()))
2633 .or_else(|| self.synthetic_tool_call_id_for_start(event))
2634 }
2635
2636 fn current_step_tool_call_id_by_name(&self, name: &str) -> Option<String> {
2637 let step_idx = self.current_agent.step_idx?;
2638 let matches = self.current_step_tool_call_ids_by_name(step_idx, name);
2639 match matches.as_slice() {
2640 [tool_call_id] => Some(tool_call_id.clone()),
2641 _ => None,
2642 }
2643 }
2644
2645 fn current_step_tool_call_ids_by_name(&self, step_idx: usize, name: &str) -> Vec<String> {
2646 self.steps
2647 .get(step_idx)
2648 .and_then(|step| step.tool_calls.as_deref())
2649 .unwrap_or_default()
2650 .iter()
2651 .filter(|tool_call| tool_call.function_name == name)
2652 .map(|tool_call| tool_call.tool_call_id.clone())
2653 .collect()
2654 }
2655
2656 fn synthetic_tool_call_id_for_start(&self, event: &Event) -> Option<String> {
2657 if self.current_step_has_duplicate_tool_name(event.name()) {
2658 return None;
2659 }
2660 self.current_agent
2661 .has_active_step()
2662 .then(|| event.uuid().to_string())
2663 }
2664
2665 fn current_step_has_duplicate_tool_name(&self, name: &str) -> bool {
2666 let Some(step_idx) = self.current_agent.step_idx else {
2667 return false;
2668 };
2669 self.current_step_tool_call_ids_by_name(step_idx, name)
2670 .len()
2671 > 1
2672 }
2673
2674 fn ensure_tool_call_on_current_agent(&mut self, event: &Event, source_call_id: &str) -> bool {
2675 if self.current_agent.has_tool_call_id(source_call_id) {
2676 self.tool_scope_call_ids
2677 .insert(event.uuid(), source_call_id.to_string());
2678 return true;
2679 }
2680 let Some(step_idx) = self.current_agent.step_idx else {
2681 return false;
2682 };
2683 let Some(step) = self.steps.get_mut(step_idx) else {
2684 return false;
2685 };
2686 let tool_calls = step.tool_calls.get_or_insert_with(Vec::new);
2687 if tool_calls
2688 .iter()
2689 .any(|tool_call| tool_call.tool_call_id == source_call_id)
2690 {
2691 self.current_agent
2692 .push_tool_call_id(source_call_id.to_string());
2693 return true;
2694 }
2695 tool_calls.push(AtifToolCall {
2696 tool_call_id: source_call_id.to_string(),
2697 function_name: event.name().to_string(),
2698 arguments: event
2699 .data()
2700 .cloned()
2701 .unwrap_or_else(|| serde_json::json!({})),
2702 extra: Some(event_extra(event)),
2703 });
2704 self.current_agent
2705 .push_tool_call_id(source_call_id.to_string());
2706 if !event.name().is_empty() {
2707 self.last_tool_call_map
2708 .insert(event.name().to_string(), source_call_id.to_string());
2709 }
2710 true
2711 }
2712
2713 fn resolve_source_call_id(&self, event: &Event, lookups: &EventLookupMaps) -> Option<String> {
2714 if let Some(tool_call_id) = event.tool_call_id() {
2715 return Some(tool_call_id.to_string());
2716 }
2717 if let Some(tool_call_id) = self.tool_scope_call_ids.get(&event.uuid()) {
2718 return Some(tool_call_id.clone());
2719 }
2720 if let Some(tool_call_id) = lookups.tool_call_ids.get(&event.uuid()) {
2721 return Some(tool_call_id.clone());
2722 }
2723
2724 let candidate = self.current_step_tool_call_id_by_name(event.name())?;
2725
2726 if self.current_agent.has_tool_call_id(&candidate)
2727 || self
2728 .last_tool_call_map
2729 .values()
2730 .any(|known_id| known_id == &candidate)
2731 || self.deferred_observations.contains_key(&candidate)
2732 || self.deferred_tool_metadata.contains_key(&candidate)
2733 {
2734 Some(candidate)
2735 } else {
2736 None
2737 }
2738 }
2739
2740 fn handle_tool_end(&mut self, event: &Event, lookups: &EventLookupMaps) {
2741 let source_call_id = self.resolve_source_call_id(event, lookups);
2742 if let Some(output) = event.data() {
2743 if self.pending_obs_timestamp.is_none() {
2744 self.pending_obs_timestamp = Some(event.timestamp().to_rfc3339());
2745 }
2746 self.pending_observations.push(AtifObservationResult {
2747 source_call_id: source_call_id.clone(),
2748 content: observation_content_value(output),
2749 subagent_trajectory_ref: None,
2750 extra: Some(observation_extra(event, output)),
2751 });
2752 }
2753
2754 if self.active_tool_call_id.as_deref() == source_call_id.as_deref() {
2755 self.active_tool_call_id = None;
2756 }
2757
2758 let Some(source_call_id) = source_call_id else {
2759 return;
2760 };
2761 let start_ts = lookups.start_ts_map.get(&event.uuid()).cloned();
2762 let invocation = build_invocation_info(
2763 start_ts,
2764 *event.timestamp(),
2765 Some(source_call_id.clone()),
2766 "nemo_relay",
2767 );
2768 let ancestry = build_ancestry(event, &lookups.name_map);
2769 if self.current_agent.has_active_step()
2770 && self.current_agent.has_tool_call_id(&source_call_id)
2771 {
2772 self.current_agent.push_tool_metadata(ancestry, invocation);
2773 } else {
2774 self.deferred_tool_metadata
2775 .entry(source_call_id)
2776 .or_default()
2777 .push((ancestry, invocation));
2778 }
2779 }
2780
2781 fn resolve_subagent_source_call_id(&self, event: &Event) -> Option<String> {
2782 let candidate = event
2783 .metadata()
2784 .and_then(delegation_tool_call_id)
2785 .or_else(|| event.data().and_then(delegation_tool_call_id))
2786 .or_else(|| self.active_tool_call_id.clone())?;
2787
2788 self.current_agent
2789 .has_tool_call_id(&candidate)
2790 .then_some(candidate)
2791 }
2792
2793 fn subagent_reference_result(
2794 child: &AgentScopeNode,
2795 event: &Event,
2796 source_call_id: Option<String>,
2797 ) -> AtifObservationResult {
2798 AtifObservationResult {
2799 source_call_id,
2800 content: None,
2801 subagent_trajectory_ref: Some(vec![AtifSubagentTrajectoryRef {
2802 trajectory_id: Some(child.uuid.to_string()),
2803 session_id: child.session_id.clone(),
2804 extra: Some(serde_json::json!({
2805 "name": child.name.clone(),
2806 "scope_uuid": child.uuid.to_string(),
2807 })),
2808 }]),
2809 extra: Some(event_extra(event)),
2810 }
2811 }
2812
2813 fn attach_subagent_ref_to_agent_step(
2814 &mut self,
2815 child: &AgentScopeNode,
2816 event: &Event,
2817 source_call_id: &str,
2818 ) -> bool {
2819 let Some(step_idx) = self.current_agent.step_idx else {
2820 return false;
2821 };
2822 let Some(step) = self.steps.get_mut(step_idx) else {
2823 return false;
2824 };
2825 let Some(tool_calls) = step.tool_calls.as_deref() else {
2826 return false;
2827 };
2828 if !tool_calls
2829 .iter()
2830 .any(|tool_call| tool_call.tool_call_id == source_call_id)
2831 {
2832 return false;
2833 }
2834
2835 let observation = step.observation.get_or_insert_with(|| AtifObservation {
2836 results: Vec::new(),
2837 });
2838 if let Some(result) = observation
2839 .results
2840 .iter_mut()
2841 .find(|result| result.source_call_id.as_deref() == Some(source_call_id))
2842 {
2843 let refs = result.subagent_trajectory_ref.get_or_insert_with(Vec::new);
2844 refs.push(AtifSubagentTrajectoryRef {
2845 trajectory_id: Some(child.uuid.to_string()),
2846 session_id: child.session_id.clone(),
2847 extra: Some(serde_json::json!({
2848 "name": child.name.clone(),
2849 "scope_uuid": child.uuid.to_string(),
2850 })),
2851 });
2852 return true;
2853 }
2854
2855 observation.results.push(Self::subagent_reference_result(
2856 child,
2857 event,
2858 Some(source_call_id.to_string()),
2859 ));
2860 true
2861 }
2862
2863 fn handle_subagent_start(&mut self, child: &AgentScopeNode, event: &Event) {
2864 let source_call_id = self.resolve_subagent_source_call_id(event);
2865 self.flush_observations();
2866 if let Some(source_call_id) = source_call_id
2867 && self.attach_subagent_ref_to_agent_step(child, event, &source_call_id)
2868 {
2869 return;
2870 }
2871 self.finalize_agent_extra();
2872
2873 let source_call_id = format!("subagent:{}", child.uuid);
2874 self.steps.push(AtifStep {
2875 step_id: 0,
2876 source: "agent".to_string(),
2877 message: empty_message(),
2878 timestamp: Some(event.timestamp().to_rfc3339()),
2879 model_name: None,
2880 reasoning_effort: None,
2881 reasoning_content: None,
2882 tool_calls: Some(vec![AtifToolCall {
2883 tool_call_id: source_call_id.clone(),
2884 function_name: child.name.clone(),
2885 arguments: subagent_dispatch_arguments(child, event),
2886 extra: Some(event_extra(event)),
2887 }]),
2888 observation: Some(AtifObservation {
2889 results: vec![Self::subagent_reference_result(
2890 child,
2891 event,
2892 Some(source_call_id),
2893 )],
2894 }),
2895 metrics: None,
2896 llm_call_count: Some(0),
2897 is_copied_context: None,
2898 extra: None,
2899 });
2900 }
2901
2902 fn finish(mut self) -> Vec<AtifStep> {
2903 self.flush_observations();
2904 self.flush_deferred_observations_as_standalone();
2905 self.finalize_agent_extra();
2906 remove_projected_tool_call_duplicates(&mut self.steps);
2907 renumber_steps(&mut self.steps);
2908 self.steps
2909 }
2910}
2911
2912fn normalized_response_model_name(event: &Event) -> Option<String> {
2913 event
2914 .normalized_llm_response()
2915 .and_then(|response| response.as_ref().model.clone())
2916}
2917
2918fn effective_model_for_pair(start: &Event, end: &Event) -> Option<String> {
2919 normalized_response_model_name(end)
2920 .or_else(|| manual::model_name_from_manual_llm_output(end.output()).map(ToOwned::to_owned))
2921 .or_else(|| {
2922 start
2923 .model_name()
2924 .or_else(|| end.model_name())
2925 .map(ToOwned::to_owned)
2926 })
2927 .or_else(|| model_name_for_llm_event(start))
2928 .or_else(|| model_name_for_llm_event(end))
2929}
2930
2931fn effective_response_model_name(event: &Event) -> Option<String> {
2932 effective_model_for_pair(event, event)
2933}
2934
2935fn remove_projected_tool_call_duplicates(steps: &mut Vec<AtifStep>) {
2936 let mut observed_later = HashSet::new();
2937 let mut keep = vec![true; steps.len()];
2938
2939 for (idx, step) in steps.iter().enumerate().rev() {
2940 if step.source != "agent" {
2941 observed_later.clear();
2942 continue;
2943 }
2944 if projected_tool_call_duplicate(step, &observed_later) {
2945 keep[idx] = false;
2946 }
2947 extend_observed_tool_call_keys(&mut observed_later, step);
2948 }
2949
2950 let mut idx = 0;
2951 steps.retain(|_| {
2952 let retain_step = keep[idx];
2953 idx += 1;
2954 retain_step
2955 });
2956}
2957
2958#[derive(Debug, Clone, PartialEq, Eq, Hash)]
2959struct ToolCallDedupeKey {
2960 tool_call_id: String,
2961 function_name: String,
2962 arguments: String,
2963}
2964
2965fn projected_tool_call_duplicate(
2966 step: &AtifStep,
2967 observed_later: &HashSet<ToolCallDedupeKey>,
2968) -> bool {
2969 if !projected_tool_call_candidate(step) {
2970 return false;
2971 }
2972 let tool_call_keys = step_tool_call_dedupe_keys(step);
2973 !tool_call_keys.is_empty()
2974 && tool_call_keys
2975 .iter()
2976 .all(|tool_call_key| observed_later.contains(tool_call_key))
2977}
2978
2979fn projected_tool_call_candidate(step: &AtifStep) -> bool {
2980 step.source == "agent"
2981 && step.message == empty_message()
2982 && step.observation.is_none()
2983 && step.reasoning_content.is_none()
2984 && step.reasoning_effort.is_none()
2985 && step.llm_call_count == Some(1)
2986 && step
2987 .tool_calls
2988 .as_ref()
2989 .is_some_and(|tool_calls| !tool_calls.is_empty())
2990}
2991
2992fn extend_observed_tool_call_keys(
2993 observed_later: &mut HashSet<ToolCallDedupeKey>,
2994 step: &AtifStep,
2995) {
2996 for tool_call_key in observed_tool_call_keys(step) {
2997 observed_later.insert(tool_call_key);
2998 }
2999}
3000
3001fn observed_tool_call_keys(step: &AtifStep) -> Vec<ToolCallDedupeKey> {
3002 let step_tool_call_keys = step_tool_call_dedupe_keys(step);
3003 step.observation
3004 .as_ref()
3005 .map(|observation| matching_observation_tool_call_keys(observation, &step_tool_call_keys))
3006 .unwrap_or_default()
3007}
3008
3009fn matching_observation_tool_call_keys(
3010 observation: &AtifObservation,
3011 step_tool_call_keys: &[ToolCallDedupeKey],
3012) -> Vec<ToolCallDedupeKey> {
3013 observation
3014 .results
3015 .iter()
3016 .filter_map(|result| result.source_call_id.as_ref())
3017 .flat_map(|source_call_id| matching_tool_call_keys(source_call_id, step_tool_call_keys))
3018 .collect()
3019}
3020
3021fn matching_tool_call_keys(
3022 source_call_id: &str,
3023 step_tool_call_keys: &[ToolCallDedupeKey],
3024) -> Vec<ToolCallDedupeKey> {
3025 step_tool_call_keys
3026 .iter()
3027 .filter(|tool_call_key| tool_call_key.tool_call_id == source_call_id)
3028 .cloned()
3029 .collect()
3030}
3031
3032fn step_tool_call_dedupe_keys(step: &AtifStep) -> Vec<ToolCallDedupeKey> {
3033 step.tool_calls
3034 .as_deref()
3035 .unwrap_or_default()
3036 .iter()
3037 .filter(|tool_call| !tool_call.tool_call_id.is_empty())
3038 .map(|tool_call| ToolCallDedupeKey {
3039 tool_call_id: tool_call.tool_call_id.clone(),
3040 function_name: tool_call.function_name.clone(),
3041 arguments: json_to_string(&tool_call.arguments),
3042 })
3043 .collect()
3044}
3045
3046#[cfg(test)]
3047fn step_tool_call_ids(step: &AtifStep) -> Vec<String> {
3048 step.tool_calls
3049 .as_deref()
3050 .unwrap_or_default()
3051 .iter()
3052 .map(|tool_call| tool_call.tool_call_id.clone())
3053 .filter(|tool_call_id| !tool_call_id.is_empty())
3054 .collect()
3055}
3056
3057fn merge_observation_result(observation: &mut AtifObservation, mut result: AtifObservationResult) {
3058 if let Some(source_call_id) = result.source_call_id.as_deref()
3059 && let Some(existing) = observation
3060 .results
3061 .iter_mut()
3062 .find(|existing| existing.source_call_id.as_deref() == Some(source_call_id))
3063 {
3064 if existing.content.is_none() {
3065 existing.content = result.content.take();
3066 }
3067 if let Some(mut refs) = result.subagent_trajectory_ref.take() {
3068 existing
3069 .subagent_trajectory_ref
3070 .get_or_insert_with(Vec::new)
3071 .append(&mut refs);
3072 }
3073 if let Some(extra) = result.extra.take() {
3074 merge_observation_extra(&mut existing.extra, extra);
3075 }
3076 return;
3077 }
3078
3079 observation.results.push(result);
3080}
3081
3082fn merge_observation_extra(existing: &mut Option<Json>, incoming: Json) {
3083 let Some(existing_extra) = existing.as_mut() else {
3084 *existing = Some(incoming);
3085 return;
3086 };
3087 let (Json::Object(existing_object), Json::Object(incoming_object)) = (existing_extra, incoming)
3088 else {
3089 return;
3090 };
3091 for (key, value) in incoming_object {
3092 existing_object.entry(key).or_insert(value);
3093 }
3094}
3095
3096fn subagent_dispatch_arguments(child: &AgentScopeNode, event: &Event) -> Json {
3097 let mut arguments = serde_json::Map::new();
3098 arguments.insert("name".to_string(), Json::String(child.name.clone()));
3099 if let Some(session_id) = &child.session_id {
3100 arguments.insert("session_id".to_string(), Json::String(session_id.clone()));
3101 }
3102 if let Some(data) = event.data()
3103 && !data.is_null()
3104 {
3105 arguments.insert("payload".to_string(), data.clone());
3106 }
3107 Json::Object(arguments)
3108}
3109
3110fn prune_subagent_refs(steps: &mut Vec<AtifStep>, child_trajectory_ids: &HashSet<String>) {
3111 for step in steps.iter_mut() {
3112 let Some(observation) = &mut step.observation else {
3113 continue;
3114 };
3115 observation.results.retain_mut(|result| {
3116 if let Some(refs) = &mut result.subagent_trajectory_ref {
3117 refs.retain(|reference| {
3118 reference
3119 .trajectory_id
3120 .as_ref()
3121 .is_some_and(|trajectory_id| child_trajectory_ids.contains(trajectory_id))
3122 });
3123 if refs.is_empty() {
3124 result.subagent_trajectory_ref = None;
3125 }
3126 }
3127 result.content.is_some()
3128 || result.subagent_trajectory_ref.is_some()
3129 || observation_result_has_tool_result_extra(result)
3130 });
3131 if observation.results.is_empty() {
3132 step.observation = None;
3133 }
3134 }
3135 steps.retain(|step| {
3136 !(step.source == "system"
3137 && step.observation.is_none()
3138 && step.message == empty_message()
3139 && step.extra.is_none())
3140 && !(step.source == "agent"
3141 && step.llm_call_count == Some(0)
3142 && step.observation.is_none()
3143 && step.message == empty_message()
3144 && step.extra.is_none())
3145 });
3146 renumber_steps(steps);
3147}
3148
3149fn renumber_steps(steps: &mut [AtifStep]) {
3150 for (index, step) in steps.iter_mut().enumerate() {
3151 step.step_id = index + 1;
3152 }
3153}
3154
3155fn observation_result_has_tool_result_extra(result: &AtifObservationResult) -> bool {
3156 result
3157 .extra
3158 .as_ref()
3159 .and_then(|extra| extra.as_object())
3160 .is_some_and(|extra| extra.contains_key("tool_result"))
3161}
3162
3163fn refresh_tool_call_lookup(
3164 last_tool_call_map: &mut std::collections::HashMap<String, String>,
3165 tool_calls: &Option<Vec<AtifToolCall>>,
3166) -> Vec<String> {
3167 last_tool_call_map.clear();
3168 let mut tool_call_order = Vec::new();
3169 if let Some(tool_calls) = tool_calls {
3170 for tool_call in tool_calls {
3171 if !tool_call.function_name.is_empty() {
3172 last_tool_call_map.insert(
3173 tool_call.function_name.clone(),
3174 tool_call.tool_call_id.clone(),
3175 );
3176 }
3177 tool_call_order.push(tool_call.tool_call_id.clone());
3178 }
3179 }
3180 tool_call_order
3181}
3182
3183#[derive(Debug, Clone)]
3184struct AgentScopeNode {
3185 uuid: Uuid,
3186 name: String,
3187 session_id: Option<String>,
3188 referenced_by_parent: bool,
3189 parent_agent: Option<Uuid>,
3190 children: Vec<Uuid>,
3191 start_timestamp: DateTime<Utc>,
3192}
3193
3194struct AgentScopeTree {
3195 nodes: HashMap<Uuid, AgentScopeNode>,
3196 roots: Vec<Uuid>,
3197 scope_parent_map: HashMap<Uuid, Uuid>,
3198 agent_uuids: HashSet<Uuid>,
3199}
3200
3201type AgentScopeRoles = HashMap<Uuid, Option<String>>;
3202
3203impl AgentScopeTree {
3204 fn from_events(events: &[&Event]) -> Self {
3205 let (scope_parent_map, agent_scope_roles) = agent_scope_maps(events);
3206 let agent_uuids = agent_uuids_from_events(events, &scope_parent_map, &agent_scope_roles);
3207 let mut nodes = agent_scope_nodes(events, &scope_parent_map, &agent_uuids);
3208 let mut roots = link_agent_children(&mut nodes);
3209 sort_agent_tree(&mut roots, &mut nodes);
3210
3211 Self {
3212 nodes,
3213 roots,
3214 scope_parent_map,
3215 agent_uuids,
3216 }
3217 }
3218
3219 fn choose_root(&self, session_id: &str) -> Option<Uuid> {
3220 Uuid::parse_str(session_id)
3221 .ok()
3222 .filter(|uuid| self.nodes.contains_key(uuid))
3223 .or_else(|| (self.roots.len() == 1).then(|| self.roots[0]))
3224 }
3225
3226 fn owner_agent(&self, event: &Event) -> Option<Uuid> {
3227 if event.scope_type() == Some(crate::api::scope::ScopeType::Agent) {
3228 return Some(event.uuid()).filter(|uuid| self.agent_uuids.contains(uuid));
3229 }
3230 nearest_agent_parent(
3231 event.parent_uuid(),
3232 &self.scope_parent_map,
3233 &self.agent_uuids,
3234 None,
3235 )
3236 }
3237
3238 fn direct_child_for_start(&self, parent: Uuid, event: &Event) -> Option<&AgentScopeNode> {
3239 if !is_start_event(event) || event.scope_type() != Some(crate::api::scope::ScopeType::Agent)
3240 {
3241 return None;
3242 }
3243 let child = self.nodes.get(&event.uuid())?;
3244 (child.parent_agent == Some(parent)).then_some(child)
3245 }
3246}
3247
3248fn agent_scope_maps(events: &[&Event]) -> (HashMap<Uuid, Uuid>, AgentScopeRoles) {
3249 let mut scope_parent_map = HashMap::new();
3250 let mut agent_scope_roles = HashMap::new();
3251
3252 for event in events.iter().copied().filter(|event| is_start_event(event)) {
3253 if let Some(parent_uuid) = event.parent_uuid() {
3254 scope_parent_map.insert(event.uuid(), parent_uuid);
3255 }
3256 if event.scope_type() == Some(crate::api::scope::ScopeType::Agent) {
3257 agent_scope_roles.insert(event.uuid(), agent_scope_role(event).map(str::to_string));
3258 }
3259 }
3260 (scope_parent_map, agent_scope_roles)
3261}
3262
3263fn agent_uuids_from_events(
3264 events: &[&Event],
3265 scope_parent_map: &HashMap<Uuid, Uuid>,
3266 agent_scope_roles: &AgentScopeRoles,
3267) -> HashSet<Uuid> {
3268 events
3269 .iter()
3270 .copied()
3271 .filter(|event| should_include_agent_scope(event, scope_parent_map, agent_scope_roles))
3272 .map(Event::uuid)
3273 .collect()
3274}
3275
3276fn should_include_agent_scope(
3277 event: &Event,
3278 scope_parent_map: &HashMap<Uuid, Uuid>,
3279 agent_scope_roles: &AgentScopeRoles,
3280) -> bool {
3281 if !is_start_event(event) || event.scope_type() != Some(crate::api::scope::ScopeType::Agent) {
3282 return false;
3283 }
3284 agent_scope_role(event) != Some("turn")
3285 || nearest_non_turn_agent_parent(
3286 event.parent_uuid(),
3287 scope_parent_map,
3288 agent_scope_roles,
3289 Some(event.uuid()),
3290 )
3291 .is_none()
3292}
3293
3294fn agent_scope_nodes(
3295 events: &[&Event],
3296 scope_parent_map: &HashMap<Uuid, Uuid>,
3297 agent_uuids: &HashSet<Uuid>,
3298) -> HashMap<Uuid, AgentScopeNode> {
3299 events
3300 .iter()
3301 .copied()
3302 .filter(|event| is_included_agent_scope(event, agent_uuids))
3303 .map(|event| {
3304 let uuid = event.uuid();
3305 (
3306 uuid,
3307 AgentScopeNode {
3308 uuid,
3309 name: event.name().to_string(),
3310 session_id: agent_session_id(event),
3311 referenced_by_parent: is_subagent_reference_event(event),
3312 parent_agent: nearest_agent_parent(
3313 event.parent_uuid(),
3314 scope_parent_map,
3315 agent_uuids,
3316 Some(uuid),
3317 ),
3318 children: Vec::new(),
3319 start_timestamp: *event.timestamp(),
3320 },
3321 )
3322 })
3323 .collect()
3324}
3325
3326fn is_included_agent_scope(event: &Event, agent_uuids: &HashSet<Uuid>) -> bool {
3327 is_start_event(event)
3328 && event.scope_type() == Some(crate::api::scope::ScopeType::Agent)
3329 && agent_uuids.contains(&event.uuid())
3330}
3331
3332fn agent_session_id(event: &Event) -> Option<String> {
3333 event
3334 .metadata()
3335 .and_then(|metadata| metadata.get("session_id"))
3336 .and_then(Json::as_str)
3337 .map(ToString::to_string)
3338}
3339
3340fn link_agent_children(nodes: &mut HashMap<Uuid, AgentScopeNode>) -> Vec<Uuid> {
3341 let mut child_links = Vec::new();
3342 let mut roots = Vec::new();
3343 for node in nodes.values() {
3344 if let Some(parent_agent) = node.parent_agent {
3345 child_links.push((parent_agent, node.uuid));
3346 } else {
3347 roots.push(node.uuid);
3348 }
3349 }
3350 for (parent_agent, child) in child_links {
3351 if let Some(parent) = nodes.get_mut(&parent_agent) {
3352 parent.children.push(child);
3353 }
3354 }
3355 roots
3356}
3357
3358fn sort_agent_tree(roots: &mut [Uuid], nodes: &mut HashMap<Uuid, AgentScopeNode>) {
3359 let start_timestamps = nodes
3360 .iter()
3361 .map(|(uuid, node)| (*uuid, node.start_timestamp))
3362 .collect::<HashMap<_, _>>();
3363 roots.sort_by_key(|uuid| start_timestamps.get(uuid).copied());
3364 for node in nodes.values_mut() {
3365 node.children
3366 .sort_by_key(|uuid| start_timestamps.get(uuid).copied());
3367 }
3368}
3369
3370fn agent_scope_role(event: &Event) -> Option<&str> {
3371 event
3372 .metadata()
3373 .and_then(|metadata| metadata.get("nemo_relay_scope_role"))
3374 .and_then(Json::as_str)
3375}
3376
3377fn is_agent_scope_event(event: &Event) -> bool {
3378 event.scope_type() == Some(crate::api::scope::ScopeType::Agent)
3379}
3380
3381fn is_subagent_reference_event(event: &Event) -> bool {
3382 agent_scope_role(event) == Some("subagent")
3383 || event.metadata().and_then(delegation_tool_call_id).is_some()
3384 || event.data().and_then(delegation_tool_call_id).is_some()
3385}
3386
3387fn nearest_agent_parent(
3388 mut current: Option<Uuid>,
3389 scope_parent_map: &HashMap<Uuid, Uuid>,
3390 agent_uuids: &HashSet<Uuid>,
3391 excluded_uuid: Option<Uuid>,
3392) -> Option<Uuid> {
3393 while let Some(uuid) = current {
3394 if Some(uuid) != excluded_uuid && agent_uuids.contains(&uuid) {
3395 return Some(uuid);
3396 }
3397 current = scope_parent_map.get(&uuid).copied();
3398 }
3399 None
3400}
3401
3402fn nearest_non_turn_agent_parent(
3403 mut current: Option<Uuid>,
3404 scope_parent_map: &HashMap<Uuid, Uuid>,
3405 agent_scope_roles: &HashMap<Uuid, Option<String>>,
3406 excluded_uuid: Option<Uuid>,
3407) -> Option<Uuid> {
3408 while let Some(uuid) = current {
3409 if Some(uuid) != excluded_uuid
3410 && let Some(role) = agent_scope_roles.get(&uuid)
3411 && role.as_deref() != Some("turn")
3412 {
3413 return Some(uuid);
3414 }
3415 current = scope_parent_map.get(&uuid).copied();
3416 }
3417 None
3418}
3419
3420fn events_to_trajectory(
3425 session_id: &str,
3426 agent_info: AtifAgentInfo,
3427 events: &[&Event],
3428) -> AtifTrajectory {
3429 let mut sorted: Vec<&Event> = events.to_vec();
3430 sorted.sort_by_key(|event| *event.timestamp());
3431 let tree = AgentScopeTree::from_events(&sorted);
3432
3433 if let Some(root_uuid) = tree.choose_root(session_id)
3434 && can_use_agent_scope_tree(&tree, &sorted)
3435 {
3436 return agent_scope_to_trajectory(&tree, root_uuid, session_id, &agent_info, &sorted, true);
3437 }
3438
3439 let steps = events_to_steps(&sorted);
3440 trajectory_from_parts(
3441 session_id.to_string(),
3442 Some(session_id.to_string()),
3443 agent_info,
3444 steps,
3445 None,
3446 )
3447}
3448
3449fn can_use_agent_scope_tree(tree: &AgentScopeTree, events: &[&Event]) -> bool {
3450 events.iter().all(|event| {
3451 is_agent_scope_event(event) || !is_step_event(event) || tree.owner_agent(event).is_some()
3452 })
3453}
3454
3455fn is_step_event(event: &Event) -> bool {
3456 matches!(
3457 (
3458 event.kind(),
3459 event.scope_category(),
3460 event.category().map(|category| category.as_str()),
3461 ),
3462 (
3463 "scope",
3464 Some(crate::api::event::ScopeCategory::Start),
3465 Some("llm")
3466 ) | (
3467 "scope",
3468 Some(crate::api::event::ScopeCategory::End),
3469 Some("llm")
3470 ) | (
3471 "scope",
3472 Some(crate::api::event::ScopeCategory::End),
3473 Some("tool")
3474 )
3475 )
3476}
3477
3478fn agent_scope_to_trajectory(
3479 tree: &AgentScopeTree,
3480 agent_uuid: Uuid,
3481 session_id: &str,
3482 agent_info: &AtifAgentInfo,
3483 sorted_events: &[&Event],
3484 is_root: bool,
3485) -> AtifTrajectory {
3486 let mut steps = events_to_steps_for_agent(sorted_events, tree, agent_uuid);
3487 let subagent_trajectories = tree
3488 .nodes
3489 .get(&agent_uuid)
3490 .map(|node| {
3491 node.children
3492 .iter()
3493 .map(|child_uuid| {
3494 agent_scope_to_trajectory(
3495 tree,
3496 *child_uuid,
3497 session_id,
3498 agent_info,
3499 sorted_events,
3500 false,
3501 )
3502 })
3503 .filter(|trajectory| {
3504 !trajectory.steps.is_empty()
3505 || trajectory
3506 .trajectory_id
3507 .as_deref()
3508 .and_then(|trajectory_id| Uuid::parse_str(trajectory_id).ok())
3509 .and_then(|uuid| tree.nodes.get(&uuid))
3510 .is_some_and(|child| !child.referenced_by_parent)
3511 })
3512 .collect::<Vec<_>>()
3513 })
3514 .filter(|children| !children.is_empty());
3515 let child_trajectory_ids = subagent_trajectories
3516 .as_deref()
3517 .unwrap_or_default()
3518 .iter()
3519 .filter_map(|trajectory| trajectory.trajectory_id.clone())
3520 .collect::<HashSet<_>>();
3521 prune_subagent_refs(&mut steps, &child_trajectory_ids);
3522 let trajectory_id = if is_root {
3523 session_id.to_string()
3524 } else {
3525 agent_uuid.to_string()
3526 };
3527 let trajectory_session_id = if is_root {
3528 session_id.to_string()
3529 } else {
3530 tree.nodes
3531 .get(&agent_uuid)
3532 .and_then(|node| node.session_id.clone())
3533 .unwrap_or_else(|| session_id.to_string())
3534 };
3535
3536 trajectory_from_parts(
3537 trajectory_session_id,
3538 Some(trajectory_id),
3539 agent_info.clone(),
3540 steps,
3541 subagent_trajectories,
3542 )
3543}
3544
3545fn trajectory_from_parts(
3546 session_id: String,
3547 trajectory_id: Option<String>,
3548 agent: AtifAgentInfo,
3549 steps: Vec<AtifStep>,
3550 subagent_trajectories: Option<Vec<AtifTrajectory>>,
3551) -> AtifTrajectory {
3552 let final_metrics = compute_final_metrics(&steps);
3553
3554 AtifTrajectory {
3555 schema_version: ATIF_SCHEMA_VERSION.to_string(),
3556 session_id,
3557 trajectory_id,
3558 agent,
3559 steps,
3560 notes: None,
3561 final_metrics,
3562 continued_trajectory_ref: None,
3563 subagent_trajectories,
3564 extra: None,
3565 }
3566}
3567
3568fn events_to_steps_for_agent(
3569 events: &[&Event],
3570 tree: &AgentScopeTree,
3571 agent_uuid: Uuid,
3572) -> Vec<AtifStep> {
3573 let lookups = EventLookupMaps::from_events_for_agent(events, tree, agent_uuid);
3574 let mut state = StepConversionState::default();
3575
3576 for event in events {
3577 if let Some(child) = tree.direct_child_for_start(agent_uuid, event) {
3578 state.handle_subagent_start(child, event);
3579 continue;
3580 }
3581
3582 if tree.owner_agent(event) != Some(agent_uuid) {
3583 continue;
3584 }
3585
3586 state.handle_event(event, &lookups);
3587 }
3588
3589 state.finish()
3590}
3591
3592fn events_to_steps(events: &[&Event]) -> Vec<AtifStep> {
3612 let mut sorted: Vec<&Event> = events.to_vec();
3613 sorted.sort_by_key(|e| *e.timestamp());
3614 let lookups = EventLookupMaps::from_events(&sorted);
3615 let mut state = StepConversionState::default();
3616
3617 for event in &sorted {
3618 state.handle_event(event, &lookups);
3619 }
3620
3621 state.finish()
3622}
3623
3624fn is_start_event(event: &Event) -> bool {
3625 event.scope_category() == Some(crate::api::event::ScopeCategory::Start)
3626}
3627
3628#[cfg(test)]
3633#[path = "../../tests/unit/atif_tests.rs"]
3634mod tests;