vtcode_core/core/
context_curator.rs

1//! Context Curator - Dynamic per-turn context selection
2//!
3//! Implements the iterative curation principle from Anthropic's context engineering guide.
4//! Each turn, we select the most relevant context from available information to pass to the model.
5
6use anyhow::Result;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::sync::Arc;
10use tokio::sync::RwLock;
11use tracing::{debug, info, warn};
12
13use super::decision_tracker::DecisionTracker;
14use super::token_budget::TokenBudgetManager;
15
16/// Conversation phase detection
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum ConversationPhase {
19    /// Initial exploration - understanding the codebase
20    Exploration,
21    /// Implementation - making changes
22    Implementation,
23    /// Validation - testing and verifying
24    Validation,
25    /// Debugging - fixing errors
26    Debugging,
27    /// Unknown - default state
28    Unknown,
29}
30
31impl Default for ConversationPhase {
32    fn default() -> Self {
33        Self::Unknown
34    }
35}
36
37/// Error context for tracking and learning
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct ErrorContext {
40    pub error_message: String,
41    pub tool_name: Option<String>,
42    pub resolution: Option<String>,
43    pub timestamp: std::time::SystemTime,
44}
45
46/// File summary for compact context
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct FileSummary {
49    pub path: String,
50    pub size_lines: usize,
51    pub last_modified: Option<std::time::SystemTime>,
52    pub summary: String,
53}
54
55/// Tool definition for context selection
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ToolDefinition {
58    pub name: String,
59    pub description: String,
60    pub estimated_tokens: usize,
61}
62
63/// Message for conversation history
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Message {
66    pub role: String,
67    pub content: String,
68    pub estimated_tokens: usize,
69}
70
71/// Curated context result
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct CuratedContext {
74    pub recent_messages: Vec<Message>,
75    pub active_files: Vec<FileSummary>,
76    pub ledger_summary: Option<String>,
77    pub recent_errors: Vec<ErrorContext>,
78    pub relevant_tools: Vec<ToolDefinition>,
79    pub estimated_tokens: usize,
80    pub phase: ConversationPhase,
81}
82
83impl CuratedContext {
84    pub fn new() -> Self {
85        Self {
86            recent_messages: Vec::new(),
87            active_files: Vec::new(),
88            ledger_summary: None,
89            recent_errors: Vec::new(),
90            relevant_tools: Vec::new(),
91            estimated_tokens: 0,
92            phase: ConversationPhase::Unknown,
93        }
94    }
95
96    pub fn add_recent_messages(&mut self, messages: &[Message], count: usize) {
97        let start = messages.len().saturating_sub(count);
98        self.recent_messages.extend_from_slice(&messages[start..]);
99        self.estimated_tokens += self
100            .recent_messages
101            .iter()
102            .map(|m| m.estimated_tokens)
103            .sum::<usize>();
104    }
105
106    pub fn add_file_context(&mut self, summary: FileSummary) {
107        self.estimated_tokens += summary.summary.len() / 4; // Rough estimate
108        self.active_files.push(summary);
109    }
110
111    pub fn add_ledger_summary(&mut self, summary: String) {
112        self.estimated_tokens += summary.len() / 4; // Rough estimate
113        self.ledger_summary = Some(summary);
114    }
115
116    pub fn add_error_context(&mut self, error: ErrorContext) {
117        self.estimated_tokens += error.error_message.len() / 4; // Rough estimate
118        self.recent_errors.push(error);
119    }
120
121    pub fn add_tools(&mut self, tools: Vec<ToolDefinition>) {
122        for tool in &tools {
123            self.estimated_tokens += tool.estimated_tokens;
124        }
125        self.relevant_tools = tools;
126    }
127}
128
129impl Default for CuratedContext {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135/// Configuration for context curation
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ContextCurationConfig {
138    /// Enable dynamic context curation
139    pub enabled: bool,
140    /// Maximum tokens per turn
141    pub max_tokens_per_turn: usize,
142    /// Number of recent messages to always include
143    pub preserve_recent_messages: usize,
144    /// Maximum tool descriptions to include
145    pub max_tool_descriptions: usize,
146    /// Include decision ledger summary
147    pub include_ledger: bool,
148    /// Maximum ledger entries
149    pub ledger_max_entries: usize,
150    /// Include recent errors
151    pub include_recent_errors: bool,
152    /// Maximum recent errors to include
153    pub max_recent_errors: usize,
154}
155
156impl Default for ContextCurationConfig {
157    fn default() -> Self {
158        Self {
159            enabled: true,
160            max_tokens_per_turn: 100_000,
161            preserve_recent_messages: 5,
162            max_tool_descriptions: 10,
163            include_ledger: true,
164            ledger_max_entries: 12,
165            include_recent_errors: true,
166            max_recent_errors: 3,
167        }
168    }
169}
170
171/// Dynamic context curator
172pub struct ContextCurator {
173    config: ContextCurationConfig,
174    token_budget: Arc<TokenBudgetManager>,
175    decision_ledger: Arc<RwLock<DecisionTracker>>,
176    active_files: HashSet<String>,
177    recent_errors: VecDeque<ErrorContext>,
178    file_summaries: HashMap<String, FileSummary>,
179    current_phase: ConversationPhase,
180}
181
182impl ContextCurator {
183    /// Create a new context curator
184    pub fn new(
185        config: ContextCurationConfig,
186        token_budget: Arc<TokenBudgetManager>,
187        decision_ledger: Arc<RwLock<DecisionTracker>>,
188    ) -> Self {
189        Self {
190            config,
191            token_budget,
192            decision_ledger,
193            active_files: HashSet::new(),
194            recent_errors: VecDeque::new(),
195            file_summaries: HashMap::new(),
196            current_phase: ConversationPhase::Unknown,
197        }
198    }
199
200    /// Mark a file as active in current context
201    pub fn mark_file_active(&mut self, path: String) {
202        self.active_files.insert(path);
203    }
204
205    /// Add error context
206    pub fn add_error(&mut self, error: ErrorContext) {
207        self.recent_errors.push_back(error);
208        if self.recent_errors.len() > self.config.max_recent_errors {
209            self.recent_errors.pop_front();
210        }
211        // Errors trigger debugging phase
212        self.current_phase = ConversationPhase::Debugging;
213    }
214
215    /// Add file summary
216    pub fn add_file_summary(&mut self, summary: FileSummary) {
217        self.file_summaries.insert(summary.path.clone(), summary);
218    }
219
220    /// Detect conversation phase from recent messages
221    fn detect_phase(&mut self, messages: &[Message]) -> ConversationPhase {
222        let mut detected_phase = ConversationPhase::Unknown;
223
224        if let Some(recent) = messages.last() {
225            let content_lower = recent.content.to_lowercase();
226
227            // Simple heuristic-based phase detection
228            if content_lower.contains("search")
229                || content_lower.contains("find")
230                || content_lower.contains("list")
231            {
232                detected_phase = ConversationPhase::Exploration;
233            } else if content_lower.contains("edit")
234                || content_lower.contains("write")
235                || content_lower.contains("create")
236                || content_lower.contains("modify")
237            {
238                detected_phase = ConversationPhase::Implementation;
239            } else if content_lower.contains("test")
240                || content_lower.contains("run")
241                || content_lower.contains("check")
242                || content_lower.contains("verify")
243            {
244                detected_phase = ConversationPhase::Validation;
245            } else if content_lower.contains("error")
246                || content_lower.contains("fix")
247                || content_lower.contains("debug")
248            {
249                detected_phase = ConversationPhase::Debugging;
250            }
251        }
252
253        if detected_phase == ConversationPhase::Unknown && !self.recent_errors.is_empty() {
254            detected_phase = ConversationPhase::Debugging;
255        }
256
257        if detected_phase == ConversationPhase::Unknown {
258            detected_phase = self.current_phase;
259        }
260
261        self.current_phase = detected_phase;
262        detected_phase
263    }
264
265    /// Select relevant tools based on phase
266    fn select_relevant_tools(
267        &self,
268        available_tools: &[ToolDefinition],
269        phase: ConversationPhase,
270    ) -> Vec<ToolDefinition> {
271        let mut selected = Vec::new();
272        let max_tools = self.config.max_tool_descriptions;
273
274        match phase {
275            ConversationPhase::Exploration => {
276                // Prioritize search and exploration tools
277                for tool in available_tools {
278                    if tool.name.contains("grep")
279                        || tool.name.contains("list")
280                        || tool.name.contains("search")
281                        || tool.name.contains("ast_grep")
282                    {
283                        selected.push(tool.clone());
284                        if selected.len() >= max_tools {
285                            break;
286                        }
287                    }
288                }
289            }
290            ConversationPhase::Implementation => {
291                // Prioritize file operation tools
292                for tool in available_tools {
293                    if tool.name.contains("edit")
294                        || tool.name.contains("write")
295                        || tool.name.contains("read")
296                    {
297                        selected.push(tool.clone());
298                        if selected.len() >= max_tools {
299                            break;
300                        }
301                    }
302                }
303            }
304            ConversationPhase::Validation => {
305                // Prioritize execution tools
306                for tool in available_tools {
307                    if tool.name.contains("run") || tool.name.contains("terminal") {
308                        selected.push(tool.clone());
309                        if selected.len() >= max_tools {
310                            break;
311                        }
312                    }
313                }
314            }
315            ConversationPhase::Debugging => {
316                // Include diverse tools for debugging
317                selected
318                    .extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
319            }
320            ConversationPhase::Unknown => {
321                // Include most commonly used tools
322                selected
323                    .extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
324            }
325        }
326
327        // If we haven't filled our quota, add more tools
328        if selected.len() < max_tools {
329            for tool in available_tools {
330                if !selected.iter().any(|t| t.name == tool.name) {
331                    selected.push(tool.clone());
332                    if selected.len() >= max_tools {
333                        break;
334                    }
335                }
336            }
337        }
338
339        selected
340    }
341
342    /// Compress context if needed
343    fn compress_context(&self, mut context: CuratedContext, budget: usize) -> CuratedContext {
344        if context.estimated_tokens <= budget {
345            return context;
346        }
347
348        info!(
349            "Context compression needed: {} tokens > {} budget",
350            context.estimated_tokens, budget
351        );
352
353        // Reduce tools first
354        while context.estimated_tokens > budget && context.relevant_tools.len() > 5 {
355            if let Some(tool) = context.relevant_tools.pop() {
356                context.estimated_tokens = context
357                    .estimated_tokens
358                    .saturating_sub(tool.estimated_tokens);
359            }
360        }
361
362        // Reduce file contexts
363        while context.estimated_tokens > budget && !context.active_files.is_empty() {
364            context.active_files.pop();
365            context.estimated_tokens = context.estimated_tokens.saturating_sub(100); // Rough estimate
366        }
367
368        // Reduce errors
369        while context.estimated_tokens > budget && !context.recent_errors.is_empty() {
370            if let Some(error) = context.recent_errors.pop() {
371                context.estimated_tokens = context
372                    .estimated_tokens
373                    .saturating_sub(error.error_message.len() / 4);
374            }
375        }
376
377        // Reduce messages (keep at least 3)
378        while context.estimated_tokens > budget && context.recent_messages.len() > 3 {
379            if let Some(msg) = context.recent_messages.first() {
380                context.estimated_tokens = context
381                    .estimated_tokens
382                    .saturating_sub(msg.estimated_tokens);
383                context.recent_messages.remove(0);
384            }
385        }
386
387        warn!(
388            "Context compressed to {} tokens (target: {})",
389            context.estimated_tokens, budget
390        );
391
392        context
393    }
394
395    /// Curate context for the next model call
396    pub async fn curate_context(
397        &mut self,
398        conversation: &[Message],
399        available_tools: &[ToolDefinition],
400    ) -> Result<CuratedContext> {
401        if !self.config.enabled {
402            debug!("Context curation disabled, returning default context");
403            let mut context = CuratedContext::new();
404            context.add_recent_messages(conversation, conversation.len());
405            context.add_tools(available_tools.to_vec());
406            return Ok(context);
407        }
408
409        let remaining = self.token_budget.remaining_tokens().await;
410        let budget = remaining.min(self.config.max_tokens_per_turn);
411
412        debug!("Curating context with budget: {} tokens", budget);
413
414        let mut context = CuratedContext::new();
415
416        // Detect phase
417        let phase = self.detect_phase(conversation);
418        context.phase = phase;
419        debug!("Detected conversation phase: {:?}", phase);
420
421        // Priority 1: Recent messages (always include)
422        let message_count = self.config.preserve_recent_messages.min(conversation.len());
423        context.add_recent_messages(conversation, message_count);
424        debug!("Added {} recent messages", message_count);
425
426        // Priority 2: Active work context (files being modified)
427        for file_path in &self.active_files {
428            if let Some(summary) = self.file_summaries.get(file_path) {
429                context.add_file_context(summary.clone());
430            }
431        }
432        debug!("Added {} active files", context.active_files.len());
433
434        // Priority 3: Decision ledger (compact)
435        if self.config.include_ledger {
436            let ledger = self.decision_ledger.read().await;
437            let summary = ledger.render_ledger_brief(self.config.ledger_max_entries);
438            if !summary.is_empty() {
439                context.add_ledger_summary(summary);
440                debug!("Added decision ledger summary");
441            }
442        }
443
444        // Priority 4: Recent errors and resolutions
445        if self.config.include_recent_errors {
446            let error_count = self.config.max_recent_errors.min(self.recent_errors.len());
447            for error in self.recent_errors.iter().rev().take(error_count) {
448                context.add_error_context(error.clone());
449            }
450            debug!("Added {} recent errors", error_count);
451        }
452
453        // Priority 5: Relevant tools (phase-aware selection)
454        let relevant_tools = self.select_relevant_tools(available_tools, phase);
455        context.add_tools(relevant_tools.clone());
456        debug!("Added {} relevant tools", relevant_tools.len());
457
458        // Check budget and compress if needed
459        if context.estimated_tokens > budget {
460            context = self.compress_context(context, budget);
461        }
462
463        info!(
464            "Curated context: {} tokens (budget: {}), phase: {:?}",
465            context.estimated_tokens, budget, phase
466        );
467
468        Ok(context)
469    }
470
471    /// Get current conversation phase
472    pub fn current_phase(&self) -> ConversationPhase {
473        self.current_phase
474    }
475
476    /// Clear active files (after task completion)
477    pub fn clear_active_files(&mut self) {
478        self.active_files.clear();
479    }
480
481    /// Clear recent errors (after resolution)
482    pub fn clear_errors(&mut self) {
483        self.recent_errors.clear();
484    }
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::core::token_budget::TokenBudgetConfig as CoreTokenBudgetConfig;
491
492    #[tokio::test]
493    async fn test_context_curation_basic() {
494        let token_budget_config = CoreTokenBudgetConfig::for_model("gpt-4o-mini", 128_000);
495        let token_budget = Arc::new(TokenBudgetManager::new(token_budget_config));
496        let decision_ledger = Arc::new(RwLock::new(DecisionTracker::new()));
497        let curation_config = ContextCurationConfig::default();
498
499        let mut curator = ContextCurator::new(curation_config, token_budget, decision_ledger);
500
501        let messages = vec![Message {
502            role: "user".to_string(),
503            content: "Search for the main function".to_string(),
504            estimated_tokens: 10,
505        }];
506
507        let tools = vec![
508            ToolDefinition {
509                name: "grep_search".to_string(),
510                description: "Search for patterns".to_string(),
511                estimated_tokens: 50,
512            },
513            ToolDefinition {
514                name: "edit_file".to_string(),
515                description: "Edit a file".to_string(),
516                estimated_tokens: 50,
517            },
518        ];
519
520        let context = curator.curate_context(&messages, &tools).await.unwrap();
521
522        assert_eq!(context.phase, ConversationPhase::Exploration);
523        assert_eq!(context.recent_messages.len(), 1);
524        assert!(!context.relevant_tools.is_empty());
525    }
526
527    #[test]
528    fn test_phase_detection() {
529        let token_budget_config = CoreTokenBudgetConfig::for_model("gpt-4o-mini", 128_000);
530        let token_budget = Arc::new(TokenBudgetManager::new(token_budget_config));
531        let decision_ledger = Arc::new(RwLock::new(DecisionTracker::new()));
532        let curation_config = ContextCurationConfig::default();
533
534        let mut curator = ContextCurator::new(curation_config, token_budget, decision_ledger);
535
536        let messages = vec![Message {
537            role: "user".to_string(),
538            content: "Edit the config file".to_string(),
539            estimated_tokens: 10,
540        }];
541
542        let phase = curator.detect_phase(&messages);
543        assert_eq!(phase, ConversationPhase::Implementation);
544    }
545}