1use std::collections::HashMap;
22use std::sync::{Arc, Mutex};
23
24use agent_base::{ChatMessage, ContextWindowManager, LlmClient, Middleware, PreLlmCtx};
25use async_trait::async_trait;
26use serde_json::Value;
27
28#[derive(Clone, Debug)]
30pub struct CompressionConfig {
31 pub enabled: bool,
33 pub trigger_tokens: usize,
35 pub keep_last_messages: usize,
37 pub max_transcript_chars: usize,
39 pub max_summary_chars: usize,
41}
42
43impl Default for CompressionConfig {
44 fn default() -> Self {
45 Self {
46 enabled: true,
47 trigger_tokens: 30_000,
48 keep_last_messages: 40,
49 max_transcript_chars: 20_000,
50 max_summary_chars: 2_000,
51 }
52 }
53}
54
55pub struct SummarizingMiddleware {
57 client: Arc<dyn LlmClient>,
58 config: CompressionConfig,
59 cache: Mutex<HashMap<(u64, u64), (usize, String)>>,
65}
66
67#[allow(missing_docs)]
68impl SummarizingMiddleware {
69 pub fn new(client: Arc<dyn LlmClient>) -> Self {
70 Self { client, config: CompressionConfig::default(), cache: Mutex::new(HashMap::new()) }
71 }
72
73 pub fn with_config(mut self, config: CompressionConfig) -> Self {
74 self.config = config;
75 self
76 }
77
78 pub fn with_trigger_tokens(mut self, tokens: usize) -> Self {
79 self.config.trigger_tokens = tokens;
80 self
81 }
82
83 pub fn with_keep_last_messages(mut self, n: usize) -> Self {
84 self.config.keep_last_messages = n;
85 self
86 }
87
88 pub fn with_max_summary_chars(mut self, chars: usize) -> Self {
89 self.config.max_summary_chars = chars;
90 self
91 }
92
93 pub fn config(&self) -> &CompressionConfig {
94 &self.config
95 }
96}
97
98#[async_trait]
99impl Middleware for SummarizingMiddleware {
100 async fn on_pre_llm(&self, ctx: &mut PreLlmCtx) -> agent_base::AgentResult<()> {
101 if !self.config.enabled {
102 return Ok(());
103 }
104
105 let messages = &ctx.messages;
106 if messages.len() <= self.config.keep_last_messages + 2 {
107 return Ok(());
108 }
109
110 let total_tokens: usize = messages.iter().map(estimate_message_tokens).sum();
111 if total_tokens <= self.config.trigger_tokens {
112 return Ok(());
113 }
114
115 let keep_first = if matches!(messages.first(), Some(ChatMessage::System { .. })) { 1 } else { 0 };
117
118 let mut recent_start = messages.len().saturating_sub(self.config.keep_last_messages);
119 if recent_start <= keep_first + 1 {
120 return Ok(());
121 }
122 recent_start = safe_cut_index(messages, keep_first, recent_start);
123 if recent_start <= keep_first {
124 return Ok(());
125 }
126
127 let old = &messages[keep_first..recent_start];
128 if old.is_empty() {
129 return Ok(());
130 }
131 if matches!(old.first(), Some(ChatMessage::Tool { .. })) {
133 tracing::warn!("context compression skipped: old block starts with a tool result");
134 return Ok(());
135 }
136
137 let transcript = serialize_block(old, self.config.max_transcript_chars);
138 if transcript.trim().is_empty() {
139 return Ok(());
140 }
141
142 const CACHE_PREFIX_CHARS: usize = 4096;
147 let prefix: String = transcript.chars().take(CACHE_PREFIX_CHARS).collect();
148 let key = (ctx.session_id.id, transcript_hash(&prefix));
149
150 let cached = self.cache.lock().ok().and_then(|c| c.get(&key).cloned());
151 let summary = match cached {
152 Some((cached_len, s)) if cached_len <= transcript.len() => s,
153 _ => {
154 let s = match summarize(self.client.as_ref(), &transcript, self.config.max_summary_chars).await {
155 Ok(s) => s,
156 Err(e) => {
157 tracing::warn!(
158 session_id = ctx.session_id.id,
159 "context compression summarization failed, dropping old block: {e}"
160 );
161 String::new()
162 },
163 };
164 if !s.is_empty()
165 && let Ok(mut cache) = self.cache.lock()
166 {
167 cache.insert(key, (transcript.len(), s.clone()));
168 }
169 s
170 },
171 };
172
173 let mut new_messages: Vec<ChatMessage> = messages[..keep_first].to_vec();
174 let trimmed = summary.trim();
175 if !trimmed.is_empty() {
176 new_messages.push(ChatMessage::user(format!("[Earlier conversation summary]\n{trimmed}")));
177 }
178 new_messages.extend_from_slice(&messages[recent_start..]);
179
180 tracing::info!(
181 session_id = ctx.session_id.id,
182 before = messages.len(),
183 after = new_messages.len(),
184 estimated_tokens = total_tokens,
185 "context compressed"
186 );
187 ctx.messages = new_messages;
188 Ok(())
189 }
190}
191
192fn safe_cut_index(messages: &[ChatMessage], keep_first: usize, mut cut: usize) -> usize {
196 while cut > keep_first {
197 let left_is_tool_call = matches!(messages[cut - 1], ChatMessage::Assistant { tool_calls: Some(_), .. });
198 let right_is_tool = matches!(messages[cut], ChatMessage::Tool { .. });
199 if !left_is_tool_call && !right_is_tool {
200 break;
201 }
202 cut -= 1;
203 }
204 cut
205}
206
207fn estimate_message_tokens(msg: &ChatMessage) -> usize {
210 match msg {
211 ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
212 ChatMessage::User { content, images, .. } => {
213 ContextWindowManager::estimate_tokens(content) + images.len() * 85
215 },
216 ChatMessage::Assistant { content, reasoning_content, tool_calls } => {
217 let mut tokens = content.as_deref().map(ContextWindowManager::estimate_tokens).unwrap_or(0);
218 if let Some(rc) = reasoning_content {
219 tokens += ContextWindowManager::estimate_tokens(rc);
220 }
221 if let Some(calls) = tool_calls {
222 for c in calls {
223 tokens += ContextWindowManager::estimate_tokens(&c.id);
224 tokens += ContextWindowManager::estimate_tokens(&c.name);
225 tokens += ContextWindowManager::estimate_tokens(&c.arguments);
226 }
227 }
228 tokens
229 },
230 ChatMessage::Tool { tool_call_id, content } => {
231 ContextWindowManager::estimate_tokens(tool_call_id) + ContextWindowManager::estimate_tokens(content)
232 },
233 }
234}
235
236fn serialize_block(messages: &[ChatMessage], max_chars: usize) -> String {
238 let mut parts: Vec<String> = Vec::with_capacity(messages.len());
239 for msg in messages {
240 let line = match msg {
241 ChatMessage::System { content, .. } => format!("[system] {}", truncate(content, 400)),
242 ChatMessage::User { content, .. } => format!("[user] {}", truncate(content, 400)),
243 ChatMessage::Assistant { content, tool_calls, .. } => match tool_calls {
244 Some(calls) if !calls.is_empty() => {
245 let calls: Vec<String> =
246 calls.iter().map(|c| format!("{}({})", c.name, truncate(&c.arguments, 150))).collect();
247 format!("[assistant tool_call] {}", calls.join("; "))
248 },
249 _ => format!("[assistant] {}", content.as_deref().map(|c| truncate(c, 400)).unwrap_or_default()),
250 },
251 ChatMessage::Tool { tool_call_id, content } => format!("[tool:{tool_call_id}] {}", truncate(content, 300)),
252 };
253 parts.push(line);
254 }
255 truncate(&parts.join("\n"), max_chars)
256}
257
258fn truncate(s: &str, max_chars: usize) -> String {
259 let count = s.chars().count();
260 if count <= max_chars {
261 return s.to_string();
262 }
263 let head: String = s.chars().take(max_chars).collect();
264 format!("{head}…")
265}
266
267fn transcript_hash(s: &str) -> u64 {
268 use std::hash::{DefaultHasher, Hash, Hasher};
269 let mut h = DefaultHasher::new();
270 s.hash(&mut h);
271 h.finish()
272}
273
274async fn summarize(client: &dyn LlmClient, transcript: &str, max_chars: usize) -> agent_base::AgentResult<String> {
276 let system = ChatMessage::system(
277 "You are a conversation summarizer for an AI agent that can call tools \
278 (browser, shell, search, etc.).",
279 );
280 let user = ChatMessage::user(format!(
281 "Compress the earlier portion of this agent conversation. Preserve:\n\
282 - the user's original goal and any constraints they stated;\n\
283 - every important fact, decision and intermediate result;\n\
284 - which tools were used and their key findings/returned data;\n\
285 - blockers, errors, and anything the agent still needs to remember to continue.\n\
286 Detect the conversation language and write the summary in that same language.\n\
287 Output ONLY the summary text, no preamble, about {max_chars} characters max.\n\n\
288 === CONVERSATION ===\n{transcript}",
289 ));
290
291 let response = client.chat(&[system, user], &[], None, None).await?;
292 Ok(extract_content(&response).unwrap_or_default())
293}
294
295fn extract_content(response: &Value) -> Option<String> {
296 if let Some(s) = response
298 .get("choices")
299 .and_then(Value::as_array)
300 .and_then(|arr| arr.first())
301 .and_then(|ch| ch.get("message"))
302 .and_then(|m| m.get("content"))
303 .and_then(Value::as_str)
304 {
305 return Some(s.to_string());
306 }
307 response.get("content").and_then(Value::as_str).map(str::to_string)
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314 use std::sync::atomic::{AtomicUsize, Ordering};
315
316 struct MockClient {
317 summary: String,
318 calls: AtomicUsize,
319 }
320
321 #[async_trait]
322 impl LlmClient for MockClient {
323 async fn chat(
324 &self,
325 _messages: &[ChatMessage],
326 _tools: &[Value],
327 _reasoning: Option<&agent_base::ReasoningConfig>,
328 _response_format: Option<&agent_base::ResponseFormat>,
329 ) -> agent_base::AgentResult<Value> {
330 self.calls.fetch_add(1, Ordering::SeqCst);
331 Ok(serde_json::json!({
332 "choices": [{ "message": { "content": self.summary } }]
333 }))
334 }
335
336 async fn chat_stream(
337 &self,
338 _messages: &[ChatMessage],
339 _tools: &[Value],
340 _reasoning: Option<&agent_base::ReasoningConfig>,
341 _response_format: Option<&agent_base::ResponseFormat>,
342 ) -> agent_base::AgentResult<
343 std::pin::Pin<
344 Box<dyn futures_core::Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>,
345 >,
346 > {
347 unreachable!("not used in tests")
348 }
349
350 fn capabilities(&self) -> agent_base::LlmCapabilities {
351 agent_base::LlmCapabilities::default()
352 }
353 }
354
355 fn sample_messages() -> Vec<ChatMessage> {
356 vec![
357 ChatMessage::system("sys"),
358 ChatMessage::user("q1"),
359 ChatMessage::assistant_tool_call("call_1", "browser_navigate", r#"{"url":"a"}"#),
360 ChatMessage::tool("call_1", "loaded ok"),
361 ChatMessage::assistant("found the page"),
362 ChatMessage::user("q2"),
363 ChatMessage::assistant("done"),
364 ]
365 }
366
367 #[test]
368 fn test_safe_cut_never_splits_tool_pair() {
369 let msgs = sample_messages();
370 let cut = safe_cut_index(&msgs, 1, 3);
372 assert!(cut < 3, "must walk backward from an unsafe cut");
373 assert!(!matches!(msgs[cut - 1], ChatMessage::Assistant { tool_calls: Some(_), .. }));
374 assert!(!matches!(msgs[cut], ChatMessage::Tool { .. }));
375 }
376
377 #[test]
378 fn test_safe_cut_prefers_given_boundary_when_safe() {
379 let msgs = vec![
380 ChatMessage::system("sys"),
381 ChatMessage::user("q1"),
382 ChatMessage::assistant("a1"),
383 ChatMessage::user("q2"),
384 ChatMessage::assistant("a2"),
385 ];
386 let cut = safe_cut_index(&msgs, 1, 3);
387 assert_eq!(cut, 3);
388 }
389
390 #[test]
391 fn test_estimate_message_tokens_cjk_and_tool() {
392 let sys = ChatMessage::system("中文系统提示");
393 let tool = ChatMessage::tool("call_9", "hello 世界".repeat(100));
394 assert!(estimate_message_tokens(&sys) < estimate_message_tokens(&tool));
395 assert!(estimate_message_tokens(&sys) > 0);
396 }
397
398 #[test]
399 fn test_serialize_block_preserves_tool_calls() {
400 let msgs =
401 vec![ChatMessage::user("short question"), ChatMessage::assistant_tool_call("c1", "browser_navigate", "{}")];
402 let out = serialize_block(&msgs, 1000);
403 assert!(out.contains("tool_call"));
404 assert!(out.contains("browser_navigate"));
405 }
406
407 #[test]
408 fn test_serialize_block_truncates_oversized_fields() {
409 let long = "x".repeat(1000);
410 let msgs = vec![ChatMessage::user(long.clone())];
411 let out = serialize_block(&msgs, 200);
412 assert!(out.chars().count() <= 201); assert!(!out.contains(&long), "full payload must not leak through");
414 }
415
416 #[test]
417 fn test_extract_content_shapes() {
418 let openai = serde_json::json!({
419 "choices": [{ "message": { "content": "SUMMARY" } }]
420 });
421 assert_eq!(extract_content(&openai).as_deref(), Some("SUMMARY"));
422
423 let flat = serde_json::json!({ "content": "FLAT" });
424 assert_eq!(extract_content(&flat).as_deref(), Some("FLAT"));
425
426 assert_eq!(extract_content(&serde_json::json!({ "nope": 1 })), None);
427 }
428
429 #[tokio::test]
430 async fn test_on_pre_llm_noop_when_under_threshold() {
431 let client = Arc::new(MockClient { summary: "S".to_string(), calls: AtomicUsize::new(0) });
432 let mw = SummarizingMiddleware::new(client);
433 let mut ctx = PreLlmCtx {
434 session_id: agent_base::SessionId { id: 1, external_id: None },
435 messages: vec![ChatMessage::system("sys"), ChatMessage::user("hi")],
436 tools: vec![],
437 };
438 mw.on_pre_llm(&mut ctx).await.unwrap();
439 assert_eq!(ctx.messages.len(), 2);
440 }
441
442 #[tokio::test]
443 async fn test_on_pre_llm_compresses_and_caches() {
444 let client = Arc::new(MockClient {
445 summary: "The user wanted to scrape articles.".to_string(),
446 calls: AtomicUsize::new(0),
447 });
448 let mw = SummarizingMiddleware::new(client.clone())
449 .with_trigger_tokens(1) .with_keep_last_messages(2);
451
452 let make_ctx = || PreLlmCtx {
453 session_id: agent_base::SessionId { id: 1, external_id: None },
454 messages: sample_messages(),
455 tools: vec![],
456 };
457
458 let mut ctx = make_ctx();
459 mw.on_pre_llm(&mut ctx).await.unwrap();
460 assert!(ctx.messages.len() < 7, "should have compressed, got {}", ctx.messages.len());
461 assert_eq!(client.calls.load(Ordering::SeqCst), 1, "summarizer called once");
462 assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
464 for m in &ctx.messages {
466 assert!(!matches!(m, ChatMessage::Tool { .. }), "no tool orphan after compression");
467 }
468
469 let mut ctx2 = make_ctx();
471 mw.on_pre_llm(&mut ctx2).await.unwrap();
472 assert_eq!(client.calls.load(Ordering::SeqCst), 1, "cache reused");
473 assert_eq!(ctx.messages.len(), ctx2.messages.len());
474 }
475
476 #[tokio::test]
477 async fn test_summarization_failure_drops_old_block() {
478 let failing = Arc::new(FailingClient);
481 let mw = SummarizingMiddleware::new(failing).with_trigger_tokens(1).with_keep_last_messages(2);
482
483 let mut ctx = PreLlmCtx {
484 session_id: agent_base::SessionId { id: 1, external_id: None },
485 messages: sample_messages(),
486 tools: vec![],
487 };
488 let result = mw.on_pre_llm(&mut ctx).await;
489 assert!(result.is_ok(), "must not fail the turn");
490 assert!(ctx.messages.len() < 7);
492 assert!(ctx.messages.iter().all(|m| !matches!(
493 m,
494 ChatMessage::User { content, .. } if content.contains("Earlier conversation summary")
495 )));
496 }
497
498 #[tokio::test]
499 async fn test_default_threshold_fires_on_long_conversation() {
500 let client =
503 Arc::new(MockClient { summary: "Earlier context summarised.".to_string(), calls: AtomicUsize::new(0) });
504 let mw = SummarizingMiddleware::new(client.clone()); let mut messages = vec![ChatMessage::system("sys")];
507 let chunk = "这是用于验证压缩默认阈值的中文文本。".repeat(80);
509 for i in 0..45 {
510 messages.push(ChatMessage::user(format!("q{i} {chunk}")));
511 messages.push(ChatMessage::assistant(format!("a{i}")));
512 }
513 let mut ctx =
514 PreLlmCtx { session_id: agent_base::SessionId { id: 1, external_id: None }, messages, tools: vec![] };
515 let before = ctx.messages.len();
516 assert!(before > 42, "test fixture must exceed the message-count gate");
517 mw.on_pre_llm(&mut ctx).await.unwrap();
518 assert!(
519 ctx.messages.len() < before,
520 "default threshold should compress a long conversation ({} → {})",
521 before,
522 ctx.messages.len()
523 );
524 assert!(client.calls.load(Ordering::SeqCst) >= 1, "summarizer must be invoked for a long conversation");
527 assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
529 }
530
531 struct FailingClient;
532
533 #[async_trait]
534 impl LlmClient for FailingClient {
535 async fn chat(
536 &self,
537 _messages: &[ChatMessage],
538 _tools: &[Value],
539 _reasoning: Option<&agent_base::ReasoningConfig>,
540 _response_format: Option<&agent_base::ResponseFormat>,
541 ) -> agent_base::AgentResult<Value> {
542 Err(agent_base::AgentError::internal("summarization failed"))
543 }
544
545 async fn chat_stream(
546 &self,
547 _messages: &[ChatMessage],
548 _tools: &[Value],
549 _reasoning: Option<&agent_base::ReasoningConfig>,
550 _response_format: Option<&agent_base::ResponseFormat>,
551 ) -> agent_base::AgentResult<
552 std::pin::Pin<
553 Box<dyn futures_core::Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>,
554 >,
555 > {
556 unreachable!("not used in tests")
557 }
558
559 fn capabilities(&self) -> agent_base::LlmCapabilities {
560 agent_base::LlmCapabilities::default()
561 }
562 }
563}