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
10pub struct SummaryOutput {
12 pub summary: String,
13 pub first_kept_index: usize,
14 pub tokens_before: usize,
15}
16
17#[derive(Debug, Default)]
19pub struct FileOperations {
20 pub read: HashSet<String>,
21 pub written: HashSet<String>,
22 pub edited: HashSet<String>,
23}
24
25pub 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
73pub 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 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}