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 消息恒保留且**不占预算**(M7);若 System
76    /// 消息自身超过 `max_tokens`,原样返回(结果可能超预算)。即使预算小到一条
77    /// 对话都放不下,也至少保留最新一条非 System 消息,不静默清空历史(H7)。
78    /// 调用方不应假设 `fit` 的返回结果一定在预算内。
79    ///
80    /// **`Strategy::Summarize`**: 摘要占位计入预算,但 LLM 实际产出的摘要 token
81    /// 数未知——预算语义与 Truncate 不同,两者在 System 消息上口径不一致是有意为之,
82    /// 各策略自行定义。
83    ///
84    /// # Arguments
85    /// * `messages` - The conversation messages to fit.
86    ///
87    /// # Returns
88    /// A vector of messages that fits within the token budget.
89    pub async fn fit(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
90        let total_tokens = self.counter.count_messages(&messages) as usize;
91
92        if total_tokens <= self.max_tokens {
93            return Ok(messages);
94        }
95
96        match &self.strategy {
97            Strategy::Truncate => self.truncate(messages),
98            Strategy::Summarize {
99                llm,
100                summary_prompt,
101            } => self.summarize(messages, llm, summary_prompt).await,
102        }
103    }
104
105    /// Truncates messages by removing the oldest non-system messages
106    /// until the total fits within `max_tokens`.
107    ///
108    /// System messages are always preserved and placed at the beginning,
109    /// and they do **not** count toward the budget (P1-4 契约): if the system
110    /// messages alone exceed `max_tokens`, they are returned as-is and the
111    /// result may exceed the budget.
112    ///
113    /// M7: system messages 不计入预算(base 从 0 起算),不再挤占可用上下文。
114    ///
115    /// H7: 即使预算小到一条对话都放不下,也至少保留最新一条非 System 消息,
116    /// 绝不静默丢光全部历史(结果可能超预算,契约允许)。
117    ///
118    /// M10: Optimized from O(n^2) to O(n) by computing token counts
119    /// incrementally instead of rebuilding and recounting the full candidate
120    /// list on every iteration.
121    fn truncate(&self, messages: Vec<Message>) -> Result<Vec<Message>, MemoryError> {
122        // Separate system messages from the rest.
123        let mut system_messages: Vec<Message> = Vec::new();
124        let mut other_messages: Vec<Message> = Vec::new();
125
126        for msg in messages {
127            if matches!(msg.message_type, lc_schema::MessageType::System) {
128                system_messages.push(msg);
129            } else {
130                other_messages.push(msg);
131            }
132        }
133
134        // M7: System 消息恒保留且**不占预算**,base 从 0 起算——旧实现把
135        // `count_messages(&system_messages)` 计入 base,挤占可用上下文。
136        let mut running_tokens: usize = 0;
137
138        // Pre-compute per-message incremental cost.
139        // For a single message, count_messages returns (4 + content_tokens + 2).
140        // The incremental cost of adding a message to an existing list is (4 + content_tokens).
141        // We subtract the boundary overhead (2) from single-message counts to get incremental cost.
142        let msg_incremental_costs: Vec<usize> = other_messages
143            .iter()
144            .map(|m| {
145                let single_count = self.counter.count_messages(std::slice::from_ref(m)) as usize;
146                // single_count = 4 + content_tokens + 2, incremental = 4 + content_tokens
147                single_count.saturating_sub(2)
148            })
149            .collect();
150
151        // Walk from the end (newest) and accumulate until we exceed the budget.
152        let mut kept: Vec<Message> = Vec::new();
153
154        for (msg, cost) in other_messages
155            .into_iter()
156            .rev()
157            .zip(msg_incremental_costs.into_iter().rev())
158        {
159            if running_tokens + cost <= self.max_tokens {
160                running_tokens += cost;
161                kept.push(msg);
162            } else if kept.is_empty() {
163                // H7: 预算连最新一条都放不下时,仍保留最新一条——结果可能超预算
164                // (契约允许),但绝不静默丢光全部对话历史。
165                kept.push(msg);
166            } else {
167                // This message would push us over; stop adding more.
168                break;
169            }
170        }
171
172        kept.reverse();
173
174        let mut result = system_messages;
175        result.extend(kept);
176        Ok(result)
177    }
178
179    /// Summarizes old messages using the LLM, replacing them with a
180    /// single system message containing the summary.
181    ///
182    /// System messages are preserved. The oldest non-system messages
183    /// are summarized until the remaining messages fit within the budget.
184    async fn summarize(
185        &self,
186        messages: Vec<Message>,
187        llm: &Arc<M>,
188        summary_prompt: &str,
189    ) -> Result<Vec<Message>, MemoryError> {
190        // Separate system messages from the rest.
191        let mut system_messages: Vec<Message> = Vec::new();
192        let mut other_messages: Vec<Message> = Vec::new();
193
194        for msg in messages {
195            if matches!(msg.message_type, lc_schema::MessageType::System) {
196                system_messages.push(msg);
197            } else {
198                other_messages.push(msg);
199            }
200        }
201
202        if other_messages.is_empty() {
203            // Only system messages; nothing to summarize.
204            return Ok(system_messages);
205        }
206
207        // Find how many recent messages we can keep within the budget,
208        // reserving some space for the summary message.
209        // We try keeping the newest messages and summarizing the rest.
210        // Iterate from the smallest window (fewest recent messages) to the largest,
211        // keeping track of the best (smallest i = most messages kept) that fits.
212        let mut keep_from_idx = other_messages.len(); // default: keep all (no summarization)
213
214        for i in 0..other_messages.len() {
215            let recent = &other_messages[i..];
216            let mut candidate = system_messages.clone();
217            // Reserve space for a summary message (estimate ~100 tokens).
218            candidate.push(Message::system("summary placeholder"));
219            candidate.extend(recent.iter().cloned());
220
221            let tokens = self.counter.count_messages(&candidate) as usize;
222            if tokens <= self.max_tokens {
223                keep_from_idx = i;
224                break;
225            }
226        }
227
228        // If we can't even fit the recent messages with a summary placeholder,
229        // fall back to truncation for the recent portion.
230        if keep_from_idx >= other_messages.len() {
231            // H7: 预算小到连摘要占位都放不下时,退化为截断——截断保证至少保留
232            // 最新一条;旧实现 `truncate(system_messages)` 会静默丢光全部历史。
233            let mut all = system_messages;
234            all.extend(other_messages);
235            return self.truncate(all);
236        }
237
238        let to_summarize = &other_messages[..keep_from_idx];
239        let to_keep = &other_messages[keep_from_idx..];
240
241        if to_summarize.is_empty() {
242            // All messages fit with the summary placeholder; no need to summarize.
243            let mut result = system_messages;
244            result.extend(to_keep.to_vec());
245            return Ok(result);
246        }
247
248        // Format the conversation for summarization.
249        let conversation_text = to_summarize
250            .iter()
251            .map(|msg| {
252                let role = match msg.message_type {
253                    lc_schema::MessageType::Human => "Human",
254                    lc_schema::MessageType::AI => "AI",
255                    lc_schema::MessageType::System => "System",
256                    lc_schema::MessageType::Tool { .. } => "Tool",
257                };
258                format!("{}: {}", role, msg.content)
259            })
260            .collect::<Vec<_>>()
261            .join("\n");
262
263        let prompt = summary_prompt.replace("{conversation}", &conversation_text);
264
265        let summary_messages = vec![Message::human(&prompt)];
266
267        let result = llm
268            .invoke(summary_messages, None)
269            .await
270            .map_err(|e| MemoryError::SaveError(format!("LLM summarization failed: {}", e)))?;
271
272        let summary_message = Message::system(format!("[Conversation Summary] {}", result.content));
273
274        // Build final message list: system + summary + recent.
275        let mut final_messages = system_messages;
276        final_messages.push(summary_message);
277        final_messages.extend(to_keep.to_vec());
278
279        // Verify the final result fits; if not, truncate the recent portion.
280        let final_tokens = self.counter.count_messages(&final_messages) as usize;
281        if final_tokens > self.max_tokens {
282            return self.truncate(final_messages);
283        }
284
285        Ok(final_messages)
286    }
287}