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.into()),
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!(
133                    "User: {}\n",
134                    msg.content
135                        .as_ref()
136                        .map(|c| c.to_text())
137                        .unwrap_or_default()
138                ));
139            }
140            Role::Assistant => {
141                if let Some(content) = &msg.content {
142                    prompt.push_str(&format!("Assistant: {}\n", content.to_text()));
143                }
144                if let Some(calls) = &msg.tool_calls {
145                    for call in calls {
146                        prompt.push_str(&format!(
147                            "  Tool: {}({})\n",
148                            call.function.name, call.function.arguments
149                        ));
150                    }
151                }
152            }
153            Role::Tool => {
154                prompt.push_str(&format!(
155                    "Tool result: {}\n",
156                    msg.content
157                        .as_ref()
158                        .map(|c| c.to_text())
159                        .unwrap_or_default()
160                ));
161            }
162            Role::System => {}
163        }
164    }
165
166    if !file_ops.read.is_empty() || !file_ops.written.is_empty() || !file_ops.edited.is_empty() {
167        prompt.push_str("\n**Files touched:**\n");
168        for p in &file_ops.read {
169            prompt.push_str(&format!("- Read: {}\n", p));
170        }
171        for p in &file_ops.edited {
172            prompt.push_str(&format!("- Edited: {}\n", p));
173        }
174        for p in &file_ops.written {
175            prompt.push_str(&format!("- Written: {}\n", p));
176        }
177    }
178
179    prompt.push_str("\n**Output format:**\n");
180    prompt.push_str("## Summary\n[2-3 sentences]\n\n");
181    prompt.push_str("## Key Decisions\n- [decisions]\n\n");
182    prompt.push_str("## Pending\n- [next steps]\n");
183
184    prompt
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn make_tool_call(name: &str, args: &str) -> ToolCall {
192        ToolCall {
193            id: "call_1".to_string(),
194            tool_type: "function".to_string(),
195            function: FunctionCall {
196                name: name.to_string(),
197                arguments: args.to_string(),
198            },
199        }
200    }
201
202    #[test]
203    fn test_extract_file_operations_read() {
204        let messages = vec![Message {
205            role: Role::Assistant,
206            content: None,
207            tool_calls: Some(vec![make_tool_call(
208                "file_read",
209                r#"{"path": "/src/main.rs"}"#,
210            )]),
211            tool_call_id: None,
212            cache_control: None,
213        }];
214
215        let ops = extract_file_operations(&messages);
216        assert!(ops.read.contains("/src/main.rs"));
217        assert!(ops.written.is_empty());
218        assert!(ops.edited.is_empty());
219    }
220
221    #[test]
222    fn test_extract_file_operations_edit() {
223        let messages = vec![Message {
224            role: Role::Assistant,
225            content: None,
226            tool_calls: Some(vec![make_tool_call(
227                "file_edit",
228                r#"{"filePath": "/src/lib.rs", "oldString": "fn old", "newString": "fn new"}"#,
229            )]),
230            tool_call_id: None,
231            cache_control: None,
232        }];
233
234        let ops = extract_file_operations(&messages);
235        assert!(ops.edited.contains("/src/lib.rs"));
236        assert!(ops.read.is_empty());
237    }
238
239    #[test]
240    fn test_extract_file_operations_write() {
241        let messages = vec![Message {
242            role: Role::Assistant,
243            content: None,
244            tool_calls: Some(vec![make_tool_call(
245                "file_write",
246                r#"{"path": "/src/new.rs", "content": "..."}"#,
247            )]),
248            tool_call_id: None,
249            cache_control: None,
250        }];
251
252        let ops = extract_file_operations(&messages);
253        assert!(ops.written.contains("/src/new.rs"));
254    }
255
256    #[test]
257    fn test_extract_file_operations_multiple() {
258        let messages = vec![
259            Message {
260                role: Role::Assistant,
261                content: None,
262                tool_calls: Some(vec![
263                    make_tool_call("file_read", r#"{"path": "/src/a.rs"}"#),
264                    make_tool_call("file_edit", r#"{"filePath": "/src/b.rs"}"#),
265                ]),
266                tool_call_id: None,
267                cache_control: None,
268            },
269            Message {
270                role: Role::Assistant,
271                content: None,
272                tool_calls: Some(vec![make_tool_call(
273                    "file_write",
274                    r#"{"path": "/src/c.rs"}"#,
275                )]),
276                tool_call_id: None,
277                cache_control: None,
278            },
279        ];
280
281        let ops = extract_file_operations(&messages);
282        assert_eq!(ops.read.len(), 1);
283        assert_eq!(ops.edited.len(), 1);
284        assert_eq!(ops.written.len(), 1);
285    }
286
287    #[test]
288    fn test_extract_file_operations_ignores_other_tools() {
289        let messages = vec![Message {
290            role: Role::Assistant,
291            content: None,
292            tool_calls: Some(vec![make_tool_call("bash", r#"{"command": "ls"}"#)]),
293            tool_call_id: None,
294            cache_control: None,
295        }];
296
297        let ops = extract_file_operations(&messages);
298        assert!(ops.read.is_empty());
299        assert!(ops.written.is_empty());
300        assert!(ops.edited.is_empty());
301    }
302
303    #[test]
304    fn test_build_summary_prompt_includes_files() {
305        let messages = vec![Message {
306            role: Role::Assistant,
307            content: None,
308            tool_calls: Some(vec![make_tool_call(
309                "file_read",
310                r#"{"path": "/src/main.rs"}"#,
311            )]),
312            tool_call_id: None,
313            cache_control: None,
314        }];
315
316        let ops = extract_file_operations(&messages);
317        let prompt = build_summary_prompt(&messages, None, &ops);
318
319        assert!(prompt.contains("**Files touched:**"));
320        assert!(prompt.contains("- Read: /src/main.rs"));
321    }
322
323    #[test]
324    fn test_build_summary_prompt_with_previous() {
325        let messages = vec![Message {
326            role: Role::User,
327            content: Some(crate::MessageContent::text("New message")),
328            tool_calls: None,
329            tool_call_id: None,
330            cache_control: None,
331        }];
332
333        let ops = FileOperations::default();
334        let prompt = build_summary_prompt(&messages, Some("Old summary"), &ops);
335
336        assert!(prompt.contains("**Previous summary (update and condense):**"));
337        assert!(prompt.contains("Old summary"));
338    }
339}