Skip to main content

lean_ctx_server/tools/
autonomy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The session wake-up briefing and one-shot efficiency hints. The read-path drivers (session-start preload,
3//! related hints, silent import preload, prefetch, auto-dedup) were retired on
4//! 2026-10-17: an A/B on the retrieval corpus returned byte-identical responses
5//! and equal R@5/MRR with them off, while the session start cost 2-14 s more.
6
7use std::sync::atomic::Ordering;
8
9pub use lean_ctx::core::autonomy::{AutonomyState, SearchHistory, should_auto_consolidate};
10use lean_ctx::core::tokens::count_tokens;
11
12fn profile_autonomy() -> lean_ctx::core::profiles::ProfileAutonomy {
13    lean_ctx::core::profiles::active_profile().autonomy
14}
15
16fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
17    state.is_enabled() && profile_autonomy().enabled_effective()
18}
19
20/// Whether this call is the one that may carry the session briefing and the
21/// shared-context hint: `ctx_session` with `load`, `status` or `show`, also
22/// through `ctx_call`. Every other tool result stays a deterministic function
23/// of its inputs (#498); the briefing carries per-run peer ids and memory.
24pub fn delivers_session_briefing(
25    name: &str,
26    args: Option<&serde_json::Map<String, serde_json::Value>>,
27) -> bool {
28    let (name, args) = if name == "ctx_call" {
29        (
30            args.and_then(|a| a.get("name"))
31                .and_then(serde_json::Value::as_str)
32                .unwrap_or(""),
33            args.and_then(|a| a.get("arguments"))
34                .and_then(serde_json::Value::as_object),
35        )
36    } else {
37        (name, args)
38    };
39    name == "ctx_session"
40        && matches!(
41            args.and_then(|a| a.get("action"))
42                .and_then(serde_json::Value::as_str),
43            Some("load" | "status" | "show")
44        )
45}
46
47/// Applies the auto-load context packages silently on the first call of a
48/// session that does not deliver the briefing; the result text is unchanged
49/// and the briefing names them later.
50pub fn apply_auto_load_packages_once(state: &AutonomyState, project_root: Option<&str>) {
51    if lean_ctx::core::policy::runtime::is_active() || !autonomy_enabled_effective(state) {
52        return;
53    }
54    let Some(root) = project_root.filter(|r| !r.is_empty() && *r != ".") else {
55        return;
56    };
57    if state
58        .packages_applied
59        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
60        .is_ok()
61    {
62        let loaded = lean_ctx::core::context_package::auto_load_packages(root);
63        state
64            .packages_loaded
65            .lock()
66            .unwrap_or_else(std::sync::PoisonError::into_inner)
67            .extend(loaded);
68    }
69}
70
71/// The wake-up briefing (last task, recent decisions, project facts, active
72/// peers), as `--- AUTO CONTEXT ---`, on the first briefing-delivering call
73/// of a session (see [`delivers_session_briefing`]).
74///
75/// It used to be appended to a full project overview, which on any real
76/// project exceeded the injection cap, so the briefing was built (2-14 s) and
77/// dropped. Only the briefing is built now, without the graph, and its facts
78/// are fitted to the cap: on 932 real knowledge stores the facts block alone
79/// exceeded it for 19 % (p50 319, p90 715 tokens).
80pub fn session_wakeup_briefing(
81    state: &AutonomyState,
82    tool_name: &str,
83    task: Option<&str>,
84    project_root: Option<&str>,
85) -> Option<String> {
86    // Memory text carries no original-source authority under a policy.
87    if lean_ctx::core::policy::runtime::is_active() || !autonomy_enabled_effective(state) {
88        return None;
89    }
90    // ctx_preload renders the briefing itself; it keeps the slot for the next call.
91    if tool_name == "ctx_preload" {
92        return None;
93    }
94    let root = project_root.filter(|r| !r.is_empty() && *r != ".")?;
95    if state
96        .session_briefed
97        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
98        .is_err()
99    {
100        return None;
101    }
102    // Auto-load packages not yet applied by an earlier call are applied here;
103    // both those and the ones a silent first call applied are named.
104    let mut loaded = std::mem::take(
105        &mut *state
106            .packages_loaded
107            .lock()
108            .unwrap_or_else(std::sync::PoisonError::into_inner),
109    );
110    if !state.packages_applied.swap(true, Ordering::SeqCst) {
111        loaded.extend(lean_ctx::core::context_package::auto_load_packages(root));
112    }
113    let packages = (!loaded.is_empty())
114        .then(|| format!("CONTEXT PACKAGES AUTO-LOADED: {}", loaded.join(", ")));
115    // Hive tasks (H2): the claims this machine holds here, then open tasks.
116    let tasks = lean_ctx_hive::tasks::briefing(std::path::Path::new(root));
117    let frame = |briefing: &str| format!("{AUTO_CONTEXT_OPEN}\n{briefing}\n{AUTO_CONTEXT_CLOSE}");
118    let briefing = if lean_ctx::core::config::Config::load().enable_wakeup_ctx {
119        let reserved = count_tokens(&frame(&format!(
120            "{}\n{}",
121            packages.as_deref().unwrap_or(""),
122            tasks.as_deref().unwrap_or("")
123        )));
124        let budget = AUTO_CONTEXT_MAX_TOKENS.saturating_sub(reserved);
125        crate::tools::ctx_overview::build_wakeup_briefing_within(root, task, budget)
126    } else {
127        String::new()
128    };
129    let body = [
130        packages.unwrap_or_default(),
131        tasks.unwrap_or_default(),
132        briefing,
133    ]
134    .into_iter()
135    .filter(|part| !part.trim().is_empty())
136    .collect::<Vec<_>>()
137    .join("\n");
138    (!body.is_empty()).then(|| frame(&body))
139}
140
141/// The largest `--- AUTO CONTEXT ---` block a reply carries; larger blocks are
142/// dropped, so the briefing is fitted to it.
143pub const AUTO_CONTEXT_MAX_TOKENS: usize = 400;
144const AUTO_CONTEXT_OPEN: &str = "--- AUTO CONTEXT ---";
145const AUTO_CONTEXT_CLOSE: &str = "--- END AUTO CONTEXT ---";
146
147/// Suggests a more token-efficient lean-ctx tool when shell compression is low.
148pub fn shell_efficiency_hint(
149    state: &AutonomyState,
150    command: &str,
151    input_tokens: usize,
152    output_tokens: usize,
153) -> Option<String> {
154    if !autonomy_enabled_effective(state) {
155        return None;
156    }
157
158    if input_tokens == 0 {
159        return None;
160    }
161
162    let savings_pct =
163        (input_tokens.saturating_sub(output_tokens) as f64 / input_tokens as f64) * 100.0;
164    if savings_pct >= 20.0 {
165        return None;
166    }
167
168    let cmd_lower = command.to_lowercase();
169    if cmd_lower.starts_with("grep ")
170        || cmd_lower.starts_with("rg ")
171        || cmd_lower.starts_with("find ")
172        || cmd_lower.starts_with("ag ")
173    {
174        return Some("[hint: ctx_search is more token-efficient for code search]".to_string());
175    }
176
177    if cmd_lower.starts_with("cat ") || cmd_lower.starts_with("head ") {
178        return Some("[hint: ctx_read provides cached, compressed file access]".to_string());
179    }
180
181    None
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn only_ctx_session_load_or_status_delivers_the_briefing() {
190        let args = |v: serde_json::Value| v.as_object().cloned();
191        let status = args(serde_json::json!({"action": "status"}));
192        assert!(delivers_session_briefing("ctx_session", status.as_ref()));
193        let wrapped =
194            args(serde_json::json!({"name": "ctx_session", "arguments": {"action": "load"}}));
195        assert!(delivers_session_briefing("ctx_call", wrapped.as_ref()));
196        let task = args(serde_json::json!({"action": "task", "value": "x"}));
197        assert!(!delivers_session_briefing("ctx_session", task.as_ref()));
198        for tool in ["ctx_compose", "ctx_search", "ctx_read", "ctx_shell"] {
199            assert!(!delivers_session_briefing(tool, status.as_ref()), "{tool}");
200        }
201    }
202
203    #[test]
204    fn shell_hint_for_grep() {
205        let state = AutonomyState::new();
206        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 95);
207        assert!(hint.is_some());
208        assert!(hint.unwrap().contains("ctx_search"));
209        let hint = shell_efficiency_hint(&state, "cat src/main.rs", 100, 95);
210        assert!(hint.is_some_and(|h| h.contains("ctx_read")));
211    }
212
213    #[test]
214    fn shell_hint_none_when_good_savings() {
215        let state = AutonomyState::new();
216        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 50);
217        assert!(hint.is_none());
218    }
219
220    #[test]
221    fn shell_hint_none_for_unknown_command() {
222        let state = AutonomyState::new();
223        let hint = shell_efficiency_hint(&state, "cargo build", 100, 95);
224        assert!(hint.is_none());
225    }
226
227    #[test]
228    fn disabled_state_blocks_all() {
229        let mut state = AutonomyState::new();
230        state.config.enabled = false;
231        assert!(!state.is_enabled());
232        let hint = shell_efficiency_hint(&state, "grep foo", 100, 95);
233        assert!(hint.is_none());
234    }
235
236    #[test]
237    fn track_search_none_first_three() {
238        let _lock = lean_ctx::core::data_dir::test_env_lock();
239        let state = AutonomyState::new();
240        assert!(state.track_search("foo", "src").is_none());
241        assert!(state.track_search("foo", "src").is_none());
242        assert!(state.track_search("foo", "src").is_none());
243    }
244
245    #[test]
246    fn track_search_hint_band() {
247        let _lock = lean_ctx::core::data_dir::test_env_lock();
248        let state = AutonomyState::new();
249        for _ in 0..3 {
250            assert!(state.track_search("bar", ".").is_none());
251        }
252        let h = state.track_search("bar", ".").expect("hint on 4th");
253        assert!(h.starts_with("[hint: repeated search (4/6)."));
254        assert!(h.contains("ctx_knowledge"));
255    }
256
257    #[test]
258    fn track_search_throttle_seventh() {
259        let _lock = lean_ctx::core::data_dir::test_env_lock();
260        let state = AutonomyState::new();
261        for _ in 0..6 {
262            let _ = state.track_search("baz", "p");
263        }
264        let h = state.track_search("baz", "p").expect("throttle on 7th");
265        assert!(h.starts_with("[throttle: search repeated 7 times"));
266        assert!(h.contains("ctx_pack"));
267    }
268
269    #[test]
270    fn track_search_resets_after_idle() {
271        let _lock = lean_ctx::core::data_dir::test_env_lock();
272        let state = AutonomyState::new();
273        for _ in 0..3 {
274            assert!(state.track_search("idle", "x").is_none());
275        }
276        std::thread::sleep(std::time::Duration::from_millis(600));
277        assert!(
278            state.track_search("idle", "x").is_none(),
279            "count should reset after idle window"
280        );
281    }
282
283    #[test]
284    fn track_search_disabled_no_tracking_messages() {
285        let _lock = lean_ctx::core::data_dir::test_env_lock();
286        let mut state = AutonomyState::new();
287        state.config.enabled = false;
288        for _ in 0..8 {
289            assert!(state.track_search("q", "/").is_none());
290        }
291    }
292
293    #[test]
294    fn track_search_distinct_keys() {
295        let _lock = lean_ctx::core::data_dir::test_env_lock();
296        let state = AutonomyState::new();
297        assert!(state.track_search("pat", "a").is_none());
298        assert!(state.track_search("pat", "a").is_none());
299        assert!(state.track_search("pat", "a").is_none());
300        assert!(state.track_search("pat", "a").is_some());
301        assert!(state.track_search("pat", "b").is_none());
302    }
303}