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