Skip to main content

deepstrike_core/memory/
trace_analyzer.rs

1use std::collections::HashMap;
2
3use crate::types::message::{Content, ContentPart, Message, Role};
4
5/// A single insight extracted from a session's message trace.
6#[derive(Debug, Clone)]
7pub struct TraceInsight {
8    pub kind: InsightKind,
9    /// 0.0–1.0; scales with evidence strength (error frequency, sequence length, etc.)
10    pub confidence: f64,
11    pub session_id: String,
12}
13
14#[derive(Debug, Clone)]
15pub enum InsightKind {
16    /// The same tool failed at least `min_error_count` times in one session.
17    RepeatedToolError {
18        tool_name: String,
19        error_count: usize,
20        /// First observed error output, truncated to 200 chars.
21        sample_error: String,
22    },
23    /// A consecutive run of tool calls that completed without any error,
24    /// followed by an assistant turn with no further tool calls.
25    SuccessfulToolSequence {
26        tools: Vec<String>,
27        /// The user message that kicked off the sequence, truncated to 100 chars.
28        context_hint: String,
29    },
30    /// An assistant message whose reasoning block exceeds `min_reasoning_chars`.
31    LongReasoning {
32        /// First 300 chars of the assistant text.
33        summary_hint: String,
34    },
35    /// Free-form insight generated by the LLM during the dreaming synthesis phase.
36    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    /// Minimum number of errors on the same tool before flagging. Default: 2.
53    pub min_error_count: usize,
54    /// Minimum consecutive tool calls for a sequence to be notable. Default: 2.
55    pub min_success_sequence_len: usize,
56    /// Minimum char length of an assistant message to treat as long reasoning. Default: 500.
57    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    /// Analyze all sessions in one pass. Sessions are `(session_id, messages)` tuples.
80    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    // --- private detectors ---------------------------------------------------
96
97    fn detect_repeated_errors(&self, session_id: &str, messages: &[Message]) -> Vec<TraceInsight> {
98        // Build call_id → tool_name index from assistant messages.
99        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        // Count errors per tool; store first observed sample.
109        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 scales with frequency, saturating at 1.0 after 5 errors.
145                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                    // The most recent user message becomes the context hint for the next sequence.
165                    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                        // Assistant is done — emit insight if the sequence was clean.
172                        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                // Confidence grows with length, saturating at 0.8 around 2000 chars.
220                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        // Sequence only has 1 clean tool call after the error, below min_success_sequence_len=2.
342        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}