use std::collections::HashMap;
use std::ops::Range;
use uuid::Uuid;
use crate::entities::attachment::paginate;
use crate::entities::message::{Message, MessageRole, ToolCallRecord};
use crate::shared::i18n::Locale;
use crate::shared::tokens::estimate_prompt;
pub const TOOL_RESULT_CLIP: usize = 200;
struct Rendered {
blocks: Vec<(Uuid, String)>,
aliases: HashMap<Uuid, usize>,
}
fn render(messages: &[Message], loc: &Locale, clip: Option<usize>) -> Rendered {
let by_call_id: HashMap<&str, &str> = messages
.iter()
.filter(|m| m.role == MessageRole::Tool)
.filter_map(|m| Some((m.tool_call_id.as_deref()?, m.text.as_str())))
.collect();
let mut blocks: Vec<(Uuid, String)> = Vec::new();
let mut aliases: HashMap<Uuid, usize> = HashMap::new();
for m in messages {
if m.role == MessageRole::Tool {
if let Some(i) = blocks.len().checked_sub(1) {
aliases.insert(m.id, i);
}
continue;
}
if !matches!(m.role, MessageRole::User | MessageRole::Assistant) {
continue;
}
let text = m.text.trim();
if text.is_empty() && m.tool_calls.is_empty() {
continue;
}
let who = match m.role {
MessageRole::User => loc.t("digest.role.user"),
_ => loc.t("digest.role.assistant"),
};
let mut lines = vec![if text.is_empty() {
format!("{who}:")
} else {
format!("{who}: {text}")
}];
for call in &m.tool_calls {
let result = tool_result_of(call, &by_call_id, clip);
lines.push(loc.tf(
"compaction.digest.tool",
&[("name", &call.name), ("result", &result)],
));
}
blocks.push((m.id, lines.join("\n")));
}
Rendered { blocks, aliases }
}
pub fn build_compaction_digest(messages: &[Message], loc: &Locale) -> Option<String> {
let rendered = render(messages, loc, Some(TOOL_RESULT_CLIP));
if rendered.blocks.is_empty() {
return None;
}
Some(
rendered
.blocks
.into_iter()
.map(|(_, text)| text)
.collect::<Vec<_>>()
.join("\n"),
)
}
pub struct HistoryView {
text: String,
blocks: Vec<(Uuid, Range<usize>)>,
aliases: HashMap<Uuid, usize>,
}
const BLOCK_SEPARATOR: &str = "\n\n";
impl HistoryView {
pub fn render(messages: &[Message], loc: &Locale) -> Option<Self> {
let rendered = render(messages, loc, None);
if rendered.blocks.is_empty() {
return None;
}
let mut text = String::new();
let mut blocks = Vec::with_capacity(rendered.blocks.len());
for (id, block) in rendered.blocks {
if !text.is_empty() {
text.push_str(BLOCK_SEPARATOR);
}
let start = text.len();
text.push_str(&block);
blocks.push((id, start..text.len()));
}
Some(Self {
text,
blocks,
aliases: rendered.aliases,
})
}
pub fn page_count(&self, page_tokens: usize) -> usize {
paginate(&self.text, page_tokens).len()
}
pub fn page(&self, page_tokens: usize, n: usize) -> Option<&str> {
let pages = paginate(&self.text, page_tokens);
n.checked_sub(1).and_then(|i| pages.get(i)).copied()
}
pub fn locate(&self, page_tokens: usize, id: Uuid) -> Option<(usize, &str)> {
let i = self
.blocks
.iter()
.position(|(bid, _)| *bid == id)
.or_else(|| self.aliases.get(&id).copied())?;
let range = self.blocks[i].1.clone();
let mut end = 0;
for (p, page) in paginate(&self.text, page_tokens).iter().enumerate() {
end += page.len();
if range.start < end {
return Some((p + 1, &self.text[range]));
}
}
None
}
pub fn order_of(&self, id: Uuid) -> Option<usize> {
self.blocks
.iter()
.position(|(bid, _)| *bid == id)
.or_else(|| self.aliases.get(&id).copied())
}
}
fn tool_result_of(
call: &ToolCallRecord,
by_call_id: &HashMap<&str, &str>,
clip: Option<usize>,
) -> String {
let raw = call
.result
.as_deref()
.or_else(|| by_call_id.get(call.id.as_str()).copied())
.unwrap_or("")
.trim();
match clip {
Some(budget) => clip_chars(raw, budget),
None => raw.to_string(),
}
}
fn clip_chars(text: &str, budget: usize) -> String {
if text.chars().count() <= budget {
return text.to_string();
}
let head: String = text.chars().take(budget).collect();
format!("{head}…")
}
pub fn plan_cut(messages: &[Message], tail_tokens: usize) -> Option<usize> {
let mut acc: u64 = 0;
let mut cut: Option<usize> = None;
for (i, m) in messages.iter().enumerate().rev() {
acc += estimate_prompt(None, [m.text.as_str()]);
if acc >= tail_tokens as u64 {
cut = Some(i);
break;
}
}
let cut = cut?;
let boundary = messages[..=cut]
.iter()
.rposition(|m| m.role == MessageRole::User)?;
(boundary > 0).then_some(boundary)
}
const OVERFLOW_MARKERS: &[&str] = &[
"exceed_context_size_error",
"context_length_exceeded",
"maximum context length",
"prompt is too long",
"exceeds the maximum number of tokens",
];
pub fn is_context_overflow(err: &str) -> bool {
let err = err.to_lowercase();
OVERFLOW_MARKERS.iter().any(|m| err.contains(m))
}
pub fn summary_system_message(loc: &Locale, words: usize) -> String {
loc.tf("prompt.compact.system", &[("words", &words.to_string())])
}
pub fn roll_user_message(previous: &str, digest: &str, loc: &Locale, words: usize) -> String {
loc.tf(
"prompt.compact.roll",
&[
("summary", previous),
("words", &words.to_string()),
("digest", digest),
],
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn ru() -> &'static Locale {
locale(Lang::Ru)
}
fn assistant_with_call(text: &str, id: &str, name: &str, result: Option<&str>) -> Message {
let mut m = Message::assistant(text);
m.tool_calls = vec![ToolCallRecord {
id: id.into(),
name: name.into(),
arguments: serde_json::json!({}),
result: result.map(str::to_string),
thought_signature: None,
images: 0,
subagent: None,
}];
m
}
fn tool_msg(id: &str, name: &str, text: &str) -> Message {
let mut m = Message::new(MessageRole::Tool, text);
m.tool_call_id = Some(id.into());
m.tool_name = Some(name.into());
m
}
#[test]
fn digest_carries_tool_activity() {
let msgs = vec![
Message::user("найди файл"),
assistant_with_call("сейчас", "c1", "fs_list", Some("main.rs")),
tool_msg("c1", "fs_list", "main.rs"),
Message::assistant("нашёл"),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert!(d.contains("Пользователь: найди файл"), "{d}");
assert!(d.contains("Ассистент: сейчас"), "{d}");
assert!(d.contains("fs_list"), "the tool name must be carried: {d}");
assert!(
d.contains("main.rs"),
"the tool result must be carried: {d}"
);
}
#[test]
fn tool_messages_are_not_double_counted() {
let msgs = vec![
Message::user("q"),
assistant_with_call("a", "c1", "calc", Some("42")),
tool_msg("c1", "calc", "42"),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert_eq!(d.matches("42").count(), 1, "the result appears once: {d}");
assert_eq!(d.lines().count(), 3, "user + assistant + one call: {d}");
}
#[test]
fn blank_assistant_with_tool_calls_is_kept() {
let msgs = vec![
Message::user("посчитай"),
assistant_with_call(" ", "c1", "calculate", Some("7")),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert!(d.contains("calculate"), "{d}");
assert!(d.contains("Ассистент:"), "{d}");
}
#[test]
fn blank_message_without_calls_is_skipped() {
let msgs = vec![Message::user("вопрос"), Message::assistant(" ")];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert_eq!(d, "Пользователь: вопрос");
}
#[test]
fn long_tool_result_is_clipped_at_a_character_boundary() {
let long = "я".repeat(TOOL_RESULT_CLIP + 50);
let msgs = vec![
Message::user("q"),
assistant_with_call("a", "c1", "note_recall", Some(&long)),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
let kept = d.matches('я').count();
assert_eq!(
kept, TOOL_RESULT_CLIP,
"clipped by chars, not bytes: {kept}"
);
assert!(d.contains('…'), "the clip marker: {d}");
}
#[test]
fn tool_result_falls_back_to_the_tool_message() {
let msgs = vec![
Message::user("q"),
assistant_with_call("a", "c1", "fs_read", None),
tool_msg("c1", "fs_read", "содержимое файла"),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert!(d.contains("содержимое файла"), "{d}");
}
#[test]
fn tool_call_without_any_result_is_still_named() {
let msgs = vec![
Message::user("q"),
assistant_with_call("a", "c9", "web_search", None),
];
let d = build_compaction_digest(&msgs, ru()).unwrap();
assert!(d.contains("web_search"), "{d}");
}
#[test]
fn digest_is_none_without_meaningful_messages() {
assert!(build_compaction_digest(&[], ru()).is_none());
let msgs = vec![
Message::new(MessageRole::System, "sys"),
Message::user(" "),
tool_msg("c1", "calc", "42"),
];
assert!(build_compaction_digest(&msgs, ru()).is_none());
}
#[test]
fn digest_localized_for_all_langs() {
let msgs = vec![
Message::user("hi"),
assistant_with_call("yo", "c1", "calc", Some("42")),
];
for &lang in Lang::ALL {
let l = locale(lang);
let d = build_compaction_digest(&msgs, l).unwrap();
assert!(d.contains(l.t("digest.role.user")), "{lang:?}: {d}");
assert!(d.contains(l.t("digest.role.assistant")), "{lang:?}: {d}");
assert!(d.contains("calc") && d.contains("42"), "{lang:?}: {d}");
assert!(
!d.contains('{') && !d.contains('}'),
"unsubstituted placeholder in {lang:?}: {d}"
);
}
}
fn two_exchanges() -> Vec<Message> {
vec![
Message::user("первый вопрос"),
assistant_with_call("думаю", "c1", "calc", Some("1")),
tool_msg("c1", "calc", "1"),
Message::assistant("первый ответ"),
Message::user("второй вопрос"),
assistant_with_call("считаю", "c2", "calc", Some("2")),
tool_msg("c2", "calc", "2"),
Message::assistant("второй ответ"),
]
}
#[test]
fn cut_is_none_when_the_conversation_is_short() {
let msgs = two_exchanges();
assert_eq!(plan_cut(&msgs, 100_000), None);
assert_eq!(plan_cut(&[], 10), None);
}
#[test]
fn cut_snaps_back_past_an_assistant_tool_group() {
let msgs = two_exchanges();
let raw_tail = estimate_prompt(None, msgs[6..].iter().map(|m| m.text.as_str()));
let cut = plan_cut(&msgs, raw_tail as usize).unwrap();
assert_eq!(cut, 4, "must snap back to the user message of the exchange");
assert_eq!(msgs[cut].role, MessageRole::User);
}
#[test]
fn cut_always_lands_on_a_user_message_and_never_on_zero() {
let msgs = two_exchanges();
let total = estimate_prompt(None, msgs.iter().map(|m| m.text.as_str())) as usize;
let mut seen_some = false;
for budget in 1..=total {
if let Some(i) = plan_cut(&msgs, budget) {
assert_eq!(msgs[i].role, MessageRole::User, "budget {budget} → {i}");
assert!(i > 0, "budget {budget} → {i}");
seen_some = true;
}
}
assert!(seen_some, "some budget must produce a cut");
}
#[test]
fn cut_is_none_without_a_user_boundary_above_zero() {
let msgs = vec![
Message::user("вопрос"),
Message::assistant("ответ"),
Message::assistant("ещё ответ"),
];
for budget in 1..=40 {
assert_eq!(plan_cut(&msgs, budget), None, "budget {budget}");
}
}
#[test]
fn a_bigger_tail_budget_never_moves_the_cut_later() {
let msgs = two_exchanges();
let mut prev: Option<usize> = None;
for budget in 1..=60 {
if let Some(i) = plan_cut(&msgs, budget) {
if let Some(p) = prev {
assert!(i <= p, "budget {budget}: cut moved later {p} → {i}");
}
prev = Some(i);
}
}
}
#[test]
fn prompts_localized_for_all_langs() {
for &lang in Lang::ALL {
let l = locale(lang);
let sys = summary_system_message(l, 250);
assert!(sys.contains("250"), "{lang:?}: {sys}");
assert!(
!sys.contains('{') && !sys.contains('}'),
"unsubstituted placeholder in {lang:?}: {sys}"
);
let roll = roll_user_message("ПРЕДЫДУЩЕЕ", "ДАЙДЖЕСТ", l, 250);
assert!(roll.contains("ПРЕДЫДУЩЕЕ"), "{lang:?}: {roll}");
assert!(roll.contains("ДАЙДЖЕСТ"), "{lang:?}: {roll}");
assert!(roll.contains("250"), "{lang:?}: {roll}");
assert!(
!roll.contains('{') && !roll.contains('}'),
"unsubstituted placeholder in {lang:?}: {roll}"
);
}
}
#[test]
fn a_placeholder_inside_the_digest_is_not_re_expanded() {
let roll = roll_user_message("", "the user wrote {words} literally", ru(), 250);
assert!(roll.contains("{words} literally"), "{roll}");
}
const PAGE: usize = 10;
#[test]
fn walking_every_page_reassembles_the_transcript() {
let msgs = two_exchanges();
let view = HistoryView::render(&msgs, ru()).unwrap();
let total = view.page_count(PAGE);
assert!(total > 1, "the fixture must span several pages");
let mut joined = String::new();
for p in 1..=total {
joined.push_str(view.page(PAGE, p).unwrap());
}
assert_eq!(joined, view.text);
assert!(view.page(PAGE, 0).is_none(), "pages are 1-based");
assert!(view.page(PAGE, total + 1).is_none());
}
#[test]
fn locate_maps_a_message_to_its_page_and_block() {
let msgs = two_exchanges();
let view = HistoryView::render(&msgs, ru()).unwrap();
for m in &msgs {
if m.role == MessageRole::Tool {
continue;
}
let (page, block) = view.locate(PAGE, m.id).unwrap();
assert!(
(1..=view.page_count(PAGE)).contains(&page),
"page {page} out of range for {:?}",
m.text
);
assert!(block.contains(m.text.trim()), "{block}");
}
assert_eq!(view.locate(PAGE, Uuid::new_v4()), None);
}
#[test]
fn a_tool_message_resolves_to_the_block_that_carries_its_result() {
let msgs = two_exchanges();
let tool_msg = msgs.iter().find(|m| m.role == MessageRole::Tool).unwrap();
let assistant = &msgs[1];
let view = HistoryView::render(&msgs, ru()).unwrap();
let (page, block) = view.locate(PAGE, tool_msg.id).unwrap();
let (a_page, a_block) = view.locate(PAGE, assistant.id).unwrap();
assert_eq!((page, block), (a_page, a_block));
assert_eq!(view.order_of(tool_msg.id), view.order_of(assistant.id));
}
#[test]
fn the_reader_sees_a_tool_result_the_digest_had_to_clip() {
let long = "я".repeat(TOOL_RESULT_CLIP + 50);
let msgs = vec![
Message::user("вопрос"),
assistant_with_call("ответ", "c1", "fetch_url", Some(&long)),
];
let digest = build_compaction_digest(&msgs, ru()).unwrap();
let view = HistoryView::render(&msgs, ru()).unwrap();
assert_eq!(digest.matches('я').count(), TOOL_RESULT_CLIP);
assert!(digest.contains('…'));
assert_eq!(
view.text.matches('я').count(),
TOOL_RESULT_CLIP + 50,
"the reader must not clip"
);
}
#[test]
fn order_of_follows_the_conversation() {
let msgs = two_exchanges();
let view = HistoryView::render(&msgs, ru()).unwrap();
let first = view.order_of(msgs[0].id).unwrap();
let later = view.order_of(msgs[4].id).unwrap();
assert!(first < later, "{first} !< {later}");
}
#[test]
fn view_is_none_without_meaningful_messages() {
assert!(HistoryView::render(&[], ru()).is_none());
let msgs = vec![Message::user(" "), tool_msg("c1", "calc", "42")];
assert!(HistoryView::render(&msgs, ru()).is_none());
}
#[test]
fn blocks_are_separated_in_the_readable_transcript() {
let msgs = vec![
Message::user("первая строка\nвторая строка"),
Message::assistant("ответ"),
];
let view = HistoryView::render(&msgs, ru()).unwrap();
assert!(
view.text.contains("вторая строка\n\nАссистент: ответ"),
"{}",
view.text
);
}
#[test]
fn every_provider_overflow_is_recognized() {
let bodies = [
"engine returned status 400 Bad Request: {\"error\":{\"code\":400,\
\"type\":\"exceed_context_size_error\",\"n_prompt_tokens\":32706,\"n_ctx\":16384}}",
"engine returned status 400: {\"error\":{\"code\":\"context_length_exceeded\"}}",
"This model's maximum context length is 128000 tokens",
"invalid_request_error: prompt is too long: 210000 tokens > 200000 maximum",
"INVALID_ARGUMENT: The input token count (1200000) exceeds the maximum \
number of tokens allowed (1048576)",
];
for body in bodies {
assert!(is_context_overflow(body), "not recognized: {body}");
assert!(is_context_overflow(&body.to_uppercase()), "case: {body}");
}
}
#[test]
fn ordinary_failures_are_not_mistaken_for_an_overflow() {
for body in [
"connection refused (os error 10061)",
"engine returned status 503: server is still loading the model",
"engine returned status 401: invalid api key",
"engine returned status 400: unknown field `top_k`",
"the tool returned 4096 tokens of context",
] {
assert!(!is_context_overflow(body), "false positive: {body}");
}
}
}