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