Skip to main content

lean_ctx/tools/
autonomy.rs

1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::core::cache::SessionCache;
5use crate::core::config::AutonomyConfig;
6use crate::core::graph_index::ProjectIndex;
7use crate::core::protocol;
8use crate::core::tokens::count_tokens;
9use crate::tools::CrpMode;
10
11pub struct AutonomyState {
12    pub session_initialized: AtomicBool,
13    pub dedup_applied: AtomicBool,
14    pub last_consolidation_unix: AtomicU64,
15    pub config: AutonomyConfig,
16}
17
18impl Default for AutonomyState {
19    fn default() -> Self {
20        Self::new()
21    }
22}
23
24impl AutonomyState {
25    pub fn new() -> Self {
26        Self {
27            session_initialized: AtomicBool::new(false),
28            dedup_applied: AtomicBool::new(false),
29            last_consolidation_unix: AtomicU64::new(0),
30            config: AutonomyConfig::load(),
31        }
32    }
33
34    pub fn is_enabled(&self) -> bool {
35        self.config.enabled
36    }
37}
38
39pub fn session_lifecycle_pre_hook(
40    state: &AutonomyState,
41    tool_name: &str,
42    cache: &mut SessionCache,
43    task: Option<&str>,
44    project_root: Option<&str>,
45    crp_mode: CrpMode,
46) -> Option<String> {
47    if !state.is_enabled() || !state.config.auto_preload {
48        return None;
49    }
50
51    if tool_name == "ctx_overview" || tool_name == "ctx_preload" {
52        return None;
53    }
54
55    let root = match project_root {
56        Some(r) if !r.is_empty() && r != "." => r.to_string(),
57        _ => return None,
58    };
59
60    if state
61        .session_initialized
62        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
63        .is_err()
64    {
65        return None;
66    }
67
68    let result = if let Some(task_desc) = task {
69        crate::tools::ctx_preload::handle(cache, task_desc, Some(&root), crp_mode)
70    } else {
71        let cache_readonly = &*cache;
72        crate::tools::ctx_overview::handle(cache_readonly, None, Some(&root), crp_mode)
73    };
74
75    if result.contains("No directly relevant files") || result.trim().is_empty() {
76        return None;
77    }
78
79    Some(format!(
80        "--- AUTO CONTEXT ---\n{result}\n--- END AUTO CONTEXT ---"
81    ))
82}
83
84pub fn enrich_after_read(
85    state: &AutonomyState,
86    cache: &mut SessionCache,
87    file_path: &str,
88    project_root: Option<&str>,
89) -> EnrichResult {
90    let mut result = EnrichResult::default();
91
92    if !state.is_enabled() {
93        return result;
94    }
95
96    let root = match project_root {
97        Some(r) if !r.is_empty() && r != "." => r.to_string(),
98        _ => return result,
99    };
100
101    let index = crate::core::graph_index::load_or_build(&root);
102    if index.files.is_empty() {
103        return result;
104    }
105
106    if state.config.auto_related {
107        result.related_hint = build_related_hints(cache, file_path, &index);
108    }
109
110    if state.config.silent_preload {
111        silent_preload_imports(cache, file_path, &index, &root);
112    }
113
114    result
115}
116
117#[derive(Default)]
118pub struct EnrichResult {
119    pub related_hint: Option<String>,
120}
121
122fn build_related_hints(
123    cache: &SessionCache,
124    file_path: &str,
125    index: &ProjectIndex,
126) -> Option<String> {
127    let related: Vec<_> = index
128        .edges
129        .iter()
130        .filter(|e| e.from == file_path || e.to == file_path)
131        .map(|e| if e.from == file_path { &e.to } else { &e.from })
132        .filter(|path| cache.get(path).is_none())
133        .take(3)
134        .collect();
135
136    if related.is_empty() {
137        return None;
138    }
139
140    let hints: Vec<String> = related.iter().map(|p| protocol::shorten_path(p)).collect();
141
142    Some(format!("[related: {}]", hints.join(", ")))
143}
144
145fn silent_preload_imports(
146    cache: &mut SessionCache,
147    file_path: &str,
148    index: &ProjectIndex,
149    _project_root: &str,
150) {
151    let imports: Vec<String> = index
152        .edges
153        .iter()
154        .filter(|e| e.from == file_path)
155        .map(|e| e.to.clone())
156        .filter(|path| cache.get(path).is_none())
157        .take(2)
158        .collect();
159
160    for path in imports {
161        if let Ok(content) = std::fs::read_to_string(&path) {
162            let tokens = count_tokens(&content);
163            if tokens < 5000 {
164                cache.store(&path, content);
165            }
166        }
167    }
168}
169
170pub fn maybe_auto_dedup(state: &AutonomyState, cache: &mut SessionCache) {
171    if !state.is_enabled() || !state.config.auto_dedup {
172        return;
173    }
174
175    if state
176        .dedup_applied
177        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
178        .is_err()
179    {
180        return;
181    }
182
183    let entries = cache.get_all_entries();
184    if entries.len() < state.config.dedup_threshold {
185        state.dedup_applied.store(false, Ordering::SeqCst);
186        return;
187    }
188
189    crate::tools::ctx_dedup::handle_action(cache, "apply");
190}
191
192pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool {
193    if !state.is_enabled() || !state.config.auto_consolidate {
194        return false;
195    }
196    let every = state.config.consolidate_every_calls.max(1);
197    if !tool_calls.is_multiple_of(every) {
198        return false;
199    }
200
201    let now = SystemTime::now()
202        .duration_since(UNIX_EPOCH)
203        .map(|d| d.as_secs())
204        .unwrap_or(0);
205    let last = state.last_consolidation_unix.load(Ordering::SeqCst);
206    if now.saturating_sub(last) < state.config.consolidate_cooldown_secs {
207        return false;
208    }
209    state.last_consolidation_unix.store(now, Ordering::SeqCst);
210    true
211}
212
213pub fn shell_efficiency_hint(
214    state: &AutonomyState,
215    command: &str,
216    input_tokens: usize,
217    output_tokens: usize,
218) -> Option<String> {
219    if !state.is_enabled() {
220        return None;
221    }
222
223    if input_tokens == 0 {
224        return None;
225    }
226
227    let savings_pct =
228        (input_tokens.saturating_sub(output_tokens) as f64 / input_tokens as f64) * 100.0;
229    if savings_pct >= 20.0 {
230        return None;
231    }
232
233    let cmd_lower = command.to_lowercase();
234    if cmd_lower.starts_with("grep ")
235        || cmd_lower.starts_with("rg ")
236        || cmd_lower.starts_with("find ")
237        || cmd_lower.starts_with("ag ")
238    {
239        return Some("[hint: ctx_search is more token-efficient for code search]".to_string());
240    }
241
242    if cmd_lower.starts_with("cat ") || cmd_lower.starts_with("head ") {
243        return Some("[hint: ctx_read provides cached, compressed file access]".to_string());
244    }
245
246    None
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn autonomy_state_starts_uninitialized() {
255        let state = AutonomyState::new();
256        assert!(!state.session_initialized.load(Ordering::SeqCst));
257        assert!(!state.dedup_applied.load(Ordering::SeqCst));
258    }
259
260    #[test]
261    fn session_initialized_fires_once() {
262        let state = AutonomyState::new();
263        let first = state.session_initialized.compare_exchange(
264            false,
265            true,
266            Ordering::SeqCst,
267            Ordering::SeqCst,
268        );
269        assert!(first.is_ok());
270        let second = state.session_initialized.compare_exchange(
271            false,
272            true,
273            Ordering::SeqCst,
274            Ordering::SeqCst,
275        );
276        assert!(second.is_err());
277    }
278
279    #[test]
280    fn shell_hint_for_grep() {
281        let state = AutonomyState::new();
282        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 95);
283        assert!(hint.is_some());
284        assert!(hint.unwrap().contains("ctx_search"));
285    }
286
287    #[test]
288    fn shell_hint_none_when_good_savings() {
289        let state = AutonomyState::new();
290        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 50);
291        assert!(hint.is_none());
292    }
293
294    #[test]
295    fn shell_hint_none_for_unknown_command() {
296        let state = AutonomyState::new();
297        let hint = shell_efficiency_hint(&state, "cargo build", 100, 95);
298        assert!(hint.is_none());
299    }
300
301    #[test]
302    fn disabled_state_blocks_all() {
303        let mut state = AutonomyState::new();
304        state.config.enabled = false;
305        assert!(!state.is_enabled());
306        let hint = shell_efficiency_hint(&state, "grep foo", 100, 95);
307        assert!(hint.is_none());
308    }
309}