1use serde::{Deserialize, Serialize};
4
5use super::native::NativeRetentionInspection;
6use super::token_budget::TokenAllocation;
7use ai_agents_core::{ChatMessage, Role};
8
9fn prefix_at_char_boundary(text: &str, max_chars: usize) -> &str {
10 if max_chars == 0 {
11 return "";
12 }
13
14 match text.char_indices().nth(max_chars) {
15 Some((idx, _)) => &text[..idx],
16 None => text,
17 }
18}
19
20fn recent_suffix_start(messages: &[ChatMessage], token_budget: u32) -> usize {
23 let Ok(inspection) = NativeRetentionInspection::inspect(messages) else {
24 return 0;
27 };
28 let mut start = inspection
29 .protected_suffix_start()
30 .unwrap_or(messages.len());
31 let mut used_tokens = messages[start..]
32 .iter()
33 .map(estimate_message_tokens)
34 .fold(0u32, u32::saturating_add);
35
36 while start > 0 {
37 let previous = inspection.previous_safe_prefix_len(start);
38 if previous == start {
39 break;
40 }
41 let group_tokens = messages[previous..start]
42 .iter()
43 .map(estimate_message_tokens)
44 .fold(0u32, u32::saturating_add);
45 if used_tokens.saturating_add(group_tokens) > token_budget {
46 break;
47 }
48 used_tokens = used_tokens.saturating_add(group_tokens);
49 start = previous;
50 }
51
52 start
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
56pub struct ConversationContext {
57 pub summary: Option<String>,
58 pub messages: Vec<ChatMessage>,
59 pub total_messages: usize,
60 pub summarized_count: usize,
61}
62
63impl ConversationContext {
64 pub fn new() -> Self {
65 Self::default()
66 }
67
68 pub fn with_messages(messages: Vec<ChatMessage>) -> Self {
69 let total = messages.len();
70 Self {
71 summary: None,
72 messages,
73 total_messages: total,
74 summarized_count: 0,
75 }
76 }
77
78 pub fn with_summary(mut self, summary: String, summarized_count: usize) -> Self {
79 self.summary = Some(summary);
80 self.summarized_count = summarized_count;
81 self
82 }
83
84 pub fn to_llm_messages(&self) -> Vec<ChatMessage> {
85 let mut result = Vec::new();
86
87 if let Some(ref summary) = self.summary {
88 result.push(ChatMessage {
89 role: Role::System,
90 content: format!("[Previous conversation summary]\n{}", summary),
91 name: None,
92 timestamp: None,
93 });
94 }
95
96 result.extend(self.messages.clone());
97 result
98 }
99
100 pub fn to_llm_messages_with_allocation(
106 &self,
107 allocation: &TokenAllocation,
108 ) -> Vec<ChatMessage> {
109 let mut result = Vec::new();
110
111 if let Some(ref summary) = self.summary {
113 let summary_content = format!("[Previous conversation summary]\n{}", summary);
114 let summary_tokens = estimate_tokens(&summary_content);
115
116 let final_content = if summary_tokens > allocation.summary {
117 let char_count = summary_content.chars().count() as f64;
118 let ratio = char_count / summary_tokens as f64;
119 let target_chars = (allocation.summary as f64 * ratio) as usize;
120 let truncated = prefix_at_char_boundary(&summary_content, target_chars);
121 format!("{}...", truncated)
122 } else {
123 summary_content
124 };
125
126 result.push(ChatMessage {
127 role: Role::System,
128 content: final_content,
129 name: None,
130 timestamp: None,
131 });
132 }
133
134 let recent_start = recent_suffix_start(&self.messages, allocation.recent_messages);
136 result.extend(self.messages[recent_start..].iter().cloned());
137
138 result
142 }
143
144 pub fn to_llm_messages_with_budget(&self, max_tokens: u32) -> Vec<ChatMessage> {
149 let mut result = Vec::new();
150 let mut used_tokens = 0u32;
151
152 if let Some(ref summary) = self.summary {
153 let summary_msg = ChatMessage {
154 role: Role::System,
155 content: format!("[Previous conversation summary]\n{}", summary),
156 name: None,
157 timestamp: None,
158 };
159 let tokens = estimate_message_tokens(&summary_msg);
160 if tokens <= max_tokens {
161 used_tokens = tokens;
162 result.push(summary_msg);
163 }
164 }
165
166 let message_budget = max_tokens.saturating_sub(used_tokens);
167 let recent_start = recent_suffix_start(&self.messages, message_budget);
168 result.extend(self.messages[recent_start..].iter().cloned());
169
170 result
171 }
172
173 pub fn estimated_tokens(&self) -> u32 {
174 let summary_tokens = self
175 .summary
176 .as_ref()
177 .map(|s| estimate_tokens(s))
178 .unwrap_or(0);
179
180 let message_tokens: u32 = self.messages.iter().map(estimate_message_tokens).sum();
181
182 summary_tokens + message_tokens
183 }
184
185 pub fn is_empty(&self) -> bool {
186 self.summary.is_none() && self.messages.is_empty()
187 }
188
189 pub fn message_count(&self) -> usize {
190 self.messages.len()
191 }
192}
193
194pub fn estimate_tokens(text: &str) -> u32 {
196 if text.is_empty() {
197 return 0;
198 }
199
200 let ascii_chars = text.chars().filter(|c| c.is_ascii()).count();
201 let cjk_chars = text.chars().filter(|c| is_cjk(*c)).count();
202 let other_chars = text.chars().count() - ascii_chars - cjk_chars;
203
204 let estimated =
205 (ascii_chars as f64 / 4.0) + (cjk_chars as f64 * 1.5) + (other_chars as f64 * 1.0);
206
207 estimated.ceil().max(1.0) as u32
208}
209
210fn is_cjk(c: char) -> bool {
211 matches!(c,
212 '\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{AC00}'..='\u{D7AF}' | '\u{3040}'..='\u{30FF}' | '\u{31F0}'..='\u{31FF}' )
218}
219
220pub fn estimate_message_tokens(message: &ChatMessage) -> u32 {
221 let role_tokens = 4u32;
222 let content_tokens = estimate_tokens(&message.content);
223 let name_tokens = message
224 .name
225 .as_ref()
226 .map(|n| estimate_tokens(n))
227 .unwrap_or(0);
228 role_tokens + content_tokens + name_tokens
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub enum CompressResult {
233 NotNeeded,
234 Compressed {
235 messages_summarized: usize,
236 new_summary_length: usize,
237 tokens_saved: u32,
238 },
239 AlreadyCompressed,
240 Failed {
241 error: String,
242 },
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use ai_agents_core::{
249 NativeCallBinding, NativeProviderState, NativeProviderTarget, ToolCall,
250 encode_native_tool_call_markers, encode_native_tool_result_marker,
251 };
252
253 fn make_message(role: Role, content: &str) -> ChatMessage {
254 ChatMessage {
255 role,
256 content: content.to_string(),
257 name: None,
258 timestamp: None,
259 }
260 }
261
262 fn signed_turn(exchange_id: &str) -> Vec<ChatMessage> {
263 let call = ToolCall {
264 id: format!("{exchange_id}-call"),
265 name: "lookup".to_string(),
266 arguments: serde_json::json!({"query":"fixture"}),
267 };
268 let state = NativeProviderState::new(
269 exchange_id,
270 "google",
271 "generateContent",
272 NativeProviderTarget::new("https://example.invalid/v1beta/", "fixture-model").unwrap(),
273 serde_json::json!({
274 "role":"model",
275 "parts":[{
276 "functionCall":{"name":"lookup","args":{"query":"fixture"}},
277 "thoughtSignature":"fixture-signature"
278 }]
279 }),
280 vec![NativeCallBinding::new(&call.id, 0).unwrap()],
281 )
282 .unwrap();
283 vec![
284 make_message(Role::User, "signed user request"),
285 ChatMessage::assistant(
286 encode_native_tool_call_markers(std::slice::from_ref(&call), Some(&state)).unwrap(),
287 ),
288 ChatMessage::function(
289 "lookup",
290 encode_native_tool_result_marker(&call, serde_json::json!({"ok":true})).unwrap(),
291 ),
292 make_message(Role::Assistant, "signed turn final response"),
293 ]
294 }
295
296 #[test]
297 fn test_conversation_context_new() {
298 let ctx = ConversationContext::new();
299 assert!(ctx.is_empty());
300 assert_eq!(ctx.message_count(), 0);
301 assert!(ctx.summary.is_none());
302 }
303
304 #[test]
305 fn test_conversation_context_with_messages() {
306 let messages = vec![
307 make_message(Role::User, "Hello"),
308 make_message(Role::Assistant, "Hi there!"),
309 ];
310 let ctx = ConversationContext::with_messages(messages);
311 assert_eq!(ctx.message_count(), 2);
312 assert_eq!(ctx.total_messages, 2);
313 assert!(!ctx.is_empty());
314 }
315
316 #[test]
317 fn test_conversation_context_with_summary() {
318 let messages = vec![make_message(Role::User, "Current message")];
319 let ctx = ConversationContext::with_messages(messages)
320 .with_summary("Previous discussion about weather".to_string(), 5);
321
322 assert!(ctx.summary.is_some());
323 assert_eq!(ctx.summarized_count, 5);
324
325 let llm_messages = ctx.to_llm_messages();
326 assert_eq!(llm_messages.len(), 2);
327 assert!(
328 llm_messages[0]
329 .content
330 .contains("Previous conversation summary")
331 );
332 }
333
334 #[test]
335 fn test_to_llm_messages_without_summary() {
336 let messages = vec![
337 make_message(Role::User, "Hello"),
338 make_message(Role::Assistant, "Hi!"),
339 ];
340 let ctx = ConversationContext::with_messages(messages);
341
342 let llm_messages = ctx.to_llm_messages();
343 assert_eq!(llm_messages.len(), 2);
344 assert_eq!(llm_messages[0].role, Role::User);
345 }
346
347 #[test]
348 fn test_estimated_tokens() {
349 let ctx = ConversationContext::with_messages(vec![
350 make_message(Role::User, "Hello world"),
351 make_message(Role::Assistant, "Hi there"),
352 ]);
353
354 let tokens = ctx.estimated_tokens();
355 assert!(tokens > 0);
356 }
357
358 #[test]
359 fn test_to_llm_messages_with_budget() {
360 let messages: Vec<ChatMessage> = (0..10)
361 .map(|i| make_message(Role::User, &format!("Message number {}", i)))
362 .collect();
363 let ctx = ConversationContext::with_messages(messages);
364
365 let limited = ctx.to_llm_messages_with_budget(50);
366 assert!(limited.len() < 10);
367 }
368
369 #[test]
370 fn test_to_llm_messages_with_allocation_caps_summary() {
371 let long_summary = "x".repeat(10000); let messages = vec![make_message(Role::User, "Hello")];
373 let ctx = ConversationContext::with_messages(messages).with_summary(long_summary, 50);
374
375 let allocation = TokenAllocation {
376 summary: 100,
377 recent_messages: 2048,
378 facts: 512,
379 relationships: 0,
380 };
381
382 let result = ctx.to_llm_messages_with_allocation(&allocation);
383 let summary_msg = &result[0];
385 let summary_tokens = estimate_tokens(&summary_msg.content);
386 assert!(
387 summary_tokens <= 120,
388 "Summary should be roughly capped: got {}",
389 summary_tokens
390 );
391 assert!(result.len() >= 2);
393 }
394
395 #[test]
396 fn test_to_llm_messages_with_allocation_caps_recent() {
397 let messages: Vec<ChatMessage> = (0..50)
398 .map(|i| {
399 make_message(
400 Role::User,
401 &format!(
402 "Message number {} with some extra text to increase tokens",
403 i
404 ),
405 )
406 })
407 .collect();
408 let ctx = ConversationContext::with_messages(messages);
409
410 let allocation = TokenAllocation {
411 summary: 1024,
412 recent_messages: 200,
413 facts: 512,
414 relationships: 0,
415 };
416
417 let result = ctx.to_llm_messages_with_allocation(&allocation);
418 assert!(
419 result.len() < 50,
420 "Should have fewer messages due to cap: got {}",
421 result.len()
422 );
423 let last = &result[result.len() - 1];
425 assert!(
426 last.content.contains("49"),
427 "Last message should be the most recent"
428 );
429 }
430
431 #[test]
432 fn test_prefix_at_char_boundary_handles_unicode() {
433 let text = "제 이름은 Jay이고 계약서를 확인하고 싶어요";
434 let prefix = prefix_at_char_boundary(text, 7);
435 assert_eq!(prefix.chars().count(), 7);
436 assert!(text.starts_with(prefix));
437 }
438
439 #[test]
440 fn test_to_llm_messages_with_allocation_no_summary() {
441 let messages = vec![
442 make_message(Role::User, "Hello"),
443 make_message(Role::Assistant, "Hi!"),
444 ];
445 let ctx = ConversationContext::with_messages(messages);
446
447 let allocation = TokenAllocation {
448 summary: 1024,
449 recent_messages: 2048,
450 facts: 512,
451 relationships: 0,
452 };
453
454 let result = ctx.to_llm_messages_with_allocation(&allocation);
455 assert_eq!(result.len(), 2);
456 }
457
458 #[test]
459 fn allocation_does_not_split_completed_signed_turn() {
460 let mut messages = signed_turn("allocation-past");
461 messages.push(make_message(Role::User, "latest"));
462 let latest_tokens = estimate_message_tokens(messages.last().unwrap());
463 let ctx = ConversationContext::with_messages(messages);
464 let allocation = TokenAllocation {
465 summary: 0,
466 recent_messages: latest_tokens,
467 facts: 0,
468 relationships: 0,
469 };
470
471 let selected = ctx.to_llm_messages_with_allocation(&allocation);
472
473 assert_eq!(selected.len(), 1);
474 assert_eq!(selected[0].content, "latest");
475 }
476
477 #[test]
478 fn allocation_keeps_latest_signed_turn_even_when_group_exceeds_budget() {
479 let messages = signed_turn("allocation-active");
480 let ctx = ConversationContext::with_messages(messages.clone());
481 let allocation = TokenAllocation {
482 summary: 0,
483 recent_messages: 1,
484 facts: 0,
485 relationships: 0,
486 };
487
488 let selected = ctx.to_llm_messages_with_allocation(&allocation);
489
490 assert_eq!(selected.len(), messages.len());
491 assert_eq!(selected[0].content, "signed user request");
492 assert!(selected[1].content.contains("fixture-signature"));
493 }
494
495 #[test]
496 fn budget_keeps_latest_signed_turn_even_when_group_exceeds_budget() {
497 let messages = signed_turn("budget-active");
498 let ctx = ConversationContext::with_messages(messages.clone());
499
500 let selected = ctx.to_llm_messages_with_budget(1);
501
502 assert_eq!(selected.len(), messages.len());
503 assert_eq!(selected[0].content, "signed user request");
504 assert!(selected[1].content.contains("fixture-signature"));
505 }
506
507 #[test]
508 fn test_estimate_tokens_english() {
509 assert_eq!(estimate_tokens(""), 0);
510 assert_eq!(estimate_tokens("test"), 1);
511 assert_eq!(estimate_tokens("hello world"), 3);
512 }
513
514 #[test]
515 fn test_estimate_tokens_korean() {
516 let tokens = estimate_tokens("안녕하세요");
517 assert!(
518 tokens >= 5,
519 "Korean text should have more tokens: {}",
520 tokens
521 );
522 }
523
524 #[test]
525 fn test_estimate_tokens_japanese() {
526 let tokens = estimate_tokens("こんにちは");
527 assert!(
528 tokens >= 5,
529 "Japanese text should have more tokens: {}",
530 tokens
531 );
532 }
533
534 #[test]
535 fn test_estimate_tokens_chinese() {
536 let tokens = estimate_tokens("你好世界");
537 assert!(
538 tokens >= 4,
539 "Chinese text should have more tokens: {}",
540 tokens
541 );
542 }
543
544 #[test]
545 fn test_estimate_tokens_mixed() {
546 let tokens = estimate_tokens("Hello 안녕 World 世界");
547 assert!(tokens >= 6, "Mixed text: {}", tokens);
548 }
549
550 #[test]
551 fn test_compress_result_variants() {
552 let not_needed = CompressResult::NotNeeded;
553 assert!(matches!(not_needed, CompressResult::NotNeeded));
554
555 let compressed = CompressResult::Compressed {
556 messages_summarized: 5,
557 new_summary_length: 100,
558 tokens_saved: 500,
559 };
560 if let CompressResult::Compressed {
561 messages_summarized,
562 ..
563 } = compressed
564 {
565 assert_eq!(messages_summarized, 5);
566 }
567
568 let failed = CompressResult::Failed {
569 error: "test error".to_string(),
570 };
571 if let CompressResult::Failed { error } = failed {
572 assert_eq!(error, "test error");
573 }
574 }
575}