Skip to main content

treeship_core/session/
graph.rs

1//! Agent collaboration graph built from session events.
2//!
3//! Captures the full topology of agent relationships: parent-child spawning,
4//! handoffs, and collaboration edges.
5
6use std::collections::{BTreeMap, BTreeSet};
7
8use serde::{Deserialize, Serialize};
9
10use super::event::{EventType, SessionEvent};
11
12/// Type of relationship between two agents.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[serde(rename_all = "snake_case")]
15pub enum AgentEdgeType {
16    /// Parent spawned a child agent.
17    ParentChild,
18    /// Work was handed off from one agent to another.
19    Handoff,
20    /// Agents collaborated on a shared task.
21    Collaboration,
22    /// Agent returned control to a parent.
23    Return,
24}
25
26/// A node in the agent graph representing one agent instance.
27#[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    /// Number of tool calls made by this agent.
44    #[serde(default)]
45    pub tool_calls: u32,
46    /// Model identifier (e.g. "claude-opus-4-6"). Populated from decision events.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub model: Option<String>,
49    /// Cumulative input tokens across all decisions by this agent.
50    #[serde(default)]
51    pub tokens_in: u64,
52    /// Cumulative output tokens across all decisions by this agent.
53    #[serde(default)]
54    pub tokens_out: u64,
55    /// Cumulative cost in USD across all decisions by this agent.
56    #[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/// A directed edge in the agent graph.
63#[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/// The complete agent collaboration graph for a session.
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
75pub struct AgentGraph {
76    pub nodes: Vec<AgentNode>,
77    pub edges: Vec<AgentEdge>,
78}
79
80impl AgentGraph {
81    /// Build an agent graph from a sequence of session events.
82    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(); // child -> parent instance
86
87        for event in events {
88            let instance_id = &event.agent_instance_id;
89
90            // Ensure node exists
91            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                    // Ensure the target node exists
137                    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                        // Last model wins (agents may switch models mid-session).
198                        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        // Compute depths from parent map
210        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    /// Return the maximum depth in the graph.
225    pub fn max_depth(&self) -> u32 {
226        self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
227    }
228
229    /// Return the set of unique host IDs across all agents.
230    pub fn host_ids(&self) -> BTreeSet<String> {
231        self.nodes.iter().map(|n| n.host_id.clone()).collect()
232    }
233
234    /// Total number of handoff edges.
235    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    /// Total number of spawn (parent-child) edges.
242    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}