otherone_context/compact/
mod.rs1pub 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
19pub 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 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 let keep_ratio = compact_ratio.unwrap_or(0.4);
75 let keep_token_threshold = (context_window as f32 * keep_ratio) as u32;
76
77 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 if cutoff_index == 0 {
93 return Ok(messages.to_vec());
94 }
95
96 let cutoff_message = &messages[cutoff_index];
98 let adjusted_cutoff = if cutoff_message.role == "assistant" {
99 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 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 let compressed_summary =
122 call_compact_llm(messages_to_compact, &compact_llm, has_compacted_content).await?;
123
124 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 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 let mut result = vec![compacted_message];
164 result.extend_from_slice(messages_to_keep);
165 Ok(result)
166}
167
168async fn call_compact_llm(
173 messages_to_compact: &[Message],
174 compact_llm_config: &CompactLLMConfig,
175 has_compacted_content: bool,
176) -> Result<String, ContextError> {
177 let message_sequence = messages_to_sequence(messages_to_compact);
179
180 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 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 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 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
257struct CompactLLMConfig {
259 provider: ProviderType,
260 api_key: String,
261 base_url: String,
262 model: String,
263}
264
265fn 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
313fn extract_message_text(message: &Message) -> String {
315 match &message.content {
316 MessageContent::Text(t) => t.clone(),
317 _ => "[非文本内容]".to_string(),
318 }
319}