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