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 = [
173            ".rs", ".ts", ".js", ".py", ".go", ".java", ".md", ".toml", ".json",
174        ];
175
176        for word in line.split_whitespace() {
177            let word = word.trim_matches(['\"', '\'', '(', ')', ',', ':', '[', ']']);
178
179            // Check if word ends with a known extension
180            if extensions.iter().any(|ext| word.ends_with(ext)) {
181                file_paths.insert(word.to_string());
182                continue;
183            }
184
185            // Check for path-like patterns (src/, lib/, test/, etc.)
186            if word.contains('/')
187                && (word.contains("src") || word.contains("lib") || word.contains("test"))
188            {
189                file_paths.insert(word.to_string());
190            }
191        }
192    }
193}
194
195/// Extract constraints from content
196fn extract_constraints(content: &str, constraints: &mut Vec<String>) {
197    for line in content.lines() {
198        let line = line.trim();
199
200        // Look for constraint keywords
201        let is_constraint = line.contains("MUST")
202            || line.contains("MUST NOT")
203            || line.contains("SHOULD")
204            || line.contains("SHOULD NOT")
205            || line.contains("REQUIRED")
206            || line.contains("constraint")
207            || line.contains("requirement");
208
209        if is_constraint && !line.is_empty() {
210            constraints.push(line.to_string());
211        }
212    }
213}
214
215/// Extract tasks from content
216fn extract_tasks(content: &str, tasks: &mut Vec<String>) {
217    for line in content.lines() {
218        let line = line.trim();
219
220        // Look for task indicators
221        let is_task = line.starts_with("-")
222            || line.starts_with("*")
223            || line.starts_with("+")
224            || line.contains("TODO")
225            || line.contains("FIXME")
226            || line.contains("implement")
227            || line.contains("fix")
228            || line.contains("add")
229            || line.contains("create")
230            || line.contains("update")
231            || line.contains("remove")
232            || line.contains("delete");
233
234        if is_task && !line.is_empty() {
235            tasks.push(line.to_string());
236        }
237    }
238}
239
240/// Build the handoff prompt from extracted context
241fn build_handoff_prompt(
242    context: &HandoffContext,
243    metadata: &HandoffMetadata,
244    tags: &[String],
245    notes: &str,
246    config: &HandoffConfig,
247) -> String {
248    let mut parts = Vec::new();
249
250    // Header
251    parts.push("# Session Handoff".to_string());
252    parts.push(String::new());
253
254    // Metadata
255    parts.push("## Session Summary".to_string());
256    parts.push(format!("- **Model:** {}", metadata.model));
257    parts.push(format!("- **Messages:** {}", metadata.message_count));
258    parts.push(format!("- **Tool calls:** {}", metadata.tool_calls));
259    parts.push(format!("- **Files edited:** {}", metadata.files_edited));
260    parts.push(String::new());
261
262    // Tags
263    if !tags.is_empty() {
264        parts.push("## Tags".to_string());
265        for tag in tags {
266            parts.push(format!("- {}", tag));
267        }
268        parts.push(String::new());
269    }
270
271    // Notes
272    if !notes.is_empty() {
273        parts.push("## Notes".to_string());
274        parts.push(notes.to_string());
275        parts.push(String::new());
276    }
277
278    // File paths
279    if !context.file_paths.is_empty() {
280        parts.push("## Files Referenced".to_string());
281        let mut paths = context.file_paths.clone();
282        paths.sort();
283        paths.dedup();
284        for path in paths {
285            parts.push(format!("- {}", path));
286        }
287        parts.push(String::new());
288    }
289
290    // Constraints
291    if !context.constraints.is_empty() {
292        parts.push("## Constraints & Requirements".to_string());
293        for constraint in context.constraints.iter().take(config.max_constraints) {
294            parts.push(format!("- {}", constraint));
295        }
296        if context.constraints.len() > config.max_constraints {
297            parts.push(format!(
298                "- ... and {} more",
299                context.constraints.len() - config.max_constraints
300            ));
301        }
302        parts.push(String::new());
303    }
304
305    // Tasks
306    if !context.tasks.is_empty() {
307        parts.push("## Key Tasks & Action Items".to_string());
308        for task in context.tasks.iter().take(config.max_tasks) {
309            // Remove leading dash/star/plus if present to avoid double formatting
310            let task = task.trim_start_matches(['-', '*', '+']).trim();
311            parts.push(format!("- {}", task));
312        }
313        if context.tasks.len() > config.max_tasks {
314            parts.push(format!(
315                "- ... and {} more",
316                context.tasks.len() - config.max_tasks
317            ));
318        }
319        parts.push(String::new());
320    }
321
322    // Recent context
323    if !context.recent_messages.is_empty() {
324        parts.push("## Recent Context".to_string());
325        for (role, content) in &context.recent_messages {
326            let role_name = match role {
327                Role::User => "User",
328                Role::Assistant => "Assistant",
329                Role::System => "System",
330                Role::Tool => "Tool",
331            };
332            parts.push(format!("**{}:** {}", role_name, content));
333        }
334        parts.push(String::new());
335    }
336
337    // Footer
338    parts.push("---".to_string());
339    parts.push("*Handoff generated for context transfer*".to_string());
340
341    parts.join("\n")
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn test_generate_handoff_prompt_empty() {
350        let messages = vec![];
351        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
352        assert!(prompt.contains("No conversation context available"));
353    }
354
355    #[test]
356    fn test_generate_handoff_prompt_with_content() {
357        let messages = vec![
358            Message {
359                role: Role::User,
360                content: "Fix src/main.rs".to_string(),
361                tool_calls: vec![],
362                tool_result: None,
363            },
364            Message {
365                role: Role::Assistant,
366                content: "I'll fix it".to_string(),
367                tool_calls: vec![],
368                tool_result: None,
369            },
370        ];
371
372        let prompt = generate_handoff_prompt(&messages, "test-model", 3, 1, &[], "", None);
373
374        assert!(prompt.contains("Session Handoff"));
375        assert!(prompt.contains("Model:"));
376        assert!(prompt.contains("Messages:"));
377        assert!(prompt.contains("Tool calls:"));
378        assert!(prompt.contains("Files edited:"));
379    }
380
381    #[test]
382    fn test_extract_file_paths() {
383        let messages = vec![Message {
384            role: Role::User,
385            content: "Edit src/main.rs and lib/helper.ts".to_string(),
386            tool_calls: vec![],
387            tool_result: None,
388        }];
389
390        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
391
392        assert!(prompt.contains("Files Referenced"));
393        assert!(prompt.contains("src/main.rs"));
394        assert!(prompt.contains("lib/helper.ts"));
395    }
396
397    #[test]
398    fn test_extract_constraints() {
399        let messages = vec![Message {
400            role: Role::User,
401            content: "MUST use async functions\nMUST NOT break existing tests".to_string(),
402            tool_calls: vec![],
403            tool_result: None,
404        }];
405
406        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
407
408        assert!(prompt.contains("Constraints"));
409        assert!(prompt.contains("MUST"));
410    }
411
412    #[test]
413    fn test_extract_tasks() {
414        let messages = vec![Message {
415            role: Role::User,
416            content: "- Implement feature X\n- Fix bug Y\n* Add tests".to_string(),
417            tool_calls: vec![],
418            tool_result: None,
419        }];
420
421        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
422
423        assert!(prompt.contains("Key Tasks"));
424        assert!(prompt.contains("Implement feature X") || prompt.contains("feature X"));
425    }
426
427    #[test]
428    fn test_recent_context() {
429        let messages = vec![
430            Message {
431                role: Role::User,
432                content: "First message".to_string(),
433                tool_calls: vec![],
434                tool_result: None,
435            },
436            Message {
437                role: Role::Assistant,
438                content: "First response".to_string(),
439                tool_calls: vec![],
440                tool_result: None,
441            },
442            Message {
443                role: Role::User,
444                content: "Second message".to_string(),
445                tool_calls: vec![],
446                tool_result: None,
447            },
448            Message {
449                role: Role::Assistant,
450                content: "Second response".to_string(),
451                tool_calls: vec![],
452                tool_result: None,
453            },
454        ];
455
456        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
457
458        assert!(prompt.contains("Recent Context"));
459        assert!(prompt.contains("User") || prompt.contains("Assistant"));
460    }
461
462    #[test]
463    fn test_deduplication() {
464        let messages = vec![
465            Message {
466                role: Role::User,
467                content: "Fix the bug".to_string(),
468                tool_calls: vec![],
469                tool_result: None,
470            },
471            Message {
472                role: Role::User,
473                content: "Fix the bug".to_string(), // Duplicate
474                tool_calls: vec![],
475                tool_result: None,
476            },
477        ];
478
479        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", None);
480
481        // Should show total message count (2), not deduplicated count
482        assert!(prompt.contains("**Messages:** 2"));
483    }
484
485    #[test]
486    fn test_custom_config() {
487        let messages = vec![Message {
488            role: Role::User,
489            content: "- Task 1\n- Task 2\n- Task 3\n- Task 4\n- Task 5".to_string(),
490            tool_calls: vec![],
491            tool_result: None,
492        }];
493
494        let config = HandoffConfig {
495            max_tasks: 2,
496            ..Default::default()
497        };
498
499        let prompt = generate_handoff_prompt(&messages, "test-model", 0, 0, &[], "", Some(config));
500
501        assert!(prompt.contains("Key Tasks"));
502        // Should limit to 2 tasks
503        assert!(prompt.contains("- Task 1"));
504        assert!(prompt.contains("- Task 2"));
505        // Task 3 should not be in the output (limited to 2)
506        assert!(!prompt.contains("- Task 3"));
507    }
508}