use crate::message::{Message, Role};
pub const MAX_PTL_RETRIES: usize = 3;
const FALLBACK_DROP_FRACTION: usize = 5;
const PTL_RETRY_MARKER: &str = "[earlier conversation truncated for compaction retry]";
pub fn group_by_user_boundary(messages: &[Message]) -> Vec<(usize, usize)> {
if messages.is_empty() {
return Vec::new();
}
let mut groups: Vec<(usize, usize)> = Vec::new();
let mut start = 0_usize;
for (i, msg) in messages.iter().enumerate().skip(1) {
let is_boundary = msg.role == Role::User || msg.role == Role::System;
if is_boundary {
groups.push((start, i));
start = i;
}
}
if start < messages.len() {
groups.push((start, messages.len()));
}
groups
}
fn estimate_chars_range(messages: &[Message], range: (usize, usize)) -> usize {
messages[range.0..range.1]
.iter()
.map(|m| m.content.len())
.sum()
}
pub fn truncate_head_for_retry(
messages: &[Message],
target_chars: Option<usize>,
) -> Option<Vec<Message>> {
let groups = group_by_user_boundary(messages);
if groups.len() < 2 {
return None;
}
let total_chars: usize = groups
.iter()
.map(|g| estimate_chars_range(messages, *g))
.sum();
let drop_count = match target_chars {
Some(target) => {
if target >= total_chars {
return None;
}
let need_to_drop = total_chars - target;
let mut cumulative = 0_usize;
let mut dropped = 0_usize;
for g in &groups {
let g_chars = estimate_chars_range(messages, *g);
if cumulative + g_chars > need_to_drop {
break;
}
cumulative += g_chars;
dropped += 1;
}
dropped
}
None => {
let raw = groups.len() / FALLBACK_DROP_FRACTION;
if raw < 1 {
1
} else {
raw
}
}
};
if drop_count == 0 {
return None;
}
let keep_from = if drop_count >= groups.len() {
groups.len() - 1
} else {
drop_count
};
let mut result: Vec<Message> = messages[groups[keep_from].0..].to_vec();
while !result.is_empty() {
let head = &result[0];
if head.role == Role::Tool || (head.role == Role::Assistant && !head.tool_calls.is_empty())
{
result.remove(0);
} else {
break;
}
}
if result.first().map(|m| m.role) == Some(Role::Assistant) {
result.insert(0, Message::user(PTL_RETRY_MARKER));
}
if result.is_empty() {
return None;
}
Some(result)
}
pub fn estimate_target_from_error(err: &crate::error::Error) -> Option<usize> {
let message = match err {
crate::error::Error::Llm { message, .. } => message,
_ => return None,
};
let lower = message.to_lowercase();
if let Some(tokens) = extract_number_after(&lower, "resulted in") {
return Some((tokens * 4 * 80) / 100); }
if let Some(tokens) = extract_number_before_word(&lower, "tokens") {
return Some((tokens * 4 * 80) / 100);
}
None
}
fn extract_number_after(text: &str, prefix: &str) -> Option<usize> {
text.find(prefix).and_then(|idx| {
let after = &text[idx + prefix.len()..];
let start = after.find(|c: char| c.is_ascii_digit())?;
let end = after[start..]
.find(|c: char| !c.is_ascii_digit())
.unwrap_or(after[start..].len());
after[start..start + end].parse::<usize>().ok()
})
}
fn extract_number_before_word(text: &str, word: &str) -> Option<usize> {
text.find(word).and_then(|idx| {
let before = &text[..idx];
let end = before.len();
let digit_start = before[..end].rfind(|c: char| c.is_ascii_digit())?;
let word_start = before[..=digit_start]
.rfind(|c: char| !c.is_ascii_digit())
.map(|i| i + 1)
.unwrap_or(0);
before[word_start..=digit_start].parse::<usize>().ok()
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::ToolCall;
use crate::message::Message;
#[test]
fn group_by_user_boundary_empty_input() {
let groups = group_by_user_boundary(&[]);
assert!(groups.is_empty());
}
#[test]
fn group_by_user_boundary_single_group() {
let msgs = vec![
Message::assistant("a1".to_string()),
Message::tool_result("c1", "result".to_string()),
];
let groups = group_by_user_boundary(&msgs);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0], (0, 2));
}
#[test]
fn group_by_user_boundary_isolated_assistant_no_user() {
let msgs = vec![
Message::assistant("a".to_string()),
Message::assistant("b".to_string()),
];
let groups = group_by_user_boundary(&msgs);
assert_eq!(groups.len(), 1);
assert_eq!(groups[0], (0, 2));
}
#[test]
fn group_by_user_boundary_splits_on_user_messages() {
let msgs = vec![
Message::system("sys".to_string()),
Message::user("goal 1".to_string()),
Message::assistant("reply 1".to_string()),
Message::user("goal 2".to_string()),
Message::assistant("reply 2".to_string()),
Message::user("goal 3".to_string()),
Message::assistant("reply 3".to_string()),
];
let groups = group_by_user_boundary(&msgs);
assert_eq!(groups.len(), 4);
assert_eq!(groups[0], (0, 1));
assert_eq!(groups[1], (1, 3));
assert_eq!(groups[2], (3, 5));
assert_eq!(groups[3], (5, 7));
}
#[test]
fn group_by_user_boundary_system_is_boundary() {
let msgs = vec![
Message::system("sys1".to_string()),
Message::user("u1".to_string()),
Message::assistant("a1".to_string()),
Message::system("sys2".to_string()),
Message::user("u2".to_string()),
];
let groups = group_by_user_boundary(&msgs);
assert_eq!(groups.len(), 4);
assert_eq!(groups[0], (0, 1)); assert_eq!(groups[1], (1, 3)); assert_eq!(groups[2], (3, 4)); assert_eq!(groups[3], (4, 5)); }
fn make_tool_call_asst(id: &str, content: &str) -> Message {
Message::assistant_with_tool_calls(
content.to_string(),
vec![ToolCall {
id: id.into(),
name: "Read".into(),
arguments: serde_json::json!({}),
}],
)
}
#[test]
fn truncate_head_returns_none_when_one_group() {
let msgs = vec![
Message::assistant("only msg".to_string()),
Message::assistant("another".to_string()),
];
let result = truncate_head_for_retry(&msgs, Some(10));
assert!(result.is_none(), "should return None with only 1 group");
}
#[test]
fn truncate_head_drops_oldest_groups() {
let msgs = vec![
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()), ];
let result = truncate_head_for_retry(&msgs, Some(15));
assert!(result.is_some(), "should return truncated messages");
let result = result.unwrap();
assert_eq!(result.len(), 4);
assert_eq!(result[0].content, "abc");
assert_eq!(result[0].role, Role::User);
}
#[test]
fn truncate_head_keeps_at_least_one_group() {
let msgs = vec![
Message::user("group1".to_string()),
Message::assistant("a".to_string()),
Message::user("group2".to_string()),
Message::assistant("b".to_string()),
];
let result = truncate_head_for_retry(&msgs, Some(0));
assert!(result.is_some(), "should keep at least one group");
let result = result.unwrap();
assert_eq!(result[0].content, "group2");
}
#[test]
fn truncate_head_preserves_pairing() {
let msgs = vec![
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()), ];
let result = truncate_head_for_retry(&msgs, None);
assert!(
result.is_some(),
"should handle tool-call spanning boundary"
);
let result = result.unwrap();
assert!(result[0].role != Role::Tool, "head must not be orphan Tool");
assert!(
result[0].role != Role::Assistant || result[0].tool_calls.is_empty(),
"head must not be orphan Asst+tc"
);
}
#[test]
fn truncate_head_fallback_drop_fraction() {
let mut msgs: Vec<Message> = Vec::new();
for i in 0..6 {
msgs.push(Message::user(format!("g{i}")));
msgs.push(Message::assistant(format!("r{i}")));
}
let groups = group_by_user_boundary(&msgs);
assert_eq!(groups.len(), 6);
let result = truncate_head_for_retry(&msgs, None);
assert!(result.is_some(), "should drop oldest groups via fallback");
let result = result.unwrap();
assert_eq!(result.len(), 10);
assert_eq!(result[0].content, "g1");
}
#[test]
fn truncate_head_empty_input_returns_none() {
let result = truncate_head_for_retry(&[], Some(100));
assert!(result.is_none(), "empty input should return None");
}
#[test]
fn truncate_head_target_too_high_returns_none() {
let msgs = vec![
Message::user("ab".to_string()),
Message::assistant("cd".to_string()),
Message::user("ef".to_string()),
Message::assistant("gh".to_string()),
];
let result = truncate_head_for_retry(&msgs, Some(100));
assert!(result.is_none(), "target >= total should return None");
}
#[test]
fn estimate_target_extracts_resulted_in_tokens() {
let err = crate::error::Error::Llm {
provider: "openai".into(),
message: "This model's maximum context length is 16385 tokens. \
However, your messages resulted in 17000 tokens."
.into(),
};
let target = estimate_target_from_error(&err);
assert!(target.is_some(), "should parse token count from error");
assert_eq!(target.unwrap(), 54400);
}
#[test]
fn estimate_target_extracts_basic_token_count() {
let err = crate::error::Error::Llm {
provider: "deepseek".into(),
message: "prompt is too long (16000 tokens)".into(),
};
let target = estimate_target_from_error(&err);
assert!(target.is_some(), "should parse token count");
assert_eq!(target.unwrap(), 51200);
}
#[test]
fn estimate_target_returns_none_for_non_llm_error() {
let err = crate::error::Error::Config {
message: "something".into(),
};
let target = estimate_target_from_error(&err);
assert!(target.is_none(), "non-LLM errors should return None");
}
#[test]
fn estimate_target_returns_none_for_unparseable_message() {
let err = crate::error::Error::Llm {
provider: "test".into(),
message: "some other error without numbers".into(),
};
let target = estimate_target_from_error(&err);
assert!(target.is_none(), "unparseable messages should return None");
}
#[test]
fn estimate_target_parses_tokens_in_context_window_format() {
let err = crate::error::Error::Llm {
provider: "test".into(),
message: "request exceeds the model context window of 128000 tokens".into(),
};
let target = estimate_target_from_error(&err);
assert!(target.is_some(), "should parse token count");
assert_eq!(target.unwrap(), 409600);
}
}