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