1use crate::core::context_field::{ContextItemId, ContextState};
2use crate::core::context_ledger::{ContextLedger, PressureAction};
3use crate::core::context_overlay::{OverlayOp, OverlayStore};
4
5#[derive(Debug, Clone)]
6pub struct PreDispatchResult {
7 pub overridden_mode: Option<String>,
8 pub reason: Option<&'static str>,
9 pub pressure_downgraded: bool,
10 pub budget_blocked: bool,
11 pub budget_warning: Option<String>,
12}
13
14#[derive(Debug, Clone)]
15pub struct PostDispatchResult {
16 pub eviction_hint: Option<String>,
17 pub elicitation_hint: Option<String>,
18 pub resource_changed: bool,
19}
20
21pub fn pre_dispatch_read(
22 path: &str,
23 requested_mode: &str,
24 task: Option<&str>,
25 project_root: Option<&str>,
26 pressure: Option<&PressureAction>,
27) -> PreDispatchResult {
28 pre_dispatch_read_for_agent(path, requested_mode, task, project_root, pressure, None)
29}
30
31pub fn pre_dispatch_read_for_agent(
32 path: &str,
33 requested_mode: &str,
34 task: Option<&str>,
35 project_root: Option<&str>,
36 pressure: Option<&PressureAction>,
37 agent_id: Option<&str>,
38) -> PreDispatchResult {
39 let no_change = PreDispatchResult {
40 overridden_mode: None,
41 reason: None,
42 pressure_downgraded: false,
43 budget_blocked: false,
44 budget_warning: None,
45 };
46
47 if let Some(aid) = agent_id {
48 let estimated_tokens = estimate_read_tokens(path, requested_mode);
49 match crate::core::agent_budget::check_budget(aid, estimated_tokens) {
50 crate::core::agent_budget::BudgetCheckResult::Exceeded { limit, consumed } => {
51 return PreDispatchResult {
52 overridden_mode: None,
53 reason: Some("agent-budget-exceeded"),
54 pressure_downgraded: false,
55 budget_blocked: true,
56 budget_warning: Some(format!(
57 "Agent budget exceeded: {consumed}/{limit} tokens consumed. Reset via ctx_session or set a higher limit."
58 )),
59 };
60 }
61 crate::core::agent_budget::BudgetCheckResult::Warning {
62 remaining,
63 percent_used,
64 } => {
65 let warning = format!(
66 "[BUDGET WARNING] Agent '{aid}' at {:.0}% budget ({remaining} tokens remaining)",
67 percent_used * 100.0
68 );
69 let mut result = no_change.clone();
70 result.budget_warning = Some(warning);
71 if requested_mode == "diff" || requested_mode.starts_with("lines") {
72 return result;
73 }
74 let rest = pre_dispatch_inner(path, requested_mode, task, project_root, pressure);
75 return PreDispatchResult {
76 budget_warning: result.budget_warning,
77 ..rest
78 };
79 }
80 crate::core::agent_budget::BudgetCheckResult::Allowed { .. } => {}
81 }
82 }
83
84 pre_dispatch_inner(path, requested_mode, task, project_root, pressure)
85}
86
87fn pre_dispatch_inner(
88 path: &str,
89 requested_mode: &str,
90 task: Option<&str>,
91 project_root: Option<&str>,
92 pressure: Option<&PressureAction>,
93) -> PreDispatchResult {
94 let no_change = PreDispatchResult {
95 overridden_mode: None,
96 reason: None,
97 pressure_downgraded: false,
98 budget_blocked: false,
99 budget_warning: None,
100 };
101
102 if requested_mode == "diff" || requested_mode.starts_with("lines") {
103 return no_change;
104 }
105
106 if let Some(root) = project_root {
107 let overlay = OverlayStore::load_project(&std::path::PathBuf::from(root));
108 if let Some(result) = check_overlay_mode_override(path, requested_mode, &overlay) {
109 return result;
110 }
111 }
112
113 if requested_mode == "full" {
116 return no_change;
117 }
118
119 if let Some(action) = pressure {
120 let no_degrade = crate::core::config::Config::load().no_degrade_effective();
121 let profile = crate::core::profiles::active_profile();
122 if !no_degrade && profile.degradation.enforce_effective() {
123 if let Some(downgraded) = pressure_downgrade(requested_mode, action) {
124 return PreDispatchResult {
125 overridden_mode: Some(downgraded),
126 reason: Some("pressure-auto-downgrade"),
127 pressure_downgraded: true,
128 budget_blocked: false,
129 budget_warning: None,
130 };
131 }
132 }
133 }
134
135 if let Ok(bt) = crate::core::bounce_tracker::global().lock() {
136 if bt.should_force_full(path) {
137 return PreDispatchResult {
138 overridden_mode: Some("full".to_string()),
139 reason: Some("bounce-prevention"),
140 pressure_downgraded: false,
141 budget_blocked: false,
142 budget_warning: None,
143 };
144 }
145 }
146
147 if let Some(task_str) = task {
148 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
149 let norm = crate::core::pathutil::normalize_tool_path(path);
150 let is_target = intent
151 .targets
152 .iter()
153 .any(|t| norm.ends_with(t) || norm.contains(t));
154 if is_target {
155 return PreDispatchResult {
156 overridden_mode: Some("full".to_string()),
157 reason: Some("intent-target"),
158 pressure_downgraded: false,
159 budget_blocked: false,
160 budget_warning: None,
161 };
162 }
163 }
164
165 if let Some(root) = project_root {
166 if let Some(open) = try_load_graph(root) {
167 let gp = &open.provider;
168 let related = gp.related(path, 1);
169 if let Some(task_str) = task {
170 let intent = crate::core::intent_engine::StructuredIntent::from_query(task_str);
171 for target in &intent.targets {
172 let target_related = gp.related(target, 1);
173 let norm = crate::core::pathutil::normalize_tool_path(path);
174 if target_related
175 .iter()
176 .any(|r| r.contains(&norm) || norm.contains(r))
177 {
178 return PreDispatchResult {
179 overridden_mode: Some("map".to_string()),
180 reason: Some("graph-direct-import"),
181 pressure_downgraded: false,
182 budget_blocked: false,
183 budget_warning: None,
184 };
185 }
186 }
187 }
188 if !related.is_empty() && requested_mode == "auto" {
189 let reverse_deps = gp.dependents(path);
190 if reverse_deps.len() > 3 {
191 return PreDispatchResult {
192 overridden_mode: Some("map".to_string()),
193 reason: Some("graph-hub-file"),
194 pressure_downgraded: false,
195 budget_blocked: false,
196 budget_warning: None,
197 };
198 }
199 }
200 }
201 }
202
203 if let Some(root) = project_root {
204 if let Some(knowledge) = crate::core::knowledge::ProjectKnowledge::load(root) {
205 let norm = crate::core::pathutil::normalize_tool_path(path);
206 let mentions = knowledge
207 .facts
208 .iter()
209 .filter(|f| f.value.contains(&norm) || f.key.contains(&norm))
210 .count();
211 if mentions >= 3 {
212 return PreDispatchResult {
213 overridden_mode: Some("map".to_string()),
214 reason: Some("knowledge-high-relevance"),
215 pressure_downgraded: false,
216 budget_blocked: false,
217 budget_warning: None,
218 };
219 }
220 }
221 }
222
223 no_change
224}
225
226fn estimate_read_tokens(path: &str, mode: &str) -> usize {
227 let file_size = std::fs::metadata(path).map_or(4000, |m| m.len() as usize);
228 let char_estimate = file_size;
229 let full_tokens = char_estimate / 4;
230 match mode {
231 "signatures" => full_tokens / 5,
232 "map" => full_tokens / 3,
233 "aggressive" | "entropy" => full_tokens / 4,
234 "diff" => full_tokens / 10,
235 _ if mode.starts_with("lines:") => {
236 if let Some(range) = mode.strip_prefix("lines:") {
237 let parts: Vec<&str> = range.split('-').collect();
238 if parts.len() == 2 {
239 let start = parts[0].parse::<usize>().unwrap_or(1);
240 let end = parts[1].parse::<usize>().unwrap_or(start + 100);
241 (end.saturating_sub(start) + 1) * 10
242 } else {
243 full_tokens / 10
244 }
245 } else {
246 full_tokens / 10
247 }
248 }
249 _ => full_tokens,
250 }
251}
252
253fn pressure_downgrade(requested_mode: &str, action: &PressureAction) -> Option<String> {
254 crate::core::auto_mode_resolver::pressure_downgrade(requested_mode, action)
255}
256
257fn check_overlay_mode_override(
258 path: &str,
259 requested_mode: &str,
260 overlay: &OverlayStore,
261) -> Option<PreDispatchResult> {
262 let item_id = ContextItemId::from_file(path);
263 let overlays = overlay.for_item(&item_id);
264
265 for ov in overlays.iter().rev() {
266 match &ov.operation {
267 OverlayOp::SetView(view) => {
268 let mode_str = view.as_str();
269 if mode_str != requested_mode {
270 return Some(PreDispatchResult {
271 overridden_mode: Some(mode_str.to_string()),
272 reason: Some("overlay-set-view"),
273 pressure_downgraded: false,
274 budget_blocked: false,
275 budget_warning: None,
276 });
277 }
278 }
279 OverlayOp::Pin { .. } if requested_mode != "full" => {
280 return Some(PreDispatchResult {
281 overridden_mode: Some("full".to_string()),
282 reason: Some("pinned"),
283 pressure_downgraded: false,
284 budget_blocked: false,
285 budget_warning: None,
286 });
287 }
288 OverlayOp::Exclude { .. } if requested_mode != "signatures" => {
289 return Some(PreDispatchResult {
290 overridden_mode: Some("signatures".to_string()),
291 reason: Some("excluded"),
292 pressure_downgraded: false,
293 budget_blocked: false,
294 budget_warning: None,
295 });
296 }
297 _ => {}
298 }
299 }
300 None
301}
302
303pub fn post_dispatch_record(
304 path: &str,
305 mode: &str,
306 original_tokens: usize,
307 sent_tokens: usize,
308 ledger: &mut ContextLedger,
309 overlay: &OverlayStore,
310) -> PostDispatchResult {
311 post_dispatch_record_with_task(
312 path,
313 mode,
314 original_tokens,
315 sent_tokens,
316 ledger,
317 overlay,
318 None,
319 )
320}
321
322pub fn post_dispatch_record_with_task(
323 path: &str,
324 mode: &str,
325 original_tokens: usize,
326 sent_tokens: usize,
327 ledger: &mut ContextLedger,
328 overlay: &OverlayStore,
329 task: Option<&str>,
330) -> PostDispatchResult {
331 let prev_count = ledger.entries.len();
332 let prev_pressure = ledger.pressure().recommendation;
333
334 ledger.record_with_task(path, mode, original_tokens, sent_tokens, task);
335
336 let item_id = ContextItemId::from_file(path);
337 let state = overlay.apply_to_state(&item_id, ContextState::Included);
338
339 if state == ContextState::Excluded {
340 return PostDispatchResult {
341 eviction_hint: Some(format!("File '{path}' is excluded by overlay.")),
342 elicitation_hint: None,
343 resource_changed: true,
344 };
345 }
346
347 let elicitation =
348 super::elicitation::check_elicitation_needed(ledger, Some(path), Some(sent_tokens))
349 .map(|s| s.format_fallback_hint());
350
351 let pressure = ledger.pressure();
352
353 apply_reinjection_plan(ledger, &pressure.recommendation);
354
355 let new_entry = ledger.entries.len() != prev_count;
356 let pressure_shifted = pressure.recommendation != prev_pressure;
357 let resource_changed = new_entry || pressure_shifted;
358
359 if pressure.utilization > 0.9 {
360 let candidates = ledger.eviction_candidates_by_phi(3);
361 if !candidates.is_empty() {
362 let names: Vec<_> = candidates
363 .iter()
364 .take(3)
365 .map(|p| crate::core::protocol::shorten_path(p))
366 .collect();
367 return PostDispatchResult {
368 eviction_hint: Some(format!(
369 "Context pressure {:.0}%. Evict: ctx_ledger(action=\"evict\", targets=\"{}\")",
370 pressure.utilization * 100.0,
371 names.join(", ")
372 )),
373 elicitation_hint: elicitation,
374 resource_changed,
375 };
376 }
377 }
378
379 PostDispatchResult {
380 eviction_hint: None,
381 elicitation_hint: elicitation,
382 resource_changed,
383 }
384}
385
386fn apply_reinjection_plan(ledger: &mut ContextLedger, action: &PressureAction) {
387 if *action != PressureAction::ForceCompression && *action != PressureAction::EvictLeastRelevant
388 {
389 return;
390 }
391 for entry in &mut ledger.entries {
392 if entry.mode == "full" {
393 entry.mode = "map".to_string();
394 }
395 }
396}
397
398fn try_load_graph(project_root: &str) -> Option<crate::core::graph_provider::OpenGraphProvider> {
399 crate::core::graph_provider::open_best_effort(project_root)
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn pre_dispatch_passthrough_for_full() {
408 let result = pre_dispatch_read("src/main.rs", "full", None, None, None);
409 assert!(result.overridden_mode.is_none());
410 }
411
412 #[test]
413 fn pre_dispatch_passthrough_for_diff() {
414 let result = pre_dispatch_read("src/main.rs", "diff", None, None, None);
415 assert!(result.overridden_mode.is_none());
416 }
417
418 #[test]
419 fn pre_dispatch_no_override_without_signals() {
420 let result = pre_dispatch_read("src/unknown.rs", "auto", None, None, None);
421 assert!(result.overridden_mode.is_none());
422 }
423
424 #[test]
425 fn pre_dispatch_bounce_prevention_forces_full() {
426 {
427 let mut bt = crate::core::bounce_tracker::global().lock().unwrap();
428 bt.set_seq(1);
429 bt.record_read("src/bouncy.yml", "map", 30, 400);
430 bt.set_seq(2);
431 bt.record_read("src/bouncy.yml", "full", 400, 400);
432 bt.set_seq(3);
433 bt.record_read("a2.yml", "map", 30, 400);
434 bt.set_seq(4);
435 bt.record_read("a2.yml", "full", 400, 400);
436 bt.set_seq(5);
437 bt.record_read("a3.yml", "map", 30, 400);
438 bt.set_seq(6);
439 bt.record_read("a3.yml", "full", 400, 400);
440 }
441 let result = pre_dispatch_read("new.yml", "auto", None, None, None);
442 assert_eq!(result.overridden_mode, Some("full".to_string()));
443 assert_eq!(result.reason, Some("bounce-prevention"));
444 }
445
446 #[test]
447 fn pressure_does_not_downgrade_explicit_full() {
448 let result = pre_dispatch_read(
449 "c.rs",
450 "full",
451 None,
452 None,
453 Some(&PressureAction::ForceCompression),
454 );
455 assert!(
456 result.overridden_mode.is_none(),
457 "explicit mode=full must never be downgraded by pressure"
458 );
459 assert!(!result.pressure_downgraded);
460 }
461
462 #[test]
463 fn pressure_does_not_downgrade_when_enforce_off() {
464 let result = pre_dispatch_read(
467 "c.rs",
468 "map",
469 None,
470 None,
471 Some(&PressureAction::EvictLeastRelevant),
472 );
473 assert!(
474 result.overridden_mode.is_none(),
475 "pressure must not downgrade when degradation.enforce is off"
476 );
477 assert!(!result.pressure_downgraded);
478 }
479
480 #[test]
481 fn no_pressure_downgrade_when_low() {
482 let result = pre_dispatch_read("c.rs", "full", None, None, Some(&PressureAction::NoAction));
483 assert!(result.overridden_mode.is_none());
484 assert!(!result.pressure_downgraded);
485 }
486
487 #[test]
488 fn suggest_compression_does_not_downgrade_when_enforce_off() {
489 let result = pre_dispatch_read(
491 "c.rs",
492 "auto",
493 None,
494 None,
495 Some(&PressureAction::SuggestCompression),
496 );
497 assert!(
498 result.overridden_mode.is_none(),
499 "suggest_compression must not downgrade when enforce is off"
500 );
501 assert!(!result.pressure_downgraded);
502 }
503
504 #[test]
505 fn suggest_compression_does_not_touch_explicit_full() {
506 let result = pre_dispatch_read(
507 "c.rs",
508 "full",
509 None,
510 None,
511 Some(&PressureAction::SuggestCompression),
512 );
513 assert!(result.overridden_mode.is_none());
514 assert!(!result.pressure_downgraded);
515 }
516
517 #[test]
518 fn post_dispatch_reinjection_downgrades_entries() {
519 let mut ledger = ContextLedger::with_window_size(1000);
520 ledger.record("a.rs", "full", 400, 400);
521 ledger.record("b.rs", "full", 400, 400);
522 let overlay = OverlayStore::new();
523 let result = post_dispatch_record("c.rs", "full", 300, 300, &mut ledger, &overlay);
524 assert!(result.resource_changed);
525 let a_entry = ledger.entries.iter().find(|e| e.path == "a.rs").unwrap();
526 assert_eq!(a_entry.mode, "map");
527 }
528
529 #[test]
530 fn overlay_pin_forces_full_mode() {
531 let dir = tempfile::tempdir().expect("tmp dir");
532 let root = dir.path();
533 let mut store = OverlayStore::new();
534 let target = ContextItemId::from_file("src/important.rs");
535 store.add(crate::core::context_overlay::ContextOverlay::new(
536 target,
537 OverlayOp::Pin { verbatim: false },
538 crate::core::context_overlay::OverlayScope::Project,
539 String::new(),
540 crate::core::context_overlay::OverlayAuthor::User,
541 ));
542 store.save_project(root).unwrap();
543
544 let result = pre_dispatch_read(
545 "src/important.rs",
546 "auto",
547 None,
548 Some(root.to_str().unwrap()),
549 None,
550 );
551 assert_eq!(result.overridden_mode, Some("full".to_string()));
552 assert_eq!(result.reason, Some("pinned"));
553 }
554
555 #[test]
556 fn overlay_exclude_forces_signatures_mode() {
557 let dir = tempfile::tempdir().expect("tmp dir");
558 let root = dir.path();
559 let mut store = OverlayStore::new();
560 let target = ContextItemId::from_file("src/noisy.rs");
561 store.add(crate::core::context_overlay::ContextOverlay::new(
562 target,
563 OverlayOp::Exclude {
564 reason: "noise".to_string(),
565 },
566 crate::core::context_overlay::OverlayScope::Project,
567 String::new(),
568 crate::core::context_overlay::OverlayAuthor::User,
569 ));
570 store.save_project(root).unwrap();
571
572 let result = pre_dispatch_read(
573 "src/noisy.rs",
574 "auto",
575 None,
576 Some(root.to_str().unwrap()),
577 None,
578 );
579 assert_eq!(result.overridden_mode, Some("signatures".to_string()));
580 assert_eq!(result.reason, Some("excluded"));
581 }
582
583 #[test]
586 fn pressure_downgrade_suggest_auto_to_map() {
587 let result = pressure_downgrade("auto", &PressureAction::SuggestCompression);
588 assert_eq!(result, Some("map".to_string()));
589 }
590
591 #[test]
592 fn pressure_downgrade_suggest_full_to_map() {
593 let result = pressure_downgrade("full", &PressureAction::SuggestCompression);
594 assert_eq!(result, Some("map".to_string()));
595 }
596
597 #[test]
598 fn pressure_downgrade_suggest_does_not_touch_signatures() {
599 let result = pressure_downgrade("signatures", &PressureAction::SuggestCompression);
600 assert!(result.is_none());
601 }
602
603 #[test]
604 fn pressure_downgrade_suggest_does_not_touch_diff() {
605 let result = pressure_downgrade("diff", &PressureAction::SuggestCompression);
606 assert!(result.is_none());
607 }
608
609 #[test]
610 fn pressure_downgrade_force_full_to_map() {
611 let result = pressure_downgrade("full", &PressureAction::ForceCompression);
612 assert_eq!(result, Some("map".to_string()));
613 }
614
615 #[test]
616 fn pressure_downgrade_force_auto_to_signatures() {
617 let result = pressure_downgrade("auto", &PressureAction::ForceCompression);
618 assert_eq!(result, Some("signatures".to_string()));
619 }
620
621 #[test]
622 fn pressure_downgrade_force_map_to_signatures() {
623 let result = pressure_downgrade("map", &PressureAction::ForceCompression);
624 assert_eq!(result, Some("signatures".to_string()));
625 }
626
627 #[test]
628 fn pressure_downgrade_force_does_not_touch_signatures() {
629 let result = pressure_downgrade("signatures", &PressureAction::ForceCompression);
630 assert!(result.is_none());
631 }
632
633 #[test]
634 fn pressure_downgrade_force_does_not_touch_lines() {
635 let result = pressure_downgrade("lines:1-50", &PressureAction::ForceCompression);
636 assert!(result.is_none());
637 }
638
639 #[test]
640 fn pressure_downgrade_evict_full_to_map() {
641 let result = pressure_downgrade("full", &PressureAction::EvictLeastRelevant);
642 assert_eq!(result, Some("map".to_string()));
643 }
644
645 #[test]
646 fn pressure_downgrade_evict_auto_to_signatures() {
647 let result = pressure_downgrade("auto", &PressureAction::EvictLeastRelevant);
648 assert_eq!(result, Some("signatures".to_string()));
649 }
650
651 #[test]
652 fn pressure_downgrade_evict_map_to_signatures() {
653 let result = pressure_downgrade("map", &PressureAction::EvictLeastRelevant);
654 assert_eq!(result, Some("signatures".to_string()));
655 }
656
657 #[test]
658 fn pressure_downgrade_noaction_returns_none() {
659 let result = pressure_downgrade("full", &PressureAction::NoAction);
660 assert!(result.is_none());
661 }
662
663 #[test]
664 fn pressure_downgrade_noaction_auto_returns_none() {
665 let result = pressure_downgrade("auto", &PressureAction::NoAction);
666 assert!(result.is_none());
667 }
668
669 #[test]
673 fn pre_dispatch_does_not_downgrade_full_under_force() {
674 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
675 return;
676 }
677 let result = pre_dispatch_read(
679 "nd_test.rs",
680 "full",
681 None,
682 None,
683 Some(&PressureAction::ForceCompression),
684 );
685 assert!(result.overridden_mode.is_none());
686 assert!(!result.pressure_downgraded);
687 }
688
689 #[test]
690 fn pre_dispatch_does_not_downgrade_auto_when_enforce_off() {
691 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
692 return;
693 }
694 let result = pre_dispatch_read(
697 "nd_test2.rs",
698 "auto",
699 None,
700 None,
701 Some(&PressureAction::EvictLeastRelevant),
702 );
703 assert!(result.overridden_mode.is_none());
704 assert!(!result.pressure_downgraded);
705 }
706
707 #[test]
710 fn estimate_tokens_diff_mode_is_small() {
711 let tokens = estimate_read_tokens("nonexistent.rs", "diff");
712 assert!(tokens < 500, "diff mode should estimate low: got {tokens}");
713 }
714
715 #[test]
716 fn estimate_tokens_signatures_smaller_than_full() {
717 let sig = estimate_read_tokens("nonexistent.rs", "signatures");
718 let full = estimate_read_tokens("nonexistent.rs", "full");
719 assert!(sig < full, "signatures={sig} should be < full={full}");
720 }
721
722 #[test]
723 fn estimate_tokens_lines_range() {
724 let tokens = estimate_read_tokens("nonexistent.rs", "lines:1-10");
725 assert!(tokens <= 200, "lines:1-10 should be small: got {tokens}");
726 }
727
728 #[test]
729 fn overlay_set_view_forces_specified_mode() {
730 let dir = tempfile::tempdir().expect("tmp dir");
731 let root = dir.path();
732 let mut store = OverlayStore::new();
733 let target = ContextItemId::from_file("src/big.rs");
734 store.add(crate::core::context_overlay::ContextOverlay::new(
735 target,
736 OverlayOp::SetView(crate::core::context_field::ViewKind::Map),
737 crate::core::context_overlay::OverlayScope::Project,
738 String::new(),
739 crate::core::context_overlay::OverlayAuthor::User,
740 ));
741 store.save_project(root).unwrap();
742
743 let result = pre_dispatch_read(
744 "src/big.rs",
745 "auto",
746 None,
747 Some(root.to_str().unwrap()),
748 None,
749 );
750 assert_eq!(result.overridden_mode, Some("map".to_string()));
751 assert_eq!(result.reason, Some("overlay-set-view"));
752 }
753}