1use std::collections::HashMap;
2
3use crate::types::message::{Content, ContentPart, Message, Role};
4
5#[derive(Debug, Clone)]
7pub struct TraceInsight {
8 pub kind: InsightKind,
9 pub confidence: f64,
11 pub session_id: String,
12}
13
14#[derive(Debug, Clone)]
15pub enum InsightKind {
16 RepeatedToolError {
18 tool_name: String,
19 error_count: usize,
20 sample_error: String,
22 },
23 SuccessfulToolSequence {
26 tools: Vec<String>,
27 context_hint: String,
29 },
30 LongReasoning {
32 summary_hint: String,
34 },
35 Synthesized { text: String },
37}
38
39impl InsightKind {
40 pub fn tag(&self) -> &'static str {
41 match self {
42 Self::RepeatedToolError { .. } => "repeated_tool_error",
43 Self::SuccessfulToolSequence { .. } => "successful_sequence",
44 Self::LongReasoning { .. } => "long_reasoning",
45 Self::Synthesized { .. } => "synthesized",
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
51pub struct AnalysisPolicy {
52 pub min_error_count: usize,
54 pub min_success_sequence_len: usize,
56 pub min_reasoning_chars: usize,
58}
59
60impl Default for AnalysisPolicy {
61 fn default() -> Self {
62 Self {
63 min_error_count: 2,
64 min_success_sequence_len: 2,
65 min_reasoning_chars: 500,
66 }
67 }
68}
69
70pub struct TraceAnalyzer {
71 pub policy: AnalysisPolicy,
72}
73
74impl TraceAnalyzer {
75 pub fn new(policy: AnalysisPolicy) -> Self {
76 Self { policy }
77 }
78
79 pub fn analyze_batch(&self, sessions: &[(String, Vec<Message>)]) -> Vec<TraceInsight> {
81 sessions
82 .iter()
83 .flat_map(|(id, msgs)| self.analyze(id, msgs))
84 .collect()
85 }
86
87 pub fn analyze(&self, session_id: &str, messages: &[Message]) -> Vec<TraceInsight> {
88 let mut insights = Vec::new();
89 insights.extend(self.detect_repeated_errors(session_id, messages));
90 insights.extend(self.detect_successful_sequences(session_id, messages));
91 insights.extend(self.detect_long_reasoning(session_id, messages));
92 insights
93 }
94
95 fn detect_repeated_errors(&self, session_id: &str, messages: &[Message]) -> Vec<TraceInsight> {
98 let mut call_id_to_name: HashMap<String, String> = HashMap::new();
100 for msg in messages {
101 if msg.role == Role::Assistant {
102 for tc in &msg.tool_calls {
103 call_id_to_name.insert(tc.id.to_string(), tc.name.to_string());
104 }
105 }
106 }
107
108 let mut error_counts: HashMap<String, (usize, String)> = HashMap::new();
110 for msg in messages {
111 if msg.role != Role::Tool {
112 continue;
113 }
114 if let Content::Parts(parts) = &msg.content {
115 for part in parts {
116 if let ContentPart::ToolResult {
117 call_id,
118 output,
119 is_error,
120 } = part
121 {
122 if *is_error {
123 if let Some(name) = call_id_to_name.get(call_id.as_str()) {
124 let entry = error_counts
125 .entry(name.clone())
126 .or_insert_with(|| (0, output.chars().take(200).collect()));
127 entry.0 += 1;
128 }
129 }
130 }
131 }
132 }
133 }
134
135 error_counts
136 .into_iter()
137 .filter(|(_, (count, _))| *count >= self.policy.min_error_count)
138 .map(|(tool_name, (error_count, sample_error))| TraceInsight {
139 kind: InsightKind::RepeatedToolError {
140 tool_name,
141 error_count,
142 sample_error,
143 },
144 confidence: (error_count as f64 / 5.0).min(1.0),
146 session_id: session_id.to_string(),
147 })
148 .collect()
149 }
150
151 fn detect_successful_sequences(
152 &self,
153 session_id: &str,
154 messages: &[Message],
155 ) -> Vec<TraceInsight> {
156 let mut insights = Vec::new();
157 let mut sequence: Vec<String> = Vec::new();
158 let mut context_hint = String::new();
159 let mut sequence_has_error = false;
160
161 for msg in messages {
162 match msg.role {
163 Role::User => {
164 if let Some(text) = msg.content.as_text() {
166 context_hint = text.chars().take(100).collect();
167 }
168 }
169 Role::Assistant => {
170 if msg.tool_calls.is_empty() {
171 if !sequence_has_error
173 && sequence.len() >= self.policy.min_success_sequence_len
174 {
175 let confidence =
176 (sequence.len() as f64 / 10.0).min(0.9_f64).max(0.5_f64);
177 insights.push(TraceInsight {
178 kind: InsightKind::SuccessfulToolSequence {
179 tools: sequence.clone(),
180 context_hint: context_hint.clone(),
181 },
182 confidence,
183 session_id: session_id.to_string(),
184 });
185 }
186 sequence.clear();
187 sequence_has_error = false;
188 } else {
189 for tc in &msg.tool_calls {
190 sequence.push(tc.name.to_string());
191 }
192 }
193 }
194 Role::Tool => {
195 if let Content::Parts(parts) = &msg.content {
196 if parts
197 .iter()
198 .any(|p| matches!(p, ContentPart::ToolResult { is_error: true, .. }))
199 {
200 sequence_has_error = true;
201 }
202 }
203 }
204 Role::System => {}
205 }
206 }
207
208 insights
209 }
210
211 fn detect_long_reasoning(&self, session_id: &str, messages: &[Message]) -> Vec<TraceInsight> {
212 messages
213 .iter()
214 .filter(|m| m.role == Role::Assistant)
215 .filter_map(|m| m.content.as_text())
216 .filter(|text| text.len() >= self.policy.min_reasoning_chars)
217 .map(|text| {
218 let summary_hint: String = text.chars().take(300).collect();
219 let confidence = (text.len() as f64 / 2000.0).min(0.8_f64).max(0.4_f64);
221 TraceInsight {
222 kind: InsightKind::LongReasoning { summary_hint },
223 confidence,
224 session_id: session_id.to_string(),
225 }
226 })
227 .collect()
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234 use crate::types::message::{ContentPart, ToolCall};
235 use compact_str::CompactString;
236 use pretty_assertions::assert_eq;
237
238 fn analyzer() -> TraceAnalyzer {
239 TraceAnalyzer::new(AnalysisPolicy::default())
240 }
241
242 fn assistant_with_tool(call_id: &str, tool_name: &str) -> Message {
243 let mut msg = Message::assistant("");
244 msg.tool_calls = vec![ToolCall {
245 id: CompactString::new(call_id),
246 name: CompactString::new(tool_name),
247 arguments: serde_json::Value::Null,
248 }];
249 msg
250 }
251
252 fn tool_error(call_id: &str, err: &str) -> Message {
253 Message::tool(vec![ContentPart::ToolResult {
254 call_id: CompactString::new(call_id),
255 output: err.to_string(),
256 is_error: true,
257 }])
258 }
259
260 fn tool_ok(call_id: &str) -> Message {
261 Message::tool(vec![ContentPart::ToolResult {
262 call_id: CompactString::new(call_id),
263 output: "ok".to_string(),
264 is_error: false,
265 }])
266 }
267
268 #[test]
269 fn detects_repeated_tool_errors() {
270 let messages = vec![
271 assistant_with_tool("c1", "bash"),
272 tool_error("c1", "permission denied"),
273 assistant_with_tool("c2", "bash"),
274 tool_error("c2", "permission denied"),
275 ];
276 let insights = analyzer().analyze("s1", &messages);
277 let errors: Vec<_> = insights
278 .iter()
279 .filter(|i| matches!(i.kind, InsightKind::RepeatedToolError { .. }))
280 .collect();
281 assert_eq!(errors.len(), 1);
282 if let InsightKind::RepeatedToolError {
283 tool_name,
284 error_count,
285 ..
286 } = &errors[0].kind
287 {
288 assert_eq!(tool_name, "bash");
289 assert_eq!(*error_count, 2);
290 }
291 }
292
293 #[test]
294 fn skips_single_error_below_threshold() {
295 let messages = vec![assistant_with_tool("c1", "bash"), tool_error("c1", "oops")];
296 let insights = analyzer().analyze("s1", &messages);
297 assert!(
298 insights
299 .iter()
300 .all(|i| !matches!(i.kind, InsightKind::RepeatedToolError { .. }))
301 );
302 }
303
304 #[test]
305 fn detects_successful_tool_sequence() {
306 let messages = vec![
307 Message::user("fix the bug"),
308 assistant_with_tool("c1", "read_file"),
309 tool_ok("c1"),
310 assistant_with_tool("c2", "edit_file"),
311 tool_ok("c2"),
312 Message::assistant("Done!"),
313 ];
314 let insights = analyzer().analyze("s1", &messages);
315 let seqs: Vec<_> = insights
316 .iter()
317 .filter(|i| matches!(i.kind, InsightKind::SuccessfulToolSequence { .. }))
318 .collect();
319 assert_eq!(seqs.len(), 1);
320 if let InsightKind::SuccessfulToolSequence {
321 tools,
322 context_hint,
323 } = &seqs[0].kind
324 {
325 assert_eq!(tools, &["read_file", "edit_file"]);
326 assert!(context_hint.contains("fix the bug"));
327 }
328 }
329
330 #[test]
331 fn resets_sequence_on_error() {
332 let messages = vec![
333 Message::user("do something"),
334 assistant_with_tool("c1", "bash"),
335 tool_error("c1", "fail"),
336 assistant_with_tool("c2", "bash"),
337 tool_ok("c2"),
338 Message::assistant("Done"),
339 ];
340 let insights = analyzer().analyze("s1", &messages);
341 assert!(
343 insights
344 .iter()
345 .all(|i| !matches!(i.kind, InsightKind::SuccessfulToolSequence { .. }))
346 );
347 }
348
349 #[test]
350 fn detects_long_reasoning() {
351 let long_text = "a".repeat(600);
352 let messages = vec![Message::assistant(long_text)];
353 let insights = analyzer().analyze("s1", &messages);
354 assert!(
355 insights
356 .iter()
357 .any(|i| matches!(i.kind, InsightKind::LongReasoning { .. }))
358 );
359 }
360}