Skip to main content

nemo_relay/observability/
atif.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent Trajectory Interchange Format (ATIF) exporter.
5//!
6//! This module provides types and an exporter that collects lifecycle events
7//! from the NeMo Relay runtime and converts them into ATIF trajectories conforming
8//! to the ATIF v1.7 schema.
9//!
10//! # Overview
11//!
12//! The [`AtifExporter`] registers as an event subscriber, collects all events,
13//! and can export them as an [`AtifTrajectory`] via [`AtifExporter::export`].
14//!
15//! # Event-to-Step Mapping
16//!
17//! The core conversion from NeMo Relay events to ATIF steps follows these rules:
18//!
19//! | NeMo Relay Event     | ATIF Step               | Notes                                |
20//! |-----------------|-------------------------|--------------------------------------|
21//! | LLM Start       | `user` step             | Fresh user turns only; same-turn continuations stay on the agent step |
22//! | LLM End         | `agent` step            | Response content, tool_calls promoted|
23//! | Tool Start      | *(skipped)*             | tool_calls come from LLM End instead |
24//! | Tool End        | agent observation         | Correlated by `source_call_id`       |
25//! | Mark            | *(skipped)*             | Point-in-time telemetry is not a step|
26//! | Scope Start/End | *(skipped)*             | Structural events, not trajectory    |
27//!
28//! The exporter serializes the full collected event stream into a single ATIF
29//! trajectory.
30
31use 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
48/// The ATIF schema version string embedded in all exported trajectories.
49///
50/// Currently `"ATIF-v1.7"`. This constant is used by [`AtifTrajectory`]
51/// serialization and verified by downstream consumers to ensure compatibility.
52pub const ATIF_SCHEMA_VERSION: &str = "ATIF-v1.7";
53
54// ---------------------------------------------------------------------------
55// ATIF types
56// ---------------------------------------------------------------------------
57
58/// Information about the agent that produced the trajectory.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct AtifAgentInfo {
61    /// Human-readable agent name.
62    pub name: String,
63    /// Agent version string.
64    pub version: String,
65    /// Default LLM model name used by the agent.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub model_name: Option<String>,
68    /// Tool definitions available to the agent.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub tool_definitions: Option<Vec<Json>>,
71    /// Extra metadata.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub extra: Option<Json>,
74}
75
76/// A single step in an ATIF trajectory.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct AtifStep {
79    /// 1-based ordinal step ID.
80    pub step_id: usize,
81    /// Source of the step: `"system"`, `"user"`, or `"agent"`.
82    pub source: String,
83    /// The message content (string or array of content parts).
84    pub message: Json,
85    /// ISO 8601 timestamp.
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub timestamp: Option<String>,
88    /// LLM model name, if applicable.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub model_name: Option<String>,
91    /// Qualitative or quantitative measure of reasoning effort.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub reasoning_effort: Option<Json>,
94    /// The agent's explicit internal reasoning.
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub reasoning_content: Option<String>,
97    /// Tool calls made by the agent in this step.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub tool_calls: Option<Vec<AtifToolCall>>,
100    /// Observation (tool results) for this step.
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub observation: Option<AtifObservation>,
103    /// Token usage and cost metrics.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub metrics: Option<AtifMetrics>,
106    /// Number of LLM calls represented by this step.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub llm_call_count: Option<u64>,
109    /// Whether this step was copied from a previous trajectory for context.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub is_copied_context: Option<bool>,
112    /// Extra metadata.
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub extra: Option<Json>,
115}
116
117/// Token usage and cost metrics for a single step.
118#[derive(Debug, Clone, Default, Serialize, Deserialize)]
119pub struct AtifMetrics {
120    /// Number of prompt tokens.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub prompt_tokens: Option<u64>,
123    /// Number of completion tokens.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub completion_tokens: Option<u64>,
126    /// Number of cached tokens.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub cached_tokens: Option<u64>,
129    /// Cost in USD.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub cost_usd: Option<f64>,
132    /// Token IDs for prompt (input) tokens.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub prompt_token_ids: Option<Vec<u64>>,
135    /// Token IDs for completion (response) tokens.
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub completion_token_ids: Option<Vec<u64>>,
138    /// Log probability assigned to each generated token.
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub logprobs: Option<Vec<f64>>,
141    /// Other metrics (e.g. reasoning_tokens, cache_creation_input_tokens).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub extra: Option<Json>,
144}
145
146/// Aggregate statistics for the entire trajectory (ATIF final_metrics).
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148pub struct AtifFinalMetrics {
149    /// Sum of all prompt tokens across all steps, including cached tokens.
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub total_prompt_tokens: Option<u64>,
152    /// Sum of all completion tokens across all steps.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub total_completion_tokens: Option<u64>,
155    /// Sum of all cached tokens across all steps.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub total_cached_tokens: Option<u64>,
158    /// Total real monetary cost for the entire trajectory.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub total_cost_usd: Option<f64>,
161    /// Total number of steps. If not equivalent to steps.len(), document in notes.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub total_steps: Option<u64>,
164    /// Custom aggregate metrics.
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub extra: Option<Json>,
167}
168
169/// A tool call made by the agent.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct AtifToolCall {
172    /// Correlation ID linking this call to its observation result.
173    pub tool_call_id: String,
174    /// Name of the tool/function called.
175    pub function_name: String,
176    /// Arguments passed to the tool.
177    pub arguments: Json,
178    /// Provider or host-specific metadata for this tool call.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub extra: Option<Json>,
181}
182
183/// Observation results from tool execution.
184#[derive(Debug, Clone, Serialize, Deserialize)]
185pub struct AtifObservation {
186    /// List of observation results (one per tool call).
187    pub results: Vec<AtifObservationResult>,
188}
189
190/// A single observation result from a tool call.
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct AtifObservationResult {
193    /// Correlation ID linking to the originating tool call.
194    #[serde(skip_serializing_if = "Option::is_none")]
195    pub source_call_id: Option<String>,
196    /// The tool's output content.
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub content: Option<Json>,
199    /// References to delegated subagent trajectories.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub subagent_trajectory_ref: Option<Vec<AtifSubagentTrajectoryRef>>,
202    /// Provider or host-specific metadata for this observation.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub extra: Option<Json>,
205}
206
207/// Reference to a delegated subagent trajectory.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct AtifSubagentTrajectoryRef {
210    /// Embedded trajectory identifier, resolved against `subagent_trajectories`.
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub trajectory_id: Option<String>,
213    /// Run identity for debug/search/display correlation.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub session_id: Option<String>,
216    /// Extra metadata about the subagent execution.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub extra: Option<Json>,
219}
220
221/// Lineage node identifying a callable within an ATIF step.
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct AtifAncestry {
224    /// Unique identifier for the callable node (scope UUID).
225    pub function_id: String,
226    /// Human-readable name of the callable node.
227    pub function_name: String,
228    /// Optional parent callable identifier.
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub parent_id: Option<String>,
231    /// Optional parent callable name.
232    #[serde(skip_serializing_if = "Option::is_none")]
233    pub parent_name: Option<String>,
234}
235
236/// Invocation timing and correlation metadata for one execution occurrence.
237///
238/// `start_timestamp` and `end_timestamp` are always emitted together or not
239/// at all.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AtifInvocationInfo {
242    /// Invocation start timestamp in Unix epoch seconds.
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub start_timestamp: Option<f64>,
245    /// Invocation end timestamp in Unix epoch seconds.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub end_timestamp: Option<f64>,
248    /// Stable invocation identifier for correlation.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    pub invocation_id: Option<String>,
251    /// Terminal status of the invocation.
252    #[serde(skip_serializing_if = "Option::is_none")]
253    pub status: Option<String>,
254    /// Runtime or framework label.
255    #[serde(skip_serializing_if = "Option::is_none")]
256    pub framework: Option<String>,
257}
258
259/// Lineage payload serialized into ATIF `Step.extra`.
260///
261/// `tool_ancestry[i]` and `tool_invocations[i]` align by index with
262/// `Step.tool_calls[i]`.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct AtifStepExtra {
265    /// Step-level callable lineage.
266    pub ancestry: AtifAncestry,
267    /// Step-level invocation timing.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub invocation: Option<AtifInvocationInfo>,
270    /// Full unwrapped LLM request payload for request-level fidelity.
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub llm_request: Option<Json>,
273    /// Full raw LLM response payload for response-level fidelity.
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub llm_response: Option<Json>,
276    /// Legacy event payload field retained for source compatibility.
277    ///
278    /// The ATIF exporter does not translate point-in-time mark events into
279    /// trajectory steps, so exporter-produced steps leave this field unset.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub event_payload: Option<Json>,
282    /// Per-tool callable lineage, aligned with `tool_calls`.
283    #[serde(default, skip_serializing_if = "Vec::is_empty")]
284    pub tool_ancestry: Vec<AtifAncestry>,
285    /// Per-tool invocation timing, aligned with `tool_calls`.
286    #[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/// A complete ATIF trajectory.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct AtifTrajectory {
299    /// Schema version (e.g., `"ATIF-v1.7"`).
300    pub schema_version: String,
301    /// Unique session identifier.
302    pub session_id: String,
303    /// Canonical per-trajectory-document identifier.
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub trajectory_id: Option<String>,
306    /// Information about the agent.
307    pub agent: AtifAgentInfo,
308    /// Ordered list of trajectory steps.
309    pub steps: Vec<AtifStep>,
310    /// Custom information, design notes, or explanations.
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub notes: Option<String>,
313    /// Aggregate metrics for the entire trajectory.
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub final_metrics: Option<AtifFinalMetrics>,
316    /// Reference to the continuation trajectory file if continued elsewhere.
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub continued_trajectory_ref: Option<String>,
319    /// Embedded subagent trajectories.
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub subagent_trajectories: Option<Vec<AtifTrajectory>>,
322    /// Extra metadata.
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub extra: Option<Json>,
325}
326
327// ---------------------------------------------------------------------------
328// AtifExporter
329// ---------------------------------------------------------------------------
330
331struct AtifExporterState {
332    session_id: String,
333    agent_info: AtifAgentInfo,
334    events: Vec<Event>,
335}
336
337/// Collects lifecycle events and exports them as ATIF trajectories.
338///
339/// Register this exporter as an event subscriber via [`AtifExporter::subscriber`],
340/// then call [`AtifExporter::export`] to produce an [`AtifTrajectory`].
341#[derive(Clone)]
342pub struct AtifExporter {
343    state: Arc<Mutex<AtifExporterState>>,
344}
345
346impl AtifExporter {
347    /// Create a new exporter with the given session metadata.
348    ///
349    /// # Parameters
350    /// - `session_id`: Stable identifier for the trajectory being collected.
351    /// - `agent_info`: Metadata describing the emitting agent.
352    ///
353    /// # Returns
354    /// A new [`AtifExporter`] with an empty in-memory event buffer.
355    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    /// Return an event subscriber function that records NeMo Relay events.
366    ///
367    /// The returned callback can be registered with
368    /// [`register_subscriber`](crate::api::subscriber::register_subscriber).
369    ///
370    /// # Returns
371    /// An [`EventSubscriberFn`] that appends compatible lifecycle events to
372    /// this exporter's internal buffer. Point-in-time marks are ignored.
373    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    /// Export the collected event history as an [`AtifTrajectory`].
386    ///
387    /// # Returns
388    /// An [`AtifTrajectory`] synthesized from the events observed so far.
389    ///
390    /// # Errors
391    /// Returns an error if queued subscriber delivery cannot be flushed before
392    /// the trajectory is cloned.
393    ///
394    /// # Notes
395    /// Exporting does not clear the buffered events. Call [`AtifExporter::clear`]
396    /// when you need to reset the exporter between trajectories.
397    pub fn export(&self) -> Result<AtifTrajectory> {
398        self.try_export()
399    }
400
401    /// Try to export the collected event history as an [`AtifTrajectory`].
402    ///
403    /// This is equivalent to [`AtifExporter::export`] and is retained for
404    /// callers that prefer an explicitly fallible method name.
405    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    /// Clear all collected events from the internal buffer.
424    ///
425    /// # Returns
426    /// `()`.
427    pub fn clear(&self) {
428        let mut state = self.state.lock().unwrap();
429        state.events.clear();
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Safe JSON extraction helpers
435// ---------------------------------------------------------------------------
436
437/// If `input` looks like an `LlmRequest` envelope (`{"content": ..., "headers": ...}`),
438/// return the inner `content` value. Otherwise return the input unchanged.
439///
440/// This avoids leaking the NeMo Relay transport wrapper into the trajectory.
441fn 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
451/// Extract the user-facing message content from a raw LLM response.
452///
453/// Looks for provider response content fields that can be represented as an
454/// ATIF agent message.
455/// Tool-call-only responses use an empty string message and keep the full
456/// response under `Step.extra.llm_response`.
457fn 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
679/// Known keys in token_usage that we extract to dedicated fields.
680const 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
711/// Try to extract `AtifMetrics` from a `token_usage` object in the LLM response.
712///
713/// Supports NeMo Relay `token_usage` and provider-native `usage` payloads.
714/// Populates `extra` with any unknown usage keys (e.g. reasoning_tokens or total_tokens).
715/// Returns `None` if the response has no recognized token or cost metrics.
716fn 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
909/// Extract `reasoning_effort` from an LLM request (string or number).
910///
911/// The request content may have `reasoning_effort` (e.g. `"high"`, `"medium"`,
912/// or a numeric value). Returns the value as Json for flexibility.
913fn 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
923/// Extract `reasoning` (reasoning_content) from an LLM response output.
924///
925/// The agent's explicit internal reasoning may appear in the response under the
926/// `"reasoning"` key. Returns `None` if absent or not a string.
927fn 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
936/// Extract the latest user-facing message from an LLM request payload.
937///
938/// LLM start inputs typically contain `{ "messages": [...], "model": "...",
939/// "max_tokens": ..., "tools": [...], "stream": ... }`. For ATIF we emit a
940/// schema-compatible message value (string or content-part array) and preserve
941/// the full LLM request in `Step.extra.llm_request`, either on the user step or
942/// on the matching agent step for same-turn continuations.
943///
944/// Returns the latest user message content if present, a prompt if present, or
945/// a stringified representation of the input as a last resort.
946fn 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
1145/// Try to promote `tool_calls` from the raw LLM response into `AtifToolCall` entries.
1146///
1147/// Expected shape per OpenAI convention:
1148/// ```json
1149/// "tool_calls": [{ "id": "...", "type": "function", "function": { "name": "...", "arguments": "..." } }]
1150/// ```
1151///
1152/// String `arguments` are parsed into JSON for consistency with NeMo Relay tool events
1153/// which always provide parsed arguments.
1154///
1155/// Returns `None` if there are no tool calls or the structure is unrecognized.
1156fn 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        // Skip entries with no id and no name — they are not meaningful.
1192        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
1205// Annotation adapters: read the normalized message from an annotation, returning
1206// None for multimodal content so the caller falls back to the raw extractor.
1207
1208fn 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        // Multimodal: defer to the raw extractor (returns None to fall back).
1285        Some(MessageContent::Parts(_)) => None,
1286        // No assistant text (tool-call-only, or reasoning/thinking-only): emit an
1287        // empty message rather than the raw extractor's stringified payload. Tool
1288        // calls and metrics are still recovered from the raw output downstream.
1289        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
1411/// Compute aggregate `final_metrics` by summing metrics across all steps.
1412///
1413/// Always returns `Some(AtifFinalMetrics)` with `total_steps` set. Each token
1414/// or cost total is populated only when at least one step provides that field.
1415fn 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
1505// ---------------------------------------------------------------------------
1506// AtifStepExtra helpers
1507// ---------------------------------------------------------------------------
1508
1509/// Build an [`AtifAncestry`] from a NeMo Relay [`Event`].
1510///
1511/// `name_map` is a pre-pass uuid → name lookup used to resolve `parent_name`.
1512fn 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
1524/// Build an [`AtifInvocationInfo`] from start/end timestamps.
1525///
1526/// If `start_ts` is `None`, both timestamps are omitted to preserve the
1527/// requirement that they are always emitted together or not at all.
1528fn 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
3420// ---------------------------------------------------------------------------
3421// Event-to-step mapping
3422// ---------------------------------------------------------------------------
3423
3424fn 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
3592/// Converts a slice of events into ATIF steps.
3593///
3594/// Mapping logic:
3595/// 1. Sort events by timestamp.
3596/// 2. For each LLM pair:
3597///    - Start event → user step when the request begins a fresh user turn
3598///    - Start events that continue the same turn after tool work stash
3599///      `llm_request` on the matching agent step instead of repeating the user
3600///      message
3601///    - End event → agent step (message = extracted content, metrics from
3602///      token_usage, tool_calls promoted to AtifToolCall entries with parsed
3603///      JSON arguments)
3604/// 3. For Tool events:
3605///    - Start events are **skipped** (tool_calls come from LLM End promotion)
3606///    - Consecutive End events are attached to the matching agent step
3607///      step with multiple results
3608/// 4. Tool End observation results are correlated with the preceding LLM End's
3609///    promoted tool_calls by function name → `source_call_id`.
3610/// 5. Mark and other Scope Start/End events → skipped.
3611fn 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// ---------------------------------------------------------------------------
3629// Tests
3630// ---------------------------------------------------------------------------
3631
3632#[cfg(test)]
3633#[path = "../../tests/unit/atif_tests.rs"]
3634mod tests;