Skip to main content

lean_ctx/core/
autonomy.rs

1use 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/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`).
14#[derive(Debug, Clone)]
15pub struct SearchHistory {
16    pub call_count: u32,
17    pub last_call: Instant,
18}
19
20/// Tracks autonomous action state independently of the MCP tool layer.
21pub struct AutonomyState {
22    pub session_initialized: AtomicBool,
23    pub dedup_applied: AtomicBool,
24    pub last_consolidation_unix: AtomicU64,
25    pub config: AutonomyConfig,
26    /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]).
27    pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
28    /// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens).
29    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    /// Creates a new autonomy state with config loaded from disk.
40    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    /// Returns true if autonomous actions are enabled in configuration.
52    pub fn is_enabled(&self) -> bool {
53        self.config.enabled
54    }
55
56    /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls.
57    ///
58    /// Uses interior mutability so this can be called on `Arc<AutonomyState>`. Counters reset when
59    /// the idle gap since the last call for that key is at least five minutes (500ms in unit tests).
60    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
100/// Returns true if enough tool calls have elapsed to trigger auto-consolidation.
101pub 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}