Skip to main content

lean_ctx/server/
elicitation.rs

1use 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}