1use crate::message::{Message, Role};
10
11pub const MAX_PTL_RETRIES: usize = 3;
13
14const FALLBACK_DROP_FRACTION: usize = 5;
17
18const PTL_RETRY_MARKER: &str = "[earlier conversation truncated for compaction retry]";
22
23pub fn group_by_user_boundary(messages: &[Message]) -> Vec<(usize, usize)> {
29 if messages.is_empty() {
30 return Vec::new();
31 }
32
33 let mut groups: Vec<(usize, usize)> = Vec::new();
34 let mut start = 0_usize;
35
36 for (i, msg) in messages.iter().enumerate().skip(1) {
37 let is_boundary = msg.role == Role::User || msg.role == Role::System;
38 if is_boundary {
39 groups.push((start, i));
40 start = i;
41 }
42 }
43
44 if start < messages.len() {
46 groups.push((start, messages.len()));
47 }
48
49 groups
50}
51
52fn estimate_chars_range(messages: &[Message], range: (usize, usize)) -> usize {
54 messages[range.0..range.1]
55 .iter()
56 .map(|m| m.content.len())
57 .sum()
58}
59
60pub fn truncate_head_for_retry(
79 messages: &[Message],
80 target_chars: Option<usize>,
81) -> Option<Vec<Message>> {
82 let groups = group_by_user_boundary(messages);
83 if groups.len() < 2 {
84 return None;
85 }
86
87 let total_chars: usize = groups
88 .iter()
89 .map(|g| estimate_chars_range(messages, *g))
90 .sum();
91
92 let drop_count = match target_chars {
94 Some(target) => {
95 if target >= total_chars {
96 return None;
98 }
99 let need_to_drop = total_chars - target;
100 let mut cumulative = 0_usize;
101 let mut dropped = 0_usize;
102 for g in &groups {
103 let g_chars = estimate_chars_range(messages, *g);
104 if cumulative + g_chars > need_to_drop {
105 break;
106 }
107 cumulative += g_chars;
108 dropped += 1;
109 }
110 dropped
111 }
112 None => {
113 let raw = groups.len() / FALLBACK_DROP_FRACTION;
114 if raw < 1 {
115 1
116 } else {
117 raw
118 }
119 }
120 };
121
122 if drop_count == 0 {
123 return None;
124 }
125
126 let keep_from = if drop_count >= groups.len() {
128 groups.len() - 1
129 } else {
130 drop_count
131 };
132
133 let mut result: Vec<Message> = messages[groups[keep_from].0..].to_vec();
135
136 while !result.is_empty() {
138 let head = &result[0];
139 if head.role == Role::Tool || (head.role == Role::Assistant && !head.tool_calls.is_empty())
140 {
141 result.remove(0);
142 } else {
143 break;
144 }
145 }
146
147 if result.first().map(|m| m.role) == Some(Role::Assistant) {
149 result.insert(0, Message::user(PTL_RETRY_MARKER));
150 }
151
152 if result.is_empty() {
154 return None;
155 }
156
157 Some(result)
158}
159
160pub fn estimate_target_from_error(err: &crate::error::Error) -> Option<usize> {
167 let message = match err {
168 crate::error::Error::Llm { message, .. } => message,
169 _ => return None,
170 };
171
172 let lower = message.to_lowercase();
174
175 if let Some(tokens) = extract_number_after(&lower, "resulted in") {
177 return Some((tokens * 4 * 80) / 100); }
179
180 if let Some(tokens) = extract_number_before_word(&lower, "tokens") {
182 return Some((tokens * 4 * 80) / 100);
183 }
184
185 None
186}
187
188fn extract_number_after(text: &str, prefix: &str) -> Option<usize> {
190 text.find(prefix).and_then(|idx| {
191 let after = &text[idx + prefix.len()..];
192 let start = after.find(|c: char| c.is_ascii_digit())?;
194 let end = after[start..]
195 .find(|c: char| !c.is_ascii_digit())
196 .unwrap_or(after[start..].len());
197 after[start..start + end].parse::<usize>().ok()
198 })
199}
200
201fn extract_number_before_word(text: &str, word: &str) -> Option<usize> {
203 text.find(word).and_then(|idx| {
204 let before = &text[..idx];
205 let end = before.len();
207 let digit_start = before[..end].rfind(|c: char| c.is_ascii_digit())?;
208 let word_start = before[..=digit_start]
209 .rfind(|c: char| !c.is_ascii_digit())
210 .map(|i| i + 1)
211 .unwrap_or(0);
212 before[word_start..=digit_start].parse::<usize>().ok()
213 })
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::llm::ToolCall;
220 use crate::message::Message;
221
222 #[test]
225 fn group_by_user_boundary_empty_input() {
226 let groups = group_by_user_boundary(&[]);
227 assert!(groups.is_empty());
228 }
229
230 #[test]
231 fn group_by_user_boundary_single_group() {
232 let msgs = vec![
234 Message::assistant("a1".to_string()),
235 Message::tool_result("c1", "result".to_string()),
236 ];
237 let groups = group_by_user_boundary(&msgs);
238 assert_eq!(groups.len(), 1);
239 assert_eq!(groups[0], (0, 2));
240 }
241
242 #[test]
243 fn group_by_user_boundary_isolated_assistant_no_user() {
244 let msgs = vec![
246 Message::assistant("a".to_string()),
247 Message::assistant("b".to_string()),
248 ];
249 let groups = group_by_user_boundary(&msgs);
250 assert_eq!(groups.len(), 1);
251 assert_eq!(groups[0], (0, 2));
252 }
253
254 #[test]
255 fn group_by_user_boundary_splits_on_user_messages() {
256 let msgs = vec![
257 Message::system("sys".to_string()),
258 Message::user("goal 1".to_string()),
259 Message::assistant("reply 1".to_string()),
260 Message::user("goal 2".to_string()),
261 Message::assistant("reply 2".to_string()),
262 Message::user("goal 3".to_string()),
263 Message::assistant("reply 3".to_string()),
264 ];
265 let groups = group_by_user_boundary(&msgs);
266 assert_eq!(groups.len(), 4);
268 assert_eq!(groups[0], (0, 1));
269 assert_eq!(groups[1], (1, 3));
270 assert_eq!(groups[2], (3, 5));
271 assert_eq!(groups[3], (5, 7));
272 }
273
274 #[test]
275 fn group_by_user_boundary_system_is_boundary() {
276 let msgs = vec![
278 Message::system("sys1".to_string()),
279 Message::user("u1".to_string()),
280 Message::assistant("a1".to_string()),
281 Message::system("sys2".to_string()),
282 Message::user("u2".to_string()),
283 ];
284 let groups = group_by_user_boundary(&msgs);
285 assert_eq!(groups.len(), 4);
287 assert_eq!(groups[0], (0, 1)); assert_eq!(groups[1], (1, 3)); assert_eq!(groups[2], (3, 4)); assert_eq!(groups[3], (4, 5)); }
292
293 fn make_tool_call_asst(id: &str, content: &str) -> Message {
296 Message::assistant_with_tool_calls(
297 content.to_string(),
298 vec![ToolCall {
299 id: id.into(),
300 name: "Read".into(),
301 arguments: serde_json::json!({}),
302 }],
303 )
304 }
305
306 #[test]
307 fn truncate_head_returns_none_when_one_group() {
308 let msgs = vec![
310 Message::assistant("only msg".to_string()),
311 Message::assistant("another".to_string()),
312 ];
313 let result = truncate_head_for_retry(&msgs, Some(10));
314 assert!(result.is_none(), "should return None with only 1 group");
315 }
316
317 #[test]
318 fn truncate_head_drops_oldest_groups() {
319 let msgs = vec![
321 Message::system("s".to_string()), Message::user("abc".to_string()), Message::assistant("def".to_string()), Message::user("ghij".to_string()), Message::assistant("klmno".to_string()), ];
327 let result = truncate_head_for_retry(&msgs, Some(15));
331 assert!(result.is_some(), "should return truncated messages");
332 let result = result.unwrap();
333 assert_eq!(result.len(), 4);
335 assert_eq!(result[0].content, "abc");
336 assert_eq!(result[0].role, Role::User);
337 }
338
339 #[test]
340 fn truncate_head_keeps_at_least_one_group() {
341 let msgs = vec![
343 Message::user("group1".to_string()),
344 Message::assistant("a".to_string()),
345 Message::user("group2".to_string()),
346 Message::assistant("b".to_string()),
347 ];
348 let result = truncate_head_for_retry(&msgs, Some(0));
350 assert!(result.is_some(), "should keep at least one group");
351 let result = result.unwrap();
352 assert_eq!(result[0].content, "group2");
354 }
355
356 #[test]
357 fn truncate_head_preserves_pairing() {
358 let msgs = vec![
361 Message::system("sys".to_string()), Message::user("old".to_string()), Message::assistant("old reply".to_string()), make_tool_call_asst("c1", "reading file"), Message::tool_result("c1", "contents"), Message::user("next".to_string()), Message::assistant("done".to_string()), ];
369 let result = truncate_head_for_retry(&msgs, None);
374 assert!(
375 result.is_some(),
376 "should handle tool-call spanning boundary"
377 );
378 let result = result.unwrap();
379 assert!(result[0].role != Role::Tool, "head must not be orphan Tool");
381 assert!(
382 result[0].role != Role::Assistant || result[0].tool_calls.is_empty(),
383 "head must not be orphan Asst+tc"
384 );
385 }
386
387 #[test]
388 fn truncate_head_fallback_drop_fraction() {
389 let mut msgs: Vec<Message> = Vec::new();
391 for i in 0..6 {
392 msgs.push(Message::user(format!("g{i}")));
393 msgs.push(Message::assistant(format!("r{i}")));
394 }
395 let groups = group_by_user_boundary(&msgs);
396 assert_eq!(groups.len(), 6);
397 let result = truncate_head_for_retry(&msgs, None);
398 assert!(result.is_some(), "should drop oldest groups via fallback");
399 let result = result.unwrap();
400 assert_eq!(result.len(), 10);
402 assert_eq!(result[0].content, "g1");
403 }
404
405 #[test]
406 fn truncate_head_empty_input_returns_none() {
407 let result = truncate_head_for_retry(&[], Some(100));
408 assert!(result.is_none(), "empty input should return None");
409 }
410
411 #[test]
412 fn truncate_head_target_too_high_returns_none() {
413 let msgs = vec![
415 Message::user("ab".to_string()),
416 Message::assistant("cd".to_string()),
417 Message::user("ef".to_string()),
418 Message::assistant("gh".to_string()),
419 ];
420 let result = truncate_head_for_retry(&msgs, Some(100));
421 assert!(result.is_none(), "target >= total should return None");
422 }
423
424 #[test]
427 fn estimate_target_extracts_resulted_in_tokens() {
428 let err = crate::error::Error::Llm {
429 provider: "openai".into(),
430 message: "This model's maximum context length is 16385 tokens. \
431 However, your messages resulted in 17000 tokens."
432 .into(),
433 };
434 let target = estimate_target_from_error(&err);
435 assert!(target.is_some(), "should parse token count from error");
436 assert_eq!(target.unwrap(), 54400);
438 }
439
440 #[test]
441 fn estimate_target_extracts_basic_token_count() {
442 let err = crate::error::Error::Llm {
443 provider: "deepseek".into(),
444 message: "prompt is too long (16000 tokens)".into(),
445 };
446 let target = estimate_target_from_error(&err);
447 assert!(target.is_some(), "should parse token count");
448 assert_eq!(target.unwrap(), 51200);
450 }
451
452 #[test]
453 fn estimate_target_returns_none_for_non_llm_error() {
454 let err = crate::error::Error::Config {
455 message: "something".into(),
456 };
457 let target = estimate_target_from_error(&err);
458 assert!(target.is_none(), "non-LLM errors should return None");
459 }
460
461 #[test]
462 fn estimate_target_returns_none_for_unparseable_message() {
463 let err = crate::error::Error::Llm {
464 provider: "test".into(),
465 message: "some other error without numbers".into(),
466 };
467 let target = estimate_target_from_error(&err);
468 assert!(target.is_none(), "unparseable messages should return None");
469 }
470
471 #[test]
472 fn estimate_target_parses_tokens_in_context_window_format() {
473 let err = crate::error::Error::Llm {
474 provider: "test".into(),
475 message: "request exceeds the model context window of 128000 tokens".into(),
476 };
477 let target = estimate_target_from_error(&err);
478 assert!(target.is_some(), "should parse token count");
479 assert_eq!(target.unwrap(), 409600);
481 }
482}