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