Skip to main content

lean_ctx/tools/
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::autonomy_drivers::{
7    AutonomyDriverDecisionV1, AutonomyDriverEventV1, AutonomyDriverKindV1, AutonomyPhaseV1,
8    AutonomyVerdictV1,
9};
10use crate::core::cache::SessionCache;
11use crate::core::config::AutonomyConfig;
12use crate::core::graph_provider::GraphProvider;
13use crate::core::protocol;
14use crate::core::tokens::count_tokens;
15use crate::tools::CrpMode;
16
17#[cfg(test)]
18const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_millis(500);
19#[cfg(not(test))]
20const SEARCH_REPEAT_IDLE_RESET: Duration = Duration::from_mins(5);
21
22/// Per-key stats for progressive search hints (`ctx_search` / `ctx_semantic_search`).
23#[derive(Debug, Clone)]
24pub struct SearchHistory {
25    pub call_count: u32,
26    pub last_call: Instant,
27}
28
29/// Tracks autonomous action state: session init, dedup, and consolidation timing.
30pub struct AutonomyState {
31    pub session_initialized: AtomicBool,
32    pub dedup_applied: AtomicBool,
33    pub last_consolidation_unix: AtomicU64,
34    pub config: AutonomyConfig,
35    /// Repeated `pattern|path` keys for search tools (see [`AutonomyState::track_search`]).
36    pub search_repetition: Mutex<HashMap<String, SearchHistory>>,
37    /// One-shot keys for large-output hints (`ctx_shell` bytes, `ctx_read` full tokens).
38    pub large_output_hints_shown: Mutex<HashSet<String>>,
39}
40
41impl Default for AutonomyState {
42    fn default() -> Self {
43        Self::new()
44    }
45}
46
47impl AutonomyState {
48    /// Creates a new autonomy state with config loaded from disk.
49    pub fn new() -> Self {
50        Self {
51            session_initialized: AtomicBool::new(false),
52            dedup_applied: AtomicBool::new(false),
53            last_consolidation_unix: AtomicU64::new(0),
54            config: AutonomyConfig::load(),
55            search_repetition: Mutex::new(HashMap::new()),
56            large_output_hints_shown: Mutex::new(HashSet::new()),
57        }
58    }
59
60    /// Returns true if autonomous actions are enabled in configuration.
61    pub fn is_enabled(&self) -> bool {
62        self.config.enabled
63    }
64
65    /// Records a search (`pattern` + `path` key) and returns a progressive hint after repeated calls.
66    ///
67    /// Uses interior mutability so this can be called on `Arc<AutonomyState>`. Counters reset when
68    /// the idle gap since the last call for that key is at least five minutes (50ms in unit tests).
69    pub fn track_search(&self, pattern: &str, path: &str) -> Option<String> {
70        if !autonomy_enabled_effective(self) {
71            return None;
72        }
73        let key = format!("{pattern}|{path}");
74        let now = Instant::now();
75        let mut map = self
76            .search_repetition
77            .lock()
78            .unwrap_or_else(std::sync::PoisonError::into_inner);
79        let hist = map.entry(key).or_insert(SearchHistory {
80            call_count: 0,
81            last_call: now,
82        });
83        if hist.last_call.elapsed() >= SEARCH_REPEAT_IDLE_RESET {
84            hist.call_count = 0;
85        }
86        hist.call_count = hist.call_count.saturating_add(1);
87        hist.last_call = now;
88        let n = hist.call_count;
89
90        match n {
91            1..=3 => None,
92            4..=6 => Some(format!(
93                "[hint: repeated search ({n}/6). Consider ctx_knowledge remember to store findings]"
94            )),
95            _ => Some(format!(
96                "[throttle: search repeated {n} times on same pattern. Use ctx_pack or ctx_knowledge to consolidate]"
97            )),
98        }
99    }
100}
101
102fn profile_autonomy() -> crate::core::profiles::ProfileAutonomy {
103    crate::core::profiles::active_profile().autonomy
104}
105
106fn autonomy_enabled_effective(state: &AutonomyState) -> bool {
107    state.is_enabled() && profile_autonomy().enabled_effective()
108}
109
110fn policy_allows(tool: &str) -> Result<(), (String, String)> {
111    let policy = crate::core::degradation_policy::evaluate_v1_for_tool(tool, None);
112    match policy.decision.verdict {
113        crate::core::degradation_policy::DegradationVerdictV1::Ok
114        | crate::core::degradation_policy::DegradationVerdictV1::Warn => Ok(()),
115        crate::core::degradation_policy::DegradationVerdictV1::Throttle
116        | crate::core::degradation_policy::DegradationVerdictV1::Block => {
117            Err((policy.decision.reason_code, policy.decision.reason))
118        }
119    }
120}
121
122fn record_event(
123    phase: AutonomyPhaseV1,
124    tool: &str,
125    action: Option<&str>,
126    decisions: Vec<AutonomyDriverDecisionV1>,
127) {
128    let mut store = crate::core::autonomy_drivers::AutonomyDriversV1::load();
129    let ev = AutonomyDriverEventV1 {
130        seq: 0,
131        created_at: chrono::Utc::now().to_rfc3339(),
132        phase,
133        role: crate::core::roles::active_role_name(),
134        profile: crate::core::profiles::active_profile_name(),
135        tool: tool.to_string(),
136        action: action.map(std::string::ToString::to_string),
137        decisions,
138    };
139    store.record(ev);
140    let _ = store.save();
141}
142
143/// Auto-preloads project context on the first tool call of a session.
144pub fn session_lifecycle_pre_hook(
145    state: &AutonomyState,
146    tool_name: &str,
147    cache: &mut SessionCache,
148    task: Option<&str>,
149    project_root: Option<&str>,
150    crp_mode: CrpMode,
151) -> Option<String> {
152    if !autonomy_enabled_effective(state) {
153        return None;
154    }
155
156    if tool_name == "ctx_overview" || tool_name == "ctx_preload" {
157        return None;
158    }
159
160    let prof = profile_autonomy();
161    let root = match project_root {
162        Some(r) if !r.is_empty() && r != "." => r.to_string(),
163        _ => return None,
164    };
165
166    // Keep the session eligible for a later retry: do not claim initialization
167    // while the guardian is asking opportunistic work to back off.
168    if crate::core::memory_guard::is_under_pressure() {
169        return None;
170    }
171
172    if state
173        .session_initialized
174        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
175        .is_err()
176    {
177        return None;
178    }
179
180    let mut decisions = Vec::new();
181
182    if !state.config.auto_preload || !prof.auto_preload_effective() {
183        decisions.push(AutonomyDriverDecisionV1 {
184            driver: AutonomyDriverKindV1::Preload,
185            verdict: AutonomyVerdictV1::Skip,
186            reason_code: "disabled".to_string(),
187            reason: "auto_preload disabled by config/profile".to_string(),
188            detail: None,
189        });
190        record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
191        return None;
192    }
193
194    let chosen_tool = if task.is_some() {
195        "ctx_preload"
196    } else {
197        "ctx_overview"
198    };
199    if let Err((code, reason)) = policy_allows(chosen_tool) {
200        decisions.push(AutonomyDriverDecisionV1 {
201            driver: AutonomyDriverKindV1::Preload,
202            verdict: AutonomyVerdictV1::Skip,
203            reason_code: code,
204            reason,
205            detail: Some("policy guard (budget/slo)".to_string()),
206        });
207        record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
208        return None;
209    }
210
211    let result = if let Some(task_desc) = task {
212        crate::tools::ctx_preload::handle(cache, task_desc, Some(&root), crp_mode)
213    } else {
214        let cache_readonly = &*cache;
215        crate::tools::ctx_overview::handle(cache_readonly, None, Some(&root), crp_mode)
216    };
217
218    let empty = result.trim().is_empty()
219        || result.contains("No directly relevant files")
220        || result.contains("INDEXING IN PROGRESS");
221    decisions.push(AutonomyDriverDecisionV1 {
222        driver: AutonomyDriverKindV1::Preload,
223        verdict: AutonomyVerdictV1::Run,
224        reason_code: "session_start".to_string(),
225        reason: "first tool call in session".to_string(),
226        detail: Some(format!("tool={chosen_tool} empty={empty}")),
227    });
228    record_event(AutonomyPhaseV1::PreCall, tool_name, None, decisions);
229
230    if empty {
231        return None;
232    }
233
234    Some(format!(
235        "--- AUTO CONTEXT ---\n{result}\n--- END AUTO CONTEXT ---"
236    ))
237}
238
239/// Appends related-file hints and silently preloads imports after a file read.
240pub fn enrich_after_read(
241    state: &AutonomyState,
242    cache: &mut SessionCache,
243    file_path: &str,
244    project_root: Option<&str>,
245    task: Option<&str>,
246    crp_mode: CrpMode,
247    minimal_overhead: bool,
248) -> EnrichResult {
249    let mut result = EnrichResult::default();
250
251    if !autonomy_enabled_effective(state) {
252        return result;
253    }
254
255    let prof = profile_autonomy();
256    let root = match project_root {
257        Some(r) if !r.is_empty() && r != "." => r.to_string(),
258        _ => return result,
259    };
260
261    let Some(open) = crate::core::graph_provider::open_or_build(&root) else {
262        return result;
263    };
264    let provider = &open.provider;
265    if provider.file_count() == 0 {
266        return result;
267    }
268
269    if state.config.auto_related && prof.auto_related_effective() {
270        result.related_hint = build_related_hints(cache, file_path, provider);
271    }
272
273    if state.config.silent_preload && prof.silent_preload_effective() {
274        silent_preload_imports(cache, file_path, provider, &root);
275    }
276
277    if !minimal_overhead && prof.auto_prefetch_effective() {
278        let mut decisions = Vec::new();
279        if let Err((code, reason)) = policy_allows("ctx_prefetch") {
280            decisions.push(AutonomyDriverDecisionV1 {
281                driver: AutonomyDriverKindV1::Prefetch,
282                verdict: AutonomyVerdictV1::Skip,
283                reason_code: code,
284                reason,
285                detail: Some("policy guard (budget/slo)".to_string()),
286            });
287            record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
288        } else {
289            let changed = vec![file_path.to_string()];
290            let out = crate::tools::ctx_prefetch::handle(
291                cache,
292                &root,
293                task,
294                Some(&changed),
295                prof.prefetch_budget_tokens_effective(),
296                Some(prof.prefetch_max_files_effective()),
297                crp_mode,
298            );
299            let summary = out.lines().next().unwrap_or("").trim().to_string();
300            decisions.push(AutonomyDriverDecisionV1 {
301                driver: AutonomyDriverKindV1::Prefetch,
302                verdict: AutonomyVerdictV1::Run,
303                reason_code: "after_read".to_string(),
304                reason: "bounded prefetch after ctx_read".to_string(),
305                detail: if summary.is_empty() {
306                    None
307                } else {
308                    Some(summary.clone())
309                },
310            });
311            record_event(AutonomyPhaseV1::PostRead, "ctx_read", None, decisions);
312            let _ = summary;
313        }
314    }
315
316    result
317}
318
319/// Output from post-read enrichment: optional related-file hints.
320#[derive(Default)]
321pub struct EnrichResult {
322    pub related_hint: Option<String>,
323}
324
325fn build_related_hints(
326    cache: &SessionCache,
327    file_path: &str,
328    provider: &GraphProvider,
329) -> Option<String> {
330    let mut related: Vec<String> = Vec::new();
331    for path in provider
332        .dependencies(file_path)
333        .into_iter()
334        .chain(provider.dependents(file_path))
335    {
336        if related.len() >= 3 {
337            break;
338        }
339        if cache.get(&path).is_none() && !related.contains(&path) {
340            related.push(path);
341        }
342    }
343
344    if related.is_empty() {
345        return None;
346    }
347
348    let hints: Vec<String> = related.iter().map(|p| protocol::shorten_path(p)).collect();
349
350    Some(format!("[related: {}]", hints.join(", ")))
351}
352
353fn silent_preload_imports(
354    cache: &mut SessionCache,
355    file_path: &str,
356    provider: &GraphProvider,
357    project_root: &str,
358) {
359    let imports: Vec<String> = provider
360        .dependencies(file_path)
361        .into_iter()
362        .take(2)
363        .collect();
364
365    let jail_root = std::path::Path::new(project_root);
366    for path in imports {
367        let candidate = std::path::Path::new(&path);
368        let candidate = if candidate.is_absolute() {
369            candidate.to_path_buf()
370        } else {
371            jail_root.join(&path)
372        };
373        let Ok((jailed, warning)) = crate::core::io_boundary::jail_and_check_path(
374            "autonomy:silent_preload",
375            &candidate,
376            jail_root,
377        ) else {
378            continue;
379        };
380        if warning.is_some() {
381            continue;
382        }
383        let jailed_s = jailed.to_string_lossy().to_string();
384        if cache.get(&jailed_s).is_some() {
385            continue;
386        }
387        // Don't hydrate cloud placeholders during automatic import preload (#363).
388        if crate::core::cloud_files::is_cloud_placeholder(&jailed) {
389            continue;
390        }
391
392        if let Ok(content) = std::fs::read_to_string(&jailed) {
393            let tokens = count_tokens(&content);
394            if tokens < 5000 {
395                cache.store(&jailed_s, &content);
396            }
397        }
398    }
399}
400
401/// Runs cache deduplication once the entry count exceeds the configured threshold.
402pub fn maybe_auto_dedup(state: &AutonomyState, cache: &mut SessionCache, trigger_tool: &str) {
403    if !autonomy_enabled_effective(state) {
404        return;
405    }
406
407    let prof = profile_autonomy();
408    if !state.config.auto_dedup || !prof.auto_dedup_effective() {
409        return;
410    }
411
412    if state
413        .dedup_applied
414        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
415        .is_err()
416    {
417        return;
418    }
419
420    let entries = cache.get_all_entries();
421    let threshold = state
422        .config
423        .dedup_threshold
424        .max(prof.dedup_threshold_effective())
425        .max(1);
426    if entries.len() < threshold {
427        state.dedup_applied.store(false, Ordering::SeqCst);
428        return;
429    }
430
431    let mut decisions = Vec::new();
432    if let Err((code, reason)) = policy_allows("ctx_dedup") {
433        decisions.push(AutonomyDriverDecisionV1 {
434            driver: AutonomyDriverKindV1::Dedup,
435            verdict: AutonomyVerdictV1::Skip,
436            reason_code: code,
437            reason,
438            detail: Some("policy guard (budget/slo)".to_string()),
439        });
440        record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
441        state.dedup_applied.store(false, Ordering::SeqCst);
442        return;
443    }
444
445    let out = crate::tools::ctx_dedup::handle_action(cache, "apply");
446    let summary = out.lines().next().unwrap_or("").trim().to_string();
447    decisions.push(AutonomyDriverDecisionV1 {
448        driver: AutonomyDriverKindV1::Dedup,
449        verdict: AutonomyVerdictV1::Run,
450        reason_code: "threshold_reached".to_string(),
451        reason: format!("cache entries >= {threshold}"),
452        detail: if summary.is_empty() {
453            None
454        } else {
455            Some(summary)
456        },
457    });
458    record_event(AutonomyPhaseV1::PostRead, trigger_tool, None, decisions);
459}
460
461/// Returns true if enough tool calls have elapsed to trigger auto-consolidation.
462pub fn should_auto_consolidate(state: &AutonomyState, tool_calls: u32) -> bool {
463    if !state.is_enabled() || !state.config.auto_consolidate {
464        return false;
465    }
466    let every = state.config.consolidate_every_calls.max(1);
467    if !tool_calls.is_multiple_of(every) {
468        return false;
469    }
470
471    let now = SystemTime::now()
472        .duration_since(UNIX_EPOCH)
473        .map_or(0, |d| d.as_secs());
474    let last = state.last_consolidation_unix.load(Ordering::SeqCst);
475    if now.saturating_sub(last) < state.config.consolidate_cooldown_secs {
476        return false;
477    }
478    state.last_consolidation_unix.store(now, Ordering::SeqCst);
479    true
480}
481
482fn take_large_output_hint_once(state: &AutonomyState, key: &str) -> bool {
483    if !autonomy_enabled_effective(state) {
484        return false;
485    }
486    let mut set = state
487        .large_output_hints_shown
488        .lock()
489        .unwrap_or_else(std::sync::PoisonError::into_inner);
490    set.insert(key.to_string())
491}
492
493/// `ctx_shell`: suggest sandbox / read modes when final output is large (bytes).
494pub fn large_ctx_shell_output_hint(
495    state: &AutonomyState,
496    command: &str,
497    output_bytes: usize,
498) -> Option<String> {
499    const THRESHOLD_BYTES: usize = 5000;
500    if output_bytes <= THRESHOLD_BYTES {
501        return None;
502    }
503    if !take_large_output_hint_once(state, "ctx_shell_large_bytes") {
504        return None;
505    }
506    let n = output_bytes;
507    if shell_command_looks_structured(command) {
508        Some(format!(
509            "[hint: large output ({n} bytes). For structured output (e.g. cargo test, npm test, grep), use ctx_execute for automatic compression; for file contents use ctx_read(mode=\"aggressive\")]"
510        ))
511    } else {
512        Some(format!(
513            "[hint: large output ({n} bytes). Consider piping through ctx_execute for automatic compression, or use ctx_read(mode=\"aggressive\") for file contents]"
514        ))
515    }
516}
517
518fn shell_command_looks_structured(cmd: &str) -> bool {
519    let t = cmd.trim();
520    let lower = t.to_lowercase();
521    lower.contains("cargo test")
522        || lower.contains("npm test")
523        || t.starts_with("grep ")
524        || t.starts_with("rg ")
525}
526
527/// `ctx_read` full mode: suggest compressed read modes when output is very large (tokens).
528pub fn large_ctx_read_full_hint(
529    state: &AutonomyState,
530    mode: Option<&str>,
531    output: &str,
532) -> Option<String> {
533    const THRESHOLD_TOKENS: usize = 10_000;
534    let m = mode.unwrap_or("").trim();
535    if m != "full" {
536        return None;
537    }
538    let n = count_tokens(output);
539    if n <= THRESHOLD_TOKENS {
540        return None;
541    }
542    if !take_large_output_hint_once(state, "ctx_read_full_large_tokens") {
543        return None;
544    }
545    Some(format!(
546        "[hint: large file ({n} tokens). Consider mode=\"map\" or mode=\"aggressive\" for compressed view]"
547    ))
548}
549
550/// Suggests a more token-efficient lean-ctx tool when shell compression is low.
551pub fn shell_efficiency_hint(
552    state: &AutonomyState,
553    command: &str,
554    input_tokens: usize,
555    output_tokens: usize,
556) -> Option<String> {
557    if !autonomy_enabled_effective(state) {
558        return None;
559    }
560
561    if input_tokens == 0 {
562        return None;
563    }
564
565    let savings_pct =
566        (input_tokens.saturating_sub(output_tokens) as f64 / input_tokens as f64) * 100.0;
567    if savings_pct >= 20.0 {
568        return None;
569    }
570
571    let cmd_lower = command.to_lowercase();
572    if cmd_lower.starts_with("grep ")
573        || cmd_lower.starts_with("rg ")
574        || cmd_lower.starts_with("find ")
575        || cmd_lower.starts_with("ag ")
576    {
577        return Some("[hint: ctx_search is more token-efficient for code search]".to_string());
578    }
579
580    if cmd_lower.starts_with("cat ") || cmd_lower.starts_with("head ") {
581        return Some("[hint: ctx_read provides cached, compressed file access]".to_string());
582    }
583
584    None
585}
586
587fn looks_like_json(text: &str) -> bool {
588    let t = text.trim();
589    if !(t.starts_with('{') || t.starts_with('[')) {
590        return false;
591    }
592    serde_json::from_str::<serde_json::Value>(t).is_ok()
593}
594
595/// Applies `ctx_response` automatically for large outputs (guarded + bounded).
596/// Never runs on JSON outputs to avoid breaking machine-readable responses.
597pub fn maybe_auto_response(
598    state: &AutonomyState,
599    tool_name: &str,
600    action: Option<&str>,
601    output: &str,
602    crp_mode: CrpMode,
603    minimal_overhead: bool,
604) -> String {
605    if minimal_overhead || !autonomy_enabled_effective(state) {
606        return output.to_string();
607    }
608
609    let prof = profile_autonomy();
610    if !prof.auto_response_effective() {
611        return output.to_string();
612    }
613    if tool_name == "ctx_response" {
614        return output.to_string();
615    }
616
617    let input_tokens = count_tokens(output);
618    if input_tokens < prof.response_min_tokens_effective() {
619        return output.to_string();
620    }
621    if looks_like_json(output) {
622        record_event(
623            AutonomyPhaseV1::PostCall,
624            tool_name,
625            action,
626            vec![AutonomyDriverDecisionV1 {
627                driver: AutonomyDriverKindV1::Response,
628                verdict: AutonomyVerdictV1::Skip,
629                reason_code: "json_output".to_string(),
630                reason: "skip response shaping for JSON outputs".to_string(),
631                detail: None,
632            }],
633        );
634        return output.to_string();
635    }
636
637    if let Err((code, reason)) = policy_allows("ctx_response") {
638        record_event(
639            AutonomyPhaseV1::PostCall,
640            tool_name,
641            action,
642            vec![AutonomyDriverDecisionV1 {
643                driver: AutonomyDriverKindV1::Response,
644                verdict: AutonomyVerdictV1::Skip,
645                reason_code: code,
646                reason,
647                detail: Some("policy guard (budget/slo)".to_string()),
648            }],
649        );
650        return output.to_string();
651    }
652
653    let start = std::time::Instant::now();
654    let compressed = crate::tools::ctx_response::handle(output, crp_mode);
655    let duration = start.elapsed();
656    let output_tokens = count_tokens(&compressed);
657
658    let (verdict, reason_code, reason) = if compressed == output {
659        (
660            AutonomyVerdictV1::Skip,
661            "no_savings".to_string(),
662            "ctx_response made no changes".to_string(),
663        )
664    } else {
665        (
666            AutonomyVerdictV1::Run,
667            "output_large".to_string(),
668            "response shaping applied".to_string(),
669        )
670    };
671
672    record_event(
673        AutonomyPhaseV1::PostCall,
674        tool_name,
675        action,
676        vec![AutonomyDriverDecisionV1 {
677            driver: AutonomyDriverKindV1::Response,
678            verdict,
679            reason_code,
680            reason,
681            detail: Some(format!(
682                "tokens {}→{} in {:.1}ms",
683                input_tokens,
684                output_tokens,
685                duration.as_micros() as f64 / 1000.0
686            )),
687        }],
688    );
689
690    compressed
691}
692
693#[cfg(test)]
694mod tests {
695    use super::*;
696
697    #[test]
698    fn autonomy_state_starts_uninitialized() {
699        let state = AutonomyState::new();
700        assert!(!state.session_initialized.load(Ordering::SeqCst));
701        assert!(!state.dedup_applied.load(Ordering::SeqCst));
702    }
703
704    #[test]
705    fn session_initialized_fires_once() {
706        let state = AutonomyState::new();
707        let first = state.session_initialized.compare_exchange(
708            false,
709            true,
710            Ordering::SeqCst,
711            Ordering::SeqCst,
712        );
713        assert!(first.is_ok());
714        let second = state.session_initialized.compare_exchange(
715            false,
716            true,
717            Ordering::SeqCst,
718            Ordering::SeqCst,
719        );
720        assert!(second.is_err());
721    }
722
723    #[test]
724    fn shell_hint_for_grep() {
725        let state = AutonomyState::new();
726        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 95);
727        assert!(hint.is_some());
728        assert!(hint.unwrap().contains("ctx_search"));
729    }
730
731    #[test]
732    fn shell_hint_none_when_good_savings() {
733        let state = AutonomyState::new();
734        let hint = shell_efficiency_hint(&state, "grep -rn foo .", 100, 50);
735        assert!(hint.is_none());
736    }
737
738    #[test]
739    fn shell_hint_none_for_unknown_command() {
740        let state = AutonomyState::new();
741        let hint = shell_efficiency_hint(&state, "cargo build", 100, 95);
742        assert!(hint.is_none());
743    }
744
745    #[test]
746    fn large_shell_hint_once_per_session() {
747        let state = AutonomyState::new();
748        let h1 = large_ctx_shell_output_hint(&state, "ls -la", 5001).expect("first");
749        assert!(h1.contains("5001 bytes"));
750        assert!(h1.contains("ctx_execute"));
751        assert!(large_ctx_shell_output_hint(&state, "ls -la", 5001).is_none());
752    }
753
754    #[test]
755    fn large_shell_structured_hint_mentions_execute() {
756        let state = AutonomyState::new();
757        let h = large_ctx_shell_output_hint(&state, "cargo test", 6000).expect("hint");
758        assert!(h.contains("structured"));
759        assert!(h.contains("ctx_execute"));
760    }
761
762    #[test]
763    fn large_read_full_hint_respects_mode() {
764        let state = AutonomyState::new();
765        let big = "word ".repeat(20_000);
766        assert!(large_ctx_read_full_hint(&state, Some("map"), &big).is_none());
767        let h = large_ctx_read_full_hint(&state, Some("full"), &big).expect("hint");
768        assert!(h.contains("tokens"));
769        assert!(h.contains("aggressive"));
770        assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
771    }
772
773    #[test]
774    fn large_hints_disabled_when_autonomy_off() {
775        let mut state = AutonomyState::new();
776        state.config.enabled = false;
777        let big = "word ".repeat(20_000);
778        assert!(large_ctx_shell_output_hint(&state, "cargo test", 6000).is_none());
779        assert!(large_ctx_read_full_hint(&state, Some("full"), &big).is_none());
780    }
781
782    #[test]
783    fn disabled_state_blocks_all() {
784        let mut state = AutonomyState::new();
785        state.config.enabled = false;
786        assert!(!state.is_enabled());
787        let hint = shell_efficiency_hint(&state, "grep foo", 100, 95);
788        assert!(hint.is_none());
789    }
790
791    #[test]
792    fn track_search_none_first_three() {
793        let _lock = crate::core::data_dir::test_env_lock();
794        let state = AutonomyState::new();
795        assert!(state.track_search("foo", "src").is_none());
796        assert!(state.track_search("foo", "src").is_none());
797        assert!(state.track_search("foo", "src").is_none());
798    }
799
800    #[test]
801    fn track_search_hint_band() {
802        let _lock = crate::core::data_dir::test_env_lock();
803        let state = AutonomyState::new();
804        for _ in 0..3 {
805            assert!(state.track_search("bar", ".").is_none());
806        }
807        let h = state.track_search("bar", ".").expect("hint on 4th");
808        assert!(h.starts_with("[hint: repeated search (4/6)."));
809        assert!(h.contains("ctx_knowledge"));
810    }
811
812    #[test]
813    fn track_search_throttle_seventh() {
814        let _lock = crate::core::data_dir::test_env_lock();
815        let state = AutonomyState::new();
816        for _ in 0..6 {
817            let _ = state.track_search("baz", "p");
818        }
819        let h = state.track_search("baz", "p").expect("throttle on 7th");
820        assert!(h.starts_with("[throttle: search repeated 7 times"));
821        assert!(h.contains("ctx_pack"));
822    }
823
824    #[test]
825    fn track_search_resets_after_idle() {
826        let _lock = crate::core::data_dir::test_env_lock();
827        let state = AutonomyState::new();
828        for _ in 0..3 {
829            assert!(state.track_search("idle", "x").is_none());
830        }
831        std::thread::sleep(std::time::Duration::from_millis(600));
832        assert!(
833            state.track_search("idle", "x").is_none(),
834            "count should reset after idle window"
835        );
836    }
837
838    #[test]
839    fn track_search_disabled_no_tracking_messages() {
840        let _lock = crate::core::data_dir::test_env_lock();
841        let mut state = AutonomyState::new();
842        state.config.enabled = false;
843        for _ in 0..8 {
844            assert!(state.track_search("q", "/").is_none());
845        }
846    }
847
848    #[test]
849    fn track_search_distinct_keys() {
850        let _lock = crate::core::data_dir::test_env_lock();
851        let state = AutonomyState::new();
852        assert!(state.track_search("pat", "a").is_none());
853        assert!(state.track_search("pat", "a").is_none());
854        assert!(state.track_search("pat", "a").is_none());
855        assert!(state.track_search("pat", "a").is_some());
856        assert!(state.track_search("pat", "b").is_none());
857    }
858}