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    /// Provider e.g. "anthropic", "openrouter", "bedrock"
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub provider: Option<String>,
58}
59
60/// A directed edge in the agent graph.
61#[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/// The complete agent collaboration graph for a session.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct AgentGraph {
74    pub nodes: Vec<AgentNode>,
75    pub edges: Vec<AgentEdge>,
76}
77
78impl AgentGraph {
79    /// Build an agent graph from a sequence of session events.
80    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(); // child -> parent instance
84
85        for event in events {
86            let instance_id = &event.agent_instance_id;
87
88            // Ensure node exists
89            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                    // Ensure the target node exists
146                    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                // node.tool_calls counts every action the agent took. The
203                // side-effects ledger then groups those actions by category
204                // (files_read, files_written, processes, network_connections,
205                // ports_opened, tool_invocations) -- but the per-agent total
206                // here is the cardinal count.
207                //
208                // History note: prior to v0.9.5 only AgentCalledTool and
209                // AgentCompletedProcess were counted, which made the per-agent
210                // count drop to near-zero when the Claude Code plugin started
211                // emitting specialized event types (agent.read_file, etc.).
212                // The sum of node.tool_calls across all agents (used as
213                // "Actions" in the local preview and as the headline tool
214                // count on treeship.dev/receipt/<id>) was undercounting the
215                // agent's actual activity. Adding the four file/network/port
216                // event types here fixes the counter for all consumers in
217                // one place; renderers don't need to compute the total
218                // themselves.
219                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        // Compute depths from parent map
269        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    /// Return the maximum depth in the graph.
284    pub fn max_depth(&self) -> u32 {
285        self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
286    }
287
288    /// Return the set of unique host IDs across all agents.
289    pub fn host_ids(&self) -> BTreeSet<String> {
290        self.nodes.iter().map(|n| n.host_id.clone()).collect()
291    }
292
293    /// Total number of handoff edges.
294    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    /// Total number of spawn (parent-child) edges.
302    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    /// Regression test: per-agent `tool_calls` must count every action event
451    /// type the agent emits, not just `AgentCalledTool` and `AgentCompletedProcess`.
452    ///
453    /// Pre-v0.9.5, this counter ignored AgentReadFile, AgentWroteFile,
454    /// AgentConnectedNetwork, and AgentOpenedPort. As soon as the Claude Code
455    /// plugin started emitting those specialized event types (also v0.9.5),
456    /// the per-agent count -- and the `nodes.reduce(...)` total used by the
457    /// receipt renderer -- collapsed to near-zero even when the agent had
458    /// done substantial work. The fix lives in the EventType match arm above.
459    #[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        // 6 action events (Glob, ReadFile, WroteFile, CompletedProcess,
534        // ConnectedNetwork, OpenedPort). AgentStarted is not an action.
535        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}