Skip to main content

limit_llm/
summarization.rs

1use crate::error::LlmError;
2use crate::providers::LlmProvider;
3#[allow(unused_imports)]
4use crate::types::ToolCall;
5use crate::types::{FunctionCall, Message, Role};
6use crate::ProviderResponseChunk;
7use futures::StreamExt;
8use std::collections::HashSet;
9
10/// Result of summarization
11pub struct SummaryOutput {
12    pub summary: String,
13    pub first_kept_index: usize,
14    pub tokens_before: usize,
15}
16
17/// File operations extracted from tool calls
18#[derive(Debug, Default)]
19pub struct FileOperations {
20    pub read: HashSet<String>,
21    pub written: HashSet<String>,
22    pub edited: HashSet<String>,
23}
24
25/// Extract file operations from tool calls in messages
26pub fn extract_file_operations(messages: &[Message]) -> FileOperations {
27    let mut ops = FileOperations::default();
28
29    for msg in messages {
30        if let Some(tool_calls) = &msg.tool_calls {
31            for call in tool_calls {
32                extract_from_tool_call(&call.function, &mut ops);
33            }
34        }
35    }
36
37    ops
38}
39
40fn extract_from_tool_call(func: &FunctionCall, ops: &mut FileOperations) {
41    let path = extract_path_from_args(&func.arguments);
42
43    match func.name.as_str() {
44        "file_read" => {
45            if let Some(p) = path {
46                ops.read.insert(p);
47            }
48        }
49        "file_write" => {
50            if let Some(p) = path {
51                ops.written.insert(p);
52            }
53        }
54        "file_edit" => {
55            if let Some(p) = path {
56                ops.edited.insert(p);
57            }
58        }
59        _ => {}
60    }
61}
62
63fn extract_path_from_args(args: &str) -> Option<String> {
64    serde_json::from_str::<serde_json::Value>(args)
65        .ok()
66        .and_then(|v| {
67            v.get("path")
68                .or_else(|| v.get("filePath"))
69                .and_then(|p| p.as_str().map(|s| s.to_string()))
70        })
71}
72
73/// Summarizer for context compaction
74pub struct Summarizer {
75    provider: Box<dyn LlmProvider>,
76}
77
78impl Summarizer {
79    pub fn new(provider: Box<dyn LlmProvider>) -> Self {
80        Self { provider }
81    }
82
83    /// Generate summary of messages for context compaction
84    pub async fn summarize(
85        &self,
86        messages: &[Message],
87        previous_summary: Option<&str>,
88    ) -> Result<String, LlmError> {
89        let file_ops = extract_file_operations(messages);
90        let prompt = build_summary_prompt(messages, previous_summary, &file_ops);
91
92        let summary_request = vec![Message {
93            role: Role::User,
94            content: Some(prompt),
95            tool_calls: None,
96            tool_call_id: None,
97            cache_control: None,
98        }];
99
100        let mut stream = self.provider.send(summary_request, vec![]).await?;
101        let mut result = String::new();
102
103        while let Some(chunk) = stream.next().await {
104            match chunk {
105                Ok(ProviderResponseChunk::ContentDelta(text)) => result.push_str(&text),
106                Err(e) => return Err(e),
107                _ => {}
108            }
109        }
110
111        Ok(result)
112    }
113}
114
115fn build_summary_prompt(
116    messages: &[Message],
117    previous_summary: Option<&str>,
118    file_ops: &FileOperations,
119) -> String {
120    let mut prompt = String::from("Summarize this conversation for context compaction.\n\n");
121
122    if let Some(prev) = previous_summary {
123        prompt.push_str("**Previous summary (update and condense):**\n");
124        prompt.push_str(prev);
125        prompt.push_str("\n\n");
126    }
127
128    prompt.push_str("**Messages to summarize:**\n");
129    for msg in messages {
130        match msg.role {
131            Role::User => {
132                prompt.push_str(&format!("User: {}\n", msg.content.as_deref().unwrap_or("")));
133            }
134            Role::Assistant => {
135                if let Some(content) = &msg.content {
136                    prompt.push_str(&format!("Assistant: {}\n", content));
137                }
138                if let Some(calls) = &msg.tool_calls {
139                    for call in calls {
140                        prompt.push_str(&format!(
141                            "  Tool: {}({})\n",
142                            call.function.name, call.function.arguments
143                        ));
144                    }
145                }
146            }
147            Role::Tool => {
148                prompt.push_str(&format!(
149                    "Tool result: {}\n",
150                    msg.content.as_deref().unwrap_or("")
151                ));
152            }
153            Role::System => {}
154        }
155    }
156
157    if !file_ops.read.is_empty() || !file_ops.written.is_empty() || !file_ops.edited.is_empty() {
158        prompt.push_str("\n**Files touched:**\n");
159        for p in &file_ops.read {
160            prompt.push_str(&format!("- Read: {}\n", p));
161        }
162        for p in &file_ops.edited {
163            prompt.push_str(&format!("- Edited: {}\n", p));
164        }
165        for p in &file_ops.written {
166            prompt.push_str(&format!("- Written: {}\n", p));
167        }
168    }
169
170    prompt.push_str("\n**Output format:**\n");
171    prompt.push_str("## Summary\n[2-3 sentences]\n\n");
172    prompt.push_str("## Key Decisions\n- [decisions]\n\n");
173    prompt.push_str("## Pending\n- [next steps]\n");
174
175    prompt
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn make_tool_call(name: &str, args: &str) -> ToolCall {
183        ToolCall {
184            id: "call_1".to_string(),
185            tool_type: "function".to_string(),
186            function: FunctionCall {
187                name: name.to_string(),
188                arguments: args.to_string(),
189            },
190        }
191    }
192
193    #[test]
194    fn test_extract_file_operations_read() {
195        let messages = vec![Message {
196            role: Role::Assistant,
197            content: None,
198            tool_calls: Some(vec![make_tool_call(
199                "file_read",
200                r#"{"path": "/src/main.rs"}"#,
201            )]),
202            tool_call_id: None,
203            cache_control: None,
204        }];
205
206        let ops = extract_file_operations(&messages);
207        assert!(ops.read.contains("/src/main.rs"));
208        assert!(ops.written.is_empty());
209        assert!(ops.edited.is_empty());
210    }
211
212    #[test]
213    fn test_extract_file_operations_edit() {
214        let messages = vec![Message {
215            role: Role::Assistant,
216            content: None,
217            tool_calls: Some(vec![make_tool_call(
218                "file_edit",
219                r#"{"filePath": "/src/lib.rs", "oldString": "fn old", "newString": "fn new"}"#,
220            )]),
221            tool_call_id: None,
222            cache_control: None,
223        }];
224
225        let ops = extract_file_operations(&messages);
226        assert!(ops.edited.contains("/src/lib.rs"));
227        assert!(ops.read.is_empty());
228    }
229
230    #[test]
231    fn test_extract_file_operations_write() {
232        let messages = vec![Message {
233            role: Role::Assistant,
234            content: None,
235            tool_calls: Some(vec![make_tool_call(
236                "file_write",
237                r#"{"path": "/src/new.rs", "content": "..."}"#,
238            )]),
239            tool_call_id: None,
240            cache_control: None,
241        }];
242
243        let ops = extract_file_operations(&messages);
244        assert!(ops.written.contains("/src/new.rs"));
245    }
246
247    #[test]
248    fn test_extract_file_operations_multiple() {
249        let messages = vec![
250            Message {
251                role: Role::Assistant,
252                content: None,
253                tool_calls: Some(vec![
254                    make_tool_call("file_read", r#"{"path": "/src/a.rs"}"#),
255                    make_tool_call("file_edit", r#"{"filePath": "/src/b.rs"}"#),
256                ]),
257                tool_call_id: None,
258                cache_control: None,
259            },
260            Message {
261                role: Role::Assistant,
262                content: None,
263                tool_calls: Some(vec![make_tool_call(
264                    "file_write",
265                    r#"{"path": "/src/c.rs"}"#,
266                )]),
267                tool_call_id: None,
268                cache_control: None,
269            },
270        ];
271
272        let ops = extract_file_operations(&messages);
273        assert_eq!(ops.read.len(), 1);
274        assert_eq!(ops.edited.len(), 1);
275        assert_eq!(ops.written.len(), 1);
276    }
277
278    #[test]
279    fn test_extract_file_operations_ignores_other_tools() {
280        let messages = vec![Message {
281            role: Role::Assistant,
282            content: None,
283            tool_calls: Some(vec![make_tool_call("bash", r#"{"command": "ls"}"#)]),
284            tool_call_id: None,
285            cache_control: None,
286        }];
287
288        let ops = extract_file_operations(&messages);
289        assert!(ops.read.is_empty());
290        assert!(ops.written.is_empty());
291        assert!(ops.edited.is_empty());
292    }
293
294    #[test]
295    fn test_build_summary_prompt_includes_files() {
296        let messages = vec![Message {
297            role: Role::Assistant,
298            content: None,
299            tool_calls: Some(vec![make_tool_call(
300                "file_read",
301                r#"{"path": "/src/main.rs"}"#,
302            )]),
303            tool_call_id: None,
304            cache_control: None,
305        }];
306
307        let ops = extract_file_operations(&messages);
308        let prompt = build_summary_prompt(&messages, None, &ops);
309
310        assert!(prompt.contains("**Files touched:**"));
311        assert!(prompt.contains("- Read: /src/main.rs"));
312    }
313
314    #[test]
315    fn test_build_summary_prompt_with_previous() {
316        let messages = vec![Message {
317            role: Role::User,
318            content: Some("New message".to_string()),
319            tool_calls: None,
320            tool_call_id: None,
321            cache_control: None,
322        }];
323
324        let ops = FileOperations::default();
325        let prompt = build_summary_prompt(&messages, Some("Old summary"), &ops);
326
327        assert!(prompt.contains("**Previous summary (update and condense):**"));
328        assert!(prompt.contains("Old summary"));
329    }
330}