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 = "is_zero_f64")]
57 pub cost_usd: f64,
58}
59
60fn is_zero_f64(v: &f64) -> bool { *v == 0.0 }
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct AgentEdge {
65 pub from_instance_id: String,
66 pub to_instance_id: String,
67 pub edge_type: AgentEdgeType,
68 pub timestamp: String,
69 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub artifacts: Vec<String>,
71}
72
73#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AgentGraph {
76 pub nodes: Vec<AgentNode>,
77 pub edges: Vec<AgentEdge>,
78}
79
80impl AgentGraph {
81 pub fn from_events(events: &[SessionEvent]) -> Self {
83 let mut nodes_map: BTreeMap<String, AgentNode> = BTreeMap::new();
84 let mut edges: Vec<AgentEdge> = Vec::new();
85 let mut parent_map: BTreeMap<String, String> = BTreeMap::new(); for event in events {
88 let instance_id = &event.agent_instance_id;
89
90 let node = nodes_map.entry(instance_id.clone()).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 cost_usd: 0.0,
106 });
107
108 match &event.event_type {
109 EventType::AgentStarted { parent_agent_instance_id } => {
110 node.started_at = Some(event.timestamp.clone());
111 if let Some(parent_id) = parent_agent_instance_id {
112 parent_map.insert(instance_id.clone(), parent_id.clone());
113 }
114 }
115
116 EventType::AgentSpawned { spawned_by_agent_instance_id, .. } => {
117 node.started_at = Some(event.timestamp.clone());
118 parent_map.insert(instance_id.clone(), spawned_by_agent_instance_id.clone());
119 edges.push(AgentEdge {
120 from_instance_id: spawned_by_agent_instance_id.clone(),
121 to_instance_id: instance_id.clone(),
122 edge_type: AgentEdgeType::ParentChild,
123 timestamp: event.timestamp.clone(),
124 artifacts: Vec::new(),
125 });
126 }
127
128 EventType::AgentHandoff { from_agent_instance_id, to_agent_instance_id, artifacts } => {
129 edges.push(AgentEdge {
130 from_instance_id: from_agent_instance_id.clone(),
131 to_instance_id: to_agent_instance_id.clone(),
132 edge_type: AgentEdgeType::Handoff,
133 timestamp: event.timestamp.clone(),
134 artifacts: artifacts.clone(),
135 });
136 nodes_map.entry(to_agent_instance_id.clone()).or_insert_with(|| AgentNode {
138 agent_id: String::new(),
139 agent_instance_id: to_agent_instance_id.clone(),
140 agent_name: String::new(),
141 agent_role: None,
142 host_id: event.host_id.clone(),
143 started_at: None,
144 completed_at: None,
145 status: None,
146 depth: 0,
147 tool_calls: 0,
148 model: None,
149 tokens_in: 0,
150 tokens_out: 0,
151 cost_usd: 0.0,
152 });
153 }
154
155 EventType::AgentCollaborated { collaborator_agent_instance_ids } => {
156 for collab_id in collaborator_agent_instance_ids {
157 edges.push(AgentEdge {
158 from_instance_id: instance_id.clone(),
159 to_instance_id: collab_id.clone(),
160 edge_type: AgentEdgeType::Collaboration,
161 timestamp: event.timestamp.clone(),
162 artifacts: Vec::new(),
163 });
164 }
165 }
166
167 EventType::AgentReturned { returned_to_agent_instance_id } => {
168 edges.push(AgentEdge {
169 from_instance_id: instance_id.clone(),
170 to_instance_id: returned_to_agent_instance_id.clone(),
171 edge_type: AgentEdgeType::Return,
172 timestamp: event.timestamp.clone(),
173 artifacts: Vec::new(),
174 });
175 }
176
177 EventType::AgentCompleted { .. } => {
178 node.completed_at = Some(event.timestamp.clone());
179 node.status = Some("completed".into());
180 }
181
182 EventType::AgentFailed { .. } => {
183 node.completed_at = Some(event.timestamp.clone());
184 node.status = Some("failed".into());
185 }
186
187 EventType::AgentCalledTool { .. } => {
188 node.tool_calls += 1;
189 }
190
191 EventType::AgentCompletedProcess { .. } => {
192 node.tool_calls += 1;
193 }
194
195 EventType::AgentDecision { ref model, tokens_in, tokens_out, cost_usd, .. } => {
196 if let Some(ref m) = model {
197 node.model = Some(m.clone());
199 }
200 if let Some(t) = tokens_in { node.tokens_in += t; }
201 if let Some(t) = tokens_out { node.tokens_out += t; }
202 if let Some(c) = cost_usd { node.cost_usd += c; }
203 }
204
205 _ => {}
206 }
207 }
208
209 let mut depth_cache: BTreeMap<String, u32> = BTreeMap::new();
211 let instances: Vec<String> = nodes_map.keys().cloned().collect();
212 for inst in &instances {
213 let depth = compute_depth(inst, &parent_map, &mut depth_cache);
214 if let Some(node) = nodes_map.get_mut(inst) {
215 node.depth = depth;
216 }
217 }
218
219 let nodes: Vec<AgentNode> = nodes_map.into_values().collect();
220
221 AgentGraph { nodes, edges }
222 }
223
224 pub fn max_depth(&self) -> u32 {
226 self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
227 }
228
229 pub fn host_ids(&self) -> BTreeSet<String> {
231 self.nodes.iter().map(|n| n.host_id.clone()).collect()
232 }
233
234 pub fn handoff_count(&self) -> u32 {
236 self.edges.iter()
237 .filter(|e| e.edge_type == AgentEdgeType::Handoff)
238 .count() as u32
239 }
240
241 pub fn spawn_count(&self) -> u32 {
243 self.edges.iter()
244 .filter(|e| e.edge_type == AgentEdgeType::ParentChild)
245 .count() as u32
246 }
247}
248
249fn compute_depth(
250 instance_id: &str,
251 parent_map: &BTreeMap<String, String>,
252 cache: &mut BTreeMap<String, u32>,
253) -> u32 {
254 if let Some(&d) = cache.get(instance_id) {
255 return d;
256 }
257 let depth = match parent_map.get(instance_id) {
258 Some(parent) => 1 + compute_depth(parent, parent_map, cache),
259 None => 0,
260 };
261 cache.insert(instance_id.to_string(), depth);
262 depth
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use crate::session::event::*;
269
270 fn evt(instance_id: &str, host: &str, event_type: EventType) -> SessionEvent {
271 SessionEvent {
272 session_id: "ssn_001".into(),
273 event_id: generate_event_id(),
274 timestamp: "2026-04-05T08:00:00Z".into(),
275 sequence_no: 0,
276 trace_id: "trace_1".into(),
277 span_id: generate_span_id(),
278 parent_span_id: None,
279 agent_id: format!("agent://{instance_id}"),
280 agent_instance_id: instance_id.into(),
281 agent_name: instance_id.into(),
282 agent_role: None,
283 host_id: host.into(),
284 tool_runtime_id: None,
285 event_type,
286 artifact_ref: None,
287 meta: None,
288 }
289 }
290
291 #[test]
292 fn builds_graph_from_spawn_and_handoff() {
293 let events = vec![
294 evt("root", "host_a", EventType::AgentStarted {
295 parent_agent_instance_id: None,
296 }),
297 evt("child1", "host_a", EventType::AgentSpawned {
298 spawned_by_agent_instance_id: "root".into(),
299 reason: Some("review code".into()),
300 }),
301 evt("child2", "host_b", EventType::AgentSpawned {
302 spawned_by_agent_instance_id: "root".into(),
303 reason: None,
304 }),
305 evt("root", "host_a", EventType::AgentHandoff {
306 from_agent_instance_id: "root".into(),
307 to_agent_instance_id: "child1".into(),
308 artifacts: vec!["art_001".into()],
309 }),
310 evt("child1", "host_a", EventType::AgentCompleted {
311 termination_reason: None,
312 }),
313 ];
314
315 let graph = AgentGraph::from_events(&events);
316 assert_eq!(graph.nodes.len(), 3);
317 assert_eq!(graph.max_depth(), 1);
318 assert_eq!(graph.handoff_count(), 1);
319 assert_eq!(graph.spawn_count(), 2);
320 assert_eq!(graph.host_ids().len(), 2);
321 }
322
323 #[test]
324 fn nested_depth() {
325 let events = vec![
326 evt("root", "h", EventType::AgentStarted { parent_agent_instance_id: None }),
327 evt("l1", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "root".into(), reason: None }),
328 evt("l2", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "l1".into(), reason: None }),
329 evt("l3", "h", EventType::AgentSpawned { spawned_by_agent_instance_id: "l2".into(), reason: None }),
330 ];
331
332 let graph = AgentGraph::from_events(&events);
333 assert_eq!(graph.max_depth(), 3);
334 let l3 = graph.nodes.iter().find(|n| n.agent_instance_id == "l3").unwrap();
335 assert_eq!(l3.depth, 3);
336 }
337}