1use codei_config::AgentConfig;
2use codei_llm::Message;
3
4use crate::model::{to_llm_messages, Role, Session, StoredMessage};
5
6#[derive(Debug, Clone, Copy)]
8pub struct TokenBudget {
9 pub max_tokens: u32,
10 pub compaction_threshold: f32,
11 pub keep_messages: usize,
12}
13
14impl TokenBudget {
15 pub fn limit(&self) -> u32 {
16 ((self.max_tokens as f32) * self.compaction_threshold) as u32
17 }
18
19 pub fn from_agent(agent: &AgentConfig) -> Self {
20 Self {
21 max_tokens: agent.context_window_tokens,
22 compaction_threshold: agent.compaction_threshold,
23 keep_messages: agent.compaction_keep_messages.max(2) as usize,
24 }
25 }
26}
27
28pub fn estimate_tokens(messages: &[Message]) -> u32 {
30 messages
31 .iter()
32 .map(|m| {
33 let content_len = m.content.as_ref().map(|c| c.len()).unwrap_or(0);
34 let tools_len = m
35 .tool_calls
36 .as_ref()
37 .map(|calls| calls.iter().map(|c| c.arguments.len()).sum::<usize>())
38 .unwrap_or(0);
39 (content_len + tools_len) as u32 / 4 + 8
40 })
41 .sum()
42}
43
44pub fn should_compact_session(session: &Session, system_prompt: &str, agent: &AgentConfig) -> bool {
46 let budget = TokenBudget::from_agent(agent);
47 let tokens = estimate_tokens(&to_llm_messages(session, system_prompt));
48 tokens > budget.limit() && session.messages.len() > budget.keep_messages
49}
50
51pub fn format_transcript(messages: &[StoredMessage]) -> String {
53 let mut out = String::new();
54 for msg in messages {
55 let role = match msg.role {
56 Role::User => "User",
57 Role::Assistant => "Assistant",
58 Role::Tool => "Tool",
59 Role::System => continue,
60 };
61 let Some(text) = msg.text() else { continue };
62 let trimmed = text.trim();
63 if trimmed.is_empty() {
64 continue;
65 }
66 out.push_str(role);
67 out.push_str(": ");
68 out.push_str(&truncate_chars(trimmed, 6_000));
69 out.push_str("\n\n");
70 }
71 out
72}
73
74fn truncate_chars(text: &str, max_chars: usize) -> String {
75 if text.chars().count() <= max_chars {
76 return text.to_string();
77 }
78 let truncated: String = text.chars().take(max_chars).collect();
79 format!("{truncated}…")
80}
81
82pub fn compact_messages(messages: Vec<Message>, budget: TokenBudget) -> Vec<Message> {
85 if messages.is_empty() || estimate_tokens(&messages) <= budget.limit() {
86 return messages;
87 }
88
89 const MIN_KEEP: usize = 2;
90 let keep_recent = budget.keep_messages.max(MIN_KEEP);
91 if messages.len() <= keep_recent + 1 {
92 return messages;
93 }
94
95 let system = messages.first().cloned();
96 let mut rest: Vec<Message> = messages.into_iter().skip(1).collect();
97 if rest.len() <= keep_recent {
98 return reconstruct(system, rest, 0);
99 }
100
101 let omitted = rest.len() - keep_recent;
102 rest.drain(0..omitted);
103 reconstruct(system, rest, omitted)
104}
105
106fn reconstruct(system: Option<Message>, kept: Vec<Message>, omitted: usize) -> Vec<Message> {
107 let mut out = Vec::new();
108 if let Some(sys) = system {
109 out.push(sys);
110 }
111 if omitted > 0 {
112 out.push(Message::user(format!(
113 "[Context compacted: {omitted} earlier messages omitted to fit the context window]"
114 )));
115 }
116 out.extend(kept);
117 out
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use codei_llm::Message;
124
125 #[test]
126 fn compacts_when_over_budget() {
127 let budget = TokenBudget {
128 max_tokens: 100,
129 compaction_threshold: 0.5,
130 keep_messages: 4,
131 };
132 let messages: Vec<Message> = std::iter::once(Message::system("sys"))
133 .chain((0..20).map(|i| Message::user(format!("message {i} {}", "x".repeat(50)))))
134 .collect();
135 let compacted = compact_messages(messages, budget);
136 assert!(compacted.len() < 21);
137 assert!(compacted[0].content.as_deref() == Some("sys"));
138 }
139
140 #[test]
141 fn should_compact_when_over_threshold() {
142 use codei_config::AgentConfig;
143
144 let mut session = Session::new(std::path::PathBuf::from("/tmp"));
145 for i in 0..30 {
146 session.push_user(format!("message {i} {}", "x".repeat(80)));
147 }
148 let agent = AgentConfig {
149 context_window_tokens: 200,
150 compaction_threshold: 0.5,
151 compaction_keep_messages: 6,
152 ..Default::default()
153 };
154 assert!(should_compact_session(&session, "system prompt", &agent));
155 }
156
157 #[test]
158 fn compact_with_summary_inserts_summary_message() {
159 let mut session = Session::new(std::path::PathBuf::from("/tmp"));
160 for i in 0..5 {
161 session.push_user(format!("msg {i}"));
162 }
163 session.compact_with_summary(2, "summary text".into());
164 assert_eq!(session.messages.len(), 3);
165 assert!(session.messages[0].text().unwrap().contains("summary text"));
166 assert!(session.messages[1].text().unwrap().contains("msg 3"));
167 }
168}