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