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}
77
78impl AgentGraph {
79 pub fn from_events(events: &[SessionEvent]) -> Self {
81 let mut nodes_map: BTreeMap<String, AgentNode> = BTreeMap::new();
82 let mut edges: Vec<AgentEdge> = Vec::new();
83 let mut parent_map: BTreeMap<String, String> = BTreeMap::new(); for event in events {
86 let instance_id = &event.agent_instance_id;
87
88 let node = nodes_map
90 .entry(instance_id.clone())
91 .or_insert_with(|| AgentNode {
92 agent_id: event.agent_id.clone(),
93 agent_instance_id: instance_id.clone(),
94 agent_name: event.agent_name.clone(),
95 agent_role: event.agent_role.clone(),
96 host_id: event.host_id.clone(),
97 started_at: None,
98 completed_at: None,
99 status: None,
100 depth: 0,
101 tool_calls: 0,
102 model: None,
103 tokens_in: 0,
104 tokens_out: 0,
105 provider: None,
106 });
107
108 match &event.event_type {
109 EventType::AgentStarted {
110 parent_agent_instance_id,
111 } => {
112 node.started_at = Some(event.timestamp.clone());
113 if let Some(parent_id) = parent_agent_instance_id {
114 parent_map.insert(instance_id.clone(), parent_id.clone());
115 }
116 }
117
118 EventType::AgentSpawned {
119 spawned_by_agent_instance_id,
120 ..
121 } => {
122 node.started_at = Some(event.timestamp.clone());
123 parent_map.insert(instance_id.clone(), spawned_by_agent_instance_id.clone());
124 edges.push(AgentEdge {
125 from_instance_id: spawned_by_agent_instance_id.clone(),
126 to_instance_id: instance_id.clone(),
127 edge_type: AgentEdgeType::ParentChild,
128 timestamp: event.timestamp.clone(),
129 artifacts: Vec::new(),
130 });
131 }
132
133 EventType::AgentHandoff {
134 from_agent_instance_id,
135 to_agent_instance_id,
136 artifacts,
137 } => {
138 edges.push(AgentEdge {
139 from_instance_id: from_agent_instance_id.clone(),
140 to_instance_id: to_agent_instance_id.clone(),
141 edge_type: AgentEdgeType::Handoff,
142 timestamp: event.timestamp.clone(),
143 artifacts: artifacts.clone(),
144 });
145 nodes_map
147 .entry(to_agent_instance_id.clone())
148 .or_insert_with(|| AgentNode {
149 agent_id: String::new(),
150 agent_instance_id: to_agent_instance_id.clone(),
151 agent_name: String::new(),
152 agent_role: None,
153 host_id: event.host_id.clone(),
154 started_at: None,
155 completed_at: None,
156 status: None,
157 depth: 0,
158 tool_calls: 0,
159 model: None,
160 tokens_in: 0,
161 tokens_out: 0,
162 provider: None,
163 });
164 }
165
166 EventType::AgentCollaborated {
167 collaborator_agent_instance_ids,
168 } => {
169 for collab_id in collaborator_agent_instance_ids {
170 edges.push(AgentEdge {
171 from_instance_id: instance_id.clone(),
172 to_instance_id: collab_id.clone(),
173 edge_type: AgentEdgeType::Collaboration,
174 timestamp: event.timestamp.clone(),
175 artifacts: Vec::new(),
176 });
177 }
178 }
179
180 EventType::AgentReturned {
181 returned_to_agent_instance_id,
182 } => {
183 edges.push(AgentEdge {
184 from_instance_id: instance_id.clone(),
185 to_instance_id: returned_to_agent_instance_id.clone(),
186 edge_type: AgentEdgeType::Return,
187 timestamp: event.timestamp.clone(),
188 artifacts: Vec::new(),
189 });
190 }
191
192 EventType::AgentCompleted { .. } => {
193 node.completed_at = Some(event.timestamp.clone());
194 node.status = Some("completed".into());
195 }
196
197 EventType::AgentFailed { .. } => {
198 node.completed_at = Some(event.timestamp.clone());
199 node.status = Some("failed".into());
200 }
201
202 EventType::AgentCalledTool { .. } => {
220 node.tool_calls += 1;
221 }
222
223 EventType::AgentCompletedProcess { .. } => {
224 node.tool_calls += 1;
225 }
226
227 EventType::AgentReadFile { .. } => {
228 node.tool_calls += 1;
229 }
230
231 EventType::AgentWroteFile { .. } => {
232 node.tool_calls += 1;
233 }
234
235 EventType::AgentConnectedNetwork { .. } => {
236 node.tool_calls += 1;
237 }
238
239 EventType::AgentOpenedPort { .. } => {
240 node.tool_calls += 1;
241 }
242
243 EventType::AgentDecision {
244 ref model,
245 tokens_in,
246 tokens_out,
247 ref provider,
248 ..
249 } => {
250 if let Some(ref m) = model {
251 node.model = Some(m.clone());
252 }
253 if let Some(ref p) = provider {
254 node.provider = Some(p.clone());
255 }
256 if let Some(t) = tokens_in {
257 node.tokens_in += t;
258 }
259 if let Some(t) = tokens_out {
260 node.tokens_out += t;
261 }
262 }
263
264 _ => {}
265 }
266 }
267
268 let mut depth_cache: BTreeMap<String, u32> = BTreeMap::new();
270 let instances: Vec<String> = nodes_map.keys().cloned().collect();
271 for inst in &instances {
272 let depth = compute_depth(inst, &parent_map, &mut depth_cache);
273 if let Some(node) = nodes_map.get_mut(inst) {
274 node.depth = depth;
275 }
276 }
277
278 let nodes: Vec<AgentNode> = nodes_map.into_values().collect();
279
280 AgentGraph { nodes, edges }
281 }
282
283 pub fn max_depth(&self) -> u32 {
285 self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
286 }
287
288 pub fn host_ids(&self) -> BTreeSet<String> {
290 self.nodes.iter().map(|n| n.host_id.clone()).collect()
291 }
292
293 pub fn handoff_count(&self) -> u32 {
295 self.edges
296 .iter()
297 .filter(|e| e.edge_type == AgentEdgeType::Handoff)
298 .count() as u32
299 }
300
301 pub fn spawn_count(&self) -> u32 {
303 self.edges
304 .iter()
305 .filter(|e| e.edge_type == AgentEdgeType::ParentChild)
306 .count() as u32
307 }
308}
309
310fn compute_depth(
311 instance_id: &str,
312 parent_map: &BTreeMap<String, String>,
313 cache: &mut BTreeMap<String, u32>,
314) -> u32 {
315 if let Some(&d) = cache.get(instance_id) {
316 return d;
317 }
318 let depth = match parent_map.get(instance_id) {
319 Some(parent) => 1 + compute_depth(parent, parent_map, cache),
320 None => 0,
321 };
322 cache.insert(instance_id.to_string(), depth);
323 depth
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::session::event::*;
330
331 fn evt(instance_id: &str, host: &str, event_type: EventType) -> SessionEvent {
332 SessionEvent {
333 session_id: "ssn_001".into(),
334 event_id: generate_event_id(),
335 timestamp: "2026-04-05T08:00:00Z".into(),
336 sequence_no: 0,
337 trace_id: "trace_1".into(),
338 span_id: generate_span_id(),
339 parent_span_id: None,
340 agent_id: format!("agent://{instance_id}"),
341 agent_instance_id: instance_id.into(),
342 agent_name: instance_id.into(),
343 agent_role: None,
344 host_id: host.into(),
345 tool_runtime_id: None,
346 event_type,
347 artifact_ref: None,
348 meta: None,
349 }
350 }
351
352 #[test]
353 fn builds_graph_from_spawn_and_handoff() {
354 let events = vec![
355 evt(
356 "root",
357 "host_a",
358 EventType::AgentStarted {
359 parent_agent_instance_id: None,
360 },
361 ),
362 evt(
363 "child1",
364 "host_a",
365 EventType::AgentSpawned {
366 spawned_by_agent_instance_id: "root".into(),
367 reason: Some("review code".into()),
368 },
369 ),
370 evt(
371 "child2",
372 "host_b",
373 EventType::AgentSpawned {
374 spawned_by_agent_instance_id: "root".into(),
375 reason: None,
376 },
377 ),
378 evt(
379 "root",
380 "host_a",
381 EventType::AgentHandoff {
382 from_agent_instance_id: "root".into(),
383 to_agent_instance_id: "child1".into(),
384 artifacts: vec!["art_001".into()],
385 },
386 ),
387 evt(
388 "child1",
389 "host_a",
390 EventType::AgentCompleted {
391 termination_reason: None,
392 },
393 ),
394 ];
395
396 let graph = AgentGraph::from_events(&events);
397 assert_eq!(graph.nodes.len(), 3);
398 assert_eq!(graph.max_depth(), 1);
399 assert_eq!(graph.handoff_count(), 1);
400 assert_eq!(graph.spawn_count(), 2);
401 assert_eq!(graph.host_ids().len(), 2);
402 }
403
404 #[test]
405 fn nested_depth() {
406 let events = vec![
407 evt(
408 "root",
409 "h",
410 EventType::AgentStarted {
411 parent_agent_instance_id: None,
412 },
413 ),
414 evt(
415 "l1",
416 "h",
417 EventType::AgentSpawned {
418 spawned_by_agent_instance_id: "root".into(),
419 reason: None,
420 },
421 ),
422 evt(
423 "l2",
424 "h",
425 EventType::AgentSpawned {
426 spawned_by_agent_instance_id: "l1".into(),
427 reason: None,
428 },
429 ),
430 evt(
431 "l3",
432 "h",
433 EventType::AgentSpawned {
434 spawned_by_agent_instance_id: "l2".into(),
435 reason: None,
436 },
437 ),
438 ];
439
440 let graph = AgentGraph::from_events(&events);
441 assert_eq!(graph.max_depth(), 3);
442 let l3 = graph
443 .nodes
444 .iter()
445 .find(|n| n.agent_instance_id == "l3")
446 .unwrap();
447 assert_eq!(l3.depth, 3);
448 }
449
450 #[test]
460 fn tool_calls_counts_every_action_event_type() {
461 let events = vec![
462 evt(
463 "a",
464 "h",
465 EventType::AgentStarted {
466 parent_agent_instance_id: None,
467 },
468 ),
469 evt(
470 "a",
471 "h",
472 EventType::AgentCalledTool {
473 tool_name: "Glob".into(),
474 tool_input_digest: None,
475 tool_output_digest: None,
476 duration_ms: None,
477 },
478 ),
479 evt(
480 "a",
481 "h",
482 EventType::AgentReadFile {
483 file_path: "src/foo.rs".into(),
484 digest: None,
485 },
486 ),
487 evt(
488 "a",
489 "h",
490 EventType::AgentWroteFile {
491 file_path: "src/bar.rs".into(),
492 digest: None,
493 operation: None,
494 additions: None,
495 deletions: None,
496 },
497 ),
498 evt(
499 "a",
500 "h",
501 EventType::AgentCompletedProcess {
502 process_name: "npm test".into(),
503 exit_code: Some(0),
504 duration_ms: Some(2_500),
505 command: None,
506 },
507 ),
508 evt(
509 "a",
510 "h",
511 EventType::AgentConnectedNetwork {
512 destination: "api.github.com".into(),
513 port: None,
514 },
515 ),
516 evt(
517 "a",
518 "h",
519 EventType::AgentOpenedPort {
520 port: 3000,
521 protocol: Some("tcp".into()),
522 },
523 ),
524 ];
525
526 let graph = AgentGraph::from_events(&events);
527 let agent_a = graph
528 .nodes
529 .iter()
530 .find(|n| n.agent_instance_id == "a")
531 .unwrap();
532
533 assert_eq!(
536 agent_a.tool_calls, 6,
537 "tool_calls must count all action event types (was {}, expected 6)",
538 agent_a.tool_calls
539 );
540 }
541}