Skip to main content

pawan/
handoff.rs

1//! Handoff prompt generation for session context transfer
2//!
3//! This module provides functionality to generate focused handoff prompts
4//! that preserve essential context while stripping noise from conversations.
5
6use crate::agent::{Message, Role};
7use std::collections::HashSet;
8
9/// Configuration for handoff prompt generation
10#[derive(Debug, Clone)]
11pub struct HandoffConfig {
12    /// Maximum number of constraints to include
13    pub max_constraints: usize,
14    /// Maximum number of tasks to include
15    pub max_tasks: usize,
16    /// Maximum number of recent messages to include
17    pub max_recent_messages: usize,
18    /// Maximum length for message previews
19    pub max_preview_length: usize,
20}
21
22impl Default for HandoffConfig {
23    fn default() -> Self {
24        Self {
25            max_constraints: 10,
26            max_tasks: 15,
27            max_recent_messages: 3,
28            max_preview_length: 200,
29        }
30    }
31}
32
33/// Extracted context from a session for handoff
34#[derive(Debug, Clone)]
35pub struct HandoffContext {
36    /// File paths referenced in the conversation
37    pub file_paths: Vec<String>,
38    /// Constraints and requirements
39    pub constraints: Vec<String>,
40    /// Key tasks and action items
41    pub tasks: Vec<String>,
42    /// Recent messages for context
43    pub recent_messages: Vec<(Role, String)>,
44    /// Session tags
45    pub tags: Vec<String>,
46    /// Session notes
47    pub notes: String,
48}
49
50/// Session metadata for handoff
51#[derive(Debug, Clone)]
52pub struct HandoffMetadata {
53    /// Model used
54    pub model: String,
55    /// Total message count
56    pub message_count: usize,
57    /// Tool calls made
58    pub tool_calls: usize,
59    /// Files edited
60    pub files_edited: usize,
61}
62
63/// Generate a handoff prompt from conversation messages
64///
65/// This function extracts key information from messages and generates
66/// a structured handoff prompt that preserves essential context while
67/// removing noise and redundant information.
68///
69/// # Arguments
70///
71/// * `messages` - The conversation messages
72/// * `model` - The model name used
73/// * `tool_calls` - Number of tool calls made
74/// * `files_edited` - Number of files edited
75/// * `tags` - Session tags
76/// * `notes` - Session notes
77/// * `config` - Optional configuration for handoff generation
78///
79/// # Returns
80///
81/// A structured handoff prompt string
82pub fn generate_handoff_prompt(
83    messages: &[Message],
84    model: &str,
85    tool_calls: usize,
86    files_edited: usize,
87    tags: &[String],
88    notes: &str,
89    config: Option<HandoffConfig>,
90) -> String {
91    let config = config.unwrap_or_default();
92
93    if messages.is_empty() {
94        return "No conversation context available.".to_string();
95    }
96
97    let context = extract_context(messages, &config);
98    let metadata = HandoffMetadata {
99        model: model.to_string(),
100        message_count: messages.len(),
101        tool_calls,
102        files_edited,
103    };
104
105    build_handoff_prompt(&context, &metadata, tags, notes, &config)
106}
107
108/// Extract key context from messages
109fn extract_context(messages: &[Message], config: &HandoffConfig) -> HandoffContext {
110    let mut file_paths: HashSet<String> = HashSet::new();
111    let mut constraints = Vec::new();
112    let mut tasks = Vec::new();
113    let mut seen_messages: HashSet<String> = HashSet::new();
114
115    for msg in messages {
116        let content = &msg.content;
117
118        // Skip duplicate messages (noise reduction)
119        let content_hash = format!("{:?}:{}", msg.role, content);
120        if seen_messages.contains(&content_hash) {
121            continue;
122        }
123        seen_messages.insert(content_hash);
124
125        // Extract file paths
126        extract_file_paths(content, &mut file_paths);
127
128        // Extract constraints
129        extract_constraints(content, &mut constraints);
130
131        // Extract tasks
132        extract_tasks(content, &mut tasks);
133    }
134
135    // Collect recent messages (in reverse, then reverse back)
136    let recent_messages: Vec<_> = messages
137        .iter()
138        .rev()
139        .take(config.max_recent_messages)
140        .filter_map(|msg| {
141            let content = &msg.content;
142            if content.is_empty() {
143                return None;
144            }
145            // Only take the first line to avoid duplicating task lists in preview
146            let first_line = content.lines().next().unwrap_or(content);
147            let preview = if first_line.len() > config.max_preview_length {
148                format!("{}...", &first_line[..config.max_preview_length])
149            } else {
150                first_line.to_string()
151            };
152            Some((msg.role.clone(), preview))
153        })
154        .collect::<Vec<_>>()
155        .into_iter()
156        .rev()
157        .collect();
158
159    HandoffContext {
160        file_paths: file_paths.into_iter().collect(),
161        constraints,
162        tasks,
163        recent_messages,
164        tags: Vec::new(),
165        notes: String::new(),
166    }
167}
168/// Extract file paths from content
169fn extract_file_paths(content: &str, file_paths: &mut HashSet<String>) {
170    for line in content.lines() {
171        // Match file paths with common extensions
172        let extensions = [".rs", ".ts", ".js", ".py", ".go", ".java", ".md", ".toml", ".json"];
173        
174        for word in line.split_whitespace() {
175            let word = word.trim_matches(['\"', '\'', '(', ')', ',', ':', '[', ']']);
176            
177            // Check if word ends with a known extension
178            if extensions.iter().any(|ext| word.ends_with(ext)) {
179                file_paths.insert(word.to_string());
180                continue;
181            }
182
183            // Check for path-like patterns (src/, lib/, test/, etc.)
184            if word.contains('/') && (word.contains("src") || word.contains("lib") || word.contains("test")) {
185                file_paths.insert(word.to_string());
186            }
187        }
188    }
189}
190
191/// Extract constraints from content
192fn extract_constraints(content: &str, constraints: &mut Vec<String>) {
193    for line in content.lines() {
194        let line = line.trim();
195        
196        // Look for constraint keywords
197        let is_constraint = line.contains("MUST") 
198            || line.contains("MUST NOT")
199            || line.contains("SHOULD")
200            || line.contains("SHOULD NOT")
201            || line.contains("REQUIRED")
202            || line.contains("constraint")
203            || line.contains("requirement");
204
205        if is_constraint && !line.is_empty() {
206            constraints.push(line.to_string());
207        }
208    }
209}
210
211/// Extract tasks from content
212fn extract_tasks(content: &str, tasks: &mut Vec<String>) {
213    for line in content.lines() {
214        let line = line.trim();
215        
216        // Look for task indicators
217        let is_task = line.starts_with("-")
218            || line.starts_with("*")
219            || line.starts_with("+")
220            || line.contains("TODO")
221            || line.contains("FIXME")
222            || line.contains("implement")
223            || line.contains("fix")
224            || line.contains("add")
225            || line.contains("create")
226            || line.contains("update")
227            || line.contains("remove")
228            || line.contains("delete");
229
230        if is_task && !line.is_empty() {
231            tasks.push(line.to_string());
232        }
233    }
234}
235
236/// Build the handoff prompt from extracted context
237fn build_handoff_prompt(
238    context: &HandoffContext,
239    metadata: &HandoffMetadata,
240    tags: &[String],
241    notes: &str,
242    config: &HandoffConfig,
243) -> String {
244    let mut parts = Vec::new();
245
246    // Header
247    parts.push("# Session Handoff".to_string());
248    parts.push(String::new());
249
250    // Metadata
251    parts.push("## Session Summary".to_string());
252    parts.push(format!("- **Model:** {}", metadata.model));
253    parts.push(format!("- **Messages:** {}", metadata.message_count));
254    parts.push(format!("- **Tool calls:** {}", metadata.tool_calls));
255    parts.push(format!("- **Files edited:** {}", metadata.files_edited));
256    parts.push(String::new());
257
258    // Tags
259    if !tags.is_empty() {
260        parts.push("## Tags".to_string());
261        for tag in tags {
262            parts.push(format!("- {}", tag));
263        }
264        parts.push(String::new());
265    }
266
267    // Notes
268    if !notes.is_empty() {
269        parts.push("## Notes".to_string());
270        parts.push(notes.to_string());
271        parts.push(String::new());
272    }
273
274    // File paths
275    if !context.file_paths.is_empty() {
276        parts.push("## Files Referenced".to_string());
277        let mut paths = context.file_paths.clone();
278        paths.sort();
279        paths.dedup();
280        for path in paths {
281            parts.push(format!("- {}", path));
282        }
283        parts.push(String::new());
284    }
285
286    // Constraints
287    if !context.constraints.is_empty() {
288        parts.push("## Constraints & Requirements".to_string());
289        for constraint in context.constraints.iter().take(config.max_constraints) {
290            parts.push(format!("- {}", constraint));
291        }
292        if context.constraints.len() > config.max_constraints {
293            parts.push(format!("- ... and {} more", context.constraints.len() - config.max_constraints));
294        }
295        parts.push(String::new());
296    }
297
298    // Tasks
299    if !context.tasks.is_empty() {
300        parts.push("## Key Tasks & Action Items".to_string());
301        for task in context.tasks.iter().take(config.max_tasks) {
302            // Remove leading dash/star/plus if present to avoid double formatting
303            let task = task.trim_start_matches(['-', '*', '+']).trim();
304            parts.push(format!("- {}", task));
305        }
306        if context.tasks.len() > config.max_tasks {
307            parts.push(format!("- ... and {} more", context.tasks.len() - config.max_tasks));
308        }
309        parts.push(String::new());
310    }
311
312    // Recent context
313    if !context.recent_messages.is_empty() {
314        parts.push("## Recent Context".to_string());
315        for (role, content) in &context.recent_messages {
316            let role_name = match role {
317                Role::User => "User",
318                Role::Assistant => "Assistant",
319                Role::System => "System",
320                Role::Tool => "Tool",
321            };
322            parts.push(format!("**{}:** {}", role_name, content));
323        }
324        parts.push(String::new());
325    }
326
327    // Footer
328    parts.push("---".to_string());
329    parts.push("*Handoff generated for context transfer*".to_string());
330
331    parts.join("\n")
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_generate_handoff_prompt_empty() {
340        let messages = vec![];
341        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
342        assert!(prompt.contains("No conversation context available"));
343    }
344
345    #[test]
346    fn test_generate_handoff_prompt_with_content() {
347        let messages = vec![
348            Message {
349                role: Role::User,
350                content: "Fix src/main.rs".to_string(),
351                tool_calls: vec![],
352                tool_result: None,
353            },
354            Message {
355                role: Role::Assistant,
356                content: "I'll fix it".to_string(),
357                tool_calls: vec![],
358                tool_result: None,
359            },
360        ];
361        
362        let prompt = generate_handoff_prompt(&messages, "test-model", 3, 1, &[], "", None);
363        
364        assert!(prompt.contains("Session Handoff"));
365        assert!(prompt.contains("Model:"));
366        assert!(prompt.contains("Messages:"));
367        assert!(prompt.contains("Tool calls:"));
368        assert!(prompt.contains("Files edited:"));
369    }
370
371    #[test]
372    fn test_extract_file_paths() {
373        let messages = vec![
374            Message {
375                role: Role::User,
376                content: "Edit src/main.rs and lib/helper.ts".to_string(),
377                tool_calls: vec![],
378                tool_result: None,
379            },
380        ];
381        
382        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
383        
384        assert!(prompt.contains("Files Referenced"));
385        assert!(prompt.contains("src/main.rs"));
386        assert!(prompt.contains("lib/helper.ts"));
387    }
388
389    #[test]
390    fn test_extract_constraints() {
391        let messages = vec![
392            Message {
393                role: Role::User,
394                content: "MUST use async functions\nMUST NOT break existing tests".to_string(),
395                tool_calls: vec![],
396                tool_result: None,
397            },
398        ];
399        
400        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
401        
402        assert!(prompt.contains("Constraints"));
403        assert!(prompt.contains("MUST"));
404    }
405
406    #[test]
407    fn test_extract_tasks() {
408        let messages = vec![
409            Message {
410                role: Role::User,
411                content: "- Implement feature X\n- Fix bug Y\n* Add tests".to_string(),
412                tool_calls: vec![],
413                tool_result: None,
414            },
415        ];
416        
417        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
418        
419        assert!(prompt.contains("Key Tasks"));
420        assert!(prompt.contains("Implement feature X") || prompt.contains("feature X"));
421    }
422
423    #[test]
424    fn test_recent_context() {
425        let messages = vec![
426            Message {
427                role: Role::User,
428                content: "First message".to_string(),
429                tool_calls: vec![],
430                tool_result: None,
431            },
432            Message {
433                role: Role::Assistant,
434                content: "First response".to_string(),
435                tool_calls: vec![],
436                tool_result: None,
437            },
438            Message {
439                role: Role::User,
440                content: "Second message".to_string(),
441                tool_calls: vec![],
442                tool_result: None,
443            },
444            Message {
445                role: Role::Assistant,
446                content: "Second response".to_string(),
447                tool_calls: vec![],
448                tool_result: None,
449            },
450        ];
451        
452        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
453        
454        assert!(prompt.contains("Recent Context"));
455        assert!(prompt.contains("User") || prompt.contains("Assistant"));
456    }
457
458    #[test]
459    fn test_deduplication() {
460        let messages = vec![
461            Message {
462                role: Role::User,
463                content: "Fix the bug".to_string(),
464                tool_calls: vec![],
465                tool_result: None,
466            },
467            Message {
468                role: Role::User,
469                content: "Fix the bug".to_string(), // Duplicate
470                tool_calls: vec![],
471                tool_result: None,
472            },
473        ];
474        
475        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
476        
477        // Should show total message count (2), not deduplicated count
478        assert!(prompt.contains("**Messages:** 2"));
479    }
480
481    #[test]
482    fn test_custom_config() {
483        let messages = vec![
484            Message {
485                role: Role::User,
486                content: "- Task 1\n- Task 2\n- Task 3\n- Task 4\n- Task 5".to_string(),
487                tool_calls: vec![],
488                tool_result: None,
489            },
490        ];
491        
492        let config = HandoffConfig {
493            max_tasks: 2,
494            ..Default::default()
495        };
496        
497        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", Some(config));
498        
499        assert!(prompt.contains("Key Tasks"));
500        // Should limit to 2 tasks
501        assert!(prompt.contains("- Task 1"));
502        assert!(prompt.contains("- Task 2"));
503        // Task 3 should not be in the output (limited to 2)
504        assert!(!prompt.contains("- Task 3"));
505    }
506}