Skip to main content

everruns_core/capabilities/
infinity_context.rs

1//! Infinity Context Capability
2//!
3//! Keeps recent conversation turns in prompt context while exposing a
4//! `query_history` tool for older messages that fell out of the active window.
5
6use 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
21/// Capability ID for infinity context.
22pub const INFINITY_CONTEXT_CAPABILITY_ID: &str = "infinity_context";
23
24/// Infinity context capability.
25pub 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    /// All three `InfinityContextConfig` fields are simple numeric knobs users
68    /// tune, so all are exposed. Candidate-load sizing (overfetch factor,
69    /// per-message token estimate, hard DB limit) is internal and derived from
70    /// these values, so it has no schema surface.
71    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    /// Maximum prompt budget reserved for message history.
184    #[serde(default = "default_context_budget_tokens")]
185    context_budget_tokens: usize,
186
187    /// Minimum number of recent messages to keep even when the budget is tight.
188    #[serde(default = "default_min_recent_messages")]
189    min_recent_messages: usize,
190
191    /// Optional hard cap on recent messages kept in the live prompt.
192    ///
193    /// Useful for public support chats where the prompt must stay small even
194    /// when the token-budget estimate would allow more messages.
195    #[serde(default)]
196    max_recent_messages: Option<usize>,
197
198    /// Optional leading messages kept as an anchor (the original task / goal),
199    /// regardless of token budget. Defaults to 0 so untrusted first messages
200    /// cannot bypass the configured token budget or recent-message cap. The
201    /// anchor is additional to `max_recent_messages` when explicitly enabled.
202    #[serde(default = "default_keep_first_messages")]
203    keep_first_messages: usize,
204
205    /// Derived (not user-facing): set by capability collection when the
206    /// `compaction` capability is also enabled. When true, infinity context
207    /// stops doing token-budget eviction and lets compaction own reduction, so
208    /// compaction's summary — not a bare "hidden" notice — covers old turns.
209    #[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        // When explicitly configured (`keep_first_messages > 0`), fetch the first
251        // messages alongside the latest-N tail so the task/goal anchor survives
252        // even when history is far longer than the candidate load window (the
253        // tail-only LIMIT would otherwise never fetch the genuine first message).
254        // The default is 0 so an untrusted first message cannot bypass the token
255        // budget or recent-message cap, and the value is clamped to
256        // `MAX_KEEP_FIRST_MESSAGES` to bound the extra head load.
257        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        // P2 composition: when compaction is the active reducer, defer
270        // token-budget eviction to it. Only re-surface any candidate-window
271        // overflow notice (messages the DB load could not fetch).
272        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            // Place the notice right after the anchored head so the layout reads
287            // [task anchor] -> [N hidden] -> [recent window].
288            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    // Bound the always-preserved head anchor independently of validation: filters
299    // can run over configs loaded before the cap existed or provided by tests.
300    config.keep_first_messages.min(MAX_KEEP_FIRST_MESSAGES)
301}
302
303fn resolve_candidate_load_limit(config: &InfinityContextConfig) -> usize {
304    // Cap the candidate window unconditionally at `CANDIDATE_MAX_MESSAGES` so a
305    // large `min_recent_messages` or `max_recent_messages` cannot turn into an
306    // unbounded DB `LIMIT`.
307    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/// Outcome of token-budget trimming.
394#[derive(Default)]
395struct TrimOutcome {
396    /// Messages dropped from the middle.
397    hidden_count: usize,
398    /// Length of the preserved leading anchor (where the notice is inserted).
399    head_len: usize,
400}
401
402/// Insert the hidden-history notice at `position`, clamped to the message list.
403fn 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
410/// Trim the live window to the token budget while always keeping the first
411/// `keep_first_messages` (the original task/goal) and the recent tail. Drops a
412/// single contiguous block from the middle and reports how many were hidden.
413fn 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        // Rebuild as [0, head_len) ++ [recent_start, len) without cloning.
433        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
444/// Tool for querying earlier conversation history.
445pub 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    // Metadata/tool-list constants covered by builtin_capabilities_satisfy_registry_invariants.
733
734    #[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        // Null, empty, and valid configs are accepted.
755        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        // Wrong types and out-of-range values are rejected.
768        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        // The safe default omits the head anchor so a large untrusted first
814        // message cannot bypass the configured budget or recent-message cap.
815        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        // Default configuration has no head anchor: a first user message must not
934        // bypass the configured token budget or recent-message cap.
935        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        // Hard cap keeps only the 2 recent messages by default.
970        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        // Budget large enough that the candidate load fetches every message, but
983        // small enough that the big middle turns are dropped by token budget.
984        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        // The original task is anchored at the front, the notice follows it, and
1003        // the recent tail is intact — the model still knows what it is doing.
1004        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        // The huge first assistant turn was dropped from the middle.
1011        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        // Compaction owns reduction: infinity context neither trims nor injects a
1038        // notice when compaction is active.
1039        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        // min_recent_messages=3 keeps the last 3 messages. With a 1-token budget the
1347        // two older messages are dropped, including the assistant tool call. The
1348        // OpenAI Responses path may still have that call in previous_response_id
1349        // state, so InfinityContext must not drop the tool output before provider
1350        // serialization decides whether stateful continuation is active.
1351        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            // This tool result is in the min-recent window but its call is trimmed away.
1362            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        // All 3 messages fit in the window: no orphan expected.
1384        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}