1use std::collections::{BTreeMap, HashMap, HashSet};
7use std::io::Write;
8use std::path::PathBuf;
9
10use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13
14use crate::mooncake::RollingHashIdMapper;
15use crate::request_trace::load::{
16 LoadedAgentTrace, RequestEntry, RequestTraceFinishReasonMetadata, ToolEntry,
17 load_request_trace_records,
18};
19
20pub const SATF_FORMAT: &str = "satf";
21pub const SATF_VERSION: &str = "2.0.0";
22pub const SATF_TIME_BASE: &str = "us";
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct SatfTrace {
26 pub format: String,
27 pub format_version: String,
28 pub time_base: String,
29 pub block_size_tokens: usize,
30 pub sessions: Vec<SatfSession>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub producer: Option<SatfProducer>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub extensions: Option<Value>,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct SatfProducer {
39 pub name: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
41 pub version: Option<String>,
42 #[serde(skip_serializing_if = "Option::is_none")]
43 pub commit: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct SatfSession {
48 pub session_id: String,
49 pub nodes: Vec<SatfNode>,
50 pub edges: Vec<SatfEdge>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub extensions: Option<Value>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
56#[serde(tag = "kind", rename_all = "snake_case")]
57pub enum SatfNode {
58 LlmInfer {
59 id: String,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 duration_us: Option<u64>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 min_start_delay_us: Option<u64>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 performance_metrics: Option<SatfPerformanceMetrics>,
66 payload: SatfLlmInferPayload,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 extensions: Option<Value>,
69 },
70 ToolCall {
71 id: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 min_start_delay_us: Option<u64>,
74 payload: SatfToolCallPayload,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 extensions: Option<Value>,
77 },
78 ToolResult {
79 id: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
81 duration_us: Option<u64>,
82 payload: SatfToolResultPayload,
83 #[serde(skip_serializing_if = "Option::is_none")]
84 extensions: Option<Value>,
85 },
86}
87
88impl SatfNode {
89 pub fn id(&self) -> &str {
90 match self {
91 Self::LlmInfer { id, .. } | Self::ToolCall { id, .. } | Self::ToolResult { id, .. } => {
92 id
93 }
94 }
95 }
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
99pub struct SatfEdge {
100 pub from: String,
101 pub to: String,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub delay_after_predecessor_us: Option<u64>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
107pub struct SatfLlmInferPayload {
108 pub input_tokens_total: u64,
109 pub output_tokens: u64,
110 pub kv_block_ids: Vec<u64>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 pub request_parameters: Option<SatfRequestParameters>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
116pub struct SatfRequestParameters {
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub model: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122pub struct SatfToolCallPayload {
123 pub tool_name: String,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub tool_call_id: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
129pub struct SatfToolResultPayload {
130 pub tool_call_id: String,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub is_error: Option<bool>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct SatfPerformanceMetrics {
137 pub source: String,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub time_to_first_token_us: Option<f64>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub inter_token_latency_us: Option<f64>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub queue_delay_us: Option<f64>,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub kv_cache_hit_rate: Option<f64>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub kv_transfer_latency_us: Option<f64>,
148}
149
150pub fn convert_request_trace_to_satf(paths: &[PathBuf]) -> Result<SatfTrace> {
151 convert_loaded_trace(load_request_trace_records(paths)?)
152}
153
154pub fn write_satf<W: Write>(writer: W, trace: &SatfTrace, pretty: bool) -> Result<()> {
155 if pretty {
156 serde_json::to_writer_pretty(writer, trace).context("serializing pretty SATF JSON")?;
157 } else {
158 serde_json::to_writer(writer, trace).context("serializing SATF JSON")?;
159 }
160 Ok(())
161}
162
163fn convert_loaded_trace(mut loaded: LoadedAgentTrace) -> Result<SatfTrace> {
164 loaded.requests.sort_by(|left, right| {
165 (left.start_ms, left.end_ms, &left.request.request_id).cmp(&(
166 right.start_ms,
167 right.end_ms,
168 &right.request.request_id,
169 ))
170 });
171
172 let block_size_tokens = loaded.requests[0].replay.trace_block_size;
173 if loaded
174 .requests
175 .iter()
176 .any(|request| request.replay.trace_block_size != block_size_tokens)
177 {
178 bail!("SATF conversion requires one trace block size per artifact");
179 }
180
181 let artifact_start_unix_us = loaded
182 .requests
183 .iter()
184 .map(request_start_us)
185 .min()
186 .expect("request-trace loading requires at least one request");
187
188 let mut mapper = RollingHashIdMapper::new(block_size_tokens);
189 for request in &mut loaded.requests {
190 request.replay.input_sequence_hashes =
191 mapper.ids_for_sequence_hashes(&request.replay.input_sequence_hashes);
192 }
193
194 let mut requests_by_session: BTreeMap<String, Vec<RequestEntry>> = BTreeMap::new();
195 for request in loaded.requests {
196 requests_by_session
197 .entry(session_id_for(&request))
198 .or_default()
199 .push(request);
200 }
201
202 let mut tools_by_session: HashMap<String, Vec<ToolEntry>> = HashMap::new();
203 for tool in loaded.tools {
204 tools_by_session
205 .entry(tool.session_id.clone())
206 .or_default()
207 .push(tool);
208 }
209
210 let mut sessions = requests_by_session
211 .into_iter()
212 .map(|(session_id, requests)| {
213 let tools = tools_by_session.remove(&session_id).unwrap_or_default();
214 convert_session(session_id, requests, tools, artifact_start_unix_us)
215 })
216 .collect::<Result<Vec<_>>>()?;
217 sessions.sort_by(|left, right| {
218 session_first_start_us(left)
219 .cmp(&session_first_start_us(right))
220 .then_with(|| left.session_id.cmp(&right.session_id))
221 });
222
223 Ok(SatfTrace {
224 format: SATF_FORMAT.to_string(),
225 format_version: SATF_VERSION.to_string(),
226 time_base: SATF_TIME_BASE.to_string(),
227 block_size_tokens,
228 sessions,
229 producer: Some(SatfProducer {
230 name: "dynamo-request-trace-converter".to_string(),
231 version: Some(env!("CARGO_PKG_VERSION").to_string()),
232 commit: None,
233 }),
234 extensions: Some(vendor_extension(Value::Object(Map::from_iter([(
235 "artifact_start_unix_us".to_string(),
236 artifact_start_unix_us.into(),
237 )])))),
238 })
239}
240
241fn convert_session(
242 session_id: String,
243 mut requests: Vec<RequestEntry>,
244 mut tools: Vec<ToolEntry>,
245 artifact_start_unix_us: u64,
246) -> Result<SatfSession> {
247 requests.sort_by(|left, right| {
248 (
249 request_start_us(left),
250 request_end_us(left),
251 &left.request.request_id,
252 )
253 .cmp(&(
254 request_start_us(right),
255 request_end_us(right),
256 &right.request.request_id,
257 ))
258 });
259 tools.sort_by_key(|tool| (tool_start_us(tool), tool_end_us(tool)));
260
261 let extensions = session_extensions(&requests[0]);
262 let mut nodes = requests
263 .iter()
264 .enumerate()
265 .map(|(index, request)| llm_node(request, (index == 0).then_some(artifact_start_unix_us)))
266 .collect::<Vec<_>>();
267 let mut edges = Vec::new();
268 let request_index = requests
269 .iter()
270 .enumerate()
271 .map(|(index, request)| (request.request.request_id.as_str(), index))
272 .collect::<HashMap<_, _>>();
273 let mut tool_paths = HashSet::new();
274
275 for (ordinal, tool) in tools.iter().enumerate() {
276 let source_index = tool
277 .claude
278 .as_ref()
279 .and_then(|metadata| {
280 request_index
281 .get(metadata.source_request_id.as_str())
282 .copied()
283 })
284 .or_else(|| find_tool_source(&requests, tool));
285 let consumer_index = tool
286 .claude
287 .as_ref()
288 .and_then(|metadata| metadata.consumer_request_id.as_deref())
289 .and_then(|request_id| request_index.get(request_id).copied())
290 .or_else(|| find_tool_consumer(&requests, tool, source_index));
291
292 let call_node_id = format!("tool_call:{ordinal}");
293 let result_node_id = format!("tool_result:{ordinal}");
294 let source_request = source_index.map(|index| &requests[index]);
295 let tool_name = source_request
296 .and_then(|request| tool_name_from_finish_metadata(request, &tool.tool_call_id))
297 .unwrap_or_else(|| tool.tool_class.clone());
298
299 nodes.push(SatfNode::ToolCall {
300 id: call_node_id.clone(),
301 min_start_delay_us: source_index
302 .is_none()
303 .then(|| tool_start_us(tool).saturating_sub(artifact_start_unix_us)),
304 payload: SatfToolCallPayload {
305 tool_name,
306 tool_call_id: Some(tool.tool_call_id.clone()),
307 },
308 extensions: None,
309 });
310 nodes.push(SatfNode::ToolResult {
311 id: result_node_id.clone(),
312 duration_us: Some(tool_duration_us(tool)),
313 payload: SatfToolResultPayload {
314 tool_call_id: tool.tool_call_id.clone(),
315 is_error: Some(tool_is_error(tool)),
316 },
317 extensions: Some(tool_extensions(tool)),
318 });
319
320 if let Some(source_index) = source_index {
321 edges.push(edge(
322 llm_node_id(&requests[source_index]),
323 call_node_id.clone(),
324 tool_start_us(tool).saturating_sub(request_end_us(&requests[source_index])),
325 ));
326 }
327 edges.push(edge(call_node_id, result_node_id.clone(), 0));
328 if let Some(consumer_index) = consumer_index {
329 edges.push(edge(
330 result_node_id,
331 llm_node_id(&requests[consumer_index]),
332 request_start_us(&requests[consumer_index]).saturating_sub(tool_end_us(tool)),
333 ));
334 if let Some(source_index) = source_index {
335 tool_paths.insert((source_index, consumer_index));
336 }
337 }
338 }
339
340 for current_index in 1..requests.len() {
341 let previous_index = current_index - 1;
342 if tool_paths.contains(&(previous_index, current_index)) {
343 continue;
344 }
345 edges.push(edge(
346 llm_node_id(&requests[previous_index]),
347 llm_node_id(&requests[current_index]),
348 request_start_us(&requests[current_index])
349 .saturating_sub(request_end_us(&requests[previous_index])),
350 ));
351 }
352
353 Ok(SatfSession {
354 session_id,
355 nodes,
356 edges,
357 extensions,
358 })
359}
360
361fn llm_node(request: &RequestEntry, artifact_start_unix_us: Option<u64>) -> SatfNode {
362 let metrics = SatfPerformanceMetrics {
363 source: "dynamo".to_string(),
364 time_to_first_token_us: request.request.ttft_ms.map(|value| value * 1_000.0),
365 inter_token_latency_us: request.request.avg_itl_ms.map(|value| value * 1_000.0),
366 queue_delay_us: request
367 .request
368 .prefill_wait_time_ms
369 .map(|value| value * 1_000.0),
370 kv_cache_hit_rate: request.request.kv_hit_rate,
371 kv_transfer_latency_us: request
372 .request
373 .kv_transfer_estimated_latency_ms
374 .map(|value| value * 1_000.0),
375 };
376 let metrics_present = metrics.time_to_first_token_us.is_some()
377 || metrics.inter_token_latency_us.is_some()
378 || metrics.queue_delay_us.is_some()
379 || metrics.kv_cache_hit_rate.is_some()
380 || metrics.kv_transfer_latency_us.is_some();
381
382 SatfNode::LlmInfer {
383 id: llm_node_id(request),
384 duration_us: Some(request_duration_us(request)),
385 min_start_delay_us: artifact_start_unix_us
386 .map(|start| request_start_us(request).saturating_sub(start)),
387 performance_metrics: metrics_present.then_some(metrics),
388 payload: SatfLlmInferPayload {
389 input_tokens_total: request.replay.input_length as u64,
390 output_tokens: request.request.output_tokens.unwrap_or_default(),
391 kv_block_ids: request.replay.input_sequence_hashes.clone(),
392 request_parameters: request
393 .request
394 .model
395 .as_ref()
396 .map(|model| SatfRequestParameters {
397 model: Some(model.clone()),
398 }),
399 },
400 extensions: Some(request_extensions(request)),
401 }
402}
403
404fn request_extensions(request: &RequestEntry) -> Value {
405 let mut fields = Map::from_iter([(
406 "request_id".to_string(),
407 request.request.request_id.clone().into(),
408 )]);
409 insert_optional(
410 &mut fields,
411 "x_request_id",
412 request.request.x_request_id.clone(),
413 );
414 insert_optional(&mut fields, "input_tokens", request.request.input_tokens);
415 insert_optional(&mut fields, "cached_tokens", request.request.cached_tokens);
416 insert_optional(
417 &mut fields,
418 "prefill_time_ms",
419 request.request.prefill_time_ms,
420 );
421 insert_optional(&mut fields, "queue_depth", request.request.queue_depth);
422 insert_optional(&mut fields, "worker", request.request.worker.clone());
423 insert_optional(
424 &mut fields,
425 "finish_reason_metadata",
426 request.request.finish_reason_metadata.clone(),
427 );
428 vendor_extension(Value::Object(fields))
429}
430
431fn tool_extensions(tool: &ToolEntry) -> Value {
432 let mut fields = Map::from_iter([
433 ("status".to_string(), tool.status.clone().into()),
434 ("tool_class".to_string(), tool.tool_class.clone().into()),
435 ]);
436 insert_optional(&mut fields, "output_bytes", tool.output_bytes);
437 insert_optional(&mut fields, "output_tokens", tool.output_tokens);
438 insert_optional(&mut fields, "error_type", tool.error_type.clone());
439 vendor_extension(Value::Object(fields))
440}
441
442fn session_extensions(request: &RequestEntry) -> Option<Value> {
443 let context = request.agent_context.as_ref()?;
444 let mut fields = Map::new();
445 insert_optional(
446 &mut fields,
447 "parent_session_id",
448 context.parent_session_id.clone(),
449 );
450 (!fields.is_empty()).then(|| vendor_extension(Value::Object(fields)))
451}
452
453fn vendor_extension(value: Value) -> Value {
454 Value::Object(Map::from_iter([("nvidia.dynamo".to_string(), value)]))
455}
456
457fn insert_optional<T: Serialize>(fields: &mut Map<String, Value>, name: &str, value: Option<T>) {
458 if let Some(value) = value {
459 fields.insert(
460 name.to_string(),
461 serde_json::to_value(value).expect("serializing trace metadata cannot fail"),
462 );
463 }
464}
465
466fn session_id_for(request: &RequestEntry) -> String {
467 request
468 .agent_context
469 .as_ref()
470 .map(|context| context.session_id.clone())
471 .unwrap_or_else(|| request.request.request_id.clone())
472}
473
474fn llm_node_id(request: &RequestEntry) -> String {
475 format!("llm:{}", request.request.request_id)
476}
477
478fn edge(from: String, to: String, delay_us: u64) -> SatfEdge {
479 SatfEdge {
480 from,
481 to,
482 delay_after_predecessor_us: (delay_us > 0).then_some(delay_us),
483 }
484}
485
486fn request_start_us(request: &RequestEntry) -> u64 {
487 request
488 .request
489 .request_received_ms
490 .map(|value| value.saturating_mul(1_000))
491 .unwrap_or_else(|| nonnegative_us(request.start_ms))
492}
493
494fn request_duration_us(request: &RequestEntry) -> u64 {
495 request
496 .request
497 .total_time_ms
498 .map(ms_to_us)
499 .unwrap_or_else(|| nonnegative_us(request.end_ms.saturating_sub(request.start_ms)))
500}
501
502fn request_end_us(request: &RequestEntry) -> u64 {
503 request_start_us(request).saturating_add(request_duration_us(request))
504}
505
506fn tool_duration_us(tool: &ToolEntry) -> u64 {
507 ms_to_us(tool.duration_ms)
508}
509
510fn tool_end_us(tool: &ToolEntry) -> u64 {
511 nonnegative_us(tool.end_ms)
512}
513
514fn tool_start_us(tool: &ToolEntry) -> u64 {
515 nonnegative_us(tool.start_ms)
516}
517
518fn nonnegative_us(value_ms: i64) -> u64 {
519 u64::try_from(value_ms)
520 .unwrap_or_default()
521 .saturating_mul(1_000)
522}
523
524fn ms_to_us(value: f64) -> u64 {
525 let value = (value.max(0.0) * 1_000.0).round();
526 if value >= u64::MAX as f64 {
527 u64::MAX
528 } else {
529 value as u64
530 }
531}
532
533fn find_tool_source(requests: &[RequestEntry], tool: &ToolEntry) -> Option<usize> {
534 let tool_start_us = tool_start_us(tool);
535 requests
536 .iter()
537 .enumerate()
538 .rev()
539 .find(|(_, request)| request_end_us(request) <= tool_start_us)
540 .or_else(|| {
541 requests
542 .iter()
543 .enumerate()
544 .rev()
545 .find(|(_, request)| request_start_us(request) <= tool_start_us)
546 })
547 .map(|(index, _)| index)
548}
549
550fn find_tool_consumer(
551 requests: &[RequestEntry],
552 tool: &ToolEntry,
553 source_index: Option<usize>,
554) -> Option<usize> {
555 if tool
556 .claude
557 .as_ref()
558 .is_some_and(|metadata| metadata.execution_mode == "background")
559 {
560 return None;
561 }
562 requests
563 .iter()
564 .enumerate()
565 .skip(source_index.map_or(0, |index| index + 1))
566 .find(|(_, request)| request_start_us(request) >= tool_end_us(tool))
567 .map(|(index, _)| index)
568}
569
570fn tool_name_from_finish_metadata(request: &RequestEntry, tool_call_id: &str) -> Option<String> {
571 request.request.finish_reason_metadata.as_ref().and_then(
572 |metadata: &RequestTraceFinishReasonMetadata| {
573 metadata
574 .tool_calls
575 .iter()
576 .find(|call| call.id.as_deref() == Some(tool_call_id))
577 .and_then(|call| call.name.clone())
578 },
579 )
580}
581
582fn tool_is_error(tool: &ToolEntry) -> bool {
583 tool.error_type.is_some()
584 || matches!(
585 tool.status.as_str(),
586 "error" | "failed" | "cancelled" | "canceled"
587 )
588}
589
590fn session_first_start_us(session: &SatfSession) -> u64 {
591 session
592 .nodes
593 .iter()
594 .find_map(|node| match node {
595 SatfNode::LlmInfer {
596 min_start_delay_us, ..
597 } => *min_start_delay_us,
598 _ => None,
599 })
600 .unwrap_or_default()
601}
602
603#[cfg(test)]
604mod tests {
605 use std::io::Write as _;
606
607 use tempfile::NamedTempFile;
608
609 use super::*;
610
611 #[test]
612 fn request_trace_sessions_share_kv_ids() {
613 let mut file = NamedTempFile::new().unwrap();
614 for (request_id, session_id, timestamp) in
615 [("req-a", "task-a", 1_000), ("req-b", "task-b", 1_100)]
616 {
617 writeln!(
618 file,
619 "{}",
620 serde_json::json!({
621 "schema": "dynamo.request.trace.v1",
622 "event_type": "request_end",
623 "event_time_unix_ms": timestamp + 100,
624 "agent_context": {"session_id": session_id},
625 "request": {
626 "request_id": request_id,
627 "request_received_ms": timestamp,
628 "output_tokens": 4,
629 "replay": {
630 "trace_block_size": 2,
631 "input_length": 3,
632 "input_sequence_hashes": [11, 22],
633 }
634 }
635 })
636 )
637 .unwrap();
638 }
639
640 let trace = convert_request_trace_to_satf(&[file.path().to_path_buf()]).unwrap();
641
642 assert_eq!(trace.sessions.len(), 2);
643 assert_eq!(trace.sessions[0].session_id, "task-a");
644 assert_eq!(trace.sessions[1].session_id, "task-b");
645 let first_ids = llm_payload(&trace.sessions[0].nodes[0])
646 .kv_block_ids
647 .clone();
648 let second_ids = llm_payload(&trace.sessions[1].nodes[0])
649 .kv_block_ids
650 .clone();
651 assert_eq!(first_ids, second_ids);
652 assert_eq!(first_ids, vec![0, 1]);
653 }
654
655 #[test]
656 fn request_trace_tool_span_becomes_call_and_result_path() {
657 let mut file = NamedTempFile::new().unwrap();
658 writeln!(
659 file,
660 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1100,"agent_context":{{"session_id":"root"}},"request":{{"request_id":"req-1","model":"test","request_received_ms":1000,"total_time_ms":100.0,"output_tokens":4,"replay":{{"trace_block_size":2,"input_length":3,"input_sequence_hashes":[11,22]}},"finish_reason_metadata":{{"finish_reason":"tool_calls","tool_calls":[{{"choice_index":0,"tool_call_index":0,"id":"call-1","name":"search"}}]}}}}}}"#
661 )
662 .unwrap();
663 writeln!(
664 file,
665 r#"{{"schema":"dynamo.request.trace.v1","event_type":"tool_end","event_time_unix_ms":1200,"agent_context":{{"session_id":"root"}},"tool":{{"tool_call_id":"call-1","tool_class":"fallback","started_at_unix_ms":1110,"ended_at_unix_ms":1200,"status":"succeeded","duration_ms":50.0}}}}"#
666 )
667 .unwrap();
668 writeln!(
669 file,
670 r#"{{"schema":"dynamo.request.trace.v1","event_type":"request_end","event_time_unix_ms":1400,"agent_context":{{"session_id":"root"}},"request":{{"request_id":"req-2","request_received_ms":1300,"total_time_ms":100.0,"output_tokens":2,"replay":{{"trace_block_size":2,"input_length":4,"input_sequence_hashes":[11,33]}}}}}}"#
671 )
672 .unwrap();
673
674 let trace = convert_request_trace_to_satf(&[file.path().to_path_buf()]).unwrap();
675 let session = &trace.sessions[0];
676
677 assert_eq!(session.nodes.len(), 4);
678 assert_eq!(session.edges.len(), 3);
679 assert!(session.edges.iter().any(|edge| {
680 edge.from == "llm:req-1"
681 && edge.to == "tool_call:0"
682 && edge.delay_after_predecessor_us == Some(10_000)
683 }));
684 assert!(session.edges.iter().any(|edge| {
685 edge.from == "tool_result:0"
686 && edge.to == "llm:req-2"
687 && edge.delay_after_predecessor_us == Some(100_000)
688 }));
689 let call = session
690 .nodes
691 .iter()
692 .find(|node| node.id() == "tool_call:0")
693 .unwrap();
694 assert!(matches!(
695 call,
696 SatfNode::ToolCall {
697 payload: SatfToolCallPayload { tool_name, .. },
698 ..
699 } if tool_name == "search"
700 ));
701
702 let encoded = serde_json::to_string(&trace).unwrap();
703 let decoded: SatfTrace = serde_json::from_str(&encoded).unwrap();
704 assert_eq!(decoded, trace);
705 }
706
707 fn llm_payload(node: &SatfNode) -> &SatfLlmInferPayload {
708 match node {
709 SatfNode::LlmInfer { payload, .. } => payload,
710 other => panic!("expected llm_infer node, got {other:?}"),
711 }
712 }
713}