Skip to main content

lellm_agent/runtime/context/
local_compactor.rs

1//! 本地滑动窗口压缩器 — v0.1 默认实现。
2//!
3//! 策略:
4//! 1. 保留 System 消息(始终)
5//! 2. 按 Turn 分组:Assistant + 对应的所有 ToolResult = 一个原子 Turn
6//! 3. 保留最近 N 个 Turn(N = keep_recent_turns)
7//! 4. 超出的 Turns 压缩为一条 Summary 消息
8//!
9//! 输出结构:System + Summary + 最近 N 个 Turn
10
11use lellm_core::{Message, text_block};
12
13use super::budget::ContextBudget;
14use super::compactor::{CompactionResult, ContextCompactor};
15
16/// 本地滑动窗口压缩器 — v0.1 默认实现。
17#[derive(Debug, Default)]
18pub struct LocalCompactor;
19
20impl LocalCompactor {
21    pub fn new() -> Self {
22        Self
23    }
24}
25
26impl ContextCompactor for LocalCompactor {
27    fn compact(&self, messages: &[Message], budget: &ContextBudget) -> CompactionResult {
28        let before_tokens = super::estimation::estimate_tokens(messages);
29        let before_count = messages.len();
30
31        // 1. 分离 System 消息
32        let (system_msgs, conversation): (Vec<_>, Vec<_>) = messages
33            .iter()
34            .partition(|m| matches!(m, Message::System { .. }));
35
36        // 2. 按 Turn 分组
37        let turns = extract_turns(&conversation);
38
39        // 3. 判断是否需要压缩
40        let keep = budget.keep_recent_turns;
41        if turns.len() <= keep {
42            // 不需要压缩,原样返回
43            return CompactionResult {
44                messages: messages.to_vec(),
45                before_tokens,
46                after_tokens: before_tokens,
47                removed_messages: 0,
48            };
49        }
50
51        // 4. 保留最近 keep 个 Turn
52        let recent_turns: Vec<_> = turns.iter().skip(turns.len() - keep).collect();
53        let old_turns: Vec<_> = turns.iter().take(turns.len() - keep).collect();
54
55        // 5. 对旧 Turns 生成本地摘要
56        let summary = summarize_turns(&old_turns);
57
58        // 6. 组装结果
59        let mut result = system_msgs.into_iter().cloned().collect::<Vec<_>>();
60
61        if !summary.is_empty() {
62            result.push(Message::System {
63                content: text_block(format!("[Previous conversation summary]\n{summary}")),
64            });
65        }
66
67        for turn in recent_turns {
68            for msg in turn {
69                result.push((*msg).clone());
70            }
71        }
72
73        let after_tokens = super::estimation::estimate_tokens(&result);
74        let removed = before_count.saturating_sub(result.len());
75
76        if removed > 0 {
77            tracing::debug!(
78                before_tokens,
79                after_tokens,
80                removed_messages = removed,
81                before_count,
82                after_count = result.len(),
83                "LocalCompactor: context compressed"
84            );
85        }
86
87        CompactionResult {
88            messages: result,
89            before_tokens,
90            after_tokens,
91            removed_messages: removed,
92        }
93    }
94}
95
96// ─── Turn 提取 ─────────────────────────────────────────────────────
97
98/// 从消息列表中提取 Turn。
99///
100/// 一个 Turn = Assistant 消息 + 其对应的所有 ToolResult。
101/// 这是 **不可拆分的原子块**。
102///
103/// User 消息作为 Turn 之间的分隔符,附着到下一个 Turn。
104fn extract_turns<'a>(messages: &[&'a Message]) -> Vec<Vec<&'a Message>> {
105    let mut turns: Vec<Vec<&Message>> = Vec::new();
106    let mut current_turn: Vec<&Message> = Vec::new();
107
108    for msg in messages {
109        match msg {
110            Message::Assistant { .. } => {
111                // 如果已有 turn,先保存
112                if !current_turn.is_empty() {
113                    turns.push(current_turn);
114                    current_turn = Vec::new();
115                }
116                current_turn.push(msg);
117            }
118            Message::ToolResult { .. } => {
119                // 附着到当前 turn(紧跟 Assistant)
120                current_turn.push(msg);
121            }
122            Message::User { .. } => {
123                // User 消息:如果当前 turn 为空,作为 turn 的起始;
124                // 否则保存到当前 turn(Assistant 可能回复多条 User 消息)
125                if current_turn.is_empty() {
126                    current_turn.push(msg);
127                } else {
128                    // User 出现在 ToolResult 之后 → 新轮次的起点,
129                    // 开始新的 turn
130                    turns.push(current_turn);
131                    current_turn = vec![msg];
132                }
133            }
134            Message::System { .. } => {
135                // System 不应出现在 conversation 中,忽略
136            }
137        }
138    }
139
140    if !current_turn.is_empty() {
141        turns.push(current_turn);
142    }
143
144    turns
145}
146
147// ─── 摘要生成 ──────────────────────────────────────────────────────
148
149/// 对旧 Turns 生成本地摘要。
150///
151/// 策略:提取每个 Turn 的关键信息
152/// - Assistant 有文本 → 保留前 200 字符
153/// - Assistant 有 tool_call → 记录工具名和参数概要
154/// - ToolResult → 仅记录成功/失败,截取前 100 字符
155fn summarize_turns(turns: &[&Vec<&Message>]) -> String {
156    let mut lines = Vec::new();
157
158    for (idx, turn) in turns.iter().enumerate() {
159        let _prefix = format!("Turn {}:", idx + 1);
160
161        for msg in *turn {
162            match msg {
163                Message::Assistant { content } => {
164                    let texts: Vec<_> = content.iter().filter_map(|b| b.as_text()).collect();
165                    let tool_calls = msg.extract_tool_calls();
166
167                    if !texts.is_empty() {
168                        let text = texts.join(" ");
169                        let summary = truncate_chars(&text, 200);
170                        lines.push(format!("  Assistant: {}", summary));
171                    }
172
173                    if !tool_calls.is_empty() {
174                        for tc in &tool_calls {
175                            let args_summary = truncate_chars(&tc.arguments.to_string(), 100);
176                            lines.push(format!("  Tool({}): {}", tc.name, args_summary));
177                        }
178                    }
179                }
180                Message::ToolResult {
181                    is_error, content, ..
182                } => {
183                    let status = if *is_error { "ERROR" } else { "OK" };
184                    let text: String = content
185                        .iter()
186                        .filter_map(|b| b.as_text())
187                        .collect::<Vec<_>>()
188                        .join(" ");
189                    let summary = truncate_chars(&text, 100);
190                    lines.push(format!("  {} Result: {}", status, summary));
191                }
192                Message::User { content } => {
193                    let text: String = content
194                        .iter()
195                        .filter_map(|b| b.as_text())
196                        .collect::<Vec<_>>()
197                        .join(" ");
198                    let summary = truncate_chars(&text, 200);
199                    lines.push(format!("  User: {}", summary));
200                }
201                _ => {}
202            }
203        }
204    }
205
206    if lines.is_empty() {
207        return String::new();
208    }
209
210    // 统计摘要
211    let total_turns = turns.len();
212    format!("[Compressed {} turns]\n{}", total_turns, lines.join("\n"))
213}
214
215fn truncate_chars(s: &str, max: usize) -> String {
216    let count = s.chars().count();
217    if count <= max {
218        return s.to_string();
219    }
220    let truncated: String = s.chars().take(max).collect();
221    format!("{}… ({} chars)", truncated, count)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use lellm_core::ContentBlock;
228
229    #[test]
230    fn test_extract_turns_atomic() {
231        let assistant = Message::Assistant {
232            content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
233                id: "call_1".into(),
234                name: "test".into(),
235                arguments: serde_json::json!({}),
236            })],
237        };
238        let tool_result = Message::ToolResult {
239            tool_call_id: "call_1".to_string(),
240            is_error: false,
241            content: text_block("ok".to_string()),
242        };
243
244        let messages = vec![&assistant, &tool_result];
245        let turns = extract_turns(&messages);
246
247        // Assistant + ToolResult 应在同一个 Turn 中
248        assert_eq!(turns.len(), 1);
249        assert_eq!(turns[0].len(), 2);
250    }
251
252    #[test]
253    fn test_extract_turns_multiple() {
254        let user = Message::User {
255            content: text_block("hello".to_string()),
256        };
257        let assistant = Message::Assistant {
258            content: vec![ContentBlock::ToolCall(lellm_core::ToolCall {
259                id: "call_1".into(),
260                name: "test".into(),
261                arguments: serde_json::json!({}),
262            })],
263        };
264        let tool_result = Message::ToolResult {
265            tool_call_id: "call_1".to_string(),
266            is_error: false,
267            content: text_block("ok".to_string()),
268        };
269        let assistant2 = Message::Assistant {
270            content: text_block("final answer".to_string()),
271        };
272
273        let messages = vec![&user, &assistant, &tool_result, &assistant2];
274        let turns = extract_turns(&messages);
275
276        // Turn 1: User (standalone, no following Assistant)
277        // Turn 2: Assistant(tool_call) + ToolResult (atomic block)
278        // Turn 3: Assistant(final answer)
279        assert_eq!(turns.len(), 3);
280        assert_eq!(turns[0].len(), 1); // User
281        assert_eq!(turns[1].len(), 2); // Assistant + ToolResult
282        assert_eq!(turns[2].len(), 1); // Assistant (final)
283    }
284}