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 parking_lot::RwLock;
8use serde::{Deserialize, Serialize};
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::sync::Arc;
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<RwLock<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<RwLock<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        if !messages.is_empty() {
223            let recent = messages.last().unwrap();
224            let content_lower = recent.content.to_lowercase();
225
226            // Simple heuristic-based phase detection
227            if content_lower.contains("search")
228                || content_lower.contains("find")
229                || content_lower.contains("list")
230            {
231                return ConversationPhase::Exploration;
232            } else if content_lower.contains("edit")
233                || content_lower.contains("write")
234                || content_lower.contains("create")
235                || content_lower.contains("modify")
236            {
237                return ConversationPhase::Implementation;
238            } else if content_lower.contains("test")
239                || content_lower.contains("run")
240                || content_lower.contains("check")
241                || content_lower.contains("verify")
242            {
243                return ConversationPhase::Validation;
244            } else if content_lower.contains("error")
245                || content_lower.contains("fix")
246                || content_lower.contains("debug")
247            {
248                return ConversationPhase::Debugging;
249            }
250        }
251
252        // If we have recent errors, stay in debugging
253        if !self.recent_errors.is_empty() {
254            return ConversationPhase::Debugging;
255        }
256
257        self.current_phase
258    }
259
260    /// Select relevant tools based on phase
261    fn select_relevant_tools(
262        &self,
263        available_tools: &[ToolDefinition],
264        phase: ConversationPhase,
265    ) -> Vec<ToolDefinition> {
266        let mut selected = Vec::new();
267        let max_tools = self.config.max_tool_descriptions;
268
269        match phase {
270            ConversationPhase::Exploration => {
271                // Prioritize search and exploration tools
272                for tool in available_tools {
273                    if tool.name.contains("grep")
274                        || tool.name.contains("list")
275                        || tool.name.contains("search")
276                        || tool.name.contains("ast_grep")
277                    {
278                        selected.push(tool.clone());
279                        if selected.len() >= max_tools {
280                            break;
281                        }
282                    }
283                }
284            }
285            ConversationPhase::Implementation => {
286                // Prioritize file operation tools
287                for tool in available_tools {
288                    if tool.name.contains("edit")
289                        || tool.name.contains("write")
290                        || tool.name.contains("read")
291                    {
292                        selected.push(tool.clone());
293                        if selected.len() >= max_tools {
294                            break;
295                        }
296                    }
297                }
298            }
299            ConversationPhase::Validation => {
300                // Prioritize execution tools
301                for tool in available_tools {
302                    if tool.name.contains("run") || tool.name.contains("terminal") {
303                        selected.push(tool.clone());
304                        if selected.len() >= max_tools {
305                            break;
306                        }
307                    }
308                }
309            }
310            ConversationPhase::Debugging => {
311                // Include diverse tools for debugging
312                selected.extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
313            }
314            ConversationPhase::Unknown => {
315                // Include most commonly used tools
316                selected.extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
317            }
318        }
319
320        // If we haven't filled our quota, add more tools
321        if selected.len() < max_tools {
322            for tool in available_tools {
323                if !selected.iter().any(|t| t.name == tool.name) {
324                    selected.push(tool.clone());
325                    if selected.len() >= max_tools {
326                        break;
327                    }
328                }
329            }
330        }
331
332        selected
333    }
334
335    /// Compress context if needed
336    fn compress_context(&self, mut context: CuratedContext, budget: usize) -> CuratedContext {
337        if context.estimated_tokens <= budget {
338            return context;
339        }
340
341        info!(
342            "Context compression needed: {} tokens > {} budget",
343            context.estimated_tokens, budget
344        );
345
346        // Reduce tools first
347        while context.estimated_tokens > budget && context.relevant_tools.len() > 5 {
348            if let Some(tool) = context.relevant_tools.pop() {
349                context.estimated_tokens = context.estimated_tokens.saturating_sub(tool.estimated_tokens);
350            }
351        }
352
353        // Reduce file contexts
354        while context.estimated_tokens > budget && !context.active_files.is_empty() {
355            context.active_files.pop();
356            context.estimated_tokens = context.estimated_tokens.saturating_sub(100); // Rough estimate
357        }
358
359        // Reduce errors
360        while context.estimated_tokens > budget && !context.recent_errors.is_empty() {
361            if let Some(error) = context.recent_errors.pop() {
362                context.estimated_tokens =
363                    context.estimated_tokens.saturating_sub(error.error_message.len() / 4);
364            }
365        }
366
367        // Reduce messages (keep at least 3)
368        while context.estimated_tokens > budget && context.recent_messages.len() > 3 {
369            if let Some(msg) = context.recent_messages.first() {
370                context.estimated_tokens = context.estimated_tokens.saturating_sub(msg.estimated_tokens);
371                context.recent_messages.remove(0);
372            }
373        }
374
375        warn!(
376            "Context compressed to {} tokens (target: {})",
377            context.estimated_tokens, budget
378        );
379
380        context
381    }
382
383    /// Curate context for the next model call
384    pub async fn curate_context(
385        &mut self,
386        conversation: &[Message],
387        available_tools: &[ToolDefinition],
388    ) -> Result<CuratedContext> {
389        if !self.config.enabled {
390            debug!("Context curation disabled, returning default context");
391            let mut context = CuratedContext::new();
392            context.add_recent_messages(conversation, conversation.len());
393            context.add_tools(available_tools.to_vec());
394            return Ok(context);
395        }
396
397        let budget = {
398            let token_budget = self.token_budget.read();
399            let remaining = token_budget.remaining_tokens().await;
400            remaining.min(self.config.max_tokens_per_turn)
401        };
402
403        debug!("Curating context with budget: {} tokens", budget);
404
405        let mut context = CuratedContext::new();
406
407        // Detect phase
408        let phase = self.detect_phase(conversation);
409        context.phase = phase;
410        debug!("Detected conversation phase: {:?}", phase);
411
412        // Priority 1: Recent messages (always include)
413        let message_count = self.config.preserve_recent_messages.min(conversation.len());
414        context.add_recent_messages(conversation, message_count);
415        debug!("Added {} recent messages", message_count);
416
417        // Priority 2: Active work context (files being modified)
418        for file_path in &self.active_files {
419            if let Some(summary) = self.file_summaries.get(file_path) {
420                context.add_file_context(summary.clone());
421            }
422        }
423        debug!("Added {} active files", context.active_files.len());
424
425        // Priority 3: Decision ledger (compact)
426        if self.config.include_ledger {
427            let ledger = self.decision_ledger.read();
428            let summary = ledger.render_ledger_brief(self.config.ledger_max_entries);
429            if !summary.is_empty() {
430                context.add_ledger_summary(summary);
431                debug!("Added decision ledger summary");
432            }
433        }
434
435        // Priority 4: Recent errors and resolutions
436        if self.config.include_recent_errors {
437            let error_count = self.config.max_recent_errors.min(self.recent_errors.len());
438            for error in self.recent_errors.iter().rev().take(error_count) {
439                context.add_error_context(error.clone());
440            }
441            debug!("Added {} recent errors", error_count);
442        }
443
444        // Priority 5: Relevant tools (phase-aware selection)
445        let relevant_tools = self.select_relevant_tools(available_tools, phase);
446        context.add_tools(relevant_tools.clone());
447        debug!("Added {} relevant tools", relevant_tools.len());
448
449        // Check budget and compress if needed
450        if context.estimated_tokens > budget {
451            context = self.compress_context(context, budget);
452        }
453
454        info!(
455            "Curated context: {} tokens (budget: {}), phase: {:?}",
456            context.estimated_tokens, budget, phase
457        );
458
459        Ok(context)
460    }
461
462    /// Get current conversation phase
463    pub fn current_phase(&self) -> ConversationPhase {
464        self.current_phase
465    }
466
467    /// Clear active files (after task completion)
468    pub fn clear_active_files(&mut self) {
469        self.active_files.clear();
470    }
471
472    /// Clear recent errors (after resolution)
473    pub fn clear_errors(&mut self) {
474        self.recent_errors.clear();
475    }
476}
477
478#[cfg(test)]
479mod tests {
480    use super::*;
481    use crate::core::token_budget::TokenBudgetConfig as CoreTokenBudgetConfig;
482
483    #[tokio::test]
484    async fn test_context_curation_basic() {
485        let token_budget_config = CoreTokenBudgetConfig::for_model("gpt-4o-mini", 128_000);
486        let token_budget = Arc::new(RwLock::new(TokenBudgetManager::new(token_budget_config)));
487        let decision_ledger = Arc::new(RwLock::new(DecisionTracker::new()));
488        let curation_config = ContextCurationConfig::default();
489
490        let mut curator = ContextCurator::new(curation_config, token_budget, decision_ledger);
491
492        let messages = vec![Message {
493            role: "user".to_string(),
494            content: "Search for the main function".to_string(),
495            estimated_tokens: 10,
496        }];
497
498        let tools = vec![
499            ToolDefinition {
500                name: "grep_search".to_string(),
501                description: "Search for patterns".to_string(),
502                estimated_tokens: 50,
503            },
504            ToolDefinition {
505                name: "edit_file".to_string(),
506                description: "Edit a file".to_string(),
507                estimated_tokens: 50,
508            },
509        ];
510
511        let context = curator.curate_context(&messages, &tools).await.unwrap();
512
513        assert_eq!(context.phase, ConversationPhase::Exploration);
514        assert_eq!(context.recent_messages.len(), 1);
515        assert!(!context.relevant_tools.is_empty());
516    }
517
518    #[test]
519    fn test_phase_detection() {
520        let token_budget_config = CoreTokenBudgetConfig::for_model("gpt-4o-mini", 128_000);
521        let token_budget = Arc::new(RwLock::new(TokenBudgetManager::new(token_budget_config)));
522        let decision_ledger = Arc::new(RwLock::new(DecisionTracker::new()));
523        let curation_config = ContextCurationConfig::default();
524
525        let mut curator = ContextCurator::new(curation_config, token_budget, decision_ledger);
526
527        let messages = vec![Message {
528            role: "user".to_string(),
529            content: "Edit the config file".to_string(),
530            estimated_tokens: 10,
531        }];
532
533        let phase = curator.detect_phase(&messages);
534        assert_eq!(phase, ConversationPhase::Implementation);
535    }
536}