Skip to main content

otherone_context/compact/
mod.rs

1// 作用:上下文压缩子模块
2// 关联:被 otherone-context 的 combine_context 调用
3// 预期结果:提供 token 估算、阈值检查、消息序列化、压缩 prompts、压缩执行
4
5pub mod check_threshold;
6pub mod estimate_tokens;
7pub mod messages_to_sequence;
8pub mod prompts;
9
10use crate::compact::estimate_tokens::estimate_tokens;
11use crate::compact::messages_to_sequence::messages_to_sequence;
12use crate::compact::prompts::{
13    SUMMARIZATION_SYSTEM_PROMPT, TURN_PREFIX_SUMMARIZATION_PROMPT, UPDATE_SUMMARIZATION_PROMPT,
14};
15use crate::error::ContextError;
16use otherone_ai::types::{Message, MessageContent, ProviderType};
17use otherone_storage::types::{RuntimeContext, StorageType, WriteCompactedEntryOptions};
18
19/// 压缩上下文消息
20/// 作用:保留最新的消息,压缩旧的消息为一段摘要
21/// 关联:被 combine_context 调用,当 token 使用量超过阈值时触发
22/// 预期结果:返回压缩后的 messages 数组:[compacted_message, ...messages_to_keep]
23pub async fn compact_messages(
24    messages: &[Message],
25    _context_tokens: u32,
26    context_window: u32,
27    compact_ratio: Option<f32>,
28    ai_config: Option<&serde_json::Value>,
29    has_compacted_content: bool,
30    session_id: Option<&str>,
31    storage_type: Option<&StorageType>,
32    database_config: Option<&otherone_storage::types::DatabaseConfig>,
33    original_entries: Option<&[otherone_storage::types::Entry]>,
34) -> Result<Vec<Message>, ContextError> {
35    compact_messages_with_context(
36        messages,
37        _context_tokens,
38        context_window,
39        compact_ratio,
40        ai_config,
41        has_compacted_content,
42        session_id,
43        storage_type,
44        database_config,
45        original_entries,
46        None,
47    )
48    .await
49}
50
51pub async fn compact_messages_with_context(
52    messages: &[Message],
53    _context_tokens: u32,
54    context_window: u32,
55    compact_ratio: Option<f32>,
56    ai_config: Option<&serde_json::Value>,
57    has_compacted_content: bool,
58    session_id: Option<&str>,
59    storage_type: Option<&StorageType>,
60    database_config: Option<&otherone_storage::types::DatabaseConfig>,
61    original_entries: Option<&[otherone_storage::types::Entry]>,
62    runtime_context: Option<&RuntimeContext>,
63) -> Result<Vec<Message>, ContextError> {
64    if messages.is_empty() {
65        return Ok(Vec::new());
66    }
67
68    // 提取压缩 LLM 配置
69    let compact_llm = extract_compact_llm_config(ai_config).ok_or_else(|| {
70        ContextError::ConfigError("AI configuration is required for compaction".to_string())
71    })?;
72
73    // 设置默认保留比例为 40%
74    let keep_ratio = compact_ratio.unwrap_or(0.4);
75    let keep_token_threshold = (context_window as f32 * keep_ratio) as u32;
76
77    // 从后往前查找切割点
78    let mut accumulated_tokens: u32 = 0;
79    let mut cutoff_index: usize = 0;
80
81    for i in (0..messages.len()).rev() {
82        let message_tokens = estimate_tokens(&[messages[i].clone()]);
83        if accumulated_tokens + message_tokens <= keep_token_threshold {
84            accumulated_tokens += message_tokens;
85            cutoff_index = i;
86        } else {
87            break;
88        }
89    }
90
91    // 如果所有消息都在阈值内,不需要压缩
92    if cutoff_index == 0 {
93        return Ok(messages.to_vec());
94    }
95
96    // 根据切割点消息的 role 类型调整切割位置
97    let cutoff_message = &messages[cutoff_index];
98    let adjusted_cutoff = if cutoff_message.role == "assistant" {
99        // 从切割点往前查找第一条 user 消息
100        let mut found = cutoff_index;
101        for i in (0..cutoff_index).rev() {
102            if messages[i].role == "user" {
103                found = i + 1;
104                break;
105            }
106        }
107        found
108    } else {
109        cutoff_index
110    };
111
112    // 分割消息数组
113    let messages_to_compact = &messages[..adjusted_cutoff];
114    let messages_to_keep = &messages[adjusted_cutoff..];
115
116    if messages_to_compact.is_empty() {
117        return Ok(messages.to_vec());
118    }
119
120    // 调用 LLM 进行压缩
121    let compressed_summary =
122        call_compact_llm(messages_to_compact, &compact_llm, has_compacted_content).await?;
123
124    // 创建压缩摘要消息
125    let compacted_message = Message {
126        role: "user".to_string(),
127        content: MessageContent::Text(compressed_summary.clone()),
128        name: None,
129        tool_calls: None,
130        tool_call_id: None,
131    };
132
133    // 存储压缩记录
134    if let (Some(sid), Some(st), Some(entries)) = (session_id, storage_type, original_entries) {
135        let trigger_entry_id = if has_compacted_content && adjusted_cutoff > 0 {
136            entries
137                .get(adjusted_cutoff - 1)
138                .map(|e| e.entry_id.clone())
139                .unwrap_or_default()
140        } else {
141            entries
142                .get(adjusted_cutoff - 1)
143                .map(|e| e.entry_id.clone())
144                .unwrap_or_default()
145        };
146
147        if !trigger_entry_id.is_empty() {
148            let _ = otherone_storage::write_compacted_entry(&WriteCompactedEntryOptions {
149                storage_type: st.clone(),
150                session_id: sid.to_string(),
151                summary: compressed_summary,
152                trigger_entry_id,
153                create_at: None,
154                database_config: database_config.cloned(),
155                runtime_context: runtime_context.cloned(),
156                metadata: Default::default(),
157            })
158            .await;
159        }
160    }
161
162    // 返回压缩后的消息数组
163    let mut result = vec![compacted_message];
164    result.extend_from_slice(messages_to_keep);
165    Ok(result)
166}
167
168/// 调用 LLM 进行上下文压缩
169/// 作用:调用 AI 模块将旧消息压缩为一段摘要
170/// 关联:被 compact_messages 调用
171/// 预期结果:返回压缩后的摘要文本
172async fn call_compact_llm(
173    messages_to_compact: &[Message],
174    compact_llm_config: &CompactLLMConfig,
175    has_compacted_content: bool,
176) -> Result<String, ContextError> {
177    // 将消息转换为序列文本
178    let message_sequence = messages_to_sequence(messages_to_compact);
179
180    // 根据是否已有压缩内容选择不同的 prompt
181    let user_prompt = if has_compacted_content {
182        let previous_summary = extract_message_text(&messages_to_compact[0]);
183        format!(
184            "<previous-summary>\n{}\n</previous-summary>\n\n{}\n\n{}",
185            previous_summary, UPDATE_SUMMARIZATION_PROMPT, message_sequence
186        )
187    } else {
188        format!(
189            "{}\n\n{}",
190            TURN_PREFIX_SUMMARIZATION_PROMPT, message_sequence
191        )
192    };
193
194    // 构建压缩请求的 messages
195    let compact_messages = vec![
196        Message {
197            role: "system".to_string(),
198            content: MessageContent::Text(SUMMARIZATION_SYSTEM_PROMPT.to_string()),
199            name: None,
200            tool_calls: None,
201            tool_call_id: None,
202        },
203        Message {
204            role: "user".to_string(),
205            content: MessageContent::Text(user_prompt),
206            name: None,
207            tool_calls: None,
208            tool_call_id: None,
209        },
210    ];
211
212    // 构建 AI 配置并调用
213    let ai_config = serde_json::json!({
214        "model": compact_llm_config.model,
215        "messages": compact_messages,
216    });
217
218    let response = otherone_ai::invoke_model(
219        compact_llm_config.provider.clone(),
220        &compact_llm_config.api_key,
221        &compact_llm_config.base_url,
222        ai_config,
223    )
224    .await
225    .map_err(|e| ContextError::CompactionError(e.to_string()))?;
226
227    // 提取压缩内容
228    let compressed = match compact_llm_config.provider {
229        ProviderType::OpenAI
230        | ProviderType::Fetch
231        | ProviderType::OpenRouter
232        | ProviderType::Local => response
233            .choices
234            .first()
235            .and_then(|c| c.message.as_ref())
236            .and_then(|m| m.content.as_deref())
237            .unwrap_or("")
238            .to_string(),
239        ProviderType::Anthropic => response
240            .choices
241            .first()
242            .and_then(|c| c.message.as_ref())
243            .and_then(|m| m.content.as_deref())
244            .unwrap_or("")
245            .to_string(),
246    };
247
248    if compressed.is_empty() {
249        return Err(ContextError::CompactionError(
250            "Unable to extract content from compaction response".to_string(),
251        ));
252    }
253
254    Ok(compressed)
255}
256
257/// 压缩 LLM 配置
258struct CompactLLMConfig {
259    provider: ProviderType,
260    api_key: String,
261    base_url: String,
262    model: String,
263}
264
265/// 从 AI 配置中提取压缩 LLM 配置
266fn extract_compact_llm_config(ai_config: Option<&serde_json::Value>) -> Option<CompactLLMConfig> {
267    let config = ai_config?;
268    let obj = config.as_object()?;
269
270    let provider_str = obj
271        .get("compact_llm_provider")
272        .or_else(|| obj.get("provider"))
273        .and_then(|v| v.as_str())
274        .unwrap_or("openai");
275
276    let provider = match provider_str {
277        "anthropic" => ProviderType::Anthropic,
278        "fetch" => ProviderType::Fetch,
279        _ => ProviderType::OpenAI,
280    };
281
282    let api_key: String = obj
283        .get("compact_llm_apiKey")
284        .or_else(|| obj.get("apiKey"))
285        .or_else(|| obj.get("api_key"))
286        .and_then(|v| v.as_str())
287        .filter(|s| !s.is_empty())
288        .map(|s| s.to_string())?;
289
290    let base_url: String = obj
291        .get("compact_llm_baseUrl")
292        .or_else(|| obj.get("baseUrl"))
293        .or_else(|| obj.get("base_url"))
294        .and_then(|v| v.as_str())
295        .filter(|s| !s.is_empty())
296        .map(|s| s.to_string())?;
297
298    let model: String = obj
299        .get("compact_llm_model")
300        .or_else(|| obj.get("model"))
301        .and_then(|v| v.as_str())
302        .filter(|s| !s.is_empty())
303        .map(|s| s.to_string())?;
304
305    Some(CompactLLMConfig {
306        provider,
307        api_key,
308        base_url,
309        model,
310    })
311}
312
313/// 提取 MessageContent 中的文本
314fn extract_message_text(message: &Message) -> String {
315    match &message.content {
316        MessageContent::Text(t) => t.clone(),
317        _ => "[非文本内容]".to_string(),
318    }
319}