1use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9
10use super::event::{EventType, SessionEvent};
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentEdgeType {
16 ParentChild,
18 Handoff,
20 Collaboration,
22 Return,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct AgentNode {
29 pub agent_id: String,
30 pub agent_instance_id: String,
31 pub agent_name: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub agent_role: Option<String>,
34 pub host_id: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub started_at: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub completed_at: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub status: Option<String>,
41 #[serde(default)]
42 pub depth: u32,
43 #[serde(default)]
45 pub tool_calls: u32,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub model: Option<String>,
49 #[serde(default)]
51 pub tokens_in: u64,
52 #[serde(default)]
54 pub tokens_out: u64,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub provider: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct AgentEdge {
63 pub from_instance_id: String,
64 pub to_instance_id: String,
65 pub edge_type: AgentEdgeType,
66 pub timestamp: String,
67 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub artifacts: Vec<String>,
69}
70
71#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct AgentGraph {
74 pub nodes: Vec<AgentNode>,
75 pub edges: Vec<AgentEdge>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub invalid_parent_cycles: Vec<Vec<String>>,
81}
82
83impl AgentGraph {
84 pub fn from_events(events: &[SessionEvent]) -> Self {
86 let mut nodes_map: BTreeMap<String, AgentNode> = BTreeMap::new();
87 let mut edges: Vec<AgentEdge> = Vec::new();
88 let mut parent_map: BTreeMap<String, String> = BTreeMap::new(); for event in events {
91 let instance_id = &event.agent_instance_id;
92
93 let node = nodes_map
95 .entry(instance_id.clone())
96 .or_insert_with(|| AgentNode {
97 agent_id: event.agent_id.clone(),
98 agent_instance_id: instance_id.clone(),
99 agent_name: event.agent_name.clone(),
100 agent_role: event.agent_role.clone(),
101 host_id: event.host_id.clone(),
102 started_at: None,
103 completed_at: None,
104 status: None,
105 depth: 0,
106 tool_calls: 0,
107 model: None,
108 tokens_in: 0,
109 tokens_out: 0,
110 provider: None,
111 });
112
113 match &event.event_type {
114 EventType::AgentStarted {
115 parent_agent_instance_id,
116 } => {
117 node.started_at = Some(event.timestamp.clone());
118 if let Some(parent_id) = parent_agent_instance_id {
119 parent_map.insert(instance_id.clone(), parent_id.clone());
120 }
121 }
122
123 EventType::AgentSpawned {
124 spawned_by_agent_instance_id,
125 ..
126 } => {
127 node.started_at = Some(event.timestamp.clone());
128 parent_map.insert(instance_id.clone(), spawned_by_agent_instance_id.clone());
129 edges.push(AgentEdge {
130 from_instance_id: spawned_by_agent_instance_id.clone(),
131 to_instance_id: instance_id.clone(),
132 edge_type: AgentEdgeType::ParentChild,
133 timestamp: event.timestamp.clone(),
134 artifacts: Vec::new(),
135 });
136 }
137
138 EventType::AgentHandoff {
139 from_agent_instance_id,
140 to_agent_instance_id,
141 artifacts,
142 } => {
143 edges.push(AgentEdge {
144 from_instance_id: from_agent_instance_id.clone(),
145 to_instance_id: to_agent_instance_id.clone(),
146 edge_type: AgentEdgeType::Handoff,
147 timestamp: event.timestamp.clone(),
148 artifacts: artifacts.clone(),
149 });
150 nodes_map
152 .entry(to_agent_instance_id.clone())
153 .or_insert_with(|| AgentNode {
154 agent_id: String::new(),
155 agent_instance_id: to_agent_instance_id.clone(),
156 agent_name: String::new(),
157 agent_role: None,
158 host_id: event.host_id.clone(),
159 started_at: None,
160 completed_at: None,
161 status: None,
162 depth: 0,
163 tool_calls: 0,
164 model: None,
165 tokens_in: 0,
166 tokens_out: 0,
167 provider: None,
168 });
169 }
170
171 EventType::AgentCollaborated {
172 collaborator_agent_instance_ids,
173 } => {
174 for collab_id in collaborator_agent_instance_ids {
175 edges.push(AgentEdge {
176 from_instance_id: instance_id.clone(),
177 to_instance_id: collab_id.clone(),
178 edge_type: AgentEdgeType::Collaboration,
179 timestamp: event.timestamp.clone(),
180 artifacts: Vec::new(),
181 });
182 }
183 }
184
185 EventType::AgentReturned {
186 returned_to_agent_instance_id,
187 } => {
188 edges.push(AgentEdge {
189 from_instance_id: instance_id.clone(),
190 to_instance_id: returned_to_agent_instance_id.clone(),
191 edge_type: AgentEdgeType::Return,
192 timestamp: event.timestamp.clone(),
193 artifacts: Vec::new(),
194 });
195 }
196
197 EventType::AgentCompleted { .. } => {
198 node.completed_at = Some(event.timestamp.clone());
199 node.status = Some("completed".into());
200 }
201
202 EventType::AgentFailed { .. } => {
203 node.completed_at = Some(event.timestamp.clone());
204 node.status = Some("failed".into());
205 }
206
207 EventType::AgentCalledTool { .. } => {
225 node.tool_calls += 1;
226 }
227
228 EventType::AgentCompletedProcess { .. } => {
229 node.tool_calls += 1;
230 }
231
232 EventType::AgentReadFile { .. } => {
233 node.tool_calls += 1;
234 }
235
236 EventType::AgentWroteFile { .. } => {
237 node.tool_calls += 1;
238 }
239
240 EventType::AgentConnectedNetwork { .. } => {
241 node.tool_calls += 1;
242 }
243
244 EventType::AgentOpenedPort { .. } => {
245 node.tool_calls += 1;
246 }
247
248 EventType::AgentDecision {
249 ref model,
250 tokens_in,
251 tokens_out,
252 ref provider,
253 ..
254 } => {
255 if let Some(ref m) = model {
256 node.model = Some(m.clone());
257 }
258 if let Some(ref p) = provider {
259 node.provider = Some(p.clone());
260 }
261 if let Some(t) = tokens_in {
262 node.tokens_in += t;
263 }
264 if let Some(t) = tokens_out {
265 node.tokens_out += t;
266 }
267 }
268
269 _ => {}
270 }
271 }
272
273 let mut depth_cache: BTreeMap<String, u32> = BTreeMap::new();
277 let mut invalid_parent_cycles: BTreeSet<Vec<String>> = BTreeSet::new();
278 let instances: Vec<String> = nodes_map.keys().cloned().collect();
279 for inst in &instances {
280 match compute_depth(inst, &parent_map, &mut depth_cache) {
281 Ok(depth) => {
282 if let Some(node) = nodes_map.get_mut(inst) {
283 node.depth = depth;
284 }
285 }
286 Err(mut cycle) => {
287 cycle.sort();
290 cycle.dedup();
291 invalid_parent_cycles.insert(cycle);
292 }
293 }
294 }
295
296 let nodes: Vec<AgentNode> = nodes_map.into_values().collect();
297
298 AgentGraph {
299 nodes,
300 edges,
301 invalid_parent_cycles: invalid_parent_cycles.into_iter().collect(),
302 }
303 }
304
305 pub fn max_depth(&self) -> u32 {
307 self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
308 }
309
310 pub fn host_ids(&self) -> BTreeSet<String> {
312 self.nodes.iter().map(|n| n.host_id.clone()).collect()
313 }
314
315 pub fn handoff_count(&self) -> u32 {
317 self.edges
318 .iter()
319 .filter(|e| e.edge_type == AgentEdgeType::Handoff)
320 .count() as u32
321 }
322
323 pub fn spawn_count(&self) -> u32 {
325 self.edges
326 .iter()
327 .filter(|e| e.edge_type == AgentEdgeType::ParentChild)
328 .count() as u32
329 }
330}
331
332fn compute_depth(
333 instance_id: &str,
334 parent_map: &BTreeMap<String, String>,
335 cache: &mut BTreeMap<String, u32>,
336) -> Result<u32, Vec<String>> {
337 let mut path = Vec::new();
338 let mut path_positions: BTreeMap<String, usize> = BTreeMap::new();
339 let mut current = instance_id.to_string();
340
341 let mut depth = loop {
342 if let Some(&cached) = cache.get(¤t) {
343 break cached;
344 }
345 if let Some(&cycle_start) = path_positions.get(¤t) {
346 return Err(path[cycle_start..].to_vec());
347 }
348
349 path_positions.insert(current.clone(), path.len());
350 path.push(current.clone());
351
352 match parent_map.get(¤t) {
353 Some(parent) => current = parent.clone(),
354 None => {
355 let root = path
358 .pop()
359 .expect("depth path contains the current root; please report a bug");
360 cache.insert(root, 0);
361 break 0;
362 }
363 }
364 };
365
366 for child in path.into_iter().rev() {
367 depth = depth
368 .checked_add(1)
369 .expect("agent graph depth exceeds u32; please report a bug");
370 cache.insert(child, depth);
371 }
372
373 Ok(*cache
374 .get(instance_id)
375 .expect("depth walk caches the requested instance; please report a bug"))
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381 use crate::session::event::*;
382
383 fn evt(instance_id: &str, host: &str, event_type: EventType) -> SessionEvent {
384 SessionEvent {
385 session_id: "ssn_001".into(),
386 event_id: generate_event_id(),
387 timestamp: "2026-04-05T08:00:00Z".into(),
388 sequence_no: 0,
389 trace_id: "trace_1".into(),
390 span_id: generate_span_id(),
391 parent_span_id: None,
392 agent_id: format!("agent://{instance_id}"),
393 agent_instance_id: instance_id.into(),
394 agent_name: instance_id.into(),
395 agent_role: None,
396 host_id: host.into(),
397 tool_runtime_id: None,
398 event_type,
399 artifact_ref: None,
400 meta: None,
401 }
402 }
403
404 #[test]
405 fn builds_graph_from_spawn_and_handoff() {
406 let events = vec![
407 evt(
408 "root",
409 "host_a",
410 EventType::AgentStarted {
411 parent_agent_instance_id: None,
412 },
413 ),
414 evt(
415 "child1",
416 "host_a",
417 EventType::AgentSpawned {
418 spawned_by_agent_instance_id: "root".into(),
419 reason: Some("review code".into()),
420 },
421 ),
422 evt(
423 "child2",
424 "host_b",
425 EventType::AgentSpawned {
426 spawned_by_agent_instance_id: "root".into(),
427 reason: None,
428 },
429 ),
430 evt(
431 "root",
432 "host_a",
433 EventType::AgentHandoff {
434 from_agent_instance_id: "root".into(),
435 to_agent_instance_id: "child1".into(),
436 artifacts: vec!["art_001".into()],
437 },
438 ),
439 evt(
440 "child1",
441 "host_a",
442 EventType::AgentCompleted {
443 termination_reason: None,
444 },
445 ),
446 ];
447
448 let graph = AgentGraph::from_events(&events);
449 assert_eq!(graph.nodes.len(), 3);
450 assert_eq!(graph.max_depth(), 1);
451 assert_eq!(graph.handoff_count(), 1);
452 assert_eq!(graph.spawn_count(), 2);
453 assert_eq!(graph.host_ids().len(), 2);
454 }
455
456 #[test]
457 fn parent_cycles_are_reported_without_recursing() {
458 let self_parent = AgentGraph::from_events(&[evt(
459 "a",
460 "h",
461 EventType::AgentStarted {
462 parent_agent_instance_id: Some("a".into()),
463 },
464 )]);
465 assert_eq!(self_parent.invalid_parent_cycles, vec![vec!["a"]]);
466 assert_eq!(self_parent.nodes[0].depth, 0);
467
468 let two_node_cycle = AgentGraph::from_events(&[
469 evt(
470 "a",
471 "h",
472 EventType::AgentStarted {
473 parent_agent_instance_id: Some("b".into()),
474 },
475 ),
476 evt(
477 "b",
478 "h",
479 EventType::AgentStarted {
480 parent_agent_instance_id: Some("a".into()),
481 },
482 ),
483 ]);
484 assert_eq!(
485 two_node_cycle.invalid_parent_cycles,
486 vec![vec!["a".to_string(), "b".to_string()]]
487 );
488 assert!(two_node_cycle.nodes.iter().all(|node| node.depth == 0));
489 }
490
491 #[test]
492 fn deep_parent_chains_do_not_use_the_call_stack() {
493 let mut parent_map = BTreeMap::new();
494 for i in 1..20_000 {
495 parent_map.insert(format!("n{i}"), format!("n{}", i - 1));
496 }
497
498 let depth = compute_depth("n19999", &parent_map, &mut BTreeMap::new())
499 .expect("an acyclic parent chain has a depth");
500 assert_eq!(depth, 19_999);
501 }
502
503 #[test]
504 fn nested_depth() {
505 let events = vec![
506 evt(
507 "root",
508 "h",
509 EventType::AgentStarted {
510 parent_agent_instance_id: None,
511 },
512 ),
513 evt(
514 "l1",
515 "h",
516 EventType::AgentSpawned {
517 spawned_by_agent_instance_id: "root".into(),
518 reason: None,
519 },
520 ),
521 evt(
522 "l2",
523 "h",
524 EventType::AgentSpawned {
525 spawned_by_agent_instance_id: "l1".into(),
526 reason: None,
527 },
528 ),
529 evt(
530 "l3",
531 "h",
532 EventType::AgentSpawned {
533 spawned_by_agent_instance_id: "l2".into(),
534 reason: None,
535 },
536 ),
537 ];
538
539 let graph = AgentGraph::from_events(&events);
540 assert_eq!(graph.max_depth(), 3);
541 let l3 = graph
542 .nodes
543 .iter()
544 .find(|n| n.agent_instance_id == "l3")
545 .unwrap();
546 assert_eq!(l3.depth, 3);
547 }
548
549 #[test]
559 fn tool_calls_counts_every_action_event_type() {
560 let events = vec![
561 evt(
562 "a",
563 "h",
564 EventType::AgentStarted {
565 parent_agent_instance_id: None,
566 },
567 ),
568 evt(
569 "a",
570 "h",
571 EventType::AgentCalledTool {
572 tool_name: "Glob".into(),
573 tool_input_digest: None,
574 tool_output_digest: None,
575 duration_ms: None,
576 },
577 ),
578 evt(
579 "a",
580 "h",
581 EventType::AgentReadFile {
582 file_path: "src/foo.rs".into(),
583 digest: None,
584 },
585 ),
586 evt(
587 "a",
588 "h",
589 EventType::AgentWroteFile {
590 file_path: "src/bar.rs".into(),
591 digest: None,
592 operation: None,
593 additions: None,
594 deletions: None,
595 },
596 ),
597 evt(
598 "a",
599 "h",
600 EventType::AgentCompletedProcess {
601 process_name: "npm test".into(),
602 exit_code: Some(0),
603 duration_ms: Some(2_500),
604 command: None,
605 },
606 ),
607 evt(
608 "a",
609 "h",
610 EventType::AgentConnectedNetwork {
611 destination: "api.github.com".into(),
612 port: None,
613 },
614 ),
615 evt(
616 "a",
617 "h",
618 EventType::AgentOpenedPort {
619 port: 3000,
620 protocol: Some("tcp".into()),
621 },
622 ),
623 ];
624
625 let graph = AgentGraph::from_events(&events);
626 let agent_a = graph
627 .nodes
628 .iter()
629 .find(|n| n.agent_instance_id == "a")
630 .unwrap();
631
632 assert_eq!(
635 agent_a.tool_calls, 6,
636 "tool_calls must count all action event types (was {}, expected 6)",
637 agent_a.tool_calls
638 );
639 }
640}