Skip to main content

lean_ctx_server/server/
elicitation.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::sync::atomic::{AtomicU64, Ordering};
3
4static LAST_ELICITATION_SEQ: AtomicU64 = AtomicU64::new(0);
5static TOOL_CALL_SEQ: AtomicU64 = AtomicU64::new(0);
6
7const MIN_CALLS_BETWEEN_ELICITATION: u64 = 20;
8const PRESSURE_THRESHOLD: f64 = 0.90;
9const LARGE_FILE_TOKENS: usize = 5000;
10
11pub fn increment_call() -> u64 {
12    TOOL_CALL_SEQ.fetch_add(1, Ordering::Relaxed) + 1
13}
14
15#[derive(Debug, Clone)]
16pub enum ElicitationSuggestion {
17    PressureEviction {
18        utilization_pct: f64,
19        candidates: Vec<String>,
20    },
21    LargeFileMode {
22        path: String,
23        tokens: usize,
24    },
25    BudgetExhausted {
26        utilization_pct: f64,
27    },
28}
29
30impl ElicitationSuggestion {
31    pub fn format_fallback_hint(&self) -> String {
32        match self {
33            Self::PressureEviction {
34                utilization_pct,
35                candidates,
36            } => {
37                let names = candidates.join(", ");
38                format!(
39                    "[Context {utilization_pct:.0}% full] Evict: ctx_control(action=\"evict\", targets=\"{names}\")"
40                )
41            }
42            Self::LargeFileMode { path, tokens } => {
43                format!(
44                    "[Large file: {path} ({tokens} tok)] Consider: ctx_read(\"{path}\", mode=\"map\") or mode=\"signatures\""
45                )
46            }
47            Self::BudgetExhausted { utilization_pct } => {
48                format!(
49                    "[Budget {utilization_pct:.0}% used] Consider: ctx_control(action=\"set_view\", target=\"<large_file>\", value=\"signatures\")"
50                )
51            }
52        }
53    }
54}
55
56pub fn check_elicitation_needed(
57    ledger: &lean_ctx::core::context_ledger::ContextLedger,
58    current_path: Option<&str>,
59    current_tokens: Option<usize>,
60) -> Option<ElicitationSuggestion> {
61    let current_seq = TOOL_CALL_SEQ.load(Ordering::Relaxed);
62    let last = LAST_ELICITATION_SEQ.load(Ordering::Relaxed);
63    if current_seq.saturating_sub(last) < MIN_CALLS_BETWEEN_ELICITATION {
64        return None;
65    }
66
67    let pressure = ledger.pressure();
68
69    if pressure.utilization > PRESSURE_THRESHOLD {
70        let candidates = ledger.eviction_candidates_by_phi(3);
71        if !candidates.is_empty() {
72            LAST_ELICITATION_SEQ.store(current_seq, Ordering::Relaxed);
73            let short_names: Vec<_> = candidates
74                .iter()
75                .take(5)
76                .map(|p| lean_ctx::core::protocol::shorten_path(p))
77                .collect();
78            return Some(ElicitationSuggestion::PressureEviction {
79                utilization_pct: pressure.utilization * 100.0,
80                candidates: short_names,
81            });
82        }
83    }
84
85    if let (Some(path), Some(tokens)) = (current_path, current_tokens)
86        && tokens > LARGE_FILE_TOKENS
87    {
88        LAST_ELICITATION_SEQ.store(current_seq, Ordering::Relaxed);
89        return Some(ElicitationSuggestion::LargeFileMode {
90            path: path.to_string(),
91            tokens,
92        });
93    }
94
95    if pressure.utilization > 0.95 {
96        LAST_ELICITATION_SEQ.store(current_seq, Ordering::Relaxed);
97        return Some(ElicitationSuggestion::BudgetExhausted {
98            utilization_pct: pressure.utilization * 100.0,
99        });
100    }
101
102    None
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn no_elicitation_on_low_pressure() {
111        for _ in 0..25 {
112            increment_call();
113        }
114        let ledger = lean_ctx::core::context_ledger::ContextLedger::new();
115        let result = check_elicitation_needed(&ledger, None, None);
116        assert!(result.is_none());
117    }
118
119    #[test]
120    fn fallback_hint_format() {
121        let hint = ElicitationSuggestion::PressureEviction {
122            utilization_pct: 92.0,
123            candidates: vec!["auth.rs".to_string(), "db.rs".to_string()],
124        };
125        let text = hint.format_fallback_hint();
126        assert!(text.contains("92%"));
127        assert!(text.contains("auth.rs"));
128    }
129
130    #[test]
131    fn large_file_hint_format() {
132        let hint = ElicitationSuggestion::LargeFileMode {
133            path: "big.rs".to_string(),
134            tokens: 8000,
135        };
136        let text = hint.format_fallback_hint();
137        assert!(text.contains("8000"));
138        assert!(text.contains("big.rs"));
139    }
140}