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.entry(instance_id.clone()).or_insert_with(|| AgentNode {
90 agent_id: event.agent_id.clone(),
91 agent_instance_id: instance_id.clone(),
92 agent_name: event.agent_name.clone(),
93 agent_role: event.agent_role.clone(),
94 host_id: event.host_id.clone(),
95 started_at: None,
96 completed_at: None,
97 status: None,
98 depth: 0,
99 tool_calls: 0,
100 model: None,
101 tokens_in: 0,
102 tokens_out: 0,
103 provider: None,
104 });
105
106 match &event.event_type {
107 EventType::AgentStarted { parent_agent_instance_id } => {
108 node.started_at = Some(event.timestamp.clone());
109 if let Some(parent_id) = parent_agent_instance_id {
110 parent_map.insert(instance_id.clone(), parent_id.clone());
111 }
112 }
113
114 EventType::AgentSpawned { spawned_by_agent_instance_id, .. } => {
115 node.started_at = Some(event.timestamp.clone());
116 parent_map.insert(instance_id.clone(), spawned_by_agent_instance_id.clone());
117 edges.push(AgentEdge {
118 from_instance_id: spawned_by_agent_instance_id.clone(),
119 to_instance_id: instance_id.clone(),
120 edge_type: AgentEdgeType::ParentChild,
121 timestamp: event.timestamp.clone(),
122 artifacts: Vec::new(),
123 });
124 }
125
126 EventType::AgentHandoff { from_agent_instance_id, to_agent_instance_id, artifacts } => {
127 edges.push(AgentEdge {
128 from_instance_id: from_agent_instance_id.clone(),
129 to_instance_id: to_agent_instance_id.clone(),
130 edge_type: AgentEdgeType::Handoff,
131 timestamp: event.timestamp.clone(),
132 artifacts: artifacts.clone(),
133 });
134 nodes_map.entry(to_agent_instance_id.clone()).or_insert_with(|| AgentNode {
136 agent_id: String::new(),
137 agent_instance_id: to_agent_instance_id.clone(),
138 agent_name: String::new(),
139 agent_role: None,
140 host_id: event.host_id.clone(),
141 started_at: None,
142 completed_at: None,
143 status: None,
144 depth: 0,
145 tool_calls: 0,
146 model: None,
147 tokens_in: 0,
148 tokens_out: 0,
149 provider: None,
150 });
151 }
152
153 EventType::AgentCollaborated { collaborator_agent_instance_ids } => {
154 for collab_id in collaborator_agent_instance_ids {
155 edges.push(AgentEdge {
156 from_instance_id: instance_id.clone(),
157 to_instance_id: collab_id.clone(),
158 edge_type: AgentEdgeType::Collaboration,
159 timestamp: event.timestamp.clone(),
160 artifacts: Vec::new(),
161 });
162 }
163 }
164
165 EventType::AgentReturned { returned_to_agent_instance_id } => {
166 edges.push(AgentEdge {
167 from_instance_id: instance_id.clone(),
168 to_instance_id: returned_to_agent_instance_id.clone(),
169 edge_type: AgentEdgeType::Return,
170 timestamp: event.timestamp.clone(),
171 artifacts: Vec::new(),
172 });
173 }
174
175 EventType::AgentCompleted { .. } => {
176 node.completed_at = Some(event.timestamp.clone());
177 node.status = Some("completed".into());
178 }
179
180 EventType::AgentFailed { .. } => {
181 node.completed_at = Some(event.timestamp.clone());
182 node.status = Some("failed".into());
183 }
184
185 EventType::AgentCalledTool { .. } => {
203 node.tool_calls += 1;
204 }
205
206 EventType::AgentCompletedProcess { .. } => {
207 node.tool_calls += 1;
208 }
209
210 EventType::AgentReadFile { .. } => {
211 node.tool_calls += 1;
212 }
213
214 EventType::AgentWroteFile { .. } => {
215 node.tool_calls += 1;
216 }
217
218 EventType::AgentConnectedNetwork { .. } => {
219 node.tool_calls += 1;
220 }
221
222 EventType::AgentOpenedPort { .. } => {
223 node.tool_calls += 1;
224 }
225
226 EventType::AgentDecision { ref model, tokens_in, tokens_out, ref provider, .. } => {
227 if let Some(ref m) = model {
228 node.model = Some(m.clone());
229 }
230 if let Some(ref p) = provider {
231 node.provider = Some(p.clone());
232 }
233 if let Some(t) = tokens_in { node.tokens_in += t; }
234 if let Some(t) = tokens_out { node.tokens_out += t; }
235 }
236
237 _ => {}
238 }
239 }
240
241 let mut depth_cache: BTreeMap<String, u32> = BTreeMap::new();
243 let instances: Vec<String> = nodes_map.keys().cloned().collect();
244 for inst in &instances {
245 let depth = compute_depth(inst, &parent_map, &mut depth_cache);
246 if let Some(node) = nodes_map.get_mut(inst) {
247 node.depth = depth;
248 }
249 }
250
251 let nodes: Vec<AgentNode> = nodes_map.into_values().collect();
252
253 AgentGraph { nodes, edges }
254 }
255
256 pub fn max_depth(&self) -> u32 {
258 self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
259 }
260
261 pub fn host_ids(&self) -> BTreeSet<String> {
263 self.nodes.iter().map(|n| n.host_id.clone()).collect()
264 }
265
266 pub fn handoff_count(&self) -> u32 {
268 self.edges.iter()
269 .filter(|e| e.edge_type == AgentEdgeType::Handoff)
270 .count() as u32
271 }
272
273 pub fn spawn_count(&self) -> u32 {
275 self.edges.iter()
276 .filter(|e| e.edge_type == AgentEdgeType::ParentChild)
277 .count() as u32
278 }
279}
280
281fn compute_depth(
282 instance_id: &str,
283 parent_map: &BTreeMap<String, String>,
284 cache: &mut BTreeMap<String, u32>,
285) -> u32 {
286 if let Some(&d) = cache.get(instance_id) {
287 return d;
288 }
289 let depth = match parent_map.get(instance_id) {
290 Some(parent) => 1 + compute_depth(parent, parent_map, cache),
291 None => 0,
292 };
293 cache.insert(instance_id.to_string(), depth);
294 depth
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::session::event::*;
301
302 fn evt(instance_id: &str, host: &str, event_type: EventType) -> SessionEvent {
303 SessionEvent {
304 session_id: "ssn_001".into(),
305 event_id: generate_event_id(),
306 timestamp: "2026-04-05T08:00:00Z".into(),
307 sequence_no: 0,
308 trace_id: "trace_1".into(),
309 span_id: generate_span_id(),
310 parent_span_id: None,
311 agent_id: format!("agent://{instance_id}"),
312 agent_instance_id: instance_id.into(),
313 agent_name: instance_id.into(),
314 agent_role: None,
315 host_id: host.into(),
316 tool_runtime_id: None,
317 event_type,
318 artifact_ref: None,
319 meta: None,
320 }
321 }
322
323 #[test]
324 fn builds_graph_from_spawn_and_handoff() {
325 let events = vec![
326 evt("root", "host_a", EventType::AgentStarted {
327 parent_agent_instance_id: None,
328 }),
329 evt("child1", "host_a", EventType::AgentSpawned {
330 spawned_by_agent_instance_id: "root".into(),
331 reason: Some("review code".into()),
332 }),
333 evt("child2", "host_b", EventType::AgentSpawned {
334 spawned_by_agent_instance_id: "root".into(),
335 reason: None,
336 }),
337 evt("root", "host_a", EventType::AgentHandoff {
338 from_agent_instance_id: "root".into(),
339 to_agent_instance_id: "child1".into(),
340 artifacts: vec!["art_001".into()],
341 }),
342 evt("child1", "host_a", EventType::AgentCompleted {
343 termination_reason: None,
344 }),
345 ];
346
347 let graph = AgentGraph::from_events(&events);
348 assert_eq!(graph.nodes.len(), 3);
349 assert_eq!(graph.max_depth(), 1);
350 assert_eq!(graph.handoff_count(), 1);
351 assert_eq!(graph.spawn_count(), 2);
352 assert_eq!(graph.host_ids().len(), 2);
353 }
354
355 #[test]
356 fn nested_depth() {
357 let events = vec![
358 evt("root", "h", EventType::AgentStarted { parent_agent_instance_id: None }),
359 evt("l1", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: None }),
360 evt("l2", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "l1".into(), reason: None }),
361 evt("l3", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "l2".into(), reason: None }),
362 ];
363
364 let graph = AgentGraph::from_events(&events);
365 assert_eq!(graph.max_depth(), 3);
366 let l3 = graph.nodes.iter().find(|n| n.agent_instance_id == "l3").unwrap();
367 assert_eq!(l3.depth, 3);
368 }
369
370 #[test]
380 fn tool_calls_counts_every_action_event_type() {
381 let events = vec![
382 evt("a", "h", EventType::AgentStarted { parent_agent_instance_id: None }),
383 evt("a", "h", EventType::AgentCalledTool {
384 tool_name: "Glob".into(),
385 tool_input_digest: None,
386 tool_output_digest: None,
387 duration_ms: None,
388 }),
389 evt("a", "h", EventType::AgentReadFile {
390 file_path: "src/foo.rs".into(),
391 digest: None,
392 }),
393 evt("a", "h", EventType::AgentWroteFile {
394 file_path: "src/bar.rs".into(),
395 digest: None,
396 operation: None,
397 additions: None,
398 deletions: None,
399 }),
400 evt("a", "h", EventType::AgentCompletedProcess {
401 process_name: "npm test".into(),
402 exit_code: Some(0),
403 duration_ms: Some(2_500),
404 command: None,
405 }),
406 evt("a", "h", EventType::AgentConnectedNetwork {
407 destination: "api.github.com".into(),
408 port: None,
409 }),
410 evt("a", "h", EventType::AgentOpenedPort {
411 port: 3000,
412 protocol: Some("tcp".into()),
413 }),
414 ];
415
416 let graph = AgentGraph::from_events(&events);
417 let agent_a = graph.nodes.iter().find(|n| n.agent_instance_id == "a").unwrap();
418
419 assert_eq!(
422 agent_a.tool_calls, 6,
423 "tool_calls must count all action event types (was {}, expected 6)",
424 agent_a.tool_calls
425 );
426 }
427}