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