use crate::cm_types::{
Message, is_chat_timeline_marker, is_model_context_artifact_marker,
message_content_byte_len_for_estimate, user_message_counts_for_branch_truncation,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ConversationTurnGroup {
pub start: usize,
pub end: usize,
}
#[must_use]
pub fn conversation_turn_groups(messages: &[Message]) -> Vec<ConversationTurnGroup> {
let starts: Vec<usize> = messages
.iter()
.enumerate()
.filter_map(|(idx, message)| {
user_message_counts_for_branch_truncation(message).then_some(idx)
})
.collect();
starts
.iter()
.enumerate()
.map(|(idx, start)| ConversationTurnGroup {
start: *start,
end: starts.get(idx + 1).copied().unwrap_or(messages.len()),
})
.collect()
}
pub fn remove_oldest_turn_group(messages: &mut Vec<Message>, min_groups_to_keep: usize) -> usize {
let groups = conversation_turn_groups(messages);
if groups.len() <= min_groups_to_keep {
return 0;
}
let oldest = groups[0];
let removed = oldest.end.saturating_sub(oldest.start);
messages.drain(oldest.start..oldest.end);
removed
}
pub fn take_chat_timeline_markers(messages: &mut Vec<Message>) -> Vec<Message> {
let mut parked = Vec::new();
let mut kept = Vec::with_capacity(messages.len());
for m in messages.drain(..) {
if is_chat_timeline_marker(&m) || is_model_context_artifact_marker(&m) {
parked.push(m);
} else {
kept.push(m);
}
}
*messages = kept;
parked
}
fn estimate_chars_from_bytes(s: &str) -> usize {
s.len().div_ceil(2)
}
pub fn estimate_message_chars(m: &Message) -> usize {
let mut n = message_content_byte_len_for_estimate(&m.content).div_ceil(2);
n = n.saturating_add(
m.reasoning_content
.as_deref()
.map(estimate_chars_from_bytes)
.unwrap_or(0),
);
if let Some(ref tcs) = m.tool_calls {
for tc in tcs {
n = n.saturating_add(tc.function.name.len());
n = n.saturating_add(tc.function.arguments.len());
n = n.saturating_add(tc.id.len());
}
}
n
}
pub fn estimate_non_system_chars(messages: &[Message]) -> usize {
messages
.iter()
.filter(|m| m.role != "system")
.map(estimate_message_chars)
.sum()
}
pub fn compress_tool_message_contents(messages: &mut [Message], max_chars: usize) -> usize {
let max_chars = max_chars.max(256);
let mut n = 0usize;
for m in messages.iter_mut() {
if m.role != "tool" {
continue;
}
let Some(c) = &mut m.content else {
continue;
};
if let crate::cm_types::MessageContent::Text(s) = c
&& let Some(compressed) =
crate::cm_tools::tool_result::maybe_compress_tool_message_content(s, max_chars)
{
*s = compressed;
n += 1;
}
}
n
}
fn trim_tail_after_system(after: &[Message], tail_keep: usize) -> Vec<Message> {
let skip = after.len().saturating_sub(tail_keep);
let mut tail: Vec<Message> = after.iter().skip(skip).cloned().collect();
let tail_opens_with_assistant_run = tail.len() >= 2
&& tail[0].role.trim().eq_ignore_ascii_case("assistant")
&& tail[1].role.trim().eq_ignore_ascii_case("assistant");
if tail_opens_with_assistant_run
&& let Some(ui) = after[..skip]
.iter()
.rposition(|m| m.role.trim().eq_ignore_ascii_case("user"))
{
tail.insert(0, after[ui].clone());
while tail.len() > tail_keep {
if tail.len() <= 1 {
break;
}
tail.remove(1);
}
}
tail
}
pub fn trim_messages_by_count(messages: &mut Vec<Message>, max_after_system: usize) -> bool {
if messages.is_empty() || max_after_system == 0 {
return false;
}
let before = messages.len();
let has_system_head = messages[0].role == "system";
let max_total = max_after_system.saturating_add(usize::from(has_system_head));
while messages.len() > max_total && remove_oldest_turn_group(messages, 1) > 0 {}
if messages.len() <= max_total {
return messages.len() < before;
}
if !conversation_turn_groups(messages).is_empty() {
return messages.len() < before;
}
if messages[0].role == "system" {
if messages.len() <= 1 + max_after_system {
return false;
}
let sys = messages[0].clone();
let tail = trim_tail_after_system(&messages[1..], max_after_system);
let mut out = vec![sys];
out.extend(tail);
*messages = out;
} else if messages.len() > max_after_system {
let skip = messages.len() - max_after_system;
*messages = messages.iter().skip(skip).cloned().collect();
}
messages.len() < before
}
fn trim_complete_turn_groups_by_char_budget(
messages: &mut Vec<Message>,
budget: usize,
min_total: usize,
) -> bool {
let mut removed_any = false;
while estimate_non_system_chars(messages) > budget {
let groups = conversation_turn_groups(messages);
let Some(oldest) = groups.first().copied() else {
break;
};
let remaining = messages
.len()
.saturating_sub(oldest.end.saturating_sub(oldest.start));
if groups.len() <= 1 || remaining < min_total {
break;
}
messages.drain(oldest.start..oldest.end);
removed_any = true;
}
removed_any
}
fn trim_ungrouped_messages_by_char_budget(
messages: &mut Vec<Message>,
budget: usize,
min_total: usize,
) -> bool {
let start_idx = usize::from(messages[0].role == "system");
let removable = messages.len().saturating_sub(min_total);
let mut remaining_chars = estimate_non_system_chars(messages);
let mut remove_count = 0usize;
for msg in messages.iter().skip(start_idx).take(removable) {
if remaining_chars <= budget {
break;
}
remaining_chars = remaining_chars.saturating_sub(estimate_message_chars(msg));
remove_count += 1;
}
if remove_count > 0 {
messages.drain(start_idx..start_idx + remove_count);
}
remove_count > 0
}
pub fn trim_messages_by_char_budget(
messages: &mut Vec<Message>,
budget: usize,
min_messages_after_system: usize,
) -> bool {
if budget == 0 || messages.len() <= 1 {
return false;
}
let min_total = 1 + min_messages_after_system;
if messages.len() <= min_total {
return false;
}
if estimate_non_system_chars(messages) <= budget {
return false;
}
if conversation_turn_groups(messages).is_empty() {
trim_ungrouped_messages_by_char_budget(messages, budget, min_total)
} else {
trim_complete_turn_groups_by_char_budget(messages, budget, min_total)
}
}
pub fn drop_orphan_tool_messages(messages: &mut Vec<Message>) -> usize {
let before_len = messages.len();
let mut keep = vec![true; messages.len()];
for i in 0..messages.len() {
if messages[i].role != "tool" {
continue;
}
let has_valid_predecessor = i > 0 && {
let mut prev = i - 1;
while prev > 0 && !keep[prev] {
prev -= 1;
}
keep[prev]
&& (messages[prev].role == "tool"
|| (messages[prev].role == "assistant"
&& messages[prev]
.tool_calls
.as_ref()
.is_some_and(|c| !c.is_empty())))
};
if !has_valid_predecessor {
keep[i] = false;
}
}
let mut idx = 0;
messages.retain(|_| {
let k = keep[idx];
idx += 1;
k
});
before_len.saturating_sub(messages.len())
}