lc_memory/context_window/manager.rs
1// lc-memory/src/context_window/manager.rs
2//! Context window manager for fitting messages within a token budget.
3
4use std::sync::Arc;
5
6use lc_core::language_models::BaseChatModel;
7use lc_core::token_counter::{TiktokenCounter, TokenCounter};
8use lc_schema::Message;
9
10use crate::base::MemoryError;
11
12use super::trimmer::Strategy;
13
14/// Context window manager for fitting messages within a token budget.
15///
16/// Counts tokens using a `TokenCounter` (defaults to `TiktokenCounter`),
17/// and applies a `Strategy` when messages exceed `max_tokens`.
18pub struct ContextWindow<M: BaseChatModel> {
19 /// Maximum token count allowed.
20 max_tokens: usize,
21 /// Token counter implementation.
22 counter: Arc<dyn TokenCounter>,
23 /// Strategy for reducing messages when over the limit.
24 strategy: Strategy<M>,
25}
26
27impl<M: BaseChatModel> ContextWindow<M> {
28 /// Creates a new ContextWindow with the Truncate strategy and default TiktokenCounter.
29 ///
30 /// P1-4: returns `Result` instead of panicking — a tiktoken model download/load failure
31 /// (offline / missing model) returns [`MemoryError`], so the library constructor no longer
32 /// crashes on a bad local environment.
33 pub fn new(max_tokens: usize) -> Result<Self, MemoryError> {
34 Self::build(max_tokens, Strategy::Truncate)
35 }
36
37 /// Creates a new ContextWindow with a specific strategy and default TiktokenCounter.
38 pub fn with_strategy(max_tokens: usize, strategy: Strategy<M>) -> Result<Self, MemoryError> {
39 Self::build(max_tokens, strategy)
40 }
41
42 /// Creates a new ContextWindow with a custom max token limit and default counter/strategy.
43 pub fn with_max_tokens(max_tokens: usize) -> Result<Self, MemoryError> {
44 Self::new(max_tokens)
45 }
46
47 /// Shared constructor: loads the TiktokenCounter once, propagating failures.
48 fn build(max_tokens: usize, strategy: Strategy<M>) -> Result<Self, MemoryError> {
49 let counter = TiktokenCounter::new()
50 .map_err(|e| MemoryError::Other(format!("Failed to load tiktoken encoder: {}", e)))?;
51 Ok(Self {
52 max_tokens,
53 counter: Arc::new(counter),
54 strategy,
55 })
56 }
57
58 /// Sets a custom token counter.
59 pub fn with_counter(mut self, counter: Arc<dyn TokenCounter>) -> Self {
60 self.counter = counter;
61 self
62 }
63
64 /// Returns the max token limit.
65 pub fn max_tokens(&self) -> usize {
66 self.max_tokens
67 }
68
69 /// Fits messages within the token limit by applying the configured strategy.
70 ///
71 /// - If total tokens are within `max_tokens`, returns messages as-is.
72 /// - If over, applies the `Strategy` (truncate or summarize).
73 ///
74 /// # Budget semantics (P1-4 contract)
75 ///
76 /// **`Strategy::Truncate`**: System messages are always kept and do **not** consume budget
77 /// (M7); if the System messages alone exceed `max_tokens`, they are returned as-is (the
78 /// result may exceed the budget). Even when the budget is too small for a single
79 /// conversation message, at least the newest non-System message is kept — history is never
80 /// silently emptied (H7). Callers must not assume `fit`'s result is always within budget.
81 ///
82 /// **`Strategy::Summarize`**: the summary placeholder counts toward the budget, but the LLM's
83 /// actual summary token count is unknown — the budget semantics differ from Truncate, and the
84 /// intentionally inconsistent handling of System messages between the two is deliberate; each
85 /// strategy defines its own.
86 ///
87 /// # Arguments
88 /// * `messages` - The conversation messages to fit.
89 ///
90 /// # Returns
91 /// A vector of messages that fits within the token budget.
92 pub async fn fit(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
93 let total_tokens = self.counter.count_messages(&messages) as usize;
94
95 if total_tokens <= self.max_tokens {
96 return Ok(messages);
97 }
98
99 match &self.strategy {
100 Strategy::Truncate => self.truncate(messages),
101 Strategy::Summarize {
102 llm,
103 summary_prompt,
104 } => self.summarize(messages, llm, summary_prompt).await,
105 }
106 }
107
108 /// Truncates messages by removing the oldest non-system messages
109 /// until the total fits within `max_tokens`.
110 ///
111 /// System messages are always preserved and placed at the beginning,
112 /// and they do **not** count toward the budget (P1-4 contract): if the system
113 /// messages alone exceed `max_tokens`, they are returned as-is and the
114 /// result may exceed the budget.
115 ///
116 /// M7: system messages do not consume budget (base starts at 0), so they no longer crowd
117 /// out usable context.
118 ///
119 /// H7: even when the budget is too small for a single conversation message, at least the
120 /// newest non-System message is kept — history is never silently emptied (the result may
121 /// exceed the budget, which the contract allows).
122 ///
123 /// M10: Optimized from O(n^2) to O(n) by computing token counts
124 /// incrementally instead of rebuilding and recounting the full candidate
125 /// list on every iteration.
126 fn truncate(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
127 // Separate system messages from the rest.
128 let mut system_messages: Vec<Message> = Vec::new();
129 let mut other_messages: Vec<Message> = Vec::new();
130
131 for msg in messages {
132 if matches!(msg.message_type, lc_schema::MessageType::System) {
133 system_messages.push(msg);
134 } else {
135 other_messages.push(msg);
136 }
137 }
138
139 // M7: System messages are always kept and do **not** consume budget — base starts at 0.
140 // The old implementation counted `count_messages(&system_messages)` into base, crowding
141 // out usable context.
142 let mut running_tokens: usize = 0;
143
144 // Pre-compute per-message incremental cost.
145 // For a single message, count_messages returns (4 + content_tokens + 2).
146 // The incremental cost of adding a message to an existing list is (4 + content_tokens).
147 // We subtract the boundary overhead (2) from single-message counts to get incremental cost.
148 let msg_incremental_costs: Vec<usize> = other_messages
149 .iter()
150 .map(|m| {
151 let single_count = self.counter.count_messages(std::slice::from_ref(m)) as usize;
152 // single_count = 4 + content_tokens + 2, incremental = 4 + content_tokens
153 single_count.saturating_sub(2)
154 })
155 .collect();
156
157 // Walk from the end (newest) and accumulate until we exceed the budget.
158 let mut kept: Vec<Message> = Vec::new();
159
160 for (msg, cost) in other_messages
161 .into_iter()
162 .rev()
163 .zip(msg_incremental_costs.into_iter().rev())
164 {
165 if running_tokens + cost <= self.max_tokens {
166 running_tokens += cost;
167 kept.push(msg);
168 } else if kept.is_empty() {
169 // H7: when even the newest message cannot fit, still keep the newest one — the
170 // result may exceed the budget (contract allows), but history is never silently
171 // emptied.
172 kept.push(msg);
173 } else {
174 // This message would push us over; stop adding more.
175 break;
176 }
177 }
178
179 kept.reverse();
180
181 let mut result = system_messages;
182 result.extend(kept);
183 Ok(result)
184 }
185
186 /// Summarizes old messages using the LLM, replacing them with a
187 /// single system message containing the summary.
188 ///
189 /// System messages are preserved. The oldest non-system messages
190 /// are summarized until the remaining messages fit within the budget.
191 async fn summarize(
192 &self,
193 messages: Vec<Message>,
194 llm: &Arc<M>,
195 summary_prompt: &str,
196 ) -> Result<Vec<Message>, MemoryError> {
197 // Separate system messages from the rest.
198 let mut system_messages: Vec<Message> = Vec::new();
199 let mut other_messages: Vec<Message> = Vec::new();
200
201 for msg in messages {
202 if matches!(msg.message_type, lc_schema::MessageType::System) {
203 system_messages.push(msg);
204 } else {
205 other_messages.push(msg);
206 }
207 }
208
209 if other_messages.is_empty() {
210 // Only system messages; nothing to summarize.
211 return Ok(system_messages);
212 }
213
214 // Find how many recent messages we can keep within the budget,
215 // reserving some space for the summary message.
216 // We try keeping the newest messages and summarizing the rest.
217 // Iterate from the smallest window (fewest recent messages) to the largest,
218 // keeping track of the best (smallest i = most messages kept) that fits.
219 let mut keep_from_idx = other_messages.len(); // default: keep all (no summarization)
220
221 for i in 0..other_messages.len() {
222 let recent = &other_messages[i..];
223 let mut candidate = system_messages.clone();
224 // Reserve space for a summary message (estimate ~100 tokens).
225 candidate.push(Message::system("summary placeholder"));
226 candidate.extend(recent.iter().cloned());
227
228 let tokens = self.counter.count_messages(&candidate) as usize;
229 if tokens <= self.max_tokens {
230 keep_from_idx = i;
231 break;
232 }
233 }
234
235 // If we can't even fit the recent messages with a summary placeholder,
236 // fall back to truncation for the recent portion.
237 if keep_from_idx >= other_messages.len() {
238 // H7: when the budget is too small even for the summary placeholder, fall back to
239 // truncation — truncation guarantees at least the newest message is kept; the old
240 // implementation `truncate(system_messages)` silently emptied all history.
241 let mut all = system_messages;
242 all.extend(other_messages);
243 return self.truncate(all);
244 }
245
246 let to_summarize = &other_messages[..keep_from_idx];
247 let to_keep = &other_messages[keep_from_idx..];
248
249 if to_summarize.is_empty() {
250 // All messages fit with the summary placeholder; no need to summarize.
251 let mut result = system_messages;
252 result.extend(to_keep.to_vec());
253 return Ok(result);
254 }
255
256 // Format the conversation for summarization.
257 let conversation_text = to_summarize
258 .iter()
259 .map(|msg| {
260 let role = match msg.message_type {
261 lc_schema::MessageType::Human => "Human",
262 lc_schema::MessageType::AI => "AI",
263 lc_schema::MessageType::System => "System",
264 lc_schema::MessageType::Tool { .. } => "Tool",
265 };
266 format!("{}: {}", role, msg.content)
267 })
268 .collect::<Vec<_>>()
269 .join("\n");
270
271 let prompt = summary_prompt.replace("{conversation}", &conversation_text);
272
273 let summary_messages = vec![Message::human(&prompt)];
274
275 let result = llm
276 .invoke(summary_messages, None)
277 .await
278 .map_err(|e| MemoryError::SaveError(format!("LLM summarization failed: {}", e)))?;
279
280 let summary_message = Message::system(format!("[Conversation Summary] {}", result.content));
281
282 // Build final message list: system + summary + recent.
283 let mut final_messages = system_messages;
284 final_messages.push(summary_message);
285 final_messages.extend(to_keep.to_vec());
286
287 // Verify the final result fits; if not, truncate the recent portion.
288 let final_tokens = self.counter.count_messages(&final_messages) as usize;
289 if final_tokens > self.max_tokens {
290 return self.truncate(final_messages);
291 }
292
293 Ok(final_messages)
294 }
295}