Skip to main content

opendev_memory/
reflector.rs

1//! Execution reflector for extracting learnable patterns from tool executions.
2//!
3//! Mirrors `opendev/core/context_engineering/memory/reflection/reflector.py`.
4
5use std::collections::HashMap;
6
7/// Result of reflecting on a tool execution sequence.
8#[derive(Debug, Clone)]
9pub struct ReflectionResult {
10    pub category: String,
11    pub content: String,
12    pub confidence: f64,
13    pub reasoning: String,
14}
15
16/// Lightweight representation of a tool call for reflection analysis.
17#[derive(Debug, Clone)]
18pub struct ToolCallInfo {
19    pub name: String,
20    pub parameters: HashMap<String, String>,
21}
22
23/// Analyzes tool execution sequences to extract learnable patterns.
24///
25/// Identifies patterns worth learning from tool executions,
26/// such as multi-step workflows, error recovery, and best practices.
27pub struct ExecutionReflector {
28    pub min_tool_calls: usize,
29    pub min_confidence: f64,
30}
31
32impl ExecutionReflector {
33    /// Create a new execution reflector.
34    pub fn new(min_tool_calls: usize, min_confidence: f64) -> Self {
35        Self {
36            min_tool_calls,
37            min_confidence,
38        }
39    }
40
41    /// Extract a reusable strategy from tool execution.
42    pub fn reflect(
43        &self,
44        _query: &str,
45        tool_calls: &[ToolCallInfo],
46        outcome: &str,
47    ) -> Option<ReflectionResult> {
48        if !self.is_worth_learning(tool_calls, outcome) {
49            return None;
50        }
51
52        let result = self
53            .extract_file_operation_pattern(tool_calls)
54            .or_else(|| self.extract_code_navigation_pattern(tool_calls))
55            .or_else(|| self.extract_testing_pattern(tool_calls))
56            .or_else(|| self.extract_shell_command_pattern(tool_calls))
57            .or_else(|| self.extract_error_recovery_pattern(tool_calls, outcome));
58
59        result.filter(|r| r.confidence >= self.min_confidence)
60    }
61
62    fn is_worth_learning(&self, tool_calls: &[ToolCallInfo], outcome: &str) -> bool {
63        // Always learn from error recovery
64        if outcome == "error" && !tool_calls.is_empty() {
65            return true;
66        }
67
68        // Skip single trivial operations
69        if tool_calls.len() == 1 {
70            let name = &tool_calls[0].name;
71            if name == "read_file" || name == "list_files" {
72                return false;
73            }
74        }
75
76        // Learn from multi-step sequences
77        if tool_calls.len() >= self.min_tool_calls {
78            return true;
79        }
80
81        false
82    }
83
84    fn extract_file_operation_pattern(
85        &self,
86        tool_calls: &[ToolCallInfo],
87    ) -> Option<ReflectionResult> {
88        let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
89
90        // Pattern: list_files -> read_file
91        if let (Some(list_idx), Some(read_idx)) = (
92            names.iter().position(|&n| n == "list_files"),
93            names.iter().position(|&n| n == "read_file"),
94        ) && list_idx < read_idx
95        {
96            return Some(ReflectionResult {
97                category: "file_operations".to_string(),
98                content: "List directory contents before reading files to understand \
99                              structure and locate files"
100                    .to_string(),
101                confidence: 0.75,
102                reasoning: "Sequential list_files -> read_file pattern shows exploratory \
103                                file access"
104                    .to_string(),
105            });
106        }
107
108        // Pattern: read_file -> write_file
109        if let (Some(read_idx), Some(write_idx)) = (
110            names.iter().position(|&n| n == "read_file"),
111            names.iter().position(|&n| n == "write_file"),
112        ) && read_idx < write_idx
113        {
114            return Some(ReflectionResult {
115                category: "file_operations".to_string(),
116                content: "Read file contents before writing to understand current state \
117                              and preserve important data"
118                    .to_string(),
119                confidence: 0.8,
120                reasoning: "Sequential read_file -> write_file shows safe modification \
121                                workflow"
122                    .to_string(),
123            });
124        }
125
126        // Pattern: multiple read_file calls
127        let read_count = names.iter().filter(|&&n| n == "read_file").count();
128        if read_count >= 3 {
129            return Some(ReflectionResult {
130                category: "code_navigation".to_string(),
131                content: "When understanding complex code, read multiple related files to \
132                          build complete picture"
133                    .to_string(),
134                confidence: 0.7,
135                reasoning: format!(
136                    "Multiple file reads ({read_count}) indicates thorough code exploration"
137                ),
138            });
139        }
140
141        None
142    }
143
144    fn extract_code_navigation_pattern(
145        &self,
146        tool_calls: &[ToolCallInfo],
147    ) -> Option<ReflectionResult> {
148        let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
149
150        // Pattern: search -> read_file
151        if let (Some(search_idx), Some(read_idx)) = (
152            names.iter().position(|&n| n == "search"),
153            names.iter().position(|&n| n == "read_file"),
154        ) && search_idx < read_idx
155        {
156            return Some(ReflectionResult {
157                category: "code_navigation".to_string(),
158                content: "Search for keywords or patterns before reading files to locate \
159                              relevant code efficiently"
160                    .to_string(),
161                confidence: 0.8,
162                reasoning: "Search followed by read shows targeted file access".to_string(),
163            });
164        }
165
166        // Pattern: multiple searches
167        let search_count = names.iter().filter(|&&n| n == "search").count();
168        if search_count >= 2 {
169            return Some(ReflectionResult {
170                category: "code_navigation".to_string(),
171                content: "Use multiple searches with different keywords to thoroughly explore \
172                          codebase and find all relevant locations"
173                    .to_string(),
174                confidence: 0.7,
175                reasoning: format!(
176                    "Multiple searches ({search_count}) shows iterative code exploration"
177                ),
178            });
179        }
180
181        None
182    }
183
184    fn extract_testing_pattern(&self, tool_calls: &[ToolCallInfo]) -> Option<ReflectionResult> {
185        let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
186
187        let test_keywords = ["test", "pytest", "jest", "npm test"];
188        let has_test_command = tool_calls.iter().any(|tc| {
189            tc.name == "run_command"
190                && tc.parameters.get("command").is_some_and(|cmd| {
191                    let lower = cmd.to_lowercase();
192                    test_keywords.iter().any(|kw| lower.contains(kw))
193                })
194        });
195
196        if !has_test_command {
197            return None;
198        }
199
200        if names.contains(&"write_file") || names.contains(&"edit_file") {
201            return Some(ReflectionResult {
202                category: "testing".to_string(),
203                content: "Run tests after making code changes to verify correctness and \
204                          catch regressions early"
205                    .to_string(),
206                confidence: 0.85,
207                reasoning: "Code modification followed by test execution shows good \
208                            development practice"
209                    .to_string(),
210            });
211        }
212
213        None
214    }
215
216    fn extract_shell_command_pattern(
217        &self,
218        tool_calls: &[ToolCallInfo],
219    ) -> Option<ReflectionResult> {
220        let commands: Vec<&str> = tool_calls
221            .iter()
222            .filter(|tc| tc.name == "run_command")
223            .filter_map(|tc| tc.parameters.get("command").map(String::as_str))
224            .collect();
225
226        if commands.len() < 2 {
227            return None;
228        }
229
230        let install_keywords = [
231            "npm install",
232            "pip install",
233            "yarn install",
234            "poetry install",
235        ];
236        let run_keywords = ["npm start", "python", "node", "pytest"];
237
238        let has_install = commands.iter().any(|cmd| {
239            install_keywords
240                .iter()
241                .any(|kw| cmd.to_lowercase().contains(kw))
242        });
243        let has_run = commands.iter().any(|cmd| {
244            run_keywords
245                .iter()
246                .any(|kw| cmd.to_lowercase().contains(kw))
247        });
248
249        if has_install && has_run {
250            return Some(ReflectionResult {
251                category: "shell_commands".to_string(),
252                content: "Install dependencies before running or testing applications to \
253                          ensure all requirements are met"
254                    .to_string(),
255                confidence: 0.8,
256                reasoning: "Install followed by run/test shows proper setup workflow".to_string(),
257            });
258        }
259
260        if commands.iter().any(|cmd| cmd.contains("git status")) {
261            return Some(ReflectionResult {
262                category: "git_operations".to_string(),
263                content: "Check git status before performing git operations to understand \
264                          current state and avoid mistakes"
265                    .to_string(),
266                confidence: 0.75,
267                reasoning: "Git status check before operations shows careful version control \
268                            practice"
269                    .to_string(),
270            });
271        }
272
273        None
274    }
275
276    fn extract_error_recovery_pattern(
277        &self,
278        tool_calls: &[ToolCallInfo],
279        outcome: &str,
280    ) -> Option<ReflectionResult> {
281        if outcome != "error" {
282            return None;
283        }
284
285        let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect();
286
287        if names.contains(&"read_file") {
288            return Some(ReflectionResult {
289                category: "error_handling".to_string(),
290                content: "When file access fails, list directory first to verify file exists \
291                          and check path correctness"
292                    .to_string(),
293                confidence: 0.7,
294                reasoning: "File access error suggests need for directory verification".to_string(),
295            });
296        }
297
298        if names.contains(&"run_command") {
299            return Some(ReflectionResult {
300                category: "error_handling".to_string(),
301                content: "When commands fail, verify environment setup, dependencies, and \
302                          working directory before retrying"
303                    .to_string(),
304                confidence: 0.65,
305                reasoning: "Command execution error suggests environment or dependency issue"
306                    .to_string(),
307            });
308        }
309
310        None
311    }
312}
313
314impl Default for ExecutionReflector {
315    fn default() -> Self {
316        Self::new(2, 0.6)
317    }
318}
319
320/// Default recency decay factor per day.
321const RECENCY_DECAY: f64 = 0.95;
322
323/// Score a reflection by evidence count and recency.
324///
325/// The score combines evidence strength with temporal decay:
326///   `score = evidence_count * recency_decay^age_days`
327///
328/// - More evidence (observations supporting the reflection) increases the score.
329/// - Older reflections decay exponentially, encouraging fresh insights.
330/// - A reflection with zero evidence scores 0.0 regardless of age.
331///
332/// # Arguments
333/// * `_reflection` - The reflection text (reserved for future content-based scoring).
334/// * `evidence_count` - Number of supporting observations.
335/// * `age_days` - How many days old the reflection is.
336pub fn score_reflection(_reflection: &str, evidence_count: usize, age_days: u64) -> f64 {
337    if evidence_count == 0 {
338        return 0.0;
339    }
340    let decay = RECENCY_DECAY.powi(age_days as i32);
341    evidence_count as f64 * decay
342}
343
344#[cfg(test)]
345#[path = "reflector_tests.rs"]
346mod tests;