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 [`Middleware::on_pre_llm`]
6//! and, once it exceeds [`CompressionConfig::trigger_tokens`], summarises the
7//! *earlier* portion of the conversation into a single compact message.
8//!
9//! Design notes:
10//! - It only mutates the per-call message copy (`PreLlmCtx.messages`), never the
11//!   stored session history — the JSONL turn log keeps full fidelity.
12//! - The cut between "old" and "recent" messages is **tool-pairing safe**: it never
13//!   separates an `Assistant{tool_calls}` message from its `Tool` results, so the
14//!   resulting message list stays valid for OpenAI-compatible APIs.
15//! - Summaries are cached per `(session, transcript)` so repeated LLM calls within
16//!   one turn (after each tool result) don't re-pay the summarization LLM call.
17//! - If summarization fails, the old block is dropped entirely rather than failing
18//!   the turn — the block is the oldest context, so losing it is the graceful fallback.
19
20use 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/// Tuning knobs for [`SummarizingMiddleware`].
28#[derive(Clone, Debug)]
29pub struct CompressionConfig {
30    /// Master switch. When `false`, `on_pre_llm` is a no-op.
31    pub enabled: bool,
32    /// Compress when the estimated token count of the message list exceeds this.
33    pub trigger_tokens: usize,
34    /// Always keep the most recent N messages intact (the agent's working context).
35    pub keep_last_messages: usize,
36    /// Hard cap on the raw transcript we hand to the summarizer.
37    pub max_transcript_chars: usize,
38    /// Target max length of the generated summary.
39    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
54/// Compresses the earlier part of a long conversation via LLM summarization.
55pub struct SummarizingMiddleware {
56    client: Arc<dyn LlmClient>,
57    config: CompressionConfig,
58    /// `(session_id, prefix_hash) -> (transcript_len, summary)` cache. Keyed on a hash of a
59    /// *stable prefix* of the old-block transcript: the oldest messages never change, so the
60    /// summary is reused across the tool-call iterations of a turn (and across turns) instead
61    /// of re-paying the summarization LLM call each time. `transcript_len` guards against
62    /// reusing a summary whose block has since grown a lot (see `on_pre_llm`).
63    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        // Keep the leading system prompt untouched.
114        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        // Defensive: never start the compressed-away block on an orphaned tool result.
130        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        // Key the cache on a stable prefix of the transcript. The old block only ever
141        // grows at its tail (new tool results append), so its oldest content — and thus
142        // this prefix — stays fixed. That lets us reuse a summary across the tool-call
143        // iterations of a turn without calling the summarizer again.
144        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
190/// Walk `cut` backward until the boundary is tool-pairing safe: the message just left of
191/// the cut is not an `Assistant` with pending tool calls, and the message just right of
192/// the cut is not a `Tool` result (whose `Assistant{tool_calls}` would be cut away).
193fn 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
205/// CJK-aware token estimate for a single message, mirroring
206/// `ContextWindowManager::message_tokens` (which is `pub(crate)` and not reachable here).
207fn estimate_message_tokens(msg: &ChatMessage) -> usize {
208    match msg {
209        ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
210        ChatMessage::User { content, images, .. } => {
211            // OpenAI Vision fixed per-image overhead, same constant as agent-base.
212            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
234/// Render an old message block as a compact transcript for the summarizer.
235fn 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
272/// One-shot summarization call. Returns the model's text, or an error.
273async 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    // OpenAI-compatible shape: `{"choices":[{"message":{"content":"..."}}]}`.
295    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    // Fallback for providers that return `{"content": "..."}` directly.
306    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        // A naive cut at 3 would split the tool pair (left = assistant_tool_call).
369        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); // 200 + ellipsis
411        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) // always compress
448            .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        // Summary injected as a User message right after the system prompt.
461        assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
462        // No orphaned tool message anywhere in the result.
463        for m in &ctx.messages {
464            assert!(!matches!(m, ChatMessage::Tool { .. }), "no tool orphan after compression");
465        }
466
467        // Same original old block again → cache hit, no new LLM call.
468        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        // A client that always errors on chat() → middleware must fall back to dropping
477        // the old block instead of failing the turn.
478        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        // Old block dropped, no summary message inserted.
489        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        // Guards against the "wired but never fires" regression: the default
499        // trigger_tokens must compress a realistic long tool-heavy conversation.
500        let client =
501            Arc::new(MockClient { summary: "Earlier context summarised.".to_string(), calls: AtomicUsize::new(0) });
502        let mw = SummarizingMiddleware::new(client.clone()); // default config
503
504        let mut messages = vec![ChatMessage::system("sys")];
505        // 1440 CJK chars ≈ ~960 tokens each (estimate at ~1 token / 1.5 CJK chars).
506        let chunk = "这是用于验证压缩默认阈值的中文文本。".repeat(80);
507        for i in 0..45 {
508            messages.push(ChatMessage::user(format!("q{i} {chunk}")));
509            messages.push(ChatMessage::assistant(format!("a{i}")));
510        }
511        let mut ctx =
512            PreLlmCtx { session_id: agent_base::SessionId { id: 1, external_id: None }, messages, tools: vec![] };
513        let before = ctx.messages.len();
514        assert!(before > 42, "test fixture must exceed the message-count gate");
515        mw.on_pre_llm(&mut ctx).await.unwrap();
516        assert!(
517            ctx.messages.len() < before,
518            "default threshold should compress a long conversation ({} → {})",
519            before,
520            ctx.messages.len()
521        );
522        // Compression must have actually invoked the summarizer LLM (not just passed
523        // the message-count gate while the token estimate stayed at/below the threshold).
524        assert!(client.calls.load(Ordering::SeqCst) >= 1, "summarizer must be invoked for a long conversation");
525        // Summary injected as the first message after the system prompt.
526        assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
527    }
528
529    struct FailingClient;
530
531    #[async_trait]
532    impl LlmClient for FailingClient {
533        async fn chat(
534            &self,
535            _messages: &[ChatMessage],
536            _tools: &[Value],
537            _reasoning: Option<&agent_base::ReasoningConfig>,
538            _response_format: Option<&agent_base::ResponseFormat>,
539        ) -> agent_base::AgentResult<Value> {
540            Err(agent_base::AgentError::internal("summarization failed"))
541        }
542
543        async fn chat_stream(
544            &self,
545            _messages: &[ChatMessage],
546            _tools: &[Value],
547            _reasoning: Option<&agent_base::ReasoningConfig>,
548            _response_format: Option<&agent_base::ResponseFormat>,
549        ) -> agent_base::AgentResult<
550            std::pin::Pin<
551                Box<dyn futures_core::Stream<Item = agent_base::AgentResult<agent_base::StreamChunk>> + Send>,
552            >,
553        > {
554            unreachable!("not used in tests")
555        }
556
557        fn capabilities(&self) -> agent_base::LlmCapabilities {
558            agent_base::LlmCapabilities::default()
559        }
560    }
561}