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