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 {
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 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 pub fn is_enabled(&self) -> bool {
38 self.config.enabled
39 }
40}
41
42pub 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
88pub 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#[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
176pub 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
199pub 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
220pub 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}