agtrace_sdk/
analysis.rs

1use crate::error::Result;
2use agtrace_engine::{AgentSession, SessionSummary};
3
4/// Diagnostic lens for session analysis.
5///
6/// Each lens applies a specific diagnostic perspective to identify
7/// potential issues or patterns in agent behavior.
8#[derive(Debug, Clone, Copy)]
9pub enum Lens {
10    /// Detects tool execution failures.
11    Failures,
12    /// Detects repeated tool sequences that may indicate stuck behavior.
13    Loops,
14    /// Detects slow tool executions that may impact performance.
15    Bottlenecks,
16}
17
18/// Severity level of an insight.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Severity {
21    /// Informational observation.
22    Info,
23    /// Potential issue requiring attention.
24    Warning,
25    /// Critical problem requiring immediate attention.
26    Critical,
27}
28
29/// Diagnostic insight about a specific turn in a session.
30///
31/// Represents a finding from applying a diagnostic lens to a turn,
32/// including the turn location, the lens that detected it, and
33/// a human-readable message describing the issue.
34#[derive(Debug, Clone)]
35pub struct Insight {
36    /// Zero-based turn index where the insight was detected.
37    pub turn_index: usize,
38    /// The diagnostic lens that produced this insight.
39    pub lens: Lens,
40    /// Human-readable description of the finding.
41    pub message: String,
42    /// Severity level of this insight.
43    pub severity: Severity,
44}
45
46/// Builder for analyzing sessions through multiple diagnostic lenses.
47///
48/// Apply one or more lenses to a session to generate an analysis report
49/// with insights and a health score.
50pub struct SessionAnalyzer {
51    session: AgentSession,
52    lenses: Vec<Lens>,
53}
54
55impl SessionAnalyzer {
56    pub fn new(session: AgentSession) -> Self {
57        Self {
58            session,
59            lenses: vec![],
60        }
61    }
62
63    pub fn through(mut self, lens: Lens) -> Self {
64        self.lenses.push(lens);
65        self
66    }
67
68    pub fn report(self) -> Result<AnalysisReport> {
69        let summary = agtrace_engine::session::summarize(&self.session);
70        let mut insights = Vec::new();
71
72        for lens in &self.lenses {
73            match lens {
74                Lens::Failures => {
75                    insights.extend(self.analyze_failures());
76                }
77                Lens::Loops => {
78                    insights.extend(self.analyze_loops());
79                }
80                Lens::Bottlenecks => {
81                    insights.extend(self.analyze_bottlenecks());
82                }
83            }
84        }
85
86        let score = self.calculate_health_score(&summary, &insights);
87
88        Ok(AnalysisReport {
89            score,
90            insights,
91            summary,
92        })
93    }
94
95    fn analyze_failures(&self) -> Vec<Insight> {
96        let mut insights = Vec::new();
97        for (idx, turn) in self.session.turns.iter().enumerate() {
98            let failed_tools: Vec<_> = turn
99                .steps
100                .iter()
101                .flat_map(|step| &step.tools)
102                .filter(|tool_exec| tool_exec.is_error)
103                .collect();
104
105            if !failed_tools.is_empty() {
106                insights.push(Insight {
107                    turn_index: idx,
108                    lens: Lens::Failures,
109                    message: format!("{} tool execution(s) failed", failed_tools.len()),
110                    severity: Severity::Critical,
111                });
112            }
113        }
114        insights
115    }
116
117    fn analyze_loops(&self) -> Vec<Insight> {
118        let mut insights = Vec::new();
119        let mut prev_tool_sequence: Option<Vec<String>> = None;
120        let mut repeat_count = 0;
121
122        for (idx, turn) in self.session.turns.iter().enumerate() {
123            let tool_sequence: Vec<String> = turn
124                .steps
125                .iter()
126                .flat_map(|step| &step.tools)
127                .map(|tool_exec| format!("{:?}", tool_exec.call.content.kind()))
128                .collect();
129
130            if let Some(prev) = &prev_tool_sequence {
131                if prev == &tool_sequence && !tool_sequence.is_empty() {
132                    repeat_count += 1;
133                    if repeat_count >= 2 {
134                        insights.push(Insight {
135                            turn_index: idx,
136                            lens: Lens::Loops,
137                            message: "Repeated tool sequence detected".to_string(),
138                            severity: Severity::Warning,
139                        });
140                    }
141                } else {
142                    repeat_count = 0;
143                }
144            }
145
146            prev_tool_sequence = Some(tool_sequence);
147        }
148        insights
149    }
150
151    fn analyze_bottlenecks(&self) -> Vec<Insight> {
152        let mut insights = Vec::new();
153        for (idx, turn) in self.session.turns.iter().enumerate() {
154            for step in &turn.steps {
155                for tool_exec in &step.tools {
156                    if let Some(duration_ms) = tool_exec.duration_ms.filter(|&d| d > 10_000) {
157                        insights.push(Insight {
158                            turn_index: idx,
159                            lens: Lens::Bottlenecks,
160                            message: format!(
161                                "Slow tool execution ({:?} took {}s)",
162                                tool_exec.call.content.kind(),
163                                duration_ms / 1000
164                            ),
165                            severity: Severity::Warning,
166                        });
167                    }
168                }
169            }
170        }
171        insights
172    }
173
174    fn calculate_health_score(&self, _summary: &SessionSummary, insights: &[Insight]) -> u8 {
175        let base_score = 100;
176        let penalty_per_insight = 10;
177        let total_penalty = (insights.len() as i32) * penalty_per_insight;
178        let score = base_score - total_penalty;
179        score.max(0) as u8
180    }
181}
182
183/// Comprehensive analysis report for a session.
184///
185/// Contains a health score (0-100), detailed insights from applied lenses,
186/// and a statistical summary of the session.
187#[derive(Debug)]
188pub struct AnalysisReport {
189    /// Health score (0-100) where 100 indicates no issues detected.
190    pub score: u8,
191    /// Diagnostic insights collected from all applied lenses.
192    pub insights: Vec<Insight>,
193    /// Statistical summary of the session.
194    pub summary: SessionSummary,
195}