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    /// Cycles found in the untrusted parent relationship. A cycle has no
77    /// meaningful root depth, so affected nodes retain depth 0 and the
78    /// malformed topology is reported rather than silently normalized.
79    #[serde(default, skip_serializing_if = "Vec::is_empty")]
80    pub invalid_parent_cycles: Vec<Vec<String>>,
81}
82
83impl AgentGraph {
84    /// Build an agent graph from a sequence of session events.
85    pub fn from_events(events: &[SessionEvent]) -> Self {
86        let mut nodes_map: BTreeMap<String, AgentNode> = BTreeMap::new();
87        let mut edges: Vec<AgentEdge> = Vec::new();
88        let mut parent_map: BTreeMap<String, String> = BTreeMap::new(); // child -> parent instance
89
90        for event in events {
91            let instance_id = &event.agent_instance_id;
92
93            // Ensure node exists
94            let node = nodes_map
95                .entry(instance_id.clone())
96                .or_insert_with(|| AgentNode {
97                    agent_id: event.agent_id.clone(),
98                    agent_instance_id: instance_id.clone(),
99                    agent_name: event.agent_name.clone(),
100                    agent_role: event.agent_role.clone(),
101                    host_id: event.host_id.clone(),
102                    started_at: None,
103                    completed_at: None,
104                    status: None,
105                    depth: 0,
106                    tool_calls: 0,
107                    model: None,
108                    tokens_in: 0,
109                    tokens_out: 0,
110                    provider: None,
111                });
112
113            match &event.event_type {
114                EventType::AgentStarted {
115                    parent_agent_instance_id,
116                } => {
117                    node.started_at = Some(event.timestamp.clone());
118                    if let Some(parent_id) = parent_agent_instance_id {
119                        parent_map.insert(instance_id.clone(), parent_id.clone());
120                    }
121                }
122
123                EventType::AgentSpawned {
124                    spawned_by_agent_instance_id,
125                    ..
126                } => {
127                    node.started_at = Some(event.timestamp.clone());
128                    parent_map.insert(instance_id.clone(), spawned_by_agent_instance_id.clone());
129                    edges.push(AgentEdge {
130                        from_instance_id: spawned_by_agent_instance_id.clone(),
131                        to_instance_id: instance_id.clone(),
132                        edge_type: AgentEdgeType::ParentChild,
133                        timestamp: event.timestamp.clone(),
134                        artifacts: Vec::new(),
135                    });
136                }
137
138                EventType::AgentHandoff {
139                    from_agent_instance_id,
140                    to_agent_instance_id,
141                    artifacts,
142                } => {
143                    edges.push(AgentEdge {
144                        from_instance_id: from_agent_instance_id.clone(),
145                        to_instance_id: to_agent_instance_id.clone(),
146                        edge_type: AgentEdgeType::Handoff,
147                        timestamp: event.timestamp.clone(),
148                        artifacts: artifacts.clone(),
149                    });
150                    // Ensure the target node exists
151                    nodes_map
152                        .entry(to_agent_instance_id.clone())
153                        .or_insert_with(|| AgentNode {
154                            agent_id: String::new(),
155                            agent_instance_id: to_agent_instance_id.clone(),
156                            agent_name: String::new(),
157                            agent_role: None,
158                            host_id: event.host_id.clone(),
159                            started_at: None,
160                            completed_at: None,
161                            status: None,
162                            depth: 0,
163                            tool_calls: 0,
164                            model: None,
165                            tokens_in: 0,
166                            tokens_out: 0,
167                            provider: None,
168                        });
169                }
170
171                EventType::AgentCollaborated {
172                    collaborator_agent_instance_ids,
173                } => {
174                    for collab_id in collaborator_agent_instance_ids {
175                        edges.push(AgentEdge {
176                            from_instance_id: instance_id.clone(),
177                            to_instance_id: collab_id.clone(),
178                            edge_type: AgentEdgeType::Collaboration,
179                            timestamp: event.timestamp.clone(),
180                            artifacts: Vec::new(),
181                        });
182                    }
183                }
184
185                EventType::AgentReturned {
186                    returned_to_agent_instance_id,
187                } => {
188                    edges.push(AgentEdge {
189                        from_instance_id: instance_id.clone(),
190                        to_instance_id: returned_to_agent_instance_id.clone(),
191                        edge_type: AgentEdgeType::Return,
192                        timestamp: event.timestamp.clone(),
193                        artifacts: Vec::new(),
194                    });
195                }
196
197                EventType::AgentCompleted { .. } => {
198                    node.completed_at = Some(event.timestamp.clone());
199                    node.status = Some("completed".into());
200                }
201
202                EventType::AgentFailed { .. } => {
203                    node.completed_at = Some(event.timestamp.clone());
204                    node.status = Some("failed".into());
205                }
206
207                // node.tool_calls counts every action the agent took. The
208                // side-effects ledger then groups those actions by category
209                // (files_read, files_written, processes, network_connections,
210                // ports_opened, tool_invocations) -- but the per-agent total
211                // here is the cardinal count.
212                //
213                // History note: prior to v0.9.5 only AgentCalledTool and
214                // AgentCompletedProcess were counted, which made the per-agent
215                // count drop to near-zero when the Claude Code plugin started
216                // emitting specialized event types (agent.read_file, etc.).
217                // The sum of node.tool_calls across all agents (used as
218                // "Actions" in the local preview and as the headline tool
219                // count on treeship.dev/receipt/<id>) was undercounting the
220                // agent's actual activity. Adding the four file/network/port
221                // event types here fixes the counter for all consumers in
222                // one place; renderers don't need to compute the total
223                // themselves.
224                EventType::AgentCalledTool { .. } => {
225                    node.tool_calls += 1;
226                }
227
228                EventType::AgentCompletedProcess { .. } => {
229                    node.tool_calls += 1;
230                }
231
232                EventType::AgentReadFile { .. } => {
233                    node.tool_calls += 1;
234                }
235
236                EventType::AgentWroteFile { .. } => {
237                    node.tool_calls += 1;
238                }
239
240                EventType::AgentConnectedNetwork { .. } => {
241                    node.tool_calls += 1;
242                }
243
244                EventType::AgentOpenedPort { .. } => {
245                    node.tool_calls += 1;
246                }
247
248                EventType::AgentDecision {
249                    ref model,
250                    tokens_in,
251                    tokens_out,
252                    ref provider,
253                    ..
254                } => {
255                    if let Some(ref m) = model {
256                        node.model = Some(m.clone());
257                    }
258                    if let Some(ref p) = provider {
259                        node.provider = Some(p.clone());
260                    }
261                    if let Some(t) = tokens_in {
262                        node.tokens_in += t;
263                    }
264                    if let Some(t) = tokens_out {
265                        node.tokens_out += t;
266                    }
267                }
268
269                _ => {}
270            }
271        }
272
273        // Compute depths from the untrusted parent map without recursion.
274        // A self-parent or A -> B -> A pair used to recurse until stack
275        // overflow here while composing a session receipt.
276        let mut depth_cache: BTreeMap<String, u32> = BTreeMap::new();
277        let mut invalid_parent_cycles: BTreeSet<Vec<String>> = BTreeSet::new();
278        let instances: Vec<String> = nodes_map.keys().cloned().collect();
279        for inst in &instances {
280            match compute_depth(inst, &parent_map, &mut depth_cache) {
281                Ok(depth) => {
282                    if let Some(node) = nodes_map.get_mut(inst) {
283                        node.depth = depth;
284                    }
285                }
286                Err(mut cycle) => {
287                    // Canonicalize the cycle so starting the walk at a
288                    // different member cannot emit the same anomaly twice.
289                    cycle.sort();
290                    cycle.dedup();
291                    invalid_parent_cycles.insert(cycle);
292                }
293            }
294        }
295
296        let nodes: Vec<AgentNode> = nodes_map.into_values().collect();
297
298        AgentGraph {
299            nodes,
300            edges,
301            invalid_parent_cycles: invalid_parent_cycles.into_iter().collect(),
302        }
303    }
304
305    /// Return the maximum depth in the graph.
306    pub fn max_depth(&self) -> u32 {
307        self.nodes.iter().map(|n| n.depth).max().unwrap_or(0)
308    }
309
310    /// Return the set of unique host IDs across all agents.
311    pub fn host_ids(&self) -> BTreeSet<String> {
312        self.nodes.iter().map(|n| n.host_id.clone()).collect()
313    }
314
315    /// Total number of handoff edges.
316    pub fn handoff_count(&self) -> u32 {
317        self.edges
318            .iter()
319            .filter(|e| e.edge_type == AgentEdgeType::Handoff)
320            .count() as u32
321    }
322
323    /// Total number of spawn (parent-child) edges.
324    pub fn spawn_count(&self) -> u32 {
325        self.edges
326            .iter()
327            .filter(|e| e.edge_type == AgentEdgeType::ParentChild)
328            .count() as u32
329    }
330}
331
332fn compute_depth(
333    instance_id: &str,
334    parent_map: &BTreeMap<String, String>,
335    cache: &mut BTreeMap<String, u32>,
336) -> Result<u32, Vec<String>> {
337    let mut path = Vec::new();
338    let mut path_positions: BTreeMap<String, usize> = BTreeMap::new();
339    let mut current = instance_id.to_string();
340
341    let mut depth = loop {
342        if let Some(&cached) = cache.get(&current) {
343            break cached;
344        }
345        if let Some(&cycle_start) = path_positions.get(&current) {
346            return Err(path[cycle_start..].to_vec());
347        }
348
349        path_positions.insert(current.clone(), path.len());
350        path.push(current.clone());
351
352        match parent_map.get(&current) {
353            Some(parent) => current = parent.clone(),
354            None => {
355                // The final path member is the root. Preserve the existing
356                // convention that roots have depth 0.
357                let root = path
358                    .pop()
359                    .expect("depth path contains the current root; please report a bug");
360                cache.insert(root, 0);
361                break 0;
362            }
363        }
364    };
365
366    for child in path.into_iter().rev() {
367        depth = depth
368            .checked_add(1)
369            .expect("agent graph depth exceeds u32; please report a bug");
370        cache.insert(child, depth);
371    }
372
373    Ok(*cache
374        .get(instance_id)
375        .expect("depth walk caches the requested instance; please report a bug"))
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::session::event::*;
382
383    fn evt(instance_id: &str, host: &str, event_type: EventType) -> SessionEvent {
384        SessionEvent {
385            session_id: "ssn_001".into(),
386            event_id: generate_event_id(),
387            timestamp: "2026-04-05T08:00:00Z".into(),
388            sequence_no: 0,
389            trace_id: "trace_1".into(),
390            span_id: generate_span_id(),
391            parent_span_id: None,
392            agent_id: format!("agent://{instance_id}"),
393            agent_instance_id: instance_id.into(),
394            agent_name: instance_id.into(),
395            agent_role: None,
396            host_id: host.into(),
397            tool_runtime_id: None,
398            event_type,
399            artifact_ref: None,
400            meta: None,
401        }
402    }
403
404    #[test]
405    fn builds_graph_from_spawn_and_handoff() {
406        let events = vec![
407            evt(
408                "root",
409                "host_a",
410                EventType::AgentStarted {
411                    parent_agent_instance_id: None,
412                },
413            ),
414            evt(
415                "child1",
416                "host_a",
417                EventType::AgentSpawned {
418                    spawned_by_agent_instance_id: "root".into(),
419                    reason: Some("review code".into()),
420                },
421            ),
422            evt(
423                "child2",
424                "host_b",
425                EventType::AgentSpawned {
426                    spawned_by_agent_instance_id: "root".into(),
427                    reason: None,
428                },
429            ),
430            evt(
431                "root",
432                "host_a",
433                EventType::AgentHandoff {
434                    from_agent_instance_id: "root".into(),
435                    to_agent_instance_id: "child1".into(),
436                    artifacts: vec!["art_001".into()],
437                },
438            ),
439            evt(
440                "child1",
441                "host_a",
442                EventType::AgentCompleted {
443                    termination_reason: None,
444                },
445            ),
446        ];
447
448        let graph = AgentGraph::from_events(&events);
449        assert_eq!(graph.nodes.len(), 3);
450        assert_eq!(graph.max_depth(), 1);
451        assert_eq!(graph.handoff_count(), 1);
452        assert_eq!(graph.spawn_count(), 2);
453        assert_eq!(graph.host_ids().len(), 2);
454    }
455
456    #[test]
457    fn parent_cycles_are_reported_without_recursing() {
458        let self_parent = AgentGraph::from_events(&[evt(
459            "a",
460            "h",
461            EventType::AgentStarted {
462                parent_agent_instance_id: Some("a".into()),
463            },
464        )]);
465        assert_eq!(self_parent.invalid_parent_cycles, vec![vec!["a"]]);
466        assert_eq!(self_parent.nodes[0].depth, 0);
467
468        let two_node_cycle = AgentGraph::from_events(&[
469            evt(
470                "a",
471                "h",
472                EventType::AgentStarted {
473                    parent_agent_instance_id: Some("b".into()),
474                },
475            ),
476            evt(
477                "b",
478                "h",
479                EventType::AgentStarted {
480                    parent_agent_instance_id: Some("a".into()),
481                },
482            ),
483        ]);
484        assert_eq!(
485            two_node_cycle.invalid_parent_cycles,
486            vec![vec!["a".to_string(), "b".to_string()]]
487        );
488        assert!(two_node_cycle.nodes.iter().all(|node| node.depth == 0));
489    }
490
491    #[test]
492    fn deep_parent_chains_do_not_use_the_call_stack() {
493        let mut parent_map = BTreeMap::new();
494        for i in 1..20_000 {
495            parent_map.insert(format!("n{i}"), format!("n{}", i - 1));
496        }
497
498        let depth = compute_depth("n19999", &parent_map, &mut BTreeMap::new())
499            .expect("an acyclic parent chain has a depth");
500        assert_eq!(depth, 19_999);
501    }
502
503    #[test]
504    fn nested_depth() {
505        let events = vec![
506            evt(
507                "root",
508                "h",
509                EventType::AgentStarted {
510                    parent_agent_instance_id: None,
511                },
512            ),
513            evt(
514                "l1",
515                "h",
516                EventType::AgentSpawned {
517                    spawned_by_agent_instance_id: "root".into(),
518                    reason: None,
519                },
520            ),
521            evt(
522                "l2",
523                "h",
524                EventType::AgentSpawned {
525                    spawned_by_agent_instance_id: "l1".into(),
526                    reason: None,
527                },
528            ),
529            evt(
530                "l3",
531                "h",
532                EventType::AgentSpawned {
533                    spawned_by_agent_instance_id: "l2".into(),
534                    reason: None,
535                },
536            ),
537        ];
538
539        let graph = AgentGraph::from_events(&events);
540        assert_eq!(graph.max_depth(), 3);
541        let l3 = graph
542            .nodes
543            .iter()
544            .find(|n| n.agent_instance_id == "l3")
545            .unwrap();
546        assert_eq!(l3.depth, 3);
547    }
548
549    /// Regression test: per-agent `tool_calls` must count every action event
550    /// type the agent emits, not just `AgentCalledTool` and `AgentCompletedProcess`.
551    ///
552    /// Pre-v0.9.5, this counter ignored AgentReadFile, AgentWroteFile,
553    /// AgentConnectedNetwork, and AgentOpenedPort. As soon as the Claude Code
554    /// plugin started emitting those specialized event types (also v0.9.5),
555    /// the per-agent count -- and the `nodes.reduce(...)` total used by the
556    /// receipt renderer -- collapsed to near-zero even when the agent had
557    /// done substantial work. The fix lives in the EventType match arm above.
558    #[test]
559    fn tool_calls_counts_every_action_event_type() {
560        let events = vec![
561            evt(
562                "a",
563                "h",
564                EventType::AgentStarted {
565                    parent_agent_instance_id: None,
566                },
567            ),
568            evt(
569                "a",
570                "h",
571                EventType::AgentCalledTool {
572                    tool_name: "Glob".into(),
573                    tool_input_digest: None,
574                    tool_output_digest: None,
575                    duration_ms: None,
576                },
577            ),
578            evt(
579                "a",
580                "h",
581                EventType::AgentReadFile {
582                    file_path: "src/foo.rs".into(),
583                    digest: None,
584                },
585            ),
586            evt(
587                "a",
588                "h",
589                EventType::AgentWroteFile {
590                    file_path: "src/bar.rs".into(),
591                    digest: None,
592                    operation: None,
593                    additions: None,
594                    deletions: None,
595                },
596            ),
597            evt(
598                "a",
599                "h",
600                EventType::AgentCompletedProcess {
601                    process_name: "npm test".into(),
602                    exit_code: Some(0),
603                    duration_ms: Some(2_500),
604                    command: None,
605                },
606            ),
607            evt(
608                "a",
609                "h",
610                EventType::AgentConnectedNetwork {
611                    destination: "api.github.com".into(),
612                    port: None,
613                },
614            ),
615            evt(
616                "a",
617                "h",
618                EventType::AgentOpenedPort {
619                    port: 3000,
620                    protocol: Some("tcp".into()),
621                },
622            ),
623        ];
624
625        let graph = AgentGraph::from_events(&events);
626        let agent_a = graph
627            .nodes
628            .iter()
629            .find(|n| n.agent_instance_id == "a")
630            .unwrap();
631
632        // 6 action events (Glob, ReadFile, WroteFile, CompletedProcess,
633        // ConnectedNetwork, OpenedPort). AgentStarted is not an action.
634        assert_eq!(
635            agent_a.tool_calls, 6,
636            "tool_calls must count all action event types (was {}, expected 6)",
637            agent_a.tool_calls
638        );
639    }
640}