1use 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#[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<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<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 let mut detected_phase = ConversationPhase::Unknown;
223
224 if let Some(recent) = messages.last() {
225 let content_lower = recent.content.to_lowercase();
226
227 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 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 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 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 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 selected
318 .extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
319 }
320 ConversationPhase::Unknown => {
321 selected
323 .extend_from_slice(&available_tools[..max_tools.min(available_tools.len())]);
324 }
325 }
326
327 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 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 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 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); }
367
368 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 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 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 let phase = self.detect_phase(conversation);
418 context.phase = phase;
419 debug!("Detected conversation phase: {:?}", phase);
420
421 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 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 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 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 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 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 pub fn current_phase(&self) -> ConversationPhase {
473 self.current_phase
474 }
475
476 pub fn clear_active_files(&mut self) {
478 self.active_files.clear();
479 }
480
481 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}