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}