1use super::{Capability, CapabilityLocalization, CapabilityStatus};
7use crate::message::{ContentPart, Message, MessageRole};
8use crate::message_filter::{
9 ExcludedNoticeTransform, MessageFilterProvider, MessageQuery, anchored_window,
10};
11use crate::tool_types::ToolHints;
12use crate::tools::{Tool, ToolExecutionResult};
13use crate::traits::{ToolContext, ToolContextService};
14use async_trait::async_trait;
15use serde::{Deserialize, Serialize};
16use serde_json::{Value, json};
17use std::cmp::Ordering;
18use std::io::{self, Write};
19use std::sync::Arc;
20
21pub const INFINITY_CONTEXT_CAPABILITY_ID: &str = "infinity_context";
23
24pub struct InfinityContextCapability;
26
27pub struct InfinityContextFilterOnlyCapability;
32
33impl Capability for InfinityContextFilterOnlyCapability {
34 fn id(&self) -> &str {
35 INFINITY_CONTEXT_CAPABILITY_ID
36 }
37
38 fn name(&self) -> &str {
39 "Infinity Context"
40 }
41
42 fn description(&self) -> &str {
43 "Trims older conversation history out of the live provider prompt."
44 }
45
46 fn status(&self) -> CapabilityStatus {
47 CapabilityStatus::Available
48 }
49
50 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
51 Some(Arc::new(InfinityContextFilterProvider))
52 }
53}
54
55impl Capability for InfinityContextCapability {
56 fn id(&self) -> &str {
57 INFINITY_CONTEXT_CAPABILITY_ID
58 }
59
60 fn name(&self) -> &str {
61 "Infinity Context"
62 }
63
64 fn description(&self) -> &str {
65 r#"Trims older conversation history out of the live prompt while keeping it queryable with `query_history`.
66
67> [!TIP]
68> Use this for long-running sessions where earlier discussion still matters but should not consume prompt budget every turn."#
69 }
70
71 fn status(&self) -> CapabilityStatus {
72 CapabilityStatus::Available
73 }
74
75 fn icon(&self) -> Option<&str> {
76 Some("infinity")
77 }
78
79 fn category(&self) -> Option<&str> {
80 Some("Optimization")
81 }
82
83 fn system_prompt_addition(&self) -> Option<&str> {
84 Some(INFINITY_CONTEXT_SYSTEM_PROMPT)
85 }
86
87 fn tools(&self) -> Vec<Box<dyn Tool>> {
88 vec![Box::new(QueryHistoryTool)]
89 }
90
91 fn message_filter_provider(&self) -> Option<Arc<dyn MessageFilterProvider>> {
92 Some(Arc::new(InfinityContextFilterProvider))
93 }
94
95 fn config_schema(&self) -> Option<Value> {
100 Some(json!({
101 "type": "object",
102 "properties": {
103 "context_budget_tokens": {
104 "type": "integer",
105 "title": "Context budget (tokens)",
106 "description": "Maximum prompt budget reserved for message history.",
107 "minimum": 1,
108 "default": default_context_budget_tokens()
109 },
110 "min_recent_messages": {
111 "type": "integer",
112 "title": "Minimum recent messages",
113 "description": "Number of recent messages always kept, even when the token budget is tight.",
114 "minimum": 1,
115 "default": default_min_recent_messages()
116 },
117 "max_recent_messages": {
118 "type": "integer",
119 "title": "Maximum recent messages",
120 "description": "Optional hard cap on recent messages kept in the live prompt.",
121 "minimum": 1
122 },
123 "keep_first_messages": {
124 "type": "integer",
125 "title": "Anchored first messages",
126 "description": "Optional leading messages kept as an anchor (the original task), even under a tight budget. Additional to the maximum recent messages. The anchor is fetched as a bounded head+tail load (capped at 16), so it is guaranteed even for histories far longer than the candidate load window. Defaults to 0 so untrusted first messages cannot bypass the configured token budget or recent-message cap; raise it only for trusted sessions where anchoring leading context is worth the extra prompt cost.",
127 "minimum": 0,
128 "maximum": MAX_KEEP_FIRST_MESSAGES,
129 "default": default_keep_first_messages()
130 }
131 }
132 }))
133 }
134
135 fn validate_config(&self, config: &Value) -> Result<(), String> {
136 if config.is_null() {
137 return Ok(());
138 }
139 let typed: InfinityContextConfig = serde_json::from_value(config.clone())
140 .map_err(|e| format!("invalid infinity_context config: {e}"))?;
141 if typed.context_budget_tokens == 0 {
142 return Err("context_budget_tokens must be >= 1".to_string());
143 }
144 if typed.min_recent_messages == 0 {
145 return Err("min_recent_messages must be >= 1".to_string());
146 }
147 if typed.max_recent_messages == Some(0) {
148 return Err("max_recent_messages must be >= 1".to_string());
149 }
150 if typed.keep_first_messages > MAX_KEEP_FIRST_MESSAGES {
151 return Err(format!(
152 "keep_first_messages must be <= {MAX_KEEP_FIRST_MESSAGES}"
153 ));
154 }
155 Ok(())
156 }
157
158 fn localizations(&self) -> Vec<CapabilityLocalization> {
159 vec![
160 CapabilityLocalization {
161 locale: "en",
162 name: None,
163 description: None,
164 config_description: Some(
165 "Controls the token budget for history and the minimum/maximum number \
166 of recent messages kept in the prompt.",
167 ),
168 config_overlay: None,
169 },
170 CapabilityLocalization {
171 locale: "uk",
172 name: Some("Нескінченний контекст"),
173 description: Some(
174 "Прибирає старішу історію розмови з активного запиту, зберігаючи її \
175 доступною через інструмент query_history.",
176 ),
177 config_description: Some(
178 "Визначає бюджет токенів для історії та мінімальну й максимальну \
179 кількість останніх повідомлень у запиті.",
180 ),
181 config_overlay: Some(json!({
182 "properties": {
183 "context_budget_tokens": {
184 "title": "Бюджет контексту (токени)",
185 "description": "Максимальний бюджет запиту, зарезервований для історії повідомлень."
186 },
187 "min_recent_messages": {
188 "title": "Мінімум останніх повідомлень",
189 "description": "Кількість останніх повідомлень, які зберігаються завжди, навіть коли бюджет токенів обмежений."
190 },
191 "max_recent_messages": {
192 "title": "Максимум останніх повідомлень",
193 "description": "Необов'язкове жорстке обмеження кількості останніх повідомлень в активному запиті."
194 }
195 }
196 })),
197 },
198 ]
199 }
200}
201
202const INFINITY_CONTEXT_SYSTEM_PROMPT: &str = r#"## Conversation history
203
204Earlier messages may be trimmed from the live prompt. Use `query_history`
205to retrieve them when needed. The window is trimmed automatically; do not
206abandon tasks for token reasons — persist important state via file or
207memory tools when available."#;
208
209#[derive(Debug, Clone, Serialize, Deserialize)]
210struct InfinityContextConfig {
211 #[serde(default = "default_context_budget_tokens")]
213 context_budget_tokens: usize,
214
215 #[serde(default = "default_min_recent_messages")]
217 min_recent_messages: usize,
218
219 #[serde(default)]
224 max_recent_messages: Option<usize>,
225
226 #[serde(default = "default_keep_first_messages")]
231 keep_first_messages: usize,
232
233 #[serde(default)]
238 compaction_active: bool,
239}
240
241fn default_context_budget_tokens() -> usize {
242 100_000
243}
244
245fn default_min_recent_messages() -> usize {
246 10
247}
248
249fn default_keep_first_messages() -> usize {
250 0
251}
252
253impl Default for InfinityContextConfig {
254 fn default() -> Self {
255 Self {
256 context_budget_tokens: default_context_budget_tokens(),
257 min_recent_messages: default_min_recent_messages(),
258 max_recent_messages: None,
259 keep_first_messages: default_keep_first_messages(),
260 compaction_active: false,
261 }
262 }
263}
264
265const CANDIDATE_AVG_TOKENS_PER_MESSAGE: usize = 250;
266const CANDIDATE_OVERFETCH_FACTOR: usize = 4;
267const CANDIDATE_MAX_MESSAGES: usize = 2_000;
268const MAX_KEEP_FIRST_MESSAGES: usize = 16;
269
270struct InfinityContextFilterProvider;
271
272impl MessageFilterProvider for InfinityContextFilterProvider {
273 fn apply_filters(&self, query: &mut MessageQuery, config: &Value) {
274 let config: InfinityContextConfig =
275 serde_json::from_value(config.clone()).unwrap_or_default();
276
277 query.limit = Some(resolve_candidate_load_limit(&config) as i64);
278 let keep_first_messages = resolve_keep_first_messages(&config);
286 if keep_first_messages > 0 {
287 query.keep_head = Some(keep_first_messages);
288 }
289 query.prepend_transform = Some(Arc::new(ExcludedNoticeTransform::infinity_context()));
290 }
291
292 fn post_load(&self, messages: &mut Vec<Message>, config: &Value) {
293 let config: InfinityContextConfig =
294 serde_json::from_value(config.clone()).unwrap_or_default();
295 let existing_notice_count = take_existing_excluded_notice(messages);
296
297 if config.compaction_active {
301 if existing_notice_count > 0 {
302 insert_excluded_notice(
303 messages,
304 resolve_keep_first_messages(&config),
305 existing_notice_count,
306 );
307 }
308 return;
309 }
310
311 let outcome = trim_messages_to_token_budget(messages, &config);
312 let mut total_excluded_count = existing_notice_count.saturating_add(outcome.hidden_count);
313 if total_excluded_count > 0 {
314 let head_ids: std::collections::HashSet<String> = messages
320 .iter()
321 .take(outcome.head_len)
322 .map(|message| message.id.to_string())
323 .collect();
324 let count_before_integrity = messages.len();
325 *messages =
326 crate::tool_call_integrity::retain_complete_message_tool_exchanges(messages, true);
327 total_excluded_count = total_excluded_count
328 .saturating_add(count_before_integrity.saturating_sub(messages.len()));
329 let retained_head_len = messages
330 .iter()
331 .take_while(|message| head_ids.contains(&message.id.to_string()))
332 .count();
333 insert_excluded_notice(messages, retained_head_len, total_excluded_count);
336 }
337 }
338
339 fn priority(&self) -> i32 {
340 100
341 }
342}
343
344fn resolve_keep_first_messages(config: &InfinityContextConfig) -> usize {
345 config.keep_first_messages.min(MAX_KEEP_FIRST_MESSAGES)
348}
349
350fn resolve_candidate_load_limit(config: &InfinityContextConfig) -> usize {
351 let budget_derived_limit = (config.context_budget_tokens / CANDIDATE_AVG_TOKENS_PER_MESSAGE)
355 .saturating_mul(CANDIDATE_OVERFETCH_FACTOR)
356 .max(config.min_recent_messages)
357 .clamp(1, CANDIDATE_MAX_MESSAGES);
358
359 if let Some(max_recent_messages) = config.max_recent_messages {
360 return budget_derived_limit.min(max_recent_messages.max(1));
361 }
362
363 budget_derived_limit
364}
365
366fn estimate_message_tokens(message: &Message) -> usize {
367 const TOKEN_CHARS: usize = 4;
368 let role_overhead = message.role.to_string().len() + 8;
369 let content_len: usize = message
370 .content
371 .iter()
372 .map(|part| match part {
373 ContentPart::Text(text) => text.text.len(),
374 ContentPart::Image(image) => {
375 image.url.as_ref().map_or(0, String::len)
376 + image.base64.as_ref().map_or(50, String::len)
377 + image.media_type.as_ref().map_or(0, String::len)
378 }
379 ContentPart::ImageFile(file) => {
380 file.image_id.to_string().len() + file.filename.as_ref().map_or(0, String::len)
381 }
382 ContentPart::ToolCall(call) => {
383 call.id.len() + call.name.len() + estimate_json_value_len(&call.arguments) + 20
384 }
385 ContentPart::ToolResult(result) => {
386 result.tool_call_id.len()
387 + result.result.as_ref().map_or(0, estimate_json_value_len)
388 + result.error.as_ref().map_or(0, String::len)
389 + 20
390 }
391 })
392 .sum();
393 (role_overhead + content_len) / TOKEN_CHARS
394}
395
396struct CountingWriter {
397 len: usize,
398}
399
400impl Write for CountingWriter {
401 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
402 self.len = self.len.saturating_add(buf.len());
403 Ok(buf.len())
404 }
405
406 fn flush(&mut self) -> io::Result<()> {
407 Ok(())
408 }
409}
410
411fn estimate_json_value_len(value: &Value) -> usize {
412 let mut writer = CountingWriter { len: 0 };
413 serde_json::to_writer(&mut writer, value)
414 .map(|_| writer.len)
415 .unwrap_or(0)
416}
417
418fn take_existing_excluded_notice(messages: &mut Vec<Message>) -> usize {
419 let Some(first) = messages.first() else {
420 return 0;
421 };
422 let Some(count) = parse_excluded_notice_count(first) else {
423 return 0;
424 };
425
426 messages.remove(0);
427 count
428}
429
430fn parse_excluded_notice_count(message: &Message) -> Option<usize> {
431 let text = message.text()?;
432 let rest = text.strip_prefix("[IMPORTANT: ")?;
433 let (count, rest) = rest.split_once(' ')?;
434 if !rest.starts_with("earlier messages are NOT visible in this context.") {
435 return None;
436 }
437 count.parse().ok()
438}
439
440#[derive(Default)]
442struct TrimOutcome {
443 hidden_count: usize,
445 head_len: usize,
447}
448
449fn insert_excluded_notice(messages: &mut Vec<Message>, position: usize, count: usize) {
451 let text = ExcludedNoticeTransform::infinity_context()
452 .format
453 .replace("{}", &count.to_string());
454 messages.insert(position.min(messages.len()), Message::system(text));
455}
456
457fn trim_messages_to_token_budget(
461 messages: &mut Vec<Message>,
462 config: &InfinityContextConfig,
463) -> TrimOutcome {
464 if messages.is_empty() {
465 return TrimOutcome::default();
466 }
467
468 let costs: Vec<usize> = messages.iter().map(estimate_message_tokens).collect();
469 let window = anchored_window(
470 &costs,
471 resolve_keep_first_messages(config),
472 config.min_recent_messages,
473 config.max_recent_messages,
474 config.context_budget_tokens,
475 );
476
477 let hidden_count = window.hidden();
478 if hidden_count > 0 {
479 let tail = messages.split_off(window.recent_start);
481 messages.truncate(window.head_len);
482 messages.extend(tail);
483 }
484
485 TrimOutcome {
486 hidden_count,
487 head_len: window.head_len,
488 }
489}
490
491pub struct QueryHistoryTool;
493
494#[derive(Debug, Deserialize)]
495struct QueryHistoryParams {
496 #[serde(default)]
497 query: Option<String>,
498 #[serde(default)]
499 message_range: Option<MessageRange>,
500 #[serde(default = "default_query_limit")]
501 limit: usize,
502}
503
504#[derive(Debug, Deserialize)]
505struct MessageRange {
506 from: usize,
507 to: usize,
508}
509
510fn default_query_limit() -> usize {
511 20
512}
513
514#[async_trait]
515impl Tool for QueryHistoryTool {
516 fn narrate(
517 &self,
518 tool_call: &crate::tool_types::ToolCall,
519 phase: crate::tool_narration::ToolNarrationPhase,
520 locale: Option<&str>,
521 _ctx: crate::tool_narration::ToolNarrationContext<'_>,
522 ) -> Option<String> {
523 Some(crate::tool_narration::narrate_query_history(
524 &tool_call.arguments,
525 phase,
526 locale,
527 ))
528 }
529
530 fn name(&self) -> &str {
531 "query_history"
532 }
533
534 fn display_name(&self) -> Option<&str> {
535 Some("Query History")
536 }
537
538 fn description(&self) -> &str {
539 "Search or retrieve earlier messages from this conversation that may not be visible in the current prompt."
540 }
541
542 fn parameters_schema(&self) -> Value {
543 json!({
544 "type": "object",
545 "properties": {
546 "query": {
547 "type": "string",
548 "description": "Keyword search over earlier messages"
549 },
550 "message_range": {
551 "type": "object",
552 "properties": {
553 "from": { "type": "integer", "minimum": 0, "description": "Start index (0-based, inclusive)" },
554 "to": { "type": "integer", "minimum": 0, "description": "End index (0-based, exclusive)" }
555 },
556 "required": ["from", "to"],
557 "additionalProperties": false,
558 "description": "Retrieve messages by absolute position in the conversation"
559 },
560 "limit": {
561 "type": "integer",
562 "minimum": 1,
563 "default": 20,
564 "description": "Maximum number of messages to return"
565 }
566 },
567 "additionalProperties": false
568 })
569 }
570
571 fn hints(&self) -> ToolHints {
572 ToolHints::default()
573 .with_readonly(true)
574 .with_idempotent(true)
575 }
576
577 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
578 ToolExecutionResult::tool_error(
579 "query_history requires session context. Execute it with ToolContext.",
580 )
581 }
582
583 fn requires_context(&self) -> bool {
584 true
585 }
586
587 fn required_context_services(&self) -> &'static [ToolContextService] {
588 &[ToolContextService::MessageRetriever]
589 }
590
591 async fn execute_with_context(
592 &self,
593 arguments: Value,
594 context: &ToolContext,
595 ) -> ToolExecutionResult {
596 let params: QueryHistoryParams = match serde_json::from_value(arguments) {
597 Ok(params) => params,
598 Err(error) => {
599 return ToolExecutionResult::tool_error(format!("Invalid parameters: {error}"));
600 }
601 };
602
603 let Some(retriever) = &context.message_retriever else {
604 return ToolExecutionResult::tool_error("No message retriever available");
605 };
606
607 let messages = match retriever.load(context.session_id).await {
608 Ok(messages) => messages,
609 Err(error) => {
610 return ToolExecutionResult::internal_error(error);
611 }
612 };
613
614 if messages.is_empty() {
615 return ToolExecutionResult::success(json!({
616 "count": 0,
617 "message": "No history available."
618 }));
619 }
620
621 let limit = params.limit.min(50);
622 let total = messages.len();
623
624 if let Some(range) = params.message_range {
625 let from = range.from.min(total);
626 let to = range.to.min(total).max(from);
627 let range_messages: Vec<_> = messages[from..to].iter().take(limit).collect();
628 return format_range_result(&range_messages, from, total);
629 }
630
631 if let Some(query) = params.query.as_deref() {
632 let results = search_messages(&messages, query, limit);
633 return format_search_result(&results, total);
634 }
635
636 let recent: Vec<_> = messages.iter().rev().take(limit).collect();
637 format_recent_result(&recent, total)
638 }
639}
640
641struct SearchResult<'a> {
642 index: usize,
643 message: &'a Message,
644 score: f64,
645}
646
647fn search_messages<'a>(
648 messages: &'a [Message],
649 query: &str,
650 limit: usize,
651) -> Vec<SearchResult<'a>> {
652 let query_lower = query.to_lowercase();
653 let mut results = Vec::new();
654
655 for (index, message) in messages.iter().enumerate() {
656 let content = extract_text_content(message).to_lowercase();
657 if !content.contains(&query_lower) {
658 continue;
659 }
660
661 let mut score = 1.0;
662
663 if content.split_whitespace().any(|word| word == query_lower) {
664 score += 0.5;
665 }
666
667 if !messages.is_empty() {
668 score += (index as f64 / messages.len() as f64) * 0.3;
669 }
670
671 match message.role {
672 MessageRole::User | MessageRole::Agent => score += 0.2,
673 MessageRole::System => score += 0.1,
674 MessageRole::ToolResult => {}
675 }
676
677 results.push(SearchResult {
678 index,
679 message,
680 score,
681 });
682 }
683
684 results.sort_by(|left, right| {
685 right
686 .score
687 .partial_cmp(&left.score)
688 .unwrap_or(Ordering::Equal)
689 });
690 results.truncate(limit);
691 results
692}
693
694fn extract_text_content(message: &Message) -> String {
695 message
696 .content
697 .iter()
698 .filter_map(|part| match part {
699 ContentPart::Text(text) => Some(text.text.clone()),
700 ContentPart::ToolResult(result) => result.result.as_ref().map(ToString::to_string),
701 _ => None,
702 })
703 .collect::<Vec<_>>()
704 .join(" ")
705}
706
707fn truncate_content(content: &str, max_len: usize) -> String {
708 let char_count = content.chars().count();
709 if char_count <= max_len {
710 return content.to_string();
711 }
712
713 format!("{}...", content.chars().take(max_len).collect::<String>())
714}
715
716fn format_message(message: &Message, index: usize, total: usize) -> Value {
717 json!({
718 "index": index,
719 "position": format!("{}/{}", index + 1, total),
720 "role": message.role.to_string(),
721 "created_at": message.created_at.to_rfc3339(),
722 "content": truncate_content(&extract_text_content(message), 500)
723 })
724}
725
726fn format_range_result(
727 messages: &[&Message],
728 start_index: usize,
729 total: usize,
730) -> ToolExecutionResult {
731 if messages.is_empty() {
732 return ToolExecutionResult::success(json!({
733 "count": 0,
734 "message": "No messages in the requested range."
735 }));
736 }
737
738 let formatted: Vec<Value> = messages
739 .iter()
740 .enumerate()
741 .map(|(offset, message)| format_message(message, start_index + offset, total))
742 .collect();
743
744 ToolExecutionResult::success(json!({
745 "messages": formatted,
746 "count": messages.len(),
747 "total_in_history": total,
748 "range": format!("{}-{}", start_index + 1, start_index + messages.len())
749 }))
750}
751
752fn format_search_result(results: &[SearchResult<'_>], total: usize) -> ToolExecutionResult {
753 if results.is_empty() {
754 return ToolExecutionResult::success(json!({
755 "count": 0,
756 "message": "No matching messages found."
757 }));
758 }
759
760 let formatted: Vec<Value> = results
761 .iter()
762 .map(|result| {
763 let mut message = format_message(result.message, result.index, total);
764 message["relevance_score"] = json!(format!("{:.2}", result.score));
765 message
766 })
767 .collect();
768
769 ToolExecutionResult::success(json!({
770 "messages": formatted,
771 "count": results.len(),
772 "total_in_history": total
773 }))
774}
775
776fn format_recent_result(messages: &[&Message], total: usize) -> ToolExecutionResult {
777 let formatted: Vec<Value> = messages
778 .iter()
779 .enumerate()
780 .map(|(offset, message)| format_message(message, total - messages.len() + offset, total))
781 .collect();
782
783 ToolExecutionResult::success(json!({
784 "messages": formatted,
785 "count": messages.len(),
786 "total_in_history": total,
787 "note": "Showing most recent history. Use `query` to search or `message_range` to fetch older messages."
788 }))
789}
790
791#[cfg(test)]
792mod tests {
793 use super::*;
794 use crate::in_memory::InMemoryMessageRetriever;
795 use crate::typed_id::SessionId;
796
797 #[test]
800 fn test_provides_message_filter() {
801 let capability = InfinityContextCapability;
802 assert!(capability.message_filter_provider().is_some());
803 }
804
805 #[test]
806 fn filter_only_view_preserves_filter_without_model_contributions() {
807 let capability = InfinityContextFilterOnlyCapability;
808
809 assert!(capability.message_filter_provider().is_some());
810 assert!(capability.tools().is_empty());
811 assert!(capability.system_prompt_addition().is_none());
812 }
813
814 #[test]
815 fn test_config_schema_and_validate_config() {
816 let capability = InfinityContextCapability;
817
818 let schema = capability.config_schema().expect("config schema");
819 assert_eq!(schema["type"], "object");
820 assert!(schema["properties"]["context_budget_tokens"].is_object());
821 assert!(schema["properties"]["min_recent_messages"].is_object());
822 assert!(schema["properties"]["max_recent_messages"].is_object());
823 assert_eq!(
824 schema["properties"]["keep_first_messages"]["maximum"],
825 MAX_KEEP_FIRST_MESSAGES
826 );
827
828 assert!(capability.validate_config(&Value::Null).is_ok());
830 assert!(capability.validate_config(&json!({})).is_ok());
831 assert!(
832 capability
833 .validate_config(&json!({
834 "context_budget_tokens": 50_000,
835 "min_recent_messages": 5,
836 "max_recent_messages": 100
837 }))
838 .is_ok()
839 );
840
841 assert!(
843 capability
844 .validate_config(&json!({"context_budget_tokens": "lots"}))
845 .is_err()
846 );
847 assert!(
848 capability
849 .validate_config(&json!({"context_budget_tokens": 0}))
850 .is_err()
851 );
852 assert!(
853 capability
854 .validate_config(&json!({"max_recent_messages": 0}))
855 .is_err()
856 );
857 assert!(
858 capability
859 .validate_config(&json!({
860 "keep_first_messages": MAX_KEEP_FIRST_MESSAGES + 1
861 }))
862 .is_err()
863 );
864 }
865
866 #[test]
867 fn test_localizations_resolve_uk() {
868 let capability = InfinityContextCapability;
869 assert_eq!(
870 capability.localized_name(Some("uk-UA")),
871 "Нескінченний контекст"
872 );
873 assert!(capability.describe_schema(None).is_some());
874 }
875
876 #[test]
877 fn test_filter_provider_sets_bounded_candidate_load_limit_without_hard_cap() {
878 let mut query = MessageQuery::new(SessionId::new());
879 let provider = InfinityContextFilterProvider;
880 provider.apply_filters(
881 &mut query,
882 &json!({"context_budget_tokens": 1_000, "min_recent_messages": 3}),
883 );
884
885 assert_eq!(query.limit, Some(16));
886 assert!(query.prepend_transform.is_some());
887 assert_eq!(query.keep_head, None);
890 }
891
892 #[test]
893 fn test_filter_provider_sets_keep_head_from_keep_first_messages() {
894 let mut query = MessageQuery::new(SessionId::new());
895 let provider = InfinityContextFilterProvider;
896 provider.apply_filters(
897 &mut query,
898 &json!({"context_budget_tokens": 1_000, "keep_first_messages": 3}),
899 );
900 assert_eq!(query.keep_head, Some(3));
901 }
902
903 #[test]
904 fn test_filter_provider_omits_keep_head_when_zero() {
905 let mut query = MessageQuery::new(SessionId::new());
906 let provider = InfinityContextFilterProvider;
907 provider.apply_filters(
908 &mut query,
909 &json!({"context_budget_tokens": 1_000, "keep_first_messages": 0}),
910 );
911 assert_eq!(query.keep_head, None);
912 }
913
914 #[test]
915 fn test_filter_provider_caps_keep_head_for_unvalidated_config() {
916 let mut query = MessageQuery::new(SessionId::new());
917 let provider = InfinityContextFilterProvider;
918 provider.apply_filters(
919 &mut query,
920 &json!({
921 "context_budget_tokens": 1_000,
922 "keep_first_messages": usize::MAX
923 }),
924 );
925
926 assert_eq!(query.keep_head, Some(MAX_KEEP_FIRST_MESSAGES));
927 }
928
929 #[test]
930 fn test_filter_provider_caps_explicit_max_to_bounded_candidate_window() {
931 let mut query = MessageQuery::new(SessionId::new());
932 let provider = InfinityContextFilterProvider;
933 provider.apply_filters(
934 &mut query,
935 &json!({
936 "context_budget_tokens": 500_000,
937 "min_recent_messages": 10,
938 "max_recent_messages": 1_000_000
939 }),
940 );
941
942 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
943 assert!(query.prepend_transform.is_some());
944 }
945
946 #[test]
947 fn test_filter_provider_caps_large_min_recent_messages() {
948 let mut query = MessageQuery::new(SessionId::new());
949 let provider = InfinityContextFilterProvider;
950 provider.apply_filters(
951 &mut query,
952 &json!({
953 "context_budget_tokens": 1_000,
954 "min_recent_messages": 1_000_000,
955 }),
956 );
957
958 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
959 assert!(query.prepend_transform.is_some());
960 }
961
962 #[test]
963 fn test_filter_provider_allows_small_public_chat_window() {
964 let mut query = MessageQuery::new(SessionId::new());
965 let provider = InfinityContextFilterProvider;
966 provider.apply_filters(
967 &mut query,
968 &json!({
969 "context_budget_tokens": 10_000,
970 "min_recent_messages": 10,
971 "max_recent_messages": 30
972 }),
973 );
974
975 assert_eq!(query.limit, Some(30));
976 assert!(query.prepend_transform.is_some());
977 }
978
979 #[test]
980 fn test_filter_provider_falls_back_to_defaults_for_invalid_config() {
981 let mut query = MessageQuery::new(SessionId::new());
982 let provider = InfinityContextFilterProvider;
983 provider.apply_filters(
984 &mut query,
985 &json!({"context_budget_tokens": "not-a-number"}),
986 );
987
988 assert_eq!(query.limit, Some(1_600));
989 assert!(query.prepend_transform.is_some());
990 }
991
992 #[test]
993 fn test_filter_provider_trims_loaded_messages_by_token_budget() {
994 let provider = InfinityContextFilterProvider;
995 let mut messages = vec![
996 Message::user("the original task"),
997 Message::assistant("old ".repeat(400)),
998 Message::user("recent one"),
999 Message::assistant("recent two"),
1000 ];
1001
1002 provider.post_load(
1003 &mut messages,
1004 &json!({"context_budget_tokens": 1, "min_recent_messages": 2}),
1005 );
1006
1007 assert_eq!(messages.len(), 3);
1010 assert!(
1011 extract_text_content(&messages[0])
1012 .contains("2 earlier messages are NOT visible in this context")
1013 );
1014 assert_eq!(extract_text_content(&messages[1]), "recent one");
1015 assert_eq!(extract_text_content(&messages[2]), "recent two");
1016 assert!(
1017 !messages
1018 .iter()
1019 .any(|m| extract_text_content(m) == "the original task")
1020 );
1021 }
1022
1023 #[test]
1024 fn test_filter_provider_applies_hard_cap_after_loading() {
1025 let provider = InfinityContextFilterProvider;
1026 let mut messages = vec![
1027 Message::user("one"),
1028 Message::assistant("two"),
1029 Message::user("three"),
1030 Message::assistant("four"),
1031 Message::user("five"),
1032 ];
1033
1034 provider.post_load(
1035 &mut messages,
1036 &json!({
1037 "context_budget_tokens": 10_000,
1038 "min_recent_messages": 10,
1039 "max_recent_messages": 2
1040 }),
1041 );
1042
1043 assert_eq!(messages.len(), 3);
1045 assert!(
1046 extract_text_content(&messages[0])
1047 .contains("3 earlier messages are NOT visible in this context")
1048 );
1049 assert_eq!(extract_text_content(&messages[1]), "four");
1050 assert_eq!(extract_text_content(&messages[2]), "five");
1051 }
1052
1053 #[test]
1054 fn test_filter_provider_anchors_task_through_full_flow() {
1055 let provider = InfinityContextFilterProvider;
1056 let config = json!({
1059 "context_budget_tokens": 600,
1060 "min_recent_messages": 2,
1061 "keep_first_messages": 1
1062 });
1063 let mut query = MessageQuery::new(SessionId::new());
1064 provider.apply_filters(&mut query, &config);
1065 let mut messages = vec![
1066 Message::user("TASK: build the widget"),
1067 Message::assistant("X".repeat(2000)),
1068 Message::assistant("Y".repeat(2000)),
1069 Message::user("recent a"),
1070 Message::assistant("recent b"),
1071 ];
1072
1073 query.apply_windowing(&mut messages);
1074 provider.post_load(&mut messages, &config);
1075
1076 assert_eq!(extract_text_content(&messages[0]), "TASK: build the widget");
1079 assert!(
1080 extract_text_content(&messages[1])
1081 .contains("earlier messages are NOT visible in this context")
1082 );
1083 assert_eq!(extract_text_content(messages.last().unwrap()), "recent b");
1084 assert!(
1086 !messages
1087 .iter()
1088 .any(|m| extract_text_content(m).starts_with("XXX"))
1089 );
1090 }
1091
1092 #[test]
1093 fn test_filter_provider_defers_eviction_to_compaction() {
1094 let provider = InfinityContextFilterProvider;
1095 let mut messages = vec![
1096 Message::user("task"),
1097 Message::assistant("old ".repeat(400)),
1098 Message::user("recent one"),
1099 Message::assistant("recent two"),
1100 ];
1101
1102 provider.post_load(
1103 &mut messages,
1104 &json!({
1105 "context_budget_tokens": 1,
1106 "min_recent_messages": 2,
1107 "compaction_active": true
1108 }),
1109 );
1110
1111 assert_eq!(messages.len(), 4);
1114 assert!(
1115 messages
1116 .iter()
1117 .all(|m| !extract_text_content(m).contains("NOT visible"))
1118 );
1119 }
1120
1121 #[test]
1122 fn test_filter_provider_caps_keep_first_messages_during_post_load() {
1123 let provider = InfinityContextFilterProvider;
1124 let mut messages: Vec<Message> = (0..20)
1125 .map(|idx| Message::user(format!("message {idx}")))
1126 .collect();
1127
1128 provider.post_load(
1129 &mut messages,
1130 &json!({
1131 "context_budget_tokens": 1,
1132 "min_recent_messages": 1,
1133 "keep_first_messages": usize::MAX
1134 }),
1135 );
1136
1137 assert_eq!(extract_text_content(&messages[0]), "message 0");
1138 assert_eq!(
1139 extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES - 1]),
1140 format!("message {}", MAX_KEEP_FIRST_MESSAGES - 1)
1141 );
1142 assert!(extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES]).contains("NOT visible"));
1143 assert_eq!(extract_text_content(messages.last().unwrap()), "message 19");
1144 }
1145
1146 #[test]
1147 fn test_filter_provider_default_drops_oversized_first_message() {
1148 let provider = InfinityContextFilterProvider;
1149 let mut messages = vec![
1150 Message::user("attacker ".repeat(20_000)),
1151 Message::assistant("middle"),
1152 Message::user("recent one"),
1153 Message::assistant("recent two"),
1154 ];
1155
1156 provider.post_load(
1157 &mut messages,
1158 &json!({
1159 "context_budget_tokens": 10,
1160 "min_recent_messages": 2,
1161 "max_recent_messages": 2
1162 }),
1163 );
1164
1165 assert_eq!(messages.len(), 3);
1166 assert!(
1167 extract_text_content(&messages[0])
1168 .contains("2 earlier messages are NOT visible in this context")
1169 );
1170 assert_eq!(extract_text_content(&messages[1]), "recent one");
1171 assert_eq!(extract_text_content(&messages[2]), "recent two");
1172 assert!(
1173 !messages
1174 .iter()
1175 .any(|m| extract_text_content(m).starts_with("attacker"))
1176 );
1177 }
1178
1179 #[test]
1180 fn test_filter_provider_keep_first_messages_anchors_multiple() {
1181 let provider = InfinityContextFilterProvider;
1182 let mut messages = vec![
1183 Message::user("anchor one"),
1184 Message::user("anchor two"),
1185 Message::assistant("mid ".repeat(400)),
1186 Message::user("recent"),
1187 ];
1188
1189 provider.post_load(
1190 &mut messages,
1191 &json!({
1192 "context_budget_tokens": 1,
1193 "min_recent_messages": 1,
1194 "keep_first_messages": 2
1195 }),
1196 );
1197
1198 assert_eq!(extract_text_content(&messages[0]), "anchor one");
1199 assert_eq!(extract_text_content(&messages[1]), "anchor two");
1200 assert!(extract_text_content(&messages[2]).contains("NOT visible"));
1201 assert_eq!(extract_text_content(messages.last().unwrap()), "recent");
1202 }
1203
1204 #[test]
1205 fn test_estimate_json_value_len_matches_serialized_length() {
1206 let value = json!({
1207 "stdout": ["alpha", "beta"],
1208 "ok": true,
1209 "count": 2
1210 });
1211
1212 assert_eq!(
1213 estimate_json_value_len(&value),
1214 serde_json::to_string(&value).unwrap().len()
1215 );
1216 }
1217
1218 #[test]
1219 fn test_query_history_requires_context() {
1220 let tool = QueryHistoryTool;
1221 assert!(tool.requires_context());
1222 }
1223
1224 #[tokio::test]
1225 async fn test_query_history_tool_errors_without_retriever() {
1226 let tool = QueryHistoryTool;
1227 let result = tool
1228 .execute_with_context(json!({"query": "api"}), &ToolContext::new(SessionId::new()))
1229 .await;
1230
1231 match result {
1232 ToolExecutionResult::ToolError(message) => {
1233 assert!(message.contains("No message retriever available"));
1234 }
1235 other => panic!("expected tool error, got {other:?}"),
1236 }
1237 }
1238
1239 #[tokio::test]
1240 async fn test_query_history_tool_rejects_invalid_params() {
1241 let result = QueryHistoryTool.execute(json!({"limit": "oops"})).await;
1242
1243 match result {
1244 ToolExecutionResult::ToolError(message) => {
1245 assert!(message.contains("requires session context"));
1246 }
1247 other => panic!("expected tool error, got {other:?}"),
1248 }
1249
1250 let session_id = SessionId::new();
1251 let retriever = InMemoryMessageRetriever::new();
1252 let result = QueryHistoryTool
1253 .execute_with_context(
1254 json!({"message_range": {"from": "bad", "to": 1}}),
1255 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1256 )
1257 .await;
1258
1259 match result {
1260 ToolExecutionResult::ToolError(message) => {
1261 assert!(message.contains("Invalid parameters"));
1262 }
1263 other => panic!("expected tool error, got {other:?}"),
1264 }
1265 }
1266
1267 #[tokio::test]
1268 async fn test_query_history_tool_empty_history() {
1269 let session_id = SessionId::new();
1270 let retriever = InMemoryMessageRetriever::new();
1271
1272 let result = QueryHistoryTool
1273 .execute_with_context(
1274 json!({}),
1275 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1276 )
1277 .await;
1278
1279 match result {
1280 ToolExecutionResult::Success(value) => {
1281 assert_eq!(value["count"], 0);
1282 assert_eq!(value["message"], "No history available.");
1283 }
1284 other => panic!("expected success, got {other:?}"),
1285 }
1286 }
1287
1288 #[tokio::test]
1289 async fn test_query_history_tool_searches_history() {
1290 let session_id = SessionId::new();
1291 let retriever = InMemoryMessageRetriever::new();
1292 retriever
1293 .seed(
1294 session_id,
1295 vec![
1296 Message::user("First topic"),
1297 Message::assistant("The API key is abc123"),
1298 Message::user("We should keep discussing logging"),
1299 ],
1300 )
1301 .await;
1302
1303 let result = QueryHistoryTool
1304 .execute_with_context(
1305 json!({"query": "api key"}),
1306 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1307 )
1308 .await;
1309
1310 match result {
1311 ToolExecutionResult::Success(value) => {
1312 assert_eq!(value["count"], 1);
1313 assert_eq!(value["messages"][0]["content"], "The API key is abc123");
1314 }
1315 other => panic!("expected success, got {other:?}"),
1316 }
1317 }
1318
1319 #[tokio::test]
1320 async fn test_query_history_tool_search_no_match() {
1321 let session_id = SessionId::new();
1322 let retriever = InMemoryMessageRetriever::new();
1323 retriever
1324 .seed(
1325 session_id,
1326 vec![Message::user("one"), Message::assistant("two")],
1327 )
1328 .await;
1329
1330 let result = QueryHistoryTool
1331 .execute_with_context(
1332 json!({"query": "missing"}),
1333 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1334 )
1335 .await;
1336
1337 match result {
1338 ToolExecutionResult::Success(value) => {
1339 assert_eq!(value["count"], 0);
1340 assert_eq!(value["message"], "No matching messages found.");
1341 }
1342 other => panic!("expected success, got {other:?}"),
1343 }
1344 }
1345
1346 #[tokio::test]
1347 async fn test_query_history_tool_reads_range() {
1348 let session_id = SessionId::new();
1349 let retriever = InMemoryMessageRetriever::new();
1350 retriever
1351 .seed(
1352 session_id,
1353 vec![
1354 Message::user("one"),
1355 Message::assistant("two"),
1356 Message::user("three"),
1357 ],
1358 )
1359 .await;
1360
1361 let result = QueryHistoryTool
1362 .execute_with_context(
1363 json!({"message_range": {"from": 1, "to": 3}, "limit": 10}),
1364 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1365 )
1366 .await;
1367
1368 match result {
1369 ToolExecutionResult::Success(value) => {
1370 assert_eq!(value["count"], 2);
1371 assert_eq!(value["messages"][0]["content"], "two");
1372 assert_eq!(value["messages"][1]["content"], "three");
1373 }
1374 other => panic!("expected success, got {other:?}"),
1375 }
1376 }
1377
1378 #[tokio::test]
1379 async fn test_query_history_tool_clamps_out_of_bounds_range() {
1380 let session_id = SessionId::new();
1381 let retriever = InMemoryMessageRetriever::new();
1382 retriever
1383 .seed(
1384 session_id,
1385 vec![
1386 Message::user("one"),
1387 Message::assistant("two"),
1388 Message::user("three"),
1389 ],
1390 )
1391 .await;
1392
1393 let result = QueryHistoryTool
1394 .execute_with_context(
1395 json!({"message_range": {"from": 99, "to": 100}}),
1396 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1397 )
1398 .await;
1399
1400 match result {
1401 ToolExecutionResult::Success(value) => {
1402 assert_eq!(value["count"], 0);
1403 assert_eq!(value["message"], "No messages in the requested range.");
1404 }
1405 other => panic!("expected success, got {other:?}"),
1406 }
1407 }
1408
1409 #[test]
1410 fn test_truncate_content_is_utf8_safe() {
1411 let truncated = truncate_content("hello🙂world", 6);
1412 assert_eq!(truncated, "hello🙂...");
1413 }
1414
1415 #[test]
1416 fn trim_preserves_locally_unmatched_tool_result_for_stateful_responses() {
1417 use crate::tool_types::ToolCall;
1418
1419 let provider = InfinityContextFilterProvider;
1420 let mut messages = vec![
1426 Message::user("old question"),
1427 Message::assistant_with_tools(
1428 "calling tool",
1429 vec![ToolCall {
1430 id: "call_old".to_string(),
1431 name: "edit_file".to_string(),
1432 arguments: serde_json::json!({}),
1433 }],
1434 ),
1435 Message::tool_result("call_old", Some(serde_json::json!("done")), None),
1437 Message::user("new question"),
1438 Message::assistant("answer"),
1439 ];
1440
1441 provider.post_load(
1442 &mut messages,
1443 &serde_json::json!({"context_budget_tokens": 1, "min_recent_messages": 3}),
1444 );
1445
1446 assert!(
1447 messages.iter().any(|m| m.role == MessageRole::ToolResult),
1448 "locally unmatched tool result must be preserved until provider serialization"
1449 );
1450
1451 let llm_messages = messages
1452 .iter()
1453 .map(crate::llm_conversions::llm_message_from_message)
1454 .collect();
1455 let stateless_view =
1456 crate::tool_call_integrity::retain_complete_llm_tool_exchanges_for_request(
1457 llm_messages,
1458 false,
1459 );
1460 assert!(
1461 stateless_view
1462 .iter()
1463 .all(|message| message.tool_call_id.is_none()),
1464 "a stateless runtime view must drop a result whose call was trimmed"
1465 );
1466 }
1467
1468 #[test]
1469 fn trim_removes_a_visible_call_when_its_result_is_evicted() {
1470 use crate::tool_types::ToolCall;
1471
1472 let provider = InfinityContextFilterProvider;
1473 let mut messages = vec![
1474 Message::user("original task"),
1475 Message::assistant_with_tools(
1476 "calling tool",
1477 vec![ToolCall {
1478 id: "call_old".to_string(),
1479 name: "bash".to_string(),
1480 arguments: serde_json::json!({}),
1481 }],
1482 ),
1483 Message::tool_result(
1484 "call_old",
1485 Some(serde_json::json!("large result ".repeat(500))),
1486 None,
1487 ),
1488 Message::user("recent question"),
1489 Message::assistant("recent answer"),
1490 ];
1491
1492 provider.post_load(
1493 &mut messages,
1494 &serde_json::json!({
1495 "context_budget_tokens": 1,
1496 "min_recent_messages": 2,
1497 "keep_first_messages": 2
1498 }),
1499 );
1500
1501 assert!(
1502 messages
1503 .iter()
1504 .flat_map(Message::tool_calls)
1505 .next()
1506 .is_none(),
1507 "the anchored assistant message must not retain a call after its result is hidden"
1508 );
1509 }
1510
1511 #[test]
1512 fn trim_keeps_tool_result_when_tool_call_is_visible() {
1513 use crate::tool_types::ToolCall;
1514
1515 let provider = InfinityContextFilterProvider;
1516 let mut messages = vec![
1518 Message::assistant_with_tools(
1519 "calling tool",
1520 vec![ToolCall {
1521 id: "call_1".to_string(),
1522 name: "read_file".to_string(),
1523 arguments: serde_json::json!({}),
1524 }],
1525 ),
1526 Message::tool_result("call_1", Some(serde_json::json!("content")), None),
1527 Message::user("thanks"),
1528 ];
1529
1530 provider.post_load(
1531 &mut messages,
1532 &serde_json::json!({"context_budget_tokens": 100_000, "min_recent_messages": 10}),
1533 );
1534
1535 assert!(
1536 messages.iter().any(|m| m.role == MessageRole::ToolResult),
1537 "tool result must be kept when its tool call is visible"
1538 );
1539 }
1540}