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
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
//! Context compaction utilities for managing conversation history size.
use serde::{Deserialize, Serialize};
use crate::types::{AgentMessage, ContentBlock, LlmMessage};
// ─── Token Counter Trait ────────────────────────────────────────────────────
/// Pluggable token counting strategy.
///
/// Implement this trait to replace the built-in `chars / 4` heuristic with
/// tiktoken, a provider-native tokenizer, or any other counting scheme.
pub trait TokenCounter: Send + Sync {
/// Return the estimated token count for a single message.
fn count_tokens(&self, message: &AgentMessage) -> usize;
}
/// Default token counter using the `chars / 4` heuristic.
///
/// `LlmMessage` variants: sums character lengths of all text-bearing content
/// blocks and divides by 4. `CustomMessage` variants count as 100 tokens flat.
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct DefaultTokenCounter;
impl DefaultTokenCounter {
/// Create a new default token counter.
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl Default for DefaultTokenCounter {
fn default() -> Self {
Self::new()
}
}
impl TokenCounter for DefaultTokenCounter {
fn count_tokens(&self, message: &AgentMessage) -> usize {
match message {
AgentMessage::Llm(llm) => {
let chars: usize = content_blocks(llm)
.iter()
.map(|b| match b {
ContentBlock::Text { text } => text.len(),
ContentBlock::Thinking { thinking, .. } => thinking.len(),
ContentBlock::ToolCall { arguments, .. } => arguments.to_string().len(),
ContentBlock::Image { .. } => 0,
ContentBlock::Extension { data, .. } => data.to_string().len(),
})
.sum();
chars / 4
}
AgentMessage::Custom(_) => 100,
}
}
}
/// Estimate token count using `chars / 4` for LLM messages, 100 flat for custom.
///
/// For pluggable counting, use a [`TokenCounter`] implementation instead.
pub fn estimate_tokens(msg: &AgentMessage) -> usize {
DefaultTokenCounter.count_tokens(msg)
}
/// Estimate the token cost of a request's tool schemas.
///
/// Prices each tool's name + description + serialized parameters schema at
/// `chars / 4`, separately from the message history, so hosts budgeting
/// injected context (repo maps, file mentions) against a model's context
/// window can subtract the fixed overhead first.
pub fn estimate_tool_schema_tokens(tools: &[std::sync::Arc<dyn crate::tool::AgentTool>]) -> usize {
tools
.iter()
.map(|tool| {
(tool.name().len()
+ tool.description().len()
+ tool.parameters_schema().to_string().len())
/ 4
})
.sum()
}
/// Estimate the token cost of an entire request context.
///
/// Sums the system prompt, tool schemas, and message history with the same
/// `chars / 4` heuristic the loop's built-in [`DefaultTokenCounter`] uses
/// for messages.
///
/// Use this instead of re-deriving the arithmetic downstream: a host budget
/// computed here stays in agreement with the loop's own estimates if the
/// heuristic ever changes.
pub fn estimate_context_tokens(context: &crate::types::AgentContext) -> usize {
context.system_prompt.len() / 4
+ estimate_tool_schema_tokens(&context.tools)
+ context.messages.iter().map(estimate_tokens).sum::<usize>()
}
fn content_blocks(msg: &LlmMessage) -> &[ContentBlock] {
match msg {
LlmMessage::User(m) => &m.content,
LlmMessage::Assistant(m) => &m.content,
LlmMessage::ToolResult(m) => &m.content,
}
}
fn is_tool_result(messages: &[AgentMessage], idx: usize) -> bool {
matches!(
messages.get(idx),
Some(AgentMessage::Llm(LlmMessage::ToolResult(_)))
)
}
fn tool_call_ids(message: &AgentMessage) -> Option<Vec<&str>> {
match message {
AgentMessage::Llm(LlmMessage::Assistant(assistant)) => {
let ids: Vec<&str> = assistant
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolCall { id, .. } => Some(id.as_str()),
_ => None,
})
.collect();
(!ids.is_empty()).then_some(ids)
}
_ => None,
}
}
fn tool_result_id(message: &AgentMessage) -> Option<&str> {
match message {
AgentMessage::Llm(LlmMessage::ToolResult(result)) => Some(result.tool_call_id.as_str()),
_ => None,
}
}
fn extend_anchor_for_tool_results(messages: &[AgentMessage], anchor_end: usize) -> usize {
if anchor_end == 0 || anchor_end >= messages.len() {
return anchor_end;
}
let mut assistant_idx = anchor_end - 1;
while is_tool_result(messages, assistant_idx) {
if assistant_idx == 0 {
return anchor_end;
}
assistant_idx -= 1;
}
let Some(call_ids) = tool_call_ids(&messages[assistant_idx]) else {
return anchor_end;
};
let mut group_end = assistant_idx + 1;
while group_end < messages.len() {
let Some(result_id) = tool_result_id(&messages[group_end]) else {
break;
};
if !call_ids.contains(&result_id) {
break;
}
group_end += 1;
}
anchor_end.max(group_end)
}
/// Result of a context transformation pass.
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactionReport {
/// Number of messages that were removed during compaction.
pub dropped_count: usize,
/// Estimated tokens before compaction.
pub tokens_before: usize,
/// Estimated tokens after compaction.
pub tokens_after: usize,
/// Whether compaction was triggered by overflow.
pub overflow: bool,
/// The LLM messages that were dropped during this compaction pass.
///
/// Only `LlmMessage` variants are included; `CustomMessage` values are
/// filtered out. Populated by the sliding-window compaction routine; empty for
/// bare-closure transformers that don't have access to the dropped slice.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dropped_messages: Vec<LlmMessage>,
}
impl CompactionReport {
/// Create a new compaction report with no dropped messages recorded.
#[must_use]
pub const fn new(
dropped_count: usize,
tokens_before: usize,
tokens_after: usize,
overflow: bool,
) -> Self {
Self {
dropped_count,
tokens_before,
tokens_after,
overflow,
dropped_messages: Vec::new(),
}
}
/// Attach the LLM messages that were dropped during this compaction pass.
#[must_use]
pub fn with_dropped_messages(mut self, dropped_messages: Vec<LlmMessage>) -> Self {
self.dropped_messages = dropped_messages;
self
}
}
/// Core sliding window compaction algorithm.
///
/// Keeps messages within `budget` by removing older messages from the middle
/// while preserving the first `anchor` messages and as many recent messages as
/// fit. Tool-call / tool-result pairs are kept together even if this exceeds
/// the budget.
///
/// When `counter` is `None` the [`DefaultTokenCounter`] heuristic is used.
///
/// Returns `Some(CompactionReport)` when messages were dropped, `None` otherwise.
pub fn compact_sliding_window(
messages: &mut Vec<AgentMessage>,
budget: usize,
anchor: usize,
) -> Option<CompactionReport> {
compact_sliding_window_with(messages, budget, anchor, None)
}
/// Like [`compact_sliding_window`] but accepts a pluggable [`TokenCounter`].
pub fn compact_sliding_window_with(
messages: &mut Vec<AgentMessage>,
budget: usize,
anchor: usize,
counter: Option<&dyn TokenCounter>,
) -> Option<CompactionReport> {
let default = DefaultTokenCounter;
let counter: &dyn TokenCounter = counter.unwrap_or(&default);
let count = |m: &AgentMessage| counter.count_tokens(m);
let tokens_before: usize = messages.iter().map(count).sum();
if tokens_before <= budget {
return None;
}
let len = messages.len();
let effective_anchor = extend_anchor_for_tool_results(messages, anchor.min(len));
// Calculate tokens used by anchor messages.
let anchor_tokens: usize = messages[..effective_anchor].iter().map(count).sum();
let remaining_budget = budget.saturating_sub(anchor_tokens);
// Walk backwards from the end, accumulating messages that fit.
let mut tail_tokens = 0;
let mut tail_start = len;
for i in (effective_anchor..len).rev() {
let msg_tokens = count(&messages[i]);
if tail_tokens + msg_tokens > remaining_budget {
break;
}
tail_tokens += msg_tokens;
tail_start = i;
}
// Adjust tail_start backward to avoid splitting tool-call / tool-result
// pairs. If tail_start lands on a tool-result, include the preceding
// assistant message too (correctness > token count).
while tail_start > effective_anchor && tail_start < len && is_tool_result(messages, tail_start)
{
tail_start -= 1;
}
// If nothing would be removed, bail out.
if tail_start <= effective_anchor {
return None;
}
let dropped_count = tail_start - effective_anchor;
// Collect the dropped LLM messages before modifying the slice.
let dropped_messages: Vec<LlmMessage> = messages[effective_anchor..tail_start]
.iter()
.filter_map(|m| match m {
AgentMessage::Llm(llm) => Some(llm.clone()),
AgentMessage::Custom(_) => None,
})
.collect();
// Build the compacted list: anchor messages + tail messages.
let tail: Vec<AgentMessage> = messages.drain(tail_start..).collect();
messages.truncate(effective_anchor);
messages.extend(tail);
let tokens_after: usize = messages.iter().map(count).sum();
Some(CompactionReport {
dropped_count,
tokens_before,
tokens_after,
overflow: false,
dropped_messages,
})
}
/// Create a sliding-window context compaction function.
///
/// Keeps messages within an estimated token budget by removing older messages
/// from the middle while preserving the first `anchor` messages and as many
/// recent messages as fit.
///
/// When `overflow` is true (context window exceeded), uses `overflow_budget`
/// instead of `normal_budget`.
#[deprecated(since = "0.5.0", note = "Use SlidingWindowTransformer instead")]
pub fn sliding_window(
normal_budget: usize,
overflow_budget: usize,
anchor: usize,
) -> impl Fn(&mut Vec<AgentMessage>, bool) + Send + Sync {
move |messages: &mut Vec<AgentMessage>, overflow: bool| {
let budget = if overflow {
overflow_budget
} else {
normal_budget
};
compact_sliding_window(messages, budget, anchor);
}
}
/// Estimate whether the context exceeds the model's maximum context window.
///
/// Returns `true` if the estimated token count exceeds
/// `model.capabilities.max_context_window`. Returns `false` if the model has
/// no known context window limit.
pub fn is_context_overflow(
messages: &[AgentMessage],
model: &crate::types::ModelSpec,
counter: Option<&dyn TokenCounter>,
) -> bool {
let max_window = model
.capabilities
.as_ref()
.and_then(|c| c.max_context_window);
let Some(max_window) = max_window else {
return false;
};
let default = DefaultTokenCounter;
let counter: &dyn TokenCounter = counter.unwrap_or(&default);
let total_tokens: usize = messages.iter().map(|m| counter.count_tokens(m)).sum();
total_tokens as u64 > max_window
}
#[cfg(test)]
#[path = "context_tests.rs"]
mod tests;