lean_ctx/core/
autonomy.rs1use std::collections::{HashMap, HashSet};
2use std::sync::Mutex;
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
5
6use crate::core::config::AutonomyConfig;
7
8#[cfg(test)]
9const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_millis(500);
10#[cfg(not(test))]
11const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_mins(5);
12
13#[derive(Debug, Clone)]
15pub struct SearchHistory {
16 pub call_count: u32,
17 pub last_call: Instant,
18}
19
20pub struct AutonomyState {
22 pub session_initialized: AtomicBool,
23 pub dedup_applied: AtomicBool,
24 pub last_consolidation_unix: AtomicU64,
25 pub config: AutonomyConfig,
26 pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
28 pub large_output_hints_shown: Mutex<HashSet<String>>,
30}
31
32impl Default for AutonomyState {
33 fn default() -> Self {
34 Self::new()
35 }
36}
37
38impl AutonomyState {
39 pub fn new() -> Self {
41 Self {
42 session_initialized: AtomicBool::new(false),
43 dedup_applied: AtomicBool::new(false),
44 last_consolidation_unix: AtomicU64::new(0),
45 config: AutonomyConfig::load(),
46 search_repetition: Mutex::new(HashMap::new()),
47 large_output_hints_shown: Mutex::new(HashSet::new()),
48 }
49 }
50
51 pub fn is_enabled(&self) -> bool {
53 self.config.enabled
54 }
55
56 pub fn track_search(&self, pattern: &str, path: &str) -> Option<String> {
61 if !autonomy_enabled_effective(self) {
62 return None;
63 }
64 let key = format!("{pattern}|{path}");
65 let now = Instant::now();
66 let mut map = self
67 .search_repetition
68 .lock()
69 .unwrap_or_else(std::sync::PoisonError::into_inner);
70 let hist = map.entry(key).or_insert(SearchHistory {
71 call_count: 0,
72 last_call: now,
73 });
74 if hist.last_call.elapsed() >= SEARCH_REPEAT_IDLE_RESET {
75 hist.call_count = 0;
76 }
77 hist.call_count = hist.call_count.saturating_add(1);
78 hist.last_call = now;
79 let n = hist.call_count;
80
81 match n {
82 1..=3 => None,
83 4..=6 => Some(format!(
84 "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]"
85 )),
86 _ => Some(format!(
87 "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]"
88 )),
89 }
90 }
91}
92
93fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
94 state.is_enabled()
95 && crate::core::profiles::active_profile()
96 .autonomy
97 .enabled_effective()
98}
99
100pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool {
102 if !state.is_enabled() || !state.config.auto_consolidate {
103 return false;
104 }
105 let every = state.config.consolidate_every_calls.max(1);
106 if !tool_calls.is_multiple_of(every) {
107 return false;
108 }
109
110 let now = SystemTime::now()
111 .duration_since(UNIX_EPOCH)
112 .map_or(0, |d| d.as_secs());
113 let last = state.last_consolidation_unix.load(Ordering::SeqCst);
114 if now.saturating_sub(last) < state.config.consolidate_cooldown_secs {
115 return false;
116 }
117 state.last_consolidation_unix.store(now, Ordering::SeqCst);
118 true
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn consolidation_respects_call_interval_and_cooldown() {
127 let mut state = AutonomyState::new();
128 state.config.enabled = true;
129 state.config.auto_consolidate = true;
130 state.config.consolidate_every_calls = 5;
131 state.config.consolidate_cooldown_secs = 60;
132
133 assert!(!should_auto_consolidate(&state, 4));
134 assert!(should_auto_consolidate(&state, 5));
135 assert!(!should_auto_consolidate(&state, 10));
136 }
137
138 #[test]
139 fn consolidation_disabled_never_triggers() {
140 let mut state = AutonomyState::new();
141 state.config.enabled = false;
142 state.config.auto_consolidate = true;
143 state.config.consolidate_every_calls = 1;
144 state.config.consolidate_cooldown_secs = 0;
145
146 assert!(!should_auto_consolidate(&state, 1));
147 }
148}