Skip to main content

ai_agents_memory/
context.rs

1//! Conversation context for memory management
2
3use 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
20// Selects recent history from safe whole-turn boundaries. A protected signed suffix is retained
21// even when it exceeds the caller's allocation; the runtime performs the final hard-limit check.
22fn recent_suffix_start(messages: &[ChatMessage], token_budget: u32) -> usize {
23    let Ok(inspection) = NativeRetentionInspection::inspect(messages) else {
24        // The Vec-returning public helpers cannot surface malformed history. Keeping all input is
25        // fail-closed with respect to deletion and lets the runtime's checked boundary reject it.
26        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    /// Build LLM messages with per-component token budgets.
101    ///
102    /// Provider-state-bearing native exchanges are selected as complete signed user-turn groups.
103    /// The latest protected group is retained intact even when it exceeds the recent allocation;
104    /// callers that enforce a hard request limit must reject that over-budget result explicitly.
105    pub fn to_llm_messages_with_allocation(
106        &self,
107        allocation: &TokenAllocation,
108    ) -> Vec<ChatMessage> {
109        let mut result = Vec::new();
110
111        // Summary - capped to allocation.summary tokens
112        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        // Recent messages - capped to allocation.recent_messages tokens
135        let recent_start = recent_suffix_start(&self.messages, allocation.recent_messages);
136        result.extend(self.messages[recent_start..].iter().cloned());
137
138        // TODO:
139        // Facts - reserved for 'Session Management' feature, not injected yet.
140
141        result
142    }
143
144    /// Build a budgeted prompt without splitting provider-state-bearing signed user turns.
145    ///
146    /// A latest protected signed suffix remains intact when it alone exceeds `max_tokens`; the
147    /// runtime is responsible for rejecting the resulting hard-limit overflow before transport.
148    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
194/// Language-aware token estimation for multi-language support
195pub 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}' |   // CJK Unified Ideographs
213        '\u{3400}'..='\u{4DBF}' |   // CJK Extension A
214        '\u{AC00}'..='\u{D7AF}' |   // Korean Hangul
215        '\u{3040}'..='\u{30FF}' |   // Japanese Hiragana/Katakana
216        '\u{31F0}'..='\u{31FF}'     // Katakana Extensions
217    )
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); // ~2500 tokens
372        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        // Summary should be truncated
384        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        // Recent message should still be present
392        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        // Messages should be the most recent
424        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}