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