Skip to main content

lean_ctx/core/
autonomy.rs

1use std::collections::HashMap;
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/// How long a search key stays idle before its repeat count resets: five
9/// minutes, half a second in the unit-test harnesses (here, and above the
10/// Engine with `test-support`; never in the `lean-ctx` binary).
11fn search_repeat_idle_reset() -> Duration {
12    #[cfg(any(test, feature = "test-support"))]
13    if crate::core::data_dir::sandboxed() {
14        return Duration::from_millis(500);
15    }
16    Duration::from_mins(5)
17}
18
19/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`).
20#[derive(Debug, Clone)]
21pub struct SearchHistory {
22    pub call_count: u32,
23    pub last_call: Instant,
24}
25
26/// Tracks autonomous action state independently of the MCP tool layer.
27pub struct AutonomyState {
28    /// Set once the session's wake-up briefing was delivered.
29    pub session_briefed: AtomicBool,
30    /// Set once the auto-load context packages were applied (first call).
31    pub packages_applied: AtomicBool,
32    /// Packages a silent first call applied, named by the briefing later.
33    pub packages_loaded: Mutex<Vec<String>>,
34    pub last_consolidation_unix: AtomicU64,
35    pub config: AutonomyConfig,
36    /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]).
37    pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
38}
39
40impl Default for AutonomyState {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl AutonomyState {
47    /// Creates a new autonomy state with config loaded from disk.
48    pub fn new() -> Self {
49        Self {
50            session_briefed: AtomicBool::new(false),
51            packages_applied: AtomicBool::new(false),
52            packages_loaded: Mutex::new(Vec::new()),
53            last_consolidation_unix: AtomicU64::new(0),
54            config: AutonomyConfig::load(),
55            search_repetition: Mutex::new(HashMap::new()),
56        }
57    }
58
59    /// Returns true if autonomous actions are enabled in configuration.
60    pub fn is_enabled(&self) -> bool {
61        self.config.enabled
62    }
63
64    /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls.
65    ///
66    /// Uses interior mutability so this can be called on `Arc<AutonomyState>`. Counters reset when
67    /// the idle gap since the last call for that key is at least five minutes (500ms in unit tests).
68    pub fn track_search(&self, pattern: &str, path: &str) -> Option<String> {
69        if !autonomy_enabled_effective(self) {
70            return None;
71        }
72        let key = format!("{pattern}|{path}");
73        let now = Instant::now();
74        let mut map = self
75            .search_repetition
76            .lock()
77            .unwrap_or_else(std::sync::PoisonError::into_inner);
78        let hist = map.entry(key).or_insert(SearchHistory {
79            call_count: 0,
80            last_call: now,
81        });
82        if hist.last_call.elapsed() >= search_repeat_idle_reset() {
83            hist.call_count = 0;
84        }
85        hist.call_count = hist.call_count.saturating_add(1);
86        hist.last_call = now;
87        let n = hist.call_count;
88
89        match n {
90            1..=3 => None,
91            4..=6 => Some(format!(
92                "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]"
93            )),
94            _ => Some(format!(
95                "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]"
96            )),
97        }
98    }
99}
100
101fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
102    state.is_enabled()
103        && crate::core::profiles::active_profile()
104            .autonomy
105            .enabled_effective()
106}
107
108/// Returns true if enough tool calls have elapsed to trigger auto-consolidation.
109pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool {
110    if !state.is_enabled() || !state.config.auto_consolidate {
111        return false;
112    }
113    let every = state.config.consolidate_every_calls.max(1);
114    if !tool_calls.is_multiple_of(every) {
115        return false;
116    }
117
118    let now = SystemTime::now()
119        .duration_since(UNIX_EPOCH)
120        .map_or(0, |d| d.as_secs());
121    let last = state.last_consolidation_unix.load(Ordering::SeqCst);
122    if now.saturating_sub(last) < state.config.consolidate_cooldown_secs {
123        return false;
124    }
125    state.last_consolidation_unix.store(now, Ordering::SeqCst);
126    true
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn consolidation_respects_call_interval_and_cooldown() {
135        let mut state = AutonomyState::new();
136        state.config.enabled = true;
137        state.config.auto_consolidate = true;
138        state.config.consolidate_every_calls = 5;
139        state.config.consolidate_cooldown_secs = 60;
140
141        assert!(!should_auto_consolidate(&state, 4));
142        assert!(should_auto_consolidate(&state, 5));
143        assert!(!should_auto_consolidate(&state, 10));
144    }
145
146    #[test]
147    fn consolidation_disabled_never_triggers() {
148        let mut state = AutonomyState::new();
149        state.config.enabled = false;
150        state.config.auto_consolidate = true;
151        state.config.consolidate_every_calls = 1;
152        state.config.consolidate_cooldown_secs = 0;
153
154        assert!(!should_auto_consolidate(&state, 1));
155    }
156}