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, Middleware, PreLlmCtx};
25use async_trait::async_trait;
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 agent_base::StreamClient>,
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
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        // Keep the leading system prompt untouched.
115        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        // Defensive: never start the compressed-away block on an orphaned tool result.
131        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        // Key the cache on a stable prefix of the transcript. The old block only ever
142        // grows at its tail (new tool results append), so its oldest content — and thus
143        // this prefix — stays fixed. That lets us reuse a summary across the tool-call
144        // iterations of a turn without calling the summarizer again.
145        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
191/// Walk `cut` backward until the boundary is tool-pairing safe: the message just left of
192/// the cut is not an `Assistant` with pending tool calls, and the message just right of
193/// the cut is not a `Tool` result (whose `Assistant{tool_calls}` would be cut away).
194fn 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
206/// CJK-aware token estimate for a single message, mirroring
207/// `ContextWindowManager::message_tokens` (which is `pub(crate)` and not reachable here).
208fn estimate_message_tokens(msg: &ChatMessage) -> usize {
209    match msg {
210        ChatMessage::System { content, .. } => ContextWindowManager::estimate_tokens(content),
211        ChatMessage::User { content, images, .. } => {
212            // OpenAI Vision fixed per-image overhead, same constant as agent-base.
213            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
238/// Render an old message block as a compact transcript for the summarizer.
239fn 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
277/// One-shot summarization call. Returns the model's text, or an error.
278async 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        // A naive cut at 3 would split the tool pair (left = assistant_tool_call).
364        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); // 200 + ellipsis
406        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) // always compress
451            .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        // Summary injected as a User message right after the system prompt.
464        assert!(matches!(ctx.messages[1], ChatMessage::User { .. }));
465        // No orphaned tool message anywhere in the result.
466        for m in &ctx.messages {
467            assert!(!matches!(m, ChatMessage::Tool { .. }), "no tool orphan after compression");
468        }
469
470        // Same original old block again → cache hit, no new LLM call.
471        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        // A client that always errors on chat() → middleware must fall back to dropping
480        // the old block instead of failing the turn.
481        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        // Old block dropped, no summary message inserted.
492        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        // Guards against the "wired but never fires" regression: the default
502        // trigger_tokens must compress a realistic long tool-heavy conversation.
503        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()); // default config
507
508        let mut messages = vec![ChatMessage::system("sys")];
509        // 1440 CJK chars ≈ ~960 tokens each (estimate at ~1 token / 1.5 CJK chars).
510        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        // Compression must have actually invoked the summarizer LLM (not just passed
527        // the message-count gate while the token estimate stayed at/below the threshold).
528        assert!(mock_client.calls.load(Ordering::SeqCst) >= 1, "summarizer must be invoked for a long conversation");
529        // Summary injected as the first message after the system prompt.
530        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}