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.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}