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