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
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
// 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: returns `Result` instead of panicking — a tiktoken model download/load failure
/// (offline / missing model) returns [`MemoryError`], so the library constructor no longer
/// crashes on a bad local environment.
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 contract)
///
/// **`Strategy::Truncate`**: System messages are always kept and do **not** consume budget
/// (M7); if the System messages alone exceed `max_tokens`, they are returned as-is (the
/// result may exceed the budget). Even when the budget is too small for a single
/// conversation message, at least the newest non-System message is kept — history is never
/// silently emptied (H7). Callers must not assume `fit`'s result is always within budget.
///
/// **`Strategy::Summarize`**: the summary placeholder counts toward the budget, but the LLM's
/// actual summary token count is unknown — the budget semantics differ from Truncate, and the
/// intentionally inconsistent handling of System messages between the two is deliberate; each
/// strategy defines its own.
///
/// # 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 contract): if the system
/// messages alone exceed `max_tokens`, they are returned as-is and the
/// result may exceed the budget.
///
/// M7: system messages do not consume budget (base starts at 0), so they no longer crowd
/// out usable context.
///
/// H7: even when the budget is too small for a single conversation message, at least the
/// newest non-System message is kept — history is never silently emptied (the result may
/// exceed the budget, which the contract allows).
///
/// 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);
}
}
// M7: System messages are always kept and do **not** consume budget — base starts at 0.
// The old implementation counted `count_messages(&system_messages)` into base, crowding
// out usable context.
let mut running_tokens: usize = 0;
// 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();
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 if kept.is_empty() {
// H7: when even the newest message cannot fit, still keep the newest one — the
// result may exceed the budget (contract allows), but history is never silently
// emptied.
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() {
// H7: when the budget is too small even for the summary placeholder, fall back to
// truncation — truncation guarantees at least the newest message is kept; the old
// implementation `truncate(system_messages)` silently emptied all history.
let mut all = system_messages;
all.extend(other_messages);
return self.truncate(all);
}
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)
}
}