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}