1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum ConversationPhase {
19 Exploration,
21 Implementation,
23 Validation,
25 Debugging,
27 Unknown,
29}
30
31impl Default for ConversationPhase {
32 fn default() -> Self {
33 Self::Unknown
34 }
35}
36
37#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct ToolDefinition {
58 pub name: String,
59 pub description: String,
60 pub estimated_tokens: usize,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Message {
66 pub role: String,
67 pub content: String,
68 pub estimated_tokens: usize,
69}
70
71#[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; self.active_files.push(summary);
109 }
110
111 pub fn add_ledger_summary(&mut self, summary: String) {
112 self.estimated_tokens += summary.len() / 4; 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; 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#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ContextCurationConfig {
138 pub enabled: bool,
140 pub max_tokens_per_turn: usize,
142 pub preserve_recent_messages: usize,
144 pub max_tool_descriptions: usize,
146 pub include_ledger: bool,
148 pub ledger_max_entries: usize,
150 pub include_recent_errors: bool,
152 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
171pub 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 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 pub fn mark_file_active(&mut self, path: String) {
202 self.active_files.insert(path);
203 }
204
205 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 self.current_phase = ConversationPhase::Debugging;
213 }
214
215 pub fn add_file_summary(&mut self, summary: FileSummary) {
217 self.file_summaries.insert(summary.path.clone(), summary);
218 }
219
220 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 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 !self.recent_errors.is_empty() {
254 return ConversationPhase::Debugging;
255 }
256
257 self.current_phase
258 }
259
260 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 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 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 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 selected.extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
313 }
314 ConversationPhase::Unknown => {
315 selected.extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
317 }
318 }
319
320 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 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 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 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); }
358
359 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 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 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 let phase = self.detect_phase(conversation);
409 context.phase = phase;
410 debug!("Detected conversation phase: {:?}", phase);
411
412 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 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 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 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 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 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 pub fn current_phase(&self) -> ConversationPhase {
464 self.current_phase
465 }
466
467 pub fn clear_active_files(&mut self) {
469 self.active_files.clear();
470 }
471
472 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}