Skip to main content

agtrace_sdk/
analysis.rs

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