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    pub fn new(max_tokens: usize) -> Self
30    where
31        TiktokenCounter: TokenCounter,
32    {
33        Self {
34            max_tokens,
35            counter: Arc::new(TiktokenCounter::new().expect("tiktoken cl100k_base load failed")),
36            strategy: Strategy::Truncate,
37        }
38    }
39
40    /// Creates a new ContextWindow with a specific strategy and default TiktokenCounter.
41    pub fn with_strategy(max_tokens: usize, strategy: Strategy<M>) -> Self
42    where
43        TiktokenCounter: TokenCounter,
44    {
45        Self {
46            max_tokens,
47            counter: Arc::new(TiktokenCounter::new().expect("tiktoken cl100k_base load failed")),
48            strategy,
49        }
50    }
51
52    /// Creates a new ContextWindow with a custom max token limit and default counter/strategy.
53    pub fn with_max_tokens(max_tokens: usize) -> Self
54    where
55        TiktokenCounter: TokenCounter,
56    {
57        Self::new(max_tokens)
58    }
59
60    /// Sets a custom token counter.
61    pub fn with_counter(mut self, counter: Arc<dyn TokenCounter>) -> Self {
62        self.counter = counter;
63        self
64    }
65
66    /// Returns the max token limit.
67    pub fn max_tokens(&self) -> usize {
68        self.max_tokens
69    }
70
71    /// Fits messages within the token limit by applying the configured strategy.
72    ///
73    /// - If total tokens are within `max_tokens`, returns messages as-is.
74    /// - If over, applies the `Strategy` (truncate or summarize).
75    ///
76    /// # Arguments
77    /// * `messages` - The conversation messages to fit.
78    ///
79    /// # Returns
80    /// A vector of messages that fits within the token budget.
81    pub async fn fit(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
82        let total_tokens = self.counter.count_messages(&messages) as usize;
83
84        if total_tokens <= self.max_tokens {
85            return Ok(messages);
86        }
87
88        match &self.strategy {
89            Strategy::Truncate => self.truncate(messages),
90            Strategy::Summarize {
91                llm,
92                summary_prompt,
93            } => self.summarize(messages, llm, summary_prompt).await,
94        }
95    }
96
97    /// Truncates messages by removing the oldest non-system messages
98    /// until the total fits within `max_tokens`.
99    ///
100    /// System messages are always preserved and placed at the beginning.
101    ///
102    /// M10: Optimized from O(n^2) to O(n) by computing token counts
103    /// incrementally instead of rebuilding and recounting the full candidate
104    /// list on every iteration.
105    fn truncate(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
106        // Separate system messages from the rest.
107        let mut system_messages: Vec<Message> = Vec::new();
108        let mut other_messages: Vec<Message> = Vec::new();
109
110        for msg in messages {
111            if matches!(msg.message_type, lc_schema::MessageType::System) {
112                system_messages.push(msg);
113            } else {
114                other_messages.push(msg);
115            }
116        }
117
118        // Compute the base cost: system messages + conversation boundary overhead.
119        // count_messages includes a +2 boundary; we account for it once.
120        let base_tokens = self.counter.count_messages(&system_messages) as usize;
121
122        // Pre-compute per-message incremental cost.
123        // For a single message, count_messages returns (4 + content_tokens + 2).
124        // The incremental cost of adding a message to an existing list is (4 + content_tokens).
125        // We subtract the boundary overhead (2) from single-message counts to get incremental cost.
126        let msg_incremental_costs: Vec<usize> = other_messages
127            .iter()
128            .map(|m| {
129                let single_count = self.counter.count_messages(std::slice::from_ref(m)) as usize;
130                // single_count = 4 + content_tokens + 2, incremental = 4 + content_tokens
131                single_count.saturating_sub(2)
132            })
133            .collect();
134
135        // Walk from the end (newest) and accumulate until we exceed the budget.
136        let mut kept: Vec<Message> = Vec::new();
137        let mut running_tokens = base_tokens;
138
139        for (msg, cost) in other_messages
140            .into_iter()
141            .rev()
142            .zip(msg_incremental_costs.into_iter().rev())
143        {
144            if running_tokens + cost <= self.max_tokens {
145                running_tokens += cost;
146                kept.push(msg);
147            } else {
148                // This message would push us over; stop adding more.
149                break;
150            }
151        }
152
153        kept.reverse();
154
155        let mut result = system_messages;
156        result.extend(kept);
157        Ok(result)
158    }
159
160    /// Summarizes old messages using the LLM, replacing them with a
161    /// single system message containing the summary.
162    ///
163    /// System messages are preserved. The oldest non-system messages
164    /// are summarized until the remaining messages fit within the budget.
165    async fn summarize(
166        &self,
167        messages: Vec<Message>,
168        llm: &Arc<M>,
169        summary_prompt: &str,
170    ) -> Result<Vec<Message>, MemoryError> {
171        // Separate system messages from the rest.
172        let mut system_messages: Vec<Message> = Vec::new();
173        let mut other_messages: Vec<Message> = Vec::new();
174
175        for msg in messages {
176            if matches!(msg.message_type, lc_schema::MessageType::System) {
177                system_messages.push(msg);
178            } else {
179                other_messages.push(msg);
180            }
181        }
182
183        if other_messages.is_empty() {
184            // Only system messages; nothing to summarize.
185            return Ok(system_messages);
186        }
187
188        // Find how many recent messages we can keep within the budget,
189        // reserving some space for the summary message.
190        // We try keeping the newest messages and summarizing the rest.
191        // Iterate from the smallest window (fewest recent messages) to the largest,
192        // keeping track of the best (smallest i = most messages kept) that fits.
193        let mut keep_from_idx = other_messages.len(); // default: keep all (no summarization)
194
195        for i in 0..other_messages.len() {
196            let recent = &other_messages[i..];
197            let mut candidate = system_messages.clone();
198            // Reserve space for a summary message (estimate ~100 tokens).
199            candidate.push(Message::system("summary placeholder"));
200            candidate.extend(recent.iter().cloned());
201
202            let tokens = self.counter.count_messages(&candidate) as usize;
203            if tokens <= self.max_tokens {
204                keep_from_idx = i;
205                break;
206            }
207        }
208
209        // If we can't even fit the recent messages with a summary placeholder,
210        // fall back to truncation for the recent portion.
211        if keep_from_idx >= other_messages.len() {
212            // Nothing fits; truncate to just system messages.
213            return self.truncate(system_messages);
214        }
215
216        let to_summarize = &other_messages[..keep_from_idx];
217        let to_keep = &other_messages[keep_from_idx..];
218
219        if to_summarize.is_empty() {
220            // All messages fit with the summary placeholder; no need to summarize.
221            let mut result = system_messages;
222            result.extend(to_keep.to_vec());
223            return Ok(result);
224        }
225
226        // Format the conversation for summarization.
227        let conversation_text = to_summarize
228            .iter()
229            .map(|msg| {
230                let role = match msg.message_type {
231                    lc_schema::MessageType::Human => "Human",
232                    lc_schema::MessageType::AI => "AI",
233                    lc_schema::MessageType::System => "System",
234                    lc_schema::MessageType::Tool { .. } => "Tool",
235                };
236                format!("{}: {}", role, msg.content)
237            })
238            .collect::<Vec<_>>()
239            .join("\n");
240
241        let prompt = summary_prompt.replace("{conversation}", &conversation_text);
242
243        let summary_messages = vec![Message::human(&prompt)];
244
245        let result = llm
246            .invoke(summary_messages, None)
247            .await
248            .map_err(|e| MemoryError::SaveError(format!("LLM summarization failed: {}", e)))?;
249
250        let summary_message = Message::system(format!("[Conversation Summary] {}", result.content));
251
252        // Build final message list: system + summary + recent.
253        let mut final_messages = system_messages;
254        final_messages.push(summary_message);
255        final_messages.extend(to_keep.to_vec());
256
257        // Verify the final result fits; if not, truncate the recent portion.
258        let final_tokens = self.counter.count_messages(&final_messages) as usize;
259        if final_tokens > self.max_tokens {
260            return self.truncate(final_messages);
261        }
262
263        Ok(final_messages)
264    }
265}