Skip to main content

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}