Skip to main content

recursive/compact/
retry.rs

1//! PTL (prompt-too-long) retry helpers for compaction summarisation.
2//!
3//! When the compactor asks the provider to summarise an older transcript
4//! slice, that very request may itself exceed the provider's context window.
5//! This module provides best-effort escape hatches: drop the oldest message
6//! groups (bounded by User/System boundaries) and retry up to
7//! [`MAX_PTL_RETRIES`] times before giving up.
8
9use crate::message::{Message, Role};
10
11/// Maximum number of PTL retries before giving up and propagating the error.
12pub const MAX_PTL_RETRIES: usize = 3;
13
14/// Fallback drop fraction when no token-gap can be parsed from the error.
15/// `1/FALLBACK_DROP_FRACTION` of groups are dropped.
16const FALLBACK_DROP_FRACTION: usize = 5;
17
18/// Marker text inserted as a synthetic User message when dropping the first
19/// group (which may contain the system preamble) would leave the head of
20/// the remaining slice as an Assistant message.
21const PTL_RETRY_MARKER: &str = "[earlier conversation truncated for compaction retry]";
22
23/// Group `messages` into segments each starting at a `Role::User` or
24/// `Role::System` boundary. The first group always starts at index 0.
25///
26/// Returns the group boundaries as a `Vec<(usize, usize)>` of (start, end)
27/// index ranges (end is exclusive, matching standard slice semantics).
28pub 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    // Push the final group.
45    if start < messages.len() {
46        groups.push((start, messages.len()));
47    }
48
49    groups
50}
51
52/// Estimate number of characters in a range of messages.
53fn 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
60/// Drop the oldest groups from a to-summarise slice so the remaining
61/// slice fits within a target character budget, or by a fallback fraction.
62///
63/// Never drops all groups — at least one group is always kept. Returns
64/// `None` when there are fewer than 2 groups (nothing to drop), or when
65/// the result after truncation would be empty after retreating past
66/// orphaned Tool/Assistant-with-tool_calls messages.
67///
68/// `target_chars`:
69/// - `Some(t)`: drop oldest groups until the remaining slice's estimated
70///   chars are ≤ `t` (or as close as possible by dropping groups).
71/// - `None`: drop `max(1, n_groups / FALLBACK_DROP_FRACTION)` oldest groups.
72///
73/// After truncation, the result is run through a pairing-safe check:
74/// orphaned `Role::Tool` messages and `Role::Assistant` messages with
75/// `tool_calls` at the head are removed. If the resulting head would be
76/// `Role::Assistant` (without tool_calls), a synthetic `Message::user`
77/// marker is prepended.
78pub 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    // Determine how many groups to drop.
93    let drop_count = match target_chars {
94        Some(target) => {
95            if target >= total_chars {
96                // Already under target — no truncation needed.
97                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    // Never drop all groups — keep at least one.
127    let keep_from = if drop_count >= groups.len() {
128        groups.len() - 1
129    } else {
130        drop_count
131    };
132
133    // Build the result: flatten groups[keep_from..].
134    let mut result: Vec<Message> = messages[groups[keep_from].0..].to_vec();
135
136    // Retreat past orphaned Tool and Assistant-with-tool_calls at head.
137    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 head is now Assistant (without tool_calls), prepend user marker.
148    if result.first().map(|m| m.role) == Some(Role::Assistant) {
149        result.insert(0, Message::user(PTL_RETRY_MARKER));
150    }
151
152    // If nothing remains after retreat, return None.
153    if result.is_empty() {
154        return None;
155    }
156
157    Some(result)
158}
159
160/// Best-effort token-gap parsing from an LLM error message.
161///
162/// Tries to extract a total token count from the error (e.g. `"resulted in
163/// 17000 tokens"`) and returns a target char estimate (80% of the token
164/// count converted via the heuristic 4 chars/token). Returns `None` when
165/// nothing parseable is found — caller should use the fallback drop fraction.
166pub 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    // Try to find patterns like "resulted in 12345 tokens" or "12345 tokens"
173    let lower = message.to_lowercase();
174
175    // Pattern 1: "resulted in <number> tokens"
176    if let Some(tokens) = extract_number_after(&lower, "resulted in") {
177        return Some((tokens * 4 * 80) / 100); // 80% of estimate
178    }
179
180    // Pattern 2: "<number> tokens" (before "tokens")
181    if let Some(tokens) = extract_number_before_word(&lower, "tokens") {
182        return Some((tokens * 4 * 80) / 100);
183    }
184
185    None
186}
187
188/// Extract a number from `text` that appears after the given `prefix`.
189fn extract_number_after(text: &str, prefix: &str) -> Option<usize> {
190    text.find(prefix).and_then(|idx| {
191        let after = &text[idx + prefix.len()..];
192        // Find the first contiguous digit sequence.
193        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
201/// Extract a number from `text` that appears immediately before `word`.
202fn extract_number_before_word(text: &str, word: &str) -> Option<usize> {
203    text.find(word).and_then(|idx| {
204        let before = &text[..idx];
205        // Find the last contiguous digit sequence before the word.
206        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    // ── group_by_user_boundary ────────────────────────────────────────
223
224    #[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        // All assistant/tool messages (no User/System boundaries) → 1 group
233        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        // No User or System boundaries after the first message → single group
245        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        // Groups: [0..1) sys, [1..3) goal1+reply1, [3..5) goal2+reply2, [5..7) goal3+reply3
267        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        // System message at index 3 starts a new group, User at 4 starts another
277        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        // Boundaries at indices 1 (user), 3 (system), 4 (user) → 4 groups
286        assert_eq!(groups.len(), 4);
287        assert_eq!(groups[0], (0, 1)); // sys1
288        assert_eq!(groups[1], (1, 3)); // u1+a1
289        assert_eq!(groups[2], (3, 4)); // sys2
290        assert_eq!(groups[3], (4, 5)); // u2
291    }
292
293    // ── truncate_head_for_retry ───────────────────────────────────────
294
295    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        // Only assistant messages → single group → no truncation needed
309        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        // 3 groups, target drops 1 group
320        let msgs = vec![
321            Message::system("s".to_string()),        // group 0, 1 char
322            Message::user("abc".to_string()),        // group 1, 3 chars
323            Message::assistant("def".to_string()),   // part of group 1
324            Message::user("ghij".to_string()),       // group 2, 4 chars
325            Message::assistant("klmno".to_string()), // part of group 2
326        ];
327        // groups: [0..1), [1..3), [3..5)
328        // Total = 1+3+3+4+5 = 16 chars
329        // target=15 → need to drop 1 char. Group 0 = 1 char ≥ 1 → drop 1 group
330        let result = truncate_head_for_retry(&msgs, Some(15));
331        assert!(result.is_some(), "should return truncated messages");
332        let result = result.unwrap();
333        // Remaining: groups[1..] = [user("abc"), asst("def"), user("ghij"), asst("klmno")]
334        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        // Target would drop all → keeps the last group.
342        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        // 2 groups, target=0 → keep at least 1 group
349        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        // Should have kept the last group (group2)
353        assert_eq!(result[0].content, "group2");
354    }
355
356    #[test]
357    fn truncate_head_preserves_pairing() {
358        // Input with a tool-call pair spanning a group boundary after
359        // dropping oldest group.
360        let msgs = vec![
361            Message::system("sys".to_string()),          // group 0
362            Message::user("old".to_string()),            // group 0
363            Message::assistant("old reply".to_string()), // group 0
364            make_tool_call_asst("c1", "reading file"),   // group 1 — Asst+tc
365            Message::tool_result("c1", "contents"),      // group 1
366            Message::user("next".to_string()),           // group 2
367            Message::assistant("done".to_string()),      // group 2
368        ];
369        // groups: [0..3), [3..5), [5..7)
370        // Drop group 0 → remaining = [Asst+tc, Tool, User, Asst]
371        // Retreat past Asst+tc → [Tool, User, Asst]
372        // Retreat past Tool → [User, Asst]
373        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        // Head must not be orphan Tool or Asst+tc
380        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        // 6 groups → 6/5 = 1 → drop 1 group.
390        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        // Should have dropped 1 group (5 remaining = 10 msgs)
401        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        // target >= total_chars → already under target
414        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    // ── estimate_target_from_error ────────────────────────────────────
425
426    #[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        // 17000 * 4 * 0.8 = 54400
437        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        // 16000 * 4 * 0.8 = 51200
449        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        // 128000 * 4 * 0.8 = 409600
480        assert_eq!(target.unwrap(), 409600);
481    }
482}