lean_ctx_server/server/
elicitation.rs1use 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}