lean_ctx/tools/
autonomy.rs1use 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}