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 total_excluded_count = existing_notice_count.saturating_add(outcome.hidden_count);
285 if total_excluded_count > 0 {
286 insert_excluded_notice(messages, outcome.head_len, total_excluded_count);
289 }
290 }
291
292 fn priority(&self) -> i32 {
293 100
294 }
295}
296
297fn resolve_keep_first_messages(config: &InfinityContextConfig) -> usize {
298 config.keep_first_messages.min(MAX_KEEP_FIRST_MESSAGES)
301}
302
303fn resolve_candidate_load_limit(config: &InfinityContextConfig) -> usize {
304 let budget_derived_limit = (config.context_budget_tokens / CANDIDATE_AVG_TOKENS_PER_MESSAGE)
308 .saturating_mul(CANDIDATE_OVERFETCH_FACTOR)
309 .max(config.min_recent_messages)
310 .clamp(1, CANDIDATE_MAX_MESSAGES);
311
312 if let Some(max_recent_messages) = config.max_recent_messages {
313 return budget_derived_limit.min(max_recent_messages.max(1));
314 }
315
316 budget_derived_limit
317}
318
319fn estimate_message_tokens(message: &Message) -> usize {
320 const TOKEN_CHARS: usize = 4;
321 let role_overhead = message.role.to_string().len() + 8;
322 let content_len: usize = message
323 .content
324 .iter()
325 .map(|part| match part {
326 ContentPart::Text(text) => text.text.len(),
327 ContentPart::Image(image) => {
328 image.url.as_ref().map_or(0, String::len)
329 + image.base64.as_ref().map_or(50, String::len)
330 + image.media_type.as_ref().map_or(0, String::len)
331 }
332 ContentPart::ImageFile(file) => {
333 file.image_id.to_string().len() + file.filename.as_ref().map_or(0, String::len)
334 }
335 ContentPart::ToolCall(call) => {
336 call.id.len() + call.name.len() + estimate_json_value_len(&call.arguments) + 20
337 }
338 ContentPart::ToolResult(result) => {
339 result.tool_call_id.len()
340 + result.result.as_ref().map_or(0, estimate_json_value_len)
341 + result.error.as_ref().map_or(0, String::len)
342 + 20
343 }
344 })
345 .sum();
346 (role_overhead + content_len) / TOKEN_CHARS
347}
348
349struct CountingWriter {
350 len: usize,
351}
352
353impl Write for CountingWriter {
354 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
355 self.len = self.len.saturating_add(buf.len());
356 Ok(buf.len())
357 }
358
359 fn flush(&mut self) -> io::Result<()> {
360 Ok(())
361 }
362}
363
364fn estimate_json_value_len(value: &Value) -> usize {
365 let mut writer = CountingWriter { len: 0 };
366 serde_json::to_writer(&mut writer, value)
367 .map(|_| writer.len)
368 .unwrap_or(0)
369}
370
371fn take_existing_excluded_notice(messages: &mut Vec<Message>) -> usize {
372 let Some(first) = messages.first() else {
373 return 0;
374 };
375 let Some(count) = parse_excluded_notice_count(first) else {
376 return 0;
377 };
378
379 messages.remove(0);
380 count
381}
382
383fn parse_excluded_notice_count(message: &Message) -> Option<usize> {
384 let text = message.text()?;
385 let rest = text.strip_prefix("[IMPORTANT: ")?;
386 let (count, rest) = rest.split_once(' ')?;
387 if !rest.starts_with("earlier messages are NOT visible in this context.") {
388 return None;
389 }
390 count.parse().ok()
391}
392
393#[derive(Default)]
395struct TrimOutcome {
396 hidden_count: usize,
398 head_len: usize,
400}
401
402fn insert_excluded_notice(messages: &mut Vec<Message>, position: usize, count: usize) {
404 let text = ExcludedNoticeTransform::infinity_context()
405 .format
406 .replace("{}", &count.to_string());
407 messages.insert(position.min(messages.len()), Message::system(text));
408}
409
410fn trim_messages_to_token_budget(
414 messages: &mut Vec<Message>,
415 config: &InfinityContextConfig,
416) -> TrimOutcome {
417 if messages.is_empty() {
418 return TrimOutcome::default();
419 }
420
421 let costs: Vec<usize> = messages.iter().map(estimate_message_tokens).collect();
422 let window = anchored_window(
423 &costs,
424 resolve_keep_first_messages(config),
425 config.min_recent_messages,
426 config.max_recent_messages,
427 config.context_budget_tokens,
428 );
429
430 let hidden_count = window.hidden();
431 if hidden_count > 0 {
432 let tail = messages.split_off(window.recent_start);
434 messages.truncate(window.head_len);
435 messages.extend(tail);
436 }
437
438 TrimOutcome {
439 hidden_count,
440 head_len: window.head_len,
441 }
442}
443
444pub struct QueryHistoryTool;
446
447#[derive(Debug, Deserialize)]
448struct QueryHistoryParams {
449 #[serde(default)]
450 query: Option<String>,
451 #[serde(default)]
452 message_range: Option<MessageRange>,
453 #[serde(default = "default_query_limit")]
454 limit: usize,
455}
456
457#[derive(Debug, Deserialize)]
458struct MessageRange {
459 from: usize,
460 to: usize,
461}
462
463fn default_query_limit() -> usize {
464 20
465}
466
467#[async_trait]
468impl Tool for QueryHistoryTool {
469 fn name(&self) -> &str {
470 "query_history"
471 }
472
473 fn display_name(&self) -> Option<&str> {
474 Some("Query History")
475 }
476
477 fn description(&self) -> &str {
478 "Search or retrieve earlier messages from this conversation that may not be visible in the current prompt."
479 }
480
481 fn parameters_schema(&self) -> Value {
482 json!({
483 "type": "object",
484 "properties": {
485 "query": {
486 "type": "string",
487 "description": "Keyword search over earlier messages"
488 },
489 "message_range": {
490 "type": "object",
491 "properties": {
492 "from": { "type": "integer", "minimum": 0, "description": "Start index (0-based, inclusive)" },
493 "to": { "type": "integer", "minimum": 0, "description": "End index (0-based, exclusive)" }
494 },
495 "required": ["from", "to"],
496 "additionalProperties": false,
497 "description": "Retrieve messages by absolute position in the conversation"
498 },
499 "limit": {
500 "type": "integer",
501 "minimum": 1,
502 "default": 20,
503 "description": "Maximum number of messages to return"
504 }
505 },
506 "additionalProperties": false
507 })
508 }
509
510 fn hints(&self) -> ToolHints {
511 ToolHints::default()
512 .with_readonly(true)
513 .with_idempotent(true)
514 }
515
516 async fn execute(&self, _arguments: Value) -> ToolExecutionResult {
517 ToolExecutionResult::tool_error(
518 "query_history requires session context. Execute it with ToolContext.",
519 )
520 }
521
522 fn requires_context(&self) -> bool {
523 true
524 }
525
526 async fn execute_with_context(
527 &self,
528 arguments: Value,
529 context: &ToolContext,
530 ) -> ToolExecutionResult {
531 let params: QueryHistoryParams = match serde_json::from_value(arguments) {
532 Ok(params) => params,
533 Err(error) => {
534 return ToolExecutionResult::tool_error(format!("Invalid parameters: {error}"));
535 }
536 };
537
538 let Some(retriever) = &context.message_retriever else {
539 return ToolExecutionResult::tool_error("No message retriever available");
540 };
541
542 let messages = match retriever.load(context.session_id).await {
543 Ok(messages) => messages,
544 Err(error) => {
545 return ToolExecutionResult::internal_error(error);
546 }
547 };
548
549 if messages.is_empty() {
550 return ToolExecutionResult::success(json!({
551 "count": 0,
552 "message": "No history available."
553 }));
554 }
555
556 let limit = params.limit.min(50);
557 let total = messages.len();
558
559 if let Some(range) = params.message_range {
560 let from = range.from.min(total);
561 let to = range.to.min(total).max(from);
562 let range_messages: Vec<_> = messages[from..to].iter().take(limit).collect();
563 return format_range_result(&range_messages, from, total);
564 }
565
566 if let Some(query) = params.query.as_deref() {
567 let results = search_messages(&messages, query, limit);
568 return format_search_result(&results, total);
569 }
570
571 let recent: Vec<_> = messages.iter().rev().take(limit).collect();
572 format_recent_result(&recent, total)
573 }
574}
575
576struct SearchResult<'a> {
577 index: usize,
578 message: &'a Message,
579 score: f64,
580}
581
582fn search_messages<'a>(
583 messages: &'a [Message],
584 query: &str,
585 limit: usize,
586) -> Vec<SearchResult<'a>> {
587 let query_lower = query.to_lowercase();
588 let mut results = Vec::new();
589
590 for (index, message) in messages.iter().enumerate() {
591 let content = extract_text_content(message).to_lowercase();
592 if !content.contains(&query_lower) {
593 continue;
594 }
595
596 let mut score = 1.0;
597
598 if content.split_whitespace().any(|word| word == query_lower) {
599 score += 0.5;
600 }
601
602 if !messages.is_empty() {
603 score += (index as f64 / messages.len() as f64) * 0.3;
604 }
605
606 match message.role {
607 MessageRole::User | MessageRole::Agent => score += 0.2,
608 MessageRole::System => score += 0.1,
609 MessageRole::ToolResult => {}
610 }
611
612 results.push(SearchResult {
613 index,
614 message,
615 score,
616 });
617 }
618
619 results.sort_by(|left, right| {
620 right
621 .score
622 .partial_cmp(&left.score)
623 .unwrap_or(Ordering::Equal)
624 });
625 results.truncate(limit);
626 results
627}
628
629fn extract_text_content(message: &Message) -> String {
630 message
631 .content
632 .iter()
633 .filter_map(|part| match part {
634 ContentPart::Text(text) => Some(text.text.clone()),
635 ContentPart::ToolResult(result) => result.result.as_ref().map(ToString::to_string),
636 _ => None,
637 })
638 .collect::<Vec<_>>()
639 .join(" ")
640}
641
642fn truncate_content(content: &str, max_len: usize) -> String {
643 let char_count = content.chars().count();
644 if char_count <= max_len {
645 return content.to_string();
646 }
647
648 format!("{}...", content.chars().take(max_len).collect::<String>())
649}
650
651fn format_message(message: &Message, index: usize, total: usize) -> Value {
652 json!({
653 "index": index,
654 "position": format!("{}/{}", index + 1, total),
655 "role": message.role.to_string(),
656 "created_at": message.created_at.to_rfc3339(),
657 "content": truncate_content(&extract_text_content(message), 500)
658 })
659}
660
661fn format_range_result(
662 messages: &[&Message],
663 start_index: usize,
664 total: usize,
665) -> ToolExecutionResult {
666 if messages.is_empty() {
667 return ToolExecutionResult::success(json!({
668 "count": 0,
669 "message": "No messages in the requested range."
670 }));
671 }
672
673 let formatted: Vec<Value> = messages
674 .iter()
675 .enumerate()
676 .map(|(offset, message)| format_message(message, start_index + offset, total))
677 .collect();
678
679 ToolExecutionResult::success(json!({
680 "messages": formatted,
681 "count": messages.len(),
682 "total_in_history": total,
683 "range": format!("{}-{}", start_index + 1, start_index + messages.len())
684 }))
685}
686
687fn format_search_result(results: &[SearchResult<'_>], total: usize) -> ToolExecutionResult {
688 if results.is_empty() {
689 return ToolExecutionResult::success(json!({
690 "count": 0,
691 "message": "No matching messages found."
692 }));
693 }
694
695 let formatted: Vec<Value> = results
696 .iter()
697 .map(|result| {
698 let mut message = format_message(result.message, result.index, total);
699 message["relevance_score"] = json!(format!("{:.2}", result.score));
700 message
701 })
702 .collect();
703
704 ToolExecutionResult::success(json!({
705 "messages": formatted,
706 "count": results.len(),
707 "total_in_history": total
708 }))
709}
710
711fn format_recent_result(messages: &[&Message], total: usize) -> ToolExecutionResult {
712 let formatted: Vec<Value> = messages
713 .iter()
714 .enumerate()
715 .map(|(offset, message)| format_message(message, total - messages.len() + offset, total))
716 .collect();
717
718 ToolExecutionResult::success(json!({
719 "messages": formatted,
720 "count": messages.len(),
721 "total_in_history": total,
722 "note": "Showing most recent history. Use `query` to search or `message_range` to fetch older messages."
723 }))
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use crate::in_memory::InMemoryMessageRetriever;
730 use crate::typed_id::SessionId;
731
732 #[test]
735 fn test_provides_message_filter() {
736 let capability = InfinityContextCapability;
737 assert!(capability.message_filter_provider().is_some());
738 }
739
740 #[test]
741 fn test_config_schema_and_validate_config() {
742 let capability = InfinityContextCapability;
743
744 let schema = capability.config_schema().expect("config schema");
745 assert_eq!(schema["type"], "object");
746 assert!(schema["properties"]["context_budget_tokens"].is_object());
747 assert!(schema["properties"]["min_recent_messages"].is_object());
748 assert!(schema["properties"]["max_recent_messages"].is_object());
749 assert_eq!(
750 schema["properties"]["keep_first_messages"]["maximum"],
751 MAX_KEEP_FIRST_MESSAGES
752 );
753
754 assert!(capability.validate_config(&Value::Null).is_ok());
756 assert!(capability.validate_config(&json!({})).is_ok());
757 assert!(
758 capability
759 .validate_config(&json!({
760 "context_budget_tokens": 50_000,
761 "min_recent_messages": 5,
762 "max_recent_messages": 100
763 }))
764 .is_ok()
765 );
766
767 assert!(
769 capability
770 .validate_config(&json!({"context_budget_tokens": "lots"}))
771 .is_err()
772 );
773 assert!(
774 capability
775 .validate_config(&json!({"context_budget_tokens": 0}))
776 .is_err()
777 );
778 assert!(
779 capability
780 .validate_config(&json!({"max_recent_messages": 0}))
781 .is_err()
782 );
783 assert!(
784 capability
785 .validate_config(&json!({
786 "keep_first_messages": MAX_KEEP_FIRST_MESSAGES + 1
787 }))
788 .is_err()
789 );
790 }
791
792 #[test]
793 fn test_localizations_resolve_uk() {
794 let capability = InfinityContextCapability;
795 assert_eq!(
796 capability.localized_name(Some("uk-UA")),
797 "Нескінченний контекст"
798 );
799 assert!(capability.describe_schema(None).is_some());
800 }
801
802 #[test]
803 fn test_filter_provider_sets_bounded_candidate_load_limit_without_hard_cap() {
804 let mut query = MessageQuery::new(SessionId::new());
805 let provider = InfinityContextFilterProvider;
806 provider.apply_filters(
807 &mut query,
808 &json!({"context_budget_tokens": 1_000, "min_recent_messages": 3}),
809 );
810
811 assert_eq!(query.limit, Some(16));
812 assert!(query.prepend_transform.is_some());
813 assert_eq!(query.keep_head, None);
816 }
817
818 #[test]
819 fn test_filter_provider_sets_keep_head_from_keep_first_messages() {
820 let mut query = MessageQuery::new(SessionId::new());
821 let provider = InfinityContextFilterProvider;
822 provider.apply_filters(
823 &mut query,
824 &json!({"context_budget_tokens": 1_000, "keep_first_messages": 3}),
825 );
826 assert_eq!(query.keep_head, Some(3));
827 }
828
829 #[test]
830 fn test_filter_provider_omits_keep_head_when_zero() {
831 let mut query = MessageQuery::new(SessionId::new());
832 let provider = InfinityContextFilterProvider;
833 provider.apply_filters(
834 &mut query,
835 &json!({"context_budget_tokens": 1_000, "keep_first_messages": 0}),
836 );
837 assert_eq!(query.keep_head, None);
838 }
839
840 #[test]
841 fn test_filter_provider_caps_keep_head_for_unvalidated_config() {
842 let mut query = MessageQuery::new(SessionId::new());
843 let provider = InfinityContextFilterProvider;
844 provider.apply_filters(
845 &mut query,
846 &json!({
847 "context_budget_tokens": 1_000,
848 "keep_first_messages": usize::MAX
849 }),
850 );
851
852 assert_eq!(query.keep_head, Some(MAX_KEEP_FIRST_MESSAGES));
853 }
854
855 #[test]
856 fn test_filter_provider_caps_explicit_max_to_bounded_candidate_window() {
857 let mut query = MessageQuery::new(SessionId::new());
858 let provider = InfinityContextFilterProvider;
859 provider.apply_filters(
860 &mut query,
861 &json!({
862 "context_budget_tokens": 500_000,
863 "min_recent_messages": 10,
864 "max_recent_messages": 1_000_000
865 }),
866 );
867
868 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
869 assert!(query.prepend_transform.is_some());
870 }
871
872 #[test]
873 fn test_filter_provider_caps_large_min_recent_messages() {
874 let mut query = MessageQuery::new(SessionId::new());
875 let provider = InfinityContextFilterProvider;
876 provider.apply_filters(
877 &mut query,
878 &json!({
879 "context_budget_tokens": 1_000,
880 "min_recent_messages": 1_000_000,
881 }),
882 );
883
884 assert_eq!(query.limit, Some(CANDIDATE_MAX_MESSAGES as i64));
885 assert!(query.prepend_transform.is_some());
886 }
887
888 #[test]
889 fn test_filter_provider_allows_small_public_chat_window() {
890 let mut query = MessageQuery::new(SessionId::new());
891 let provider = InfinityContextFilterProvider;
892 provider.apply_filters(
893 &mut query,
894 &json!({
895 "context_budget_tokens": 10_000,
896 "min_recent_messages": 10,
897 "max_recent_messages": 30
898 }),
899 );
900
901 assert_eq!(query.limit, Some(30));
902 assert!(query.prepend_transform.is_some());
903 }
904
905 #[test]
906 fn test_filter_provider_falls_back_to_defaults_for_invalid_config() {
907 let mut query = MessageQuery::new(SessionId::new());
908 let provider = InfinityContextFilterProvider;
909 provider.apply_filters(
910 &mut query,
911 &json!({"context_budget_tokens": "not-a-number"}),
912 );
913
914 assert_eq!(query.limit, Some(1_600));
915 assert!(query.prepend_transform.is_some());
916 }
917
918 #[test]
919 fn test_filter_provider_trims_loaded_messages_by_token_budget() {
920 let provider = InfinityContextFilterProvider;
921 let mut messages = vec![
922 Message::user("the original task"),
923 Message::assistant("old ".repeat(400)),
924 Message::user("recent one"),
925 Message::assistant("recent two"),
926 ];
927
928 provider.post_load(
929 &mut messages,
930 &json!({"context_budget_tokens": 1, "min_recent_messages": 2}),
931 );
932
933 assert_eq!(messages.len(), 3);
936 assert!(
937 extract_text_content(&messages[0])
938 .contains("2 earlier messages are NOT visible in this context")
939 );
940 assert_eq!(extract_text_content(&messages[1]), "recent one");
941 assert_eq!(extract_text_content(&messages[2]), "recent two");
942 assert!(
943 !messages
944 .iter()
945 .any(|m| extract_text_content(m) == "the original task")
946 );
947 }
948
949 #[test]
950 fn test_filter_provider_applies_hard_cap_after_loading() {
951 let provider = InfinityContextFilterProvider;
952 let mut messages = vec![
953 Message::user("one"),
954 Message::assistant("two"),
955 Message::user("three"),
956 Message::assistant("four"),
957 Message::user("five"),
958 ];
959
960 provider.post_load(
961 &mut messages,
962 &json!({
963 "context_budget_tokens": 10_000,
964 "min_recent_messages": 10,
965 "max_recent_messages": 2
966 }),
967 );
968
969 assert_eq!(messages.len(), 3);
971 assert!(
972 extract_text_content(&messages[0])
973 .contains("3 earlier messages are NOT visible in this context")
974 );
975 assert_eq!(extract_text_content(&messages[1]), "four");
976 assert_eq!(extract_text_content(&messages[2]), "five");
977 }
978
979 #[test]
980 fn test_filter_provider_anchors_task_through_full_flow() {
981 let provider = InfinityContextFilterProvider;
982 let config = json!({
985 "context_budget_tokens": 600,
986 "min_recent_messages": 2,
987 "keep_first_messages": 1
988 });
989 let mut query = MessageQuery::new(SessionId::new());
990 provider.apply_filters(&mut query, &config);
991 let mut messages = vec![
992 Message::user("TASK: build the widget"),
993 Message::assistant("X".repeat(2000)),
994 Message::assistant("Y".repeat(2000)),
995 Message::user("recent a"),
996 Message::assistant("recent b"),
997 ];
998
999 query.apply_windowing(&mut messages);
1000 provider.post_load(&mut messages, &config);
1001
1002 assert_eq!(extract_text_content(&messages[0]), "TASK: build the widget");
1005 assert!(
1006 extract_text_content(&messages[1])
1007 .contains("earlier messages are NOT visible in this context")
1008 );
1009 assert_eq!(extract_text_content(messages.last().unwrap()), "recent b");
1010 assert!(
1012 !messages
1013 .iter()
1014 .any(|m| extract_text_content(m).starts_with("XXX"))
1015 );
1016 }
1017
1018 #[test]
1019 fn test_filter_provider_defers_eviction_to_compaction() {
1020 let provider = InfinityContextFilterProvider;
1021 let mut messages = vec![
1022 Message::user("task"),
1023 Message::assistant("old ".repeat(400)),
1024 Message::user("recent one"),
1025 Message::assistant("recent two"),
1026 ];
1027
1028 provider.post_load(
1029 &mut messages,
1030 &json!({
1031 "context_budget_tokens": 1,
1032 "min_recent_messages": 2,
1033 "compaction_active": true
1034 }),
1035 );
1036
1037 assert_eq!(messages.len(), 4);
1040 assert!(
1041 messages
1042 .iter()
1043 .all(|m| !extract_text_content(m).contains("NOT visible"))
1044 );
1045 }
1046
1047 #[test]
1048 fn test_filter_provider_caps_keep_first_messages_during_post_load() {
1049 let provider = InfinityContextFilterProvider;
1050 let mut messages: Vec<Message> = (0..20)
1051 .map(|idx| Message::user(format!("message {idx}")))
1052 .collect();
1053
1054 provider.post_load(
1055 &mut messages,
1056 &json!({
1057 "context_budget_tokens": 1,
1058 "min_recent_messages": 1,
1059 "keep_first_messages": usize::MAX
1060 }),
1061 );
1062
1063 assert_eq!(extract_text_content(&messages[0]), "message 0");
1064 assert_eq!(
1065 extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES - 1]),
1066 format!("message {}", MAX_KEEP_FIRST_MESSAGES - 1)
1067 );
1068 assert!(extract_text_content(&messages[MAX_KEEP_FIRST_MESSAGES]).contains("NOT visible"));
1069 assert_eq!(extract_text_content(messages.last().unwrap()), "message 19");
1070 }
1071
1072 #[test]
1073 fn test_filter_provider_default_drops_oversized_first_message() {
1074 let provider = InfinityContextFilterProvider;
1075 let mut messages = vec![
1076 Message::user("attacker ".repeat(20_000)),
1077 Message::assistant("middle"),
1078 Message::user("recent one"),
1079 Message::assistant("recent two"),
1080 ];
1081
1082 provider.post_load(
1083 &mut messages,
1084 &json!({
1085 "context_budget_tokens": 10,
1086 "min_recent_messages": 2,
1087 "max_recent_messages": 2
1088 }),
1089 );
1090
1091 assert_eq!(messages.len(), 3);
1092 assert!(
1093 extract_text_content(&messages[0])
1094 .contains("2 earlier messages are NOT visible in this context")
1095 );
1096 assert_eq!(extract_text_content(&messages[1]), "recent one");
1097 assert_eq!(extract_text_content(&messages[2]), "recent two");
1098 assert!(
1099 !messages
1100 .iter()
1101 .any(|m| extract_text_content(m).starts_with("attacker"))
1102 );
1103 }
1104
1105 #[test]
1106 fn test_filter_provider_keep_first_messages_anchors_multiple() {
1107 let provider = InfinityContextFilterProvider;
1108 let mut messages = vec![
1109 Message::user("anchor one"),
1110 Message::user("anchor two"),
1111 Message::assistant("mid ".repeat(400)),
1112 Message::user("recent"),
1113 ];
1114
1115 provider.post_load(
1116 &mut messages,
1117 &json!({
1118 "context_budget_tokens": 1,
1119 "min_recent_messages": 1,
1120 "keep_first_messages": 2
1121 }),
1122 );
1123
1124 assert_eq!(extract_text_content(&messages[0]), "anchor one");
1125 assert_eq!(extract_text_content(&messages[1]), "anchor two");
1126 assert!(extract_text_content(&messages[2]).contains("NOT visible"));
1127 assert_eq!(extract_text_content(messages.last().unwrap()), "recent");
1128 }
1129
1130 #[test]
1131 fn test_estimate_json_value_len_matches_serialized_length() {
1132 let value = json!({
1133 "stdout": ["alpha", "beta"],
1134 "ok": true,
1135 "count": 2
1136 });
1137
1138 assert_eq!(
1139 estimate_json_value_len(&value),
1140 serde_json::to_string(&value).unwrap().len()
1141 );
1142 }
1143
1144 #[test]
1145 fn test_query_history_requires_context() {
1146 let tool = QueryHistoryTool;
1147 assert!(tool.requires_context());
1148 }
1149
1150 #[tokio::test]
1151 async fn test_query_history_tool_errors_without_retriever() {
1152 let tool = QueryHistoryTool;
1153 let result = tool
1154 .execute_with_context(json!({"query": "api"}), &ToolContext::new(SessionId::new()))
1155 .await;
1156
1157 match result {
1158 ToolExecutionResult::ToolError(message) => {
1159 assert!(message.contains("No message retriever available"));
1160 }
1161 other => panic!("expected tool error, got {other:?}"),
1162 }
1163 }
1164
1165 #[tokio::test]
1166 async fn test_query_history_tool_rejects_invalid_params() {
1167 let result = QueryHistoryTool.execute(json!({"limit": "oops"})).await;
1168
1169 match result {
1170 ToolExecutionResult::ToolError(message) => {
1171 assert!(message.contains("requires session context"));
1172 }
1173 other => panic!("expected tool error, got {other:?}"),
1174 }
1175
1176 let session_id = SessionId::new();
1177 let retriever = InMemoryMessageRetriever::new();
1178 let result = QueryHistoryTool
1179 .execute_with_context(
1180 json!({"message_range": {"from": "bad", "to": 1}}),
1181 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1182 )
1183 .await;
1184
1185 match result {
1186 ToolExecutionResult::ToolError(message) => {
1187 assert!(message.contains("Invalid parameters"));
1188 }
1189 other => panic!("expected tool error, got {other:?}"),
1190 }
1191 }
1192
1193 #[tokio::test]
1194 async fn test_query_history_tool_empty_history() {
1195 let session_id = SessionId::new();
1196 let retriever = InMemoryMessageRetriever::new();
1197
1198 let result = QueryHistoryTool
1199 .execute_with_context(
1200 json!({}),
1201 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1202 )
1203 .await;
1204
1205 match result {
1206 ToolExecutionResult::Success(value) => {
1207 assert_eq!(value["count"], 0);
1208 assert_eq!(value["message"], "No history available.");
1209 }
1210 other => panic!("expected success, got {other:?}"),
1211 }
1212 }
1213
1214 #[tokio::test]
1215 async fn test_query_history_tool_searches_history() {
1216 let session_id = SessionId::new();
1217 let retriever = InMemoryMessageRetriever::new();
1218 retriever
1219 .seed(
1220 session_id,
1221 vec![
1222 Message::user("First topic"),
1223 Message::assistant("The API key is abc123"),
1224 Message::user("We should keep discussing logging"),
1225 ],
1226 )
1227 .await;
1228
1229 let result = QueryHistoryTool
1230 .execute_with_context(
1231 json!({"query": "api key"}),
1232 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1233 )
1234 .await;
1235
1236 match result {
1237 ToolExecutionResult::Success(value) => {
1238 assert_eq!(value["count"], 1);
1239 assert_eq!(value["messages"][0]["content"], "The API key is abc123");
1240 }
1241 other => panic!("expected success, got {other:?}"),
1242 }
1243 }
1244
1245 #[tokio::test]
1246 async fn test_query_history_tool_search_no_match() {
1247 let session_id = SessionId::new();
1248 let retriever = InMemoryMessageRetriever::new();
1249 retriever
1250 .seed(
1251 session_id,
1252 vec![Message::user("one"), Message::assistant("two")],
1253 )
1254 .await;
1255
1256 let result = QueryHistoryTool
1257 .execute_with_context(
1258 json!({"query": "missing"}),
1259 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1260 )
1261 .await;
1262
1263 match result {
1264 ToolExecutionResult::Success(value) => {
1265 assert_eq!(value["count"], 0);
1266 assert_eq!(value["message"], "No matching messages found.");
1267 }
1268 other => panic!("expected success, got {other:?}"),
1269 }
1270 }
1271
1272 #[tokio::test]
1273 async fn test_query_history_tool_reads_range() {
1274 let session_id = SessionId::new();
1275 let retriever = InMemoryMessageRetriever::new();
1276 retriever
1277 .seed(
1278 session_id,
1279 vec![
1280 Message::user("one"),
1281 Message::assistant("two"),
1282 Message::user("three"),
1283 ],
1284 )
1285 .await;
1286
1287 let result = QueryHistoryTool
1288 .execute_with_context(
1289 json!({"message_range": {"from": 1, "to": 3}, "limit": 10}),
1290 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1291 )
1292 .await;
1293
1294 match result {
1295 ToolExecutionResult::Success(value) => {
1296 assert_eq!(value["count"], 2);
1297 assert_eq!(value["messages"][0]["content"], "two");
1298 assert_eq!(value["messages"][1]["content"], "three");
1299 }
1300 other => panic!("expected success, got {other:?}"),
1301 }
1302 }
1303
1304 #[tokio::test]
1305 async fn test_query_history_tool_clamps_out_of_bounds_range() {
1306 let session_id = SessionId::new();
1307 let retriever = InMemoryMessageRetriever::new();
1308 retriever
1309 .seed(
1310 session_id,
1311 vec![
1312 Message::user("one"),
1313 Message::assistant("two"),
1314 Message::user("three"),
1315 ],
1316 )
1317 .await;
1318
1319 let result = QueryHistoryTool
1320 .execute_with_context(
1321 json!({"message_range": {"from": 99, "to": 100}}),
1322 &ToolContext::new(session_id).with_message_retriever(Arc::new(retriever)),
1323 )
1324 .await;
1325
1326 match result {
1327 ToolExecutionResult::Success(value) => {
1328 assert_eq!(value["count"], 0);
1329 assert_eq!(value["message"], "No messages in the requested range.");
1330 }
1331 other => panic!("expected success, got {other:?}"),
1332 }
1333 }
1334
1335 #[test]
1336 fn test_truncate_content_is_utf8_safe() {
1337 let truncated = truncate_content("hello🙂world", 6);
1338 assert_eq!(truncated, "hello🙂...");
1339 }
1340
1341 #[test]
1342 fn trim_preserves_locally_unmatched_tool_result_for_stateful_responses() {
1343 use crate::tool_types::ToolCall;
1344
1345 let provider = InfinityContextFilterProvider;
1346 let mut messages = vec![
1352 Message::user("old question"),
1353 Message::assistant_with_tools(
1354 "calling tool",
1355 vec![ToolCall {
1356 id: "call_old".to_string(),
1357 name: "edit_file".to_string(),
1358 arguments: serde_json::json!({}),
1359 }],
1360 ),
1361 Message::tool_result("call_old", Some(serde_json::json!("done")), None),
1363 Message::user("new question"),
1364 Message::assistant("answer"),
1365 ];
1366
1367 provider.post_load(
1368 &mut messages,
1369 &serde_json::json!({"context_budget_tokens": 1, "min_recent_messages": 3}),
1370 );
1371
1372 assert!(
1373 messages.iter().any(|m| m.role == MessageRole::ToolResult),
1374 "locally unmatched tool result must be preserved until provider serialization"
1375 );
1376 }
1377
1378 #[test]
1379 fn trim_keeps_tool_result_when_tool_call_is_visible() {
1380 use crate::tool_types::ToolCall;
1381
1382 let provider = InfinityContextFilterProvider;
1383 let mut messages = vec![
1385 Message::assistant_with_tools(
1386 "calling tool",
1387 vec![ToolCall {
1388 id: "call_1".to_string(),
1389 name: "read_file".to_string(),
1390 arguments: serde_json::json!({}),
1391 }],
1392 ),
1393 Message::tool_result("call_1", Some(serde_json::json!("content")), None),
1394 Message::user("thanks"),
1395 ];
1396
1397 provider.post_load(
1398 &mut messages,
1399 &serde_json::json!({"context_budget_tokens": 100_000, "min_recent_messages": 10}),
1400 );
1401
1402 assert!(
1403 messages.iter().any(|m| m.role == MessageRole::ToolResult),
1404 "tool result must be kept when its tool call is visible"
1405 );
1406 }
1407}