Skip to main content

phi_agent/agent/
compression.rs

1//! LLM-based context compression middleware.
2//!
3//! Long tool-heavy conversations balloon the message list that gets sent to the
4//! LLM on every turn, which slows each call down (and, past the window, fails).
5//! This middleware observes the per-LLM-call message list in
6//! [`Middleware::on_pre_llm`](agent_base::Middleware::on_pre_llm)
7//! and, once it exceeds [`CompressionConfig::trigger_tokens`], summarises the
8//! *earlier* portion of the conversation into a single compact message.
9//!
10//! Design notes:
11//! - It only mutates the per-call message copy (`PreLlmCtx.messages`), never the
12//!   stored session history — the JSONL turn log keeps full fidelity.
13//! - The cut between "old" and "recent" messages is **tool-pairing safe**: it never
14//!   separates an `Assistant{tool_calls}` message from its `Tool` results, so the
15//!   resulting message list stays valid for OpenAI-compatible APIs.
16//! - Summaries are cached per `(session, transcript)` so repeated LLM calls within
17//!   one turn (after each tool result) don't re-pay the summarization LLM call.
18//! - If summarization fails, the old block is dropped entirely rather than failing
19//!   the turn — the block is the oldest context, so losing it is the graceful fallback.
20
21use 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/// Tuning knobs for [`SummarizingMiddleware`].
29#[derive(Clone, Debug)]
30pub struct CompressionConfig {
31    /// Master switch. When `false`, `on_pre_llm` is a no-op.
32    pub enabled: bool,
33    /// Compress when the estimated token count of the message list exceeds this.
34    pub trigger_tokens: usize,
35    /// Always keep the most recent N messages intact (the agent's working context).
36    pub keep_last_messages: usize,
37    /// Hard cap on the raw transcript we hand to the summarizer.
38    pub max_transcript_chars: usize,
39    /// Target max length of the generated summary.
40    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
55/// Compresses the earlier part of a long conversation via LLM summarization.
56pub struct SummarizingMiddleware {
57    client: Arc<dyn LlmClient>,
58    config: CompressionConfig,
59    /// `(session_id, prefix_hash) -> (transcript_len, summary)` cache. Keyed on a hash of a
60    /// *stable prefix* of the old-block transcript: the oldest messages never change, so the
61    /// summary is reused across the tool-call iterations of a turn (and across turns) instead
62    /// of re-paying the summarization LLM call each time. `transcript_len` guards against
63    /// reusing a summary whose block has since grown a lot (see `on_pre_llm`).
64    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        // Keep the leading system prompt untouched.
116        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        // Defensive: never start the compressed-away block on an orphaned tool result.
132        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        // Key the cache on a stable prefix of the transcript. The old block only ever
143        // grows at its tail (new tool results append), so its oldest content — and thus
144        // this prefix — stays fixed. That lets us reuse a summary across the tool-call
145        // iterations of a turn without calling the summarizer again.
146        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
192/// Walk `cut` backward until the boundary is tool-pairing safe: the message just left of
193/// the cut is not an `Assistant` with pending tool calls, and the message just right of
194/// the cut is not a `Tool` result (whose `Assistant{tool_calls}` would be cut away).
195fn 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
207/// CJK-aware token estimate for a single message, mirroring
208/// `ContextWindowManager::message_tokens` (which is `pub(crate)` and not reachable here).
209fn estimate_message_tokens(msg: &ChatMessage) -> usize {
210    match msg {
211        ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
212        ChatMessage::User { content, images, .. } => {
213            // OpenAI Vision fixed per-image overhead, same constant as agent-base.
214            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
236/// Render an old message block as a compact transcript for the summarizer.
237fn 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
274/// One-shot summarization call. Returns the model's text, or an error.
275async 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    // OpenAI-compatible shape: `{"choices":[{"message":{"content":"..."}}]}`.
297    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    // Fallback for providers that return `{"content": "..."}` directly.
308    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        // A naive cut at 3 would split the tool pair (left = assistant_tool_call).
371        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); // 200 + ellipsis
413        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) // always compress
450            .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        // Summary injected as a User message right after the system prompt.
463        assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
464        // No orphaned tool message anywhere in the result.
465        for m in &ctx.messages {
466            assert!(!matches!(m, ChatMessage::Tool { .. }), "no tool orphan after compression");
467        }
468
469        // Same original old block again → cache hit, no new LLM call.
470        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        // A client that always errors on chat() → middleware must fall back to dropping
479        // the old block instead of failing the turn.
480        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        // Old block dropped, no summary message inserted.
491        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        // Guards against the "wired but never fires" regression: the default
501        // trigger_tokens must compress a realistic long tool-heavy conversation.
502        let client =
503            Arc::new(MockClient { summary: "Earlier context summarised.".to_string(), calls: AtomicUsize::new(0) });
504        let mw = SummarizingMiddleware::new(client.clone()); // default config
505
506        let mut messages = vec![ChatMessage::system("sys")];
507        // 1440 CJK chars ≈ ~960 tokens each (estimate at ~1 token / 1.5 CJK chars).
508        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        // Compression must have actually invoked the summarizer LLM (not just passed
525        // the message-count gate while the token estimate stayed at/below the threshold).
526        assert!(client.calls.load(Ordering::SeqCst) >= 1, "summarizer must be invoked for a long conversation");
527        // Summary injected as the first message after the system prompt.
528        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}