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 // 0.22.0 H-M2: the kept suffix must not open on an orphaned Tool
182 // message (a tool result whose matching assistant tool_calls fell
183 // outside the window). A standalone tool message is malformed for
184 // OpenAI/Anthropic → 400. Drop leading Tool messages until the window
185 // opens on a non-Tool message.
186 while kept
187 .first()
188 .is_some_and(|m| matches!(m.message_type, lc_schema::MessageType::Tool { .. }))
189 {
190 kept.remove(0);
191 if kept.is_empty() {
192 break;
193 }
194 }
195
196 let mut result = system_messages;
197 result.extend(kept);
198 Ok(result)
199 }
200
201 /// Summarizes old messages using the LLM, replacing them with a
202 /// single system message containing the summary.
203 ///
204 /// System messages are preserved. The oldest non-system messages
205 /// are summarized until the remaining messages fit within the budget.
206 async fn summarize(
207 &self,
208 messages: Vec<Message>,
209 llm: &Arc<M>,
210 summary_prompt: &str,
211 ) -> Result<Vec<Message>, MemoryError> {
212 // Separate system messages from the rest.
213 let mut system_messages: Vec<Message> = Vec::new();
214 let mut other_messages: Vec<Message> = Vec::new();
215
216 for msg in messages {
217 if matches!(msg.message_type, lc_schema::MessageType::System) {
218 system_messages.push(msg);
219 } else {
220 other_messages.push(msg);
221 }
222 }
223
224 if other_messages.is_empty() {
225 // Only system messages; nothing to summarize.
226 return Ok(system_messages);
227 }
228
229 // Find how many recent messages we can keep within the budget,
230 // reserving some space for the summary message.
231 // We try keeping the newest messages and summarizing the rest.
232 // Iterate from the smallest window (fewest recent messages) to the largest,
233 // keeping track of the best (smallest i = most messages kept) that fits.
234 let mut keep_from_idx = other_messages.len(); // default: keep all (no summarization)
235
236 for i in 0..other_messages.len() {
237 let recent = &other_messages[i..];
238 let mut candidate = system_messages.clone();
239 // Reserve space for a summary message (estimate ~100 tokens).
240 candidate.push(Message::system("summary placeholder"));
241 candidate.extend(recent.iter().cloned());
242
243 let tokens = self.counter.count_messages(&candidate) as usize;
244 if tokens <= self.max_tokens {
245 keep_from_idx = i;
246 break;
247 }
248 }
249
250 // If we can't even fit the recent messages with a summary placeholder,
251 // fall back to truncation for the recent portion.
252 if keep_from_idx >= other_messages.len() {
253 // H7: when the budget is too small even for the summary placeholder, fall back to
254 // truncation — truncation guarantees at least the newest message is kept; the old
255 // implementation `truncate(system_messages)` silently emptied all history.
256 let mut all = system_messages;
257 all.extend(other_messages);
258 return self.truncate(all);
259 }
260
261 let to_summarize = &other_messages[..keep_from_idx];
262 let to_keep = &other_messages[keep_from_idx..];
263
264 if to_summarize.is_empty() {
265 // All messages fit with the summary placeholder; no need to summarize.
266 let mut result = system_messages;
267 result.extend(to_keep.to_vec());
268 return Ok(result);
269 }
270
271 // Format the conversation for summarization.
272 let conversation_text = to_summarize
273 .iter()
274 .map(|msg| {
275 let role = match msg.message_type {
276 lc_schema::MessageType::Human => "Human",
277 lc_schema::MessageType::AI => "AI",
278 lc_schema::MessageType::System => "System",
279 lc_schema::MessageType::Tool { .. } => "Tool",
280 };
281 format!("{}: {}", role, msg.content)
282 })
283 .collect::<Vec<_>>()
284 .join("\n");
285
286 let prompt = summary_prompt.replace("{conversation}", &conversation_text);
287
288 let summary_messages = vec![Message::human(&prompt)];
289
290 let result = llm
291 .invoke(summary_messages, None)
292 .await
293 .map_err(|e| MemoryError::SaveError(format!("LLM summarization failed: {}", e)))?;
294
295 let summary_message = Message::system(format!("[Conversation Summary] {}", result.content));
296
297 // Build final message list: system + summary + recent.
298 let mut final_messages = system_messages;
299 final_messages.push(summary_message);
300 final_messages.extend(to_keep.to_vec());
301
302 // Verify the final result fits; if not, truncate the recent portion.
303 let final_tokens = self.counter.count_messages(&final_messages) as usize;
304 if final_tokens > self.max_tokens {
305 return self.truncate(final_messages);
306 }
307
308 Ok(final_messages)
309 }
310}