1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use rmcp::ErrorData;
5use rmcp::model::Tool;
6use serde_json::{Map, Value, json};
7
8use crate::server::tool_trait::{
9 McpTool, ToolContext, ToolOutput, get_bool, get_f64, get_int, get_str, get_str_array,
10 require_resolved_path,
11};
12use crate::tool_defs::tool_def;
13
14fn per_file_lock(path: &str) -> Arc<Mutex<()>> {
25 crate::core::path_locks::per_file_lock(path)
26}
27
28pub struct CtxReadTool;
29
30impl McpTool for CtxReadTool {
31 fn name(&self) -> &'static str {
32 "ctx_read"
33 }
34
35 fn tool_def(&self) -> Tool {
36 tool_def(
37 "ctx_read",
38 "Read source files. mode REQUIRED — choose by intent (see `mode` below).\n\
39 To UNDERSTAND code run ctx_compose FIRST; ctx_read after it identified files.\n\
40 anchored → edit by reference via ctx_patch (no exact-recall).",
41 json!({
42 "type": "object",
43 "properties": {
44 "path": { "type": "string", "description": "Absolute path" },
45 "paths": { "type": "array", "items": { "type": "string" }, "description": "Batch read" },
46 "mode": {
47 "type": "string",
48 "description": "REQUIRED. full=verbatim(edit-ready) anchored=full+N:hh|anchors(edit via ctx_patch) raw=exact-bytes signatures=API map=structure auto=smart diff=git-delta lines:N-M=window (comma multi-selects: lines:5,10-20) reference=quotes task=focus"
49 },
50 "raw": { "type": "boolean", "description": "Verbatim (= mode=raw + fresh)" },
51 "start_line": { "type": "integer", "description": "1-based" },
52 "offset": { "type": "integer", "description": "start_line alias" },
53 "limit": { "type": "integer", "description": "Max lines" },
54 "fresh": { "type": "boolean", "description": "Bypass cache" },
55 "aggressiveness": { "type": "number", "description": "0.0–1.0 density (entropy/task)" },
56 "protect": { "type": "array", "items": { "type": "string" }, "description": "Symbols kept verbatim" }
57 },
58 "required": []
59 }),
60 )
61 }
62
63 fn handle(
64 &self,
65 args: &Map<String, Value>,
66 ctx: &ToolContext,
67 ) -> Result<ToolOutput, ErrorData> {
68 if args
71 .get("paths")
72 .and_then(|v| v.as_array())
73 .is_some_and(|a| !a.is_empty())
74 {
75 return super::ctx_multi_read::batch_read(args, ctx);
76 }
77
78 let path = if let Some(repo) = get_str(args, "repo") {
79 let root = crate::core::multi_repo::resolve_repo_root(&repo).ok_or_else(|| {
80 let known = crate::core::multi_repo::known_aliases().join(", ");
81 let known = if known.is_empty() {
82 "none registered — use ctx_multi_repo add_root".to_string()
83 } else {
84 known
85 };
86 ErrorData::invalid_params(
87 format!("unknown repo alias: {repo} (known: {known})"),
88 None,
89 )
90 })?;
91 let rel = get_str(args, "path").unwrap_or_else(|| ".".to_string());
92 crate::core::path_resolve::resolve_tool_path(Some(&root), None, &rel)
93 .map_err(|e| ErrorData::invalid_params(e, None))?
94 } else {
95 require_resolved_path(ctx, args, "path")?
96 };
97
98 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
99 self.handle_inner(args, ctx, &path)
100 })) {
101 Ok(result) => result,
102 Err(_) => Err(ErrorData::internal_error(
103 format!(
104 "ctx_read panicked while processing '{path}'. This is a bug — please report it."
105 ),
106 None,
107 )),
108 }
109 }
110}
111
112impl CtxReadTool {
113 #[allow(clippy::unused_self)]
114 fn handle_inner(
115 &self,
116 args: &Map<String, Value>,
117 ctx: &ToolContext,
118 path: &str,
119 ) -> Result<ToolOutput, ErrorData> {
120 let session_lock = ctx
121 .session
122 .as_ref()
123 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
124 let cache_lock = ctx
125 .cache
126 .as_ref()
127 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
128
129 let current_task = {
130 let rt = tokio::runtime::Handle::current();
131 let mut attempt = 0u32;
132 loop {
133 if let Ok(session) = rt.block_on(tokio::time::timeout(
134 std::time::Duration::from_secs(5),
135 session_lock.read(),
136 )) {
137 break session.task.as_ref().map(|t| t.description.clone());
138 }
139 attempt += 1;
140 if attempt >= 3 {
141 tracing::warn!(
142 "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
143 );
144 return Err(ErrorData::internal_error(
145 "session lock timeout — another tool may be holding it. Retry in a moment.",
146 None,
147 ));
148 }
149 tracing::debug!(
150 "session read-lock attempt {attempt}/3 timed out for {path}, retrying"
151 );
152 std::thread::sleep(std::time::Duration::from_millis(100 * u64::from(attempt)));
153 }
154 };
155 let task_ref = current_task.as_deref();
156
157 let profile = crate::core::profiles::active_profile();
158 let arg_raw = get_bool(args, "raw").unwrap_or(false);
163 let explicit_mode_arg = resolve_raw_alias(arg_raw, get_str(args, "mode"));
164 let explicit_mode = explicit_mode_arg.is_some();
165 let policy_default_mode = if explicit_mode {
170 None
171 } else {
172 crate::core::policy::runtime::active()
173 .and_then(|p| p.resolved.default_read_mode.clone())
174 };
175 let persona_default_mode = if explicit_mode || policy_default_mode.is_some() {
180 None
181 } else {
182 crate::core::persona::active().read_mode_override()
183 };
184 let mut mode = if let Some(m) = explicit_mode_arg {
185 m
186 } else if let Some(pd) = policy_default_mode {
187 pd
188 } else if let Some(pm) = persona_default_mode {
189 pm
190 } else if profile.read.default_mode_effective() == "auto" {
191 if let Ok(cache) = cache_lock.try_read() {
192 crate::tools::ctx_smart_read::select_mode_with_task(&cache, path, task_ref)
193 } else {
194 tracing::debug!(
195 "cache lock contested during auto-mode selection for {path}; \
196 falling back to full"
197 );
198 "full".to_string()
199 }
200 } else {
201 profile.read.default_mode_effective().to_string()
202 };
203 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
204 if arg_raw {
207 fresh = true;
208 }
209 let cache_policy = crate::server::compaction_sync::effective_cache_policy();
210 if cache_policy == "off" {
211 fresh = true;
212 }
213 let aggressiveness =
214 crate::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
215 let protect = get_str_array(args, "protect").unwrap_or_default();
216 if !explicit_mode && let Some(a) = aggressiveness {
220 mode = crate::tools::ctx_read::ReadMode::Density(
223 crate::core::aggressiveness::AggressivenessProfile::from_level(a).density_target,
224 )
225 .to_string();
226 }
227 apply_line_window(
232 &mut mode,
233 &mut fresh,
234 explicit_mode,
235 get_int(args, "start_line"),
236 get_int(args, "offset"),
237 get_int(args, "limit"),
238 );
239
240 let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
241 let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
242 Ok(guard) => guard.clone(),
243 Err(_) => None,
244 });
245 let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
246 path,
247 &mode,
248 task_ref,
249 Some(&ctx.project_root),
250 pressure_action,
251 resolved_agent_id.as_deref(),
252 );
253 if gate_result.budget_blocked {
254 let msg = gate_result
255 .budget_warning
256 .unwrap_or_else(|| "Agent token budget exceeded".to_string());
257 return Err(ErrorData::invalid_params(msg, None));
258 }
259 let budget_warning = gate_result.budget_warning.clone();
260 if mode != "raw"
263 && let Some(overridden) = gate_result.overridden_mode
264 {
265 mode = overridden;
266 }
267
268 let (mut mode, degrade_warning) = if crate::tools::ctx_read::is_instruction_file(path) {
269 ("full".to_string(), None)
270 } else if mode == "raw" {
271 ("raw".to_string(), None)
275 } else {
276 auto_degrade_read_mode(&mode)
277 };
278
279 let mut delta_explicit_note: Option<String> = None;
290 if !fresh
291 && explicit_mode
292 && (mode == "full" || mode.starts_with("lines:"))
293 && crate::core::config::Config::load().delta_explicit_effective()
294 && let Ok(cache) = cache_lock.try_read()
295 {
296 let decision = crate::tools::ctx_read::resolve_explicit_delta_mode(
297 &cache,
298 path,
299 &mode,
300 explicit_mode,
301 fresh,
302 true,
303 );
304 mode = decision.mode;
305 delta_explicit_note = decision.note;
306 }
307
308 if mode.starts_with("lines:") {
309 fresh = true;
310 }
311
312 if crate::core::binary_detect::is_binary_file(path) {
313 let msg = crate::core::binary_detect::binary_file_message(path);
314 return Err(ErrorData::invalid_params(msg, None));
315 }
316 {
317 let cap = crate::core::limits::max_read_bytes() as u64;
318 if let Ok(meta) = std::fs::metadata(path)
319 && meta.len() > cap
320 {
321 let msg = format!(
322 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
323 Use mode=\"lines:1-100\" for partial reads or increase the limit.",
324 meta.len(),
325 cap
326 );
327 return Err(ErrorData::invalid_params(msg, None));
328 }
329 }
330
331 if !fresh
334 && let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir()
335 && let Ok(mut cache) = cache_lock.try_write()
336 {
337 crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
338 }
339
340 let read_timeout = std::time::Duration::from_secs(30);
344 let cancelled = Arc::new(AtomicBool::new(false));
345 let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
346 let crp_mode = ctx.crp_mode;
347 let task_ref = current_task.as_deref();
348
349 let fast_result = 'fast: {
350 let file_lock = per_file_lock(path);
351 let Some(_file_guard) = file_lock.try_lock().ok() else {
352 break 'fast None;
353 };
354
355 if !fresh
366 && (mode == "full" || mode == "auto")
367 && let Ok(cache) = cache_lock.try_read()
368 && let Some(read_output) =
369 crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
370 {
371 let content = read_output.content;
372 let rmode = read_output.resolved_mode;
373 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
374 let hit = content.contains(" cached ")
375 || content.contains("[unchanged")
376 || content.contains("[delta:");
377 let fref = cache.file_ref_map().get(path).cloned();
378 let stats = cache.get_stats();
379 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
380 break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
381 }
382
383 let Some(mut cache) = cache_lock.try_write().ok() else {
386 break 'fast None;
387 };
388 let read_output = if fresh {
389 crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
390 &mut cache,
391 path,
392 &mode,
393 crp_mode,
394 task_ref,
395 aggressiveness,
396 &protect,
397 )
398 } else {
399 crate::tools::ctx_read::handle_with_task_resolved_tuned(
400 &mut cache,
401 path,
402 &mode,
403 crp_mode,
404 task_ref,
405 aggressiveness,
406 &protect,
407 )
408 };
409 let content = read_output.content;
410 let rmode = read_output.resolved_mode;
411 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
412 let hit = content.contains(" cached ")
413 || content.contains("[unchanged")
414 || content.contains("[delta:");
415 let fref = cache.file_ref_map().get(path).cloned();
416 let stats = cache.get_stats();
417 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
418 Some((content, rmode, orig, hit, fref, stats_snapshot))
419 };
420
421 if let Some(result) = fast_result {
422 result
423 } else {
424 let cache_lock = cache_lock.clone();
425 let mode = mode.clone();
426 let task_owned = current_task.clone();
427 let protect_owned = protect.clone();
428 let path_owned = path.to_string();
429 let cancel_flag = cancelled.clone();
430 let (tx, rx) = std::sync::mpsc::sync_channel(1);
431 std::thread::spawn(move || {
432 let file_lock = per_file_lock(&path_owned);
433
434 let _file_guard = {
435 let deadline =
436 std::time::Instant::now() + std::time::Duration::from_secs(25);
437 loop {
438 if cancel_flag.load(Ordering::Relaxed) {
439 return;
440 }
441 if let Ok(guard) = file_lock.try_lock() {
442 break guard;
443 }
444 if std::time::Instant::now() >= deadline {
445 tracing::error!(
446 "ctx_read: per-file lock timeout after 25s for {path_owned}"
447 );
448 let _ = tx.send((
449 format!("per-file lock contention for {path_owned} — retry in a moment"),
450 "error".to_string(), 0, false, None, (0, 0),
451 ));
452 return;
453 }
454 std::thread::sleep(std::time::Duration::from_millis(50));
455 }
456 };
457
458 if cancel_flag.load(Ordering::Relaxed) {
459 return;
460 }
461
462 if !fresh
469 && (mode == "full" || mode == "auto")
470 && let Ok(cache) = cache_lock.try_read()
471 && let Some(read_output) =
472 crate::tools::ctx_read::try_stub_hit_readonly(&cache, &path_owned)
473 {
474 let content = read_output.content;
475 let rmode = read_output.resolved_mode;
476 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
477 let hit = true;
478 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
479 let stats = cache.get_stats();
480 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
481 let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
482 return;
483 }
484
485 let preread = crate::tools::ctx_read::read_file_lossy(&path_owned).ok();
487
488 if cancel_flag.load(Ordering::Relaxed) {
489 return;
490 }
491
492 let mut cache = {
494 let deadline =
495 std::time::Instant::now() + std::time::Duration::from_secs(25);
496 loop {
497 if cancel_flag.load(Ordering::Relaxed) {
498 return;
499 }
500 if let Ok(guard) = cache_lock.try_write() {
501 break guard;
502 }
503 if std::time::Instant::now() >= deadline {
504 tracing::error!(
505 "ctx_read: cache write-lock timeout after 25s for {path_owned}"
506 );
507 let _ = tx.send((
508 format!(
509 "cache lock contention for {path_owned} — retry in a moment"
510 ),
511 "error".to_string(),
512 0,
513 false,
514 None,
515 (0, 0),
516 ));
517 return;
518 }
519 std::thread::sleep(std::time::Duration::from_millis(50));
520 }
521 };
522
523 let task_ref = task_owned.as_deref();
524 let read_output = if let Some(content) = preread {
525 crate::tools::ctx_read::handle_with_preread(
526 &mut cache,
527 &path_owned,
528 &mode,
529 fresh,
530 crp_mode,
531 task_ref,
532 aggressiveness,
533 &protect_owned,
534 content,
535 )
536 } else if fresh {
537 crate::tools::ctx_read::handle_fresh_with_task_resolved_tuned(
538 &mut cache,
539 &path_owned,
540 &mode,
541 crp_mode,
542 task_ref,
543 aggressiveness,
544 &protect_owned,
545 )
546 } else {
547 crate::tools::ctx_read::handle_with_task_resolved_tuned(
548 &mut cache,
549 &path_owned,
550 &mode,
551 crp_mode,
552 task_ref,
553 aggressiveness,
554 &protect_owned,
555 )
556 };
557 let content = read_output.content;
558 let rmode = read_output.resolved_mode;
559 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
560 let hit = content.contains(" cached ");
561 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
562 let stats = cache.get_stats();
563 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
564 let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
565 });
566 if let Ok(result) = rx.recv_timeout(read_timeout) {
567 result
568 } else {
569 cancelled.store(true, Ordering::Relaxed);
570 tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
571 let msg = format!(
572 "ERROR: ctx_read timed out after {}s reading {path}. \
573 The file may be very large or a blocking I/O issue occurred. \
574 Try mode=\"lines:1-100\" for a partial read.",
575 read_timeout.as_secs()
576 );
577 return Err(ErrorData::internal_error(msg, None));
578 }
579 } };
581
582 if resolved_mode == "error" {
583 return Err(ErrorData::invalid_params(output, None));
584 }
585
586 let output_tokens = crate::core::tokens::count_tokens(&output);
587 let saved = original.saturating_sub(output_tokens);
588
589 let mut ensured_root: Option<String> = None;
591 let mut traversal_working_set: Vec<String> = Vec::new();
592 let project_root_snapshot;
593 {
594 let rt = tokio::runtime::Handle::current();
595 let session_guard = rt.block_on(tokio::time::timeout(
596 std::time::Duration::from_secs(10),
597 session_lock.write(),
598 ));
599 if let Ok(mut session) = session_guard {
600 session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
601 traversal_working_set =
604 crate::core::tool_lifecycle::recent_working_set(&session, path);
605 let file_summary = extract_file_summary(&output, path);
606 if !file_summary.is_empty() {
607 session.set_file_summary(path, &file_summary);
608 }
609 if is_cache_hit {
610 session.record_cache_hit();
611 }
612 if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
613 let touched: Vec<String> = session
614 .files_touched
615 .iter()
616 .map(|f| f.path.clone())
617 .collect();
618 let inferred =
619 crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
620 if inferred.confidence >= 0.4 {
621 session.active_structured_intent = Some(inferred);
622 }
623 }
624 if session.task.is_none() && session.stats.files_read % 5 == 0 {
625 session.auto_infer_task();
626 }
627 let root_missing = session
628 .project_root
629 .as_deref()
630 .is_none_or(|r| r.trim().is_empty());
631 if root_missing && let Some(root) = crate::core::protocol::detect_project_root(path)
632 {
633 session.project_root = Some(root.clone());
634 ensured_root = Some(root);
635 }
636 project_root_snapshot = session
637 .project_root
638 .clone()
639 .unwrap_or_else(|| ".".to_string());
640 } else {
641 tracing::warn!(
642 "session write-lock timeout (5s) in ctx_read post-update for {path}"
643 );
644 project_root_snapshot = ctx.project_root.clone();
645 }
646 }
647
648 if let Some(root) = ensured_root.as_deref() {
649 crate::core::index_orchestrator::ensure_all_background(root);
650 }
651
652 {
658 let path_bg = path.to_string();
659 let resolved_mode_bg = resolved_mode.clone();
660 let project_root_bg = project_root_snapshot.clone();
661 let (turns, hits) = cache_stats;
662 let ledger_cache = (crate::core::savings_ledger::ledger_family()
668 != crate::core::tokens::TokenizerFamily::O200kBase)
669 .then(|| cache_lock.clone());
670 let ledger_output = ledger_cache.as_ref().map(|_| output.clone());
671 std::thread::spawn(move || {
672 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
675 crate::core::heatmap::record_file_access(&path_bg, original, saved);
676
677 {
682 use crate::core::savings_ledger as ledger;
683 let (lbase, lsaved) = match (&ledger_cache, &ledger_output) {
684 (Some(cl), Some(out)) => match cl.try_read().ok().and_then(|c| {
685 c.get(&path_bg)
686 .and_then(crate::core::cache::CacheEntry::content)
687 }) {
688 Some(raw) => {
689 let lo = ledger::count_for_ledger(&raw);
690 (lo, lo.saturating_sub(ledger::count_for_ledger(out)))
691 }
692 None => (original, saved),
693 },
694 _ => (original, saved),
695 };
696 ledger::record_read_event(lbase, lsaved);
697 }
698
699 if let Some(root) =
702 crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
703 {
704 crate::core::cooccurrence::record_focus_access(
705 root,
706 &path_bg,
707 &traversal_working_set,
708 );
709 }
710
711 let sig =
712 crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
713 let density = if output_tokens > 0 {
714 original as f64 / output_tokens as f64
715 } else {
716 1.0
717 };
718 let outcome = crate::core::mode_predictor::ModeOutcome {
719 mode: resolved_mode_bg,
720 tokens_in: original,
721 tokens_out: output_tokens,
722 density: density.min(1.0),
723 };
724 let mut predictor = crate::core::mode_predictor::ModePredictor::new();
725 predictor.set_project_root(&project_root_bg);
726 predictor.record(sig, outcome);
727 predictor.save();
728
729 let ext = std::path::Path::new(&path_bg)
730 .extension()
731 .and_then(|e| e.to_str())
732 .unwrap_or("")
733 .to_string();
734 let thresholds =
735 crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
736 let feedback_outcome = crate::core::feedback::CompressionOutcome {
737 session_id: format!("{}", std::process::id()),
738 language: ext,
739 entropy_threshold: thresholds.bpe_entropy,
740 jaccard_threshold: thresholds.jaccard,
741 total_turns: turns as u32,
742 tokens_saved: saved as u64,
743 tokens_original: original as u64,
744 cache_hits: hits as u32,
745 total_reads: turns as u32,
746 task_completed: crate::core::bounce_tracker::global()
754 .lock()
755 .ok()
756 .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
757 .is_none_or(|rate| rate < 0.30),
758 timestamp: chrono::Local::now().to_rfc3339(),
759 };
760 let mut store = crate::core::feedback::FeedbackStore::load();
761 store.project_root = Some(project_root_bg);
762 store.record_outcome(feedback_outcome);
763 }));
764 });
765 }
766
767 if let Some(aid) = resolved_agent_id.as_deref() {
768 crate::core::agent_budget::record_consumption(aid, output_tokens);
769 }
770
771 let graph_hint = if !is_cache_hit
775 && !resolved_mode.starts_with("lines:")
776 && crate::core::profiles::active_profile()
777 .output_hints
778 .related_hint()
779 {
780 crate::tools::ctx_read::graph_related_hint(path)
781 } else {
782 None
783 };
784
785 let hints_suffix = {
790 let graph_db =
791 crate::core::property_graph::graph_dir(&ctx.project_root).join("graph.db");
792 let edges = if graph_db.exists() {
793 crate::core::property_graph::CodeGraph::open(&ctx.project_root)
794 .map(|g| g.all_cross_source_edges())
795 .unwrap_or_default()
796 } else {
797 Vec::new()
798 };
799 if edges.is_empty() {
800 String::new()
801 } else {
802 let hints = crate::core::cross_source_hints::hints_for_file(
803 path,
804 &edges,
805 &ctx.project_root,
806 );
807 crate::core::cross_source_hints::format_hints(&hints)
808 }
809 };
810
811 let mut warnings = Vec::new();
812 if let Some(ref w) = budget_warning {
813 warnings.push(w.as_str());
814 }
815 if let Some(ref w) = degrade_warning {
816 warnings.push(w.as_str());
817 }
818 if let Some(ref w) = delta_explicit_note {
819 warnings.push(w.as_str());
820 }
821 let graph_suffix = graph_hint.map(|h| format!("\n{h}")).unwrap_or_default();
822 let final_output = if !warnings.is_empty() {
823 format!(
824 "{output}{hints_suffix}{graph_suffix}\n\n{}",
825 warnings.join("\n")
826 )
827 } else if hints_suffix.is_empty() && graph_suffix.is_empty() {
828 output
829 } else {
830 format!("{output}{hints_suffix}{graph_suffix}")
831 };
832
833 Ok(ToolOutput {
834 text: final_output,
835 original_tokens: original,
836 saved_tokens: saved,
837 mode: Some(resolved_mode),
838 path: Some(path.to_string()),
839 changed: false,
840 shell_outcome: None,
841 })
842 }
843}
844
845fn resolve_line_window(
852 start_line: Option<i64>,
853 offset: Option<i64>,
854 limit: Option<i64>,
855) -> Option<(i64, Option<i64>)> {
856 let start = start_line.or(offset).map(|v| v.max(1));
857 let limit = limit.filter(|&l| l > 0);
858 match (start, limit) {
859 (Some(s), l) => Some((s, l)),
860 (None, Some(_)) => Some((1, limit)),
861 (None, None) => None,
862 }
863}
864
865fn lines_mode(start: i64, limit: Option<i64>) -> String {
868 match limit {
869 Some(l) => format!("lines:{start}-{}", start + l - 1),
870 None => format!("lines:{start}-999999"),
871 }
872}
873
874fn apply_line_window(
878 mode: &mut String,
879 fresh: &mut bool,
880 explicit_mode: bool,
881 start_line: Option<i64>,
882 offset: Option<i64>,
883 limit: Option<i64>,
884) {
885 let Some((start, limit)) = resolve_line_window(start_line, offset, limit) else {
886 return;
887 };
888 if start <= 1 && limit.is_none() {
889 return;
890 }
891 *fresh = true;
892 if !explicit_mode || mode.starts_with("lines") {
893 *mode = lines_mode(start, limit);
894 }
895}
896
897fn resolve_raw_alias(arg_raw: bool, mode_arg: Option<String>) -> Option<String> {
904 if arg_raw {
905 Some("raw".to_string())
906 } else {
907 mode_arg
908 }
909}
910
911fn apply_verdict(
912 mode: &str,
913 verdict: crate::core::degradation_policy::DegradationVerdictV1,
914) -> (String, bool) {
915 use crate::core::degradation_policy::DegradationVerdictV1;
916 match verdict {
917 DegradationVerdictV1::Ok => (mode.to_string(), false),
918 DegradationVerdictV1::Warn => match mode {
919 "full" => ("map".to_string(), true),
920 other => (other.to_string(), false),
921 },
922 DegradationVerdictV1::Throttle => match mode {
923 "full" | "map" => ("signatures".to_string(), true),
924 other => (other.to_string(), false),
925 },
926 DegradationVerdictV1::Block => {
927 if mode == "signatures" {
928 ("signatures".to_string(), false)
929 } else {
930 ("signatures".to_string(), true)
931 }
932 }
933 }
934}
935
936fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
937 if crate::core::config::Config::load().no_degrade_effective() {
938 return (mode.to_string(), None);
939 }
940 let profile = crate::core::profiles::active_profile();
941 if !profile.degradation.enforce_effective() {
942 return (mode.to_string(), None);
943 }
944 let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
945 let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
946 let warning = if degraded {
947 Some(format!(
948 "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
949 (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
950 policy.decision.verdict
951 ))
952 } else {
953 None
954 };
955 (new_mode, warning)
956}
957
958fn extract_file_summary(output: &str, path: &str) -> String {
959 let hint = crate::core::auto_findings::extract_content_hint(output);
960 if !hint.is_empty() {
961 return hint;
962 }
963 let ext = std::path::Path::new(path)
964 .extension()
965 .and_then(|e| e.to_str())
966 .unwrap_or("");
967 let line_count = output.lines().count();
968 if line_count > 5 {
969 format!("{ext} file, {line_count} lines")
970 } else {
971 String::new()
972 }
973}
974
975#[cfg(test)]
976mod tests {
977 use super::*;
978 use std::sync::atomic::{AtomicUsize, Ordering};
979
980 #[test]
981 fn raw_alias_forces_raw_mode_over_explicit_mode() {
982 assert_eq!(
985 resolve_raw_alias(true, Some("signatures".to_string())),
986 Some("raw".to_string())
987 );
988 assert_eq!(resolve_raw_alias(true, None), Some("raw".to_string()));
989 }
990
991 #[test]
992 fn raw_alias_absent_passes_mode_through() {
993 assert_eq!(
996 resolve_raw_alias(false, Some("full".to_string())),
997 Some("full".to_string())
998 );
999 assert_eq!(resolve_raw_alias(false, None), None);
1000 }
1001
1002 #[test]
1003 fn per_file_lock_same_path_returns_same_mutex() {
1004 let lock_a1 = per_file_lock("/tmp/test_same_path.txt");
1005 let lock_a2 = per_file_lock("/tmp/test_same_path.txt");
1006 assert!(Arc::ptr_eq(&lock_a1, &lock_a2));
1007 }
1008
1009 #[test]
1010 fn per_file_lock_different_paths_return_different_mutexes() {
1011 let lock_a = per_file_lock("/tmp/test_path_a.txt");
1012 let lock_b = per_file_lock("/tmp/test_path_b.txt");
1013 assert!(!Arc::ptr_eq(&lock_a, &lock_b));
1014 }
1015
1016 #[test]
1017 fn per_file_lock_serializes_concurrent_access() {
1018 let counter = Arc::new(AtomicUsize::new(0));
1019 let max_concurrent = Arc::new(AtomicUsize::new(0));
1020 let path = "/tmp/test_concurrent_serialization.txt";
1021 let mut handles = Vec::new();
1022
1023 for _ in 0..5 {
1024 let counter = counter.clone();
1025 let max_concurrent = max_concurrent.clone();
1026 let path = path.to_string();
1027 handles.push(std::thread::spawn(move || {
1028 let lock = per_file_lock(&path);
1029 let _guard = lock.lock().unwrap();
1030 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
1031 max_concurrent.fetch_max(active, Ordering::SeqCst);
1032 std::thread::sleep(std::time::Duration::from_millis(10));
1033 counter.fetch_sub(1, Ordering::SeqCst);
1034 }));
1035 }
1036
1037 for h in handles {
1038 h.join().unwrap();
1039 }
1040
1041 assert_eq!(max_concurrent.load(Ordering::SeqCst), 1);
1042 }
1043
1044 #[test]
1045 fn per_file_lock_allows_parallel_different_paths() {
1046 let counter = Arc::new(AtomicUsize::new(0));
1047 let max_concurrent = Arc::new(AtomicUsize::new(0));
1048 let mut handles = Vec::new();
1049
1050 for i in 0..4 {
1051 let counter = counter.clone();
1052 let max_concurrent = max_concurrent.clone();
1053 let path = format!("/tmp/test_parallel_{i}.txt");
1054 handles.push(std::thread::spawn(move || {
1055 let lock = per_file_lock(&path);
1056 let _guard = lock.lock().unwrap();
1057 let active = counter.fetch_add(1, Ordering::SeqCst) + 1;
1058 max_concurrent.fetch_max(active, Ordering::SeqCst);
1059 std::thread::sleep(std::time::Duration::from_millis(50));
1060 counter.fetch_sub(1, Ordering::SeqCst);
1061 }));
1062 }
1063
1064 for h in handles {
1065 h.join().unwrap();
1066 }
1067
1068 assert!(max_concurrent.load(Ordering::SeqCst) > 1);
1069 }
1070
1071 #[test]
1075 fn zombie_thread_does_not_block_subsequent_cache_access() {
1076 let cache: Arc<tokio::sync::RwLock<u32>> = Arc::new(tokio::sync::RwLock::new(0));
1077
1078 let zombie_lock = cache.clone();
1080 let _zombie = std::thread::spawn(move || {
1081 let _guard = zombie_lock.blocking_write();
1082 std::thread::sleep(std::time::Duration::from_secs(2));
1083 });
1084 std::thread::sleep(std::time::Duration::from_millis(50));
1085
1086 assert!(cache.try_read().is_err());
1088
1089 let cancel = Arc::new(AtomicBool::new(false));
1091 let cancel2 = cancel.clone();
1092 let lock2 = cache.clone();
1093 let waiter = std::thread::spawn(move || {
1094 let start = std::time::Instant::now();
1095 loop {
1096 if cancel2.load(Ordering::Relaxed) {
1097 return (false, start.elapsed());
1098 }
1099 if let Ok(_guard) = lock2.try_write() {
1100 return (true, start.elapsed());
1101 }
1102 std::thread::sleep(std::time::Duration::from_millis(50));
1103 }
1104 });
1105
1106 std::thread::sleep(std::time::Duration::from_millis(200));
1108 cancel.store(true, Ordering::Relaxed);
1109
1110 let (acquired, elapsed) = waiter.join().unwrap();
1111 assert!(
1112 !acquired,
1113 "should not have acquired lock while zombie holds it"
1114 );
1115 assert!(
1116 elapsed < std::time::Duration::from_secs(1),
1117 "cancellation should have stopped the loop promptly"
1118 );
1119 }
1120
1121 fn apply_start_line(
1125 mode: &mut String,
1126 fresh: &mut bool,
1127 explicit_mode: bool,
1128 start_line: Option<i64>,
1129 ) {
1130 super::apply_line_window(mode, fresh, explicit_mode, start_line, None, None);
1131 }
1132
1133 #[test]
1134 fn start_line_1_does_not_override_mode() {
1135 let mut mode = "auto".to_string();
1136 let mut fresh = false;
1137 apply_start_line(&mut mode, &mut fresh, false, Some(1));
1138 assert_eq!(mode, "auto", "start_line=1 should not change mode");
1139 assert!(!fresh, "start_line=1 should not force fresh=true");
1140 }
1141
1142 #[test]
1143 fn start_line_gt1_overrides_implicit_mode() {
1144 let mut mode = "auto".to_string();
1145 let mut fresh = false;
1146 apply_start_line(&mut mode, &mut fresh, false, Some(50));
1147 assert_eq!(mode, "lines:50-999999");
1148 assert!(fresh);
1149 }
1150
1151 #[test]
1152 fn start_line_gt1_does_not_override_explicit_map() {
1153 let mut mode = "map".to_string();
1155 let mut fresh = false;
1156 apply_start_line(&mut mode, &mut fresh, true, Some(50));
1157 assert_eq!(
1158 mode, "map",
1159 "explicit mode=map must not be clobbered by start_line"
1160 );
1161 assert!(fresh, "start_line>1 should still force fresh");
1162 }
1163
1164 #[test]
1165 fn start_line_gt1_does_not_override_explicit_signatures() {
1166 let mut mode = "signatures".to_string();
1167 let mut fresh = false;
1168 apply_start_line(&mut mode, &mut fresh, true, Some(100));
1169 assert_eq!(mode, "signatures");
1170 assert!(fresh);
1171 }
1172
1173 #[test]
1174 fn start_line_gt1_honors_explicit_lines_mode() {
1175 let mut mode = "lines:1-50".to_string();
1176 let mut fresh = false;
1177 apply_start_line(&mut mode, &mut fresh, true, Some(30));
1178 assert_eq!(
1179 mode, "lines:30-999999",
1180 "explicit lines mode should accept start_line override"
1181 );
1182 assert!(fresh);
1183 }
1184
1185 #[test]
1186 fn start_line_none_does_nothing() {
1187 let mut mode = "map".to_string();
1188 let mut fresh = false;
1189 apply_start_line(&mut mode, &mut fresh, true, None);
1190 assert_eq!(mode, "map");
1191 assert!(!fresh);
1192 }
1193
1194 #[test]
1195 fn start_line_1_with_explicit_mode_preserves_it() {
1196 let mut mode = "map".to_string();
1198 let mut fresh = false;
1199 apply_start_line(&mut mode, &mut fresh, true, Some(1));
1200 assert_eq!(mode, "map");
1201 assert!(!fresh);
1202 }
1203
1204 #[test]
1207 fn offset_is_alias_for_start_line() {
1208 let mut mode = "auto".to_string();
1209 let mut fresh = false;
1210 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), None);
1211 assert_eq!(mode, "lines:40-999999");
1212 assert!(fresh);
1213 }
1214
1215 #[test]
1216 fn offset_and_limit_make_bounded_window() {
1217 let mut mode = "auto".to_string();
1218 let mut fresh = false;
1219 super::apply_line_window(&mut mode, &mut fresh, false, None, Some(40), Some(20));
1220 assert_eq!(mode, "lines:40-59", "20 inclusive lines starting at 40");
1221 assert!(fresh);
1222 }
1223
1224 #[test]
1225 fn limit_alone_reads_from_first_line() {
1226 let mut mode = "auto".to_string();
1227 let mut fresh = false;
1228 super::apply_line_window(&mut mode, &mut fresh, false, None, None, Some(25));
1229 assert_eq!(mode, "lines:1-25");
1230 assert!(fresh);
1231 }
1232
1233 #[test]
1234 fn start_line_wins_over_offset_when_both_present() {
1235 assert_eq!(
1236 super::resolve_line_window(Some(10), Some(99), None),
1237 Some((10, None))
1238 );
1239 }
1240
1241 #[test]
1242 fn resolve_clamps_start_and_drops_nonpositive_limit() {
1243 assert_eq!(
1245 super::resolve_line_window(Some(-5), None, Some(0)),
1246 Some((1, None))
1247 );
1248 assert_eq!(super::resolve_line_window(None, None, Some(-3)), None);
1250 assert_eq!(super::resolve_line_window(None, None, None), None);
1251 }
1252
1253 #[test]
1254 fn lines_mode_bounds_are_inclusive() {
1255 assert_eq!(super::lines_mode(40, Some(20)), "lines:40-59");
1256 assert_eq!(super::lines_mode(5, None), "lines:5-999999");
1257 }
1258
1259 #[test]
1260 fn explicit_map_not_clobbered_by_offset_limit() {
1261 let mut mode = "map".to_string();
1263 let mut fresh = false;
1264 super::apply_line_window(&mut mode, &mut fresh, true, None, Some(40), Some(20));
1265 assert_eq!(mode, "map", "explicit mode wins over offset/limit");
1266 assert!(fresh);
1267 }
1268
1269 #[test]
1274 fn schema_advertises_line_window_aliases() {
1275 let tool = CtxReadTool.tool_def();
1276 let props = tool
1277 .input_schema
1278 .get("properties")
1279 .and_then(|p| p.as_object())
1280 .expect("ctx_read schema has a properties object");
1281 for key in ["path", "mode", "start_line", "offset", "limit", "fresh"] {
1282 assert!(props.contains_key(key), "ctx_read schema missing '{key}'");
1283 }
1284 }
1285
1286 use crate::core::degradation_policy::DegradationVerdictV1;
1290
1291 #[test]
1292 fn verdict_ok_does_not_degrade() {
1293 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Ok);
1294 assert_eq!(mode, "full");
1295 assert!(!degraded);
1296 }
1297
1298 #[test]
1299 fn verdict_warn_degrades_full_to_map() {
1300 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1301 assert_eq!(mode, "map");
1302 assert!(degraded, "full→map must be flagged as degraded");
1303 }
1304
1305 #[test]
1306 fn verdict_warn_keeps_map() {
1307 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Warn);
1308 assert_eq!(mode, "map");
1309 assert!(!degraded, "map is not degraded under Warn");
1310 }
1311
1312 #[test]
1313 fn verdict_warn_keeps_signatures() {
1314 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Warn);
1315 assert_eq!(mode, "signatures");
1316 assert!(!degraded);
1317 }
1318
1319 #[test]
1320 fn verdict_throttle_degrades_full_to_signatures() {
1321 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Throttle);
1322 assert_eq!(mode, "signatures");
1323 assert!(degraded);
1324 }
1325
1326 #[test]
1327 fn verdict_throttle_degrades_map_to_signatures() {
1328 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Throttle);
1329 assert_eq!(mode, "signatures");
1330 assert!(degraded);
1331 }
1332
1333 #[test]
1334 fn verdict_throttle_keeps_lines() {
1335 let (mode, degraded) = super::apply_verdict("lines:1-50", DegradationVerdictV1::Throttle);
1336 assert_eq!(mode, "lines:1-50");
1337 assert!(!degraded, "lines mode bypasses degradation");
1338 }
1339
1340 #[test]
1341 fn verdict_block_degrades_full_to_signatures() {
1342 let (mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Block);
1343 assert_eq!(mode, "signatures");
1344 assert!(degraded);
1345 }
1346
1347 #[test]
1348 fn verdict_block_does_not_degrade_signatures() {
1349 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Block);
1350 assert_eq!(mode, "signatures");
1351 assert!(!degraded, "already at signatures — no degradation needed");
1352 }
1353
1354 #[test]
1355 fn degrade_warning_message_contains_mode_info() {
1356 let (new_mode, degraded) = super::apply_verdict("full", DegradationVerdictV1::Warn);
1357 assert!(degraded);
1358 let warning = format!(
1359 "⚠ Context pressure: mode=full was downgraded to mode={new_mode} (verdict: {:?}).",
1360 DegradationVerdictV1::Warn
1361 );
1362 assert!(warning.contains("mode=full"));
1363 assert!(warning.contains("mode=map"));
1364 assert!(warning.contains("Warn"));
1365 }
1366
1367 #[test]
1372 fn auto_degrade_preserves_full_when_default_config() {
1373 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1374 return;
1375 }
1376 let (mode, warning) = super::auto_degrade_read_mode("full");
1377 assert_eq!(mode, "full");
1378 assert!(warning.is_none());
1379 }
1380
1381 #[test]
1382 fn auto_degrade_preserves_map_when_default_config() {
1383 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1384 return;
1385 }
1386 let (mode, warning) = super::auto_degrade_read_mode("map");
1387 assert_eq!(mode, "map");
1388 assert!(warning.is_none());
1389 }
1390
1391 #[test]
1392 fn auto_degrade_preserves_signatures_when_default_config() {
1393 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1394 return;
1395 }
1396 let (mode, warning) = super::auto_degrade_read_mode("signatures");
1397 assert_eq!(mode, "signatures");
1398 assert!(warning.is_none());
1399 }
1400
1401 #[test]
1402 fn auto_degrade_preserves_diff_always() {
1403 let (mode, warning) = super::auto_degrade_read_mode("diff");
1404 assert_eq!(mode, "diff");
1405 assert!(warning.is_none());
1406 }
1407
1408 #[test]
1409 fn auto_degrade_preserves_lines_mode_always() {
1410 let (mode, warning) = super::auto_degrade_read_mode("lines:10-50");
1411 assert_eq!(mode, "lines:10-50");
1412 assert!(warning.is_none());
1413 }
1414
1415 #[test]
1416 fn auto_degrade_preserves_aggressive_when_default_config() {
1417 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1418 return;
1419 }
1420 let (mode, warning) = super::auto_degrade_read_mode("aggressive");
1421 assert_eq!(mode, "aggressive");
1422 assert!(warning.is_none());
1423 }
1424
1425 #[test]
1426 fn auto_degrade_preserves_entropy_when_default_config() {
1427 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1428 return;
1429 }
1430 let (mode, warning) = super::auto_degrade_read_mode("entropy");
1431 assert_eq!(mode, "entropy");
1432 assert!(warning.is_none());
1433 }
1434
1435 #[test]
1436 fn auto_degrade_preserves_auto_when_default_config() {
1437 if std::env::var("LCTX_NO_DEGRADE").is_ok() {
1438 return;
1439 }
1440 let (mode, warning) = super::auto_degrade_read_mode("auto");
1441 assert_eq!(mode, "auto");
1442 assert!(warning.is_none());
1443 }
1444
1445 #[test]
1448 fn verdict_warn_does_not_degrade_diff() {
1449 let (mode, degraded) = super::apply_verdict("diff", DegradationVerdictV1::Warn);
1450 assert_eq!(mode, "diff");
1451 assert!(!degraded);
1452 }
1453
1454 #[test]
1455 fn verdict_throttle_does_not_degrade_signatures() {
1456 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Throttle);
1457 assert_eq!(mode, "signatures");
1458 assert!(!degraded);
1459 }
1460
1461 #[test]
1462 fn verdict_ok_preserves_map() {
1463 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Ok);
1464 assert_eq!(mode, "map");
1465 assert!(!degraded);
1466 }
1467
1468 #[test]
1469 fn verdict_ok_preserves_signatures() {
1470 let (mode, degraded) = super::apply_verdict("signatures", DegradationVerdictV1::Ok);
1471 assert_eq!(mode, "signatures");
1472 assert!(!degraded);
1473 }
1474
1475 #[test]
1476 fn verdict_ok_preserves_lines() {
1477 let (mode, degraded) = super::apply_verdict("lines:1-100", DegradationVerdictV1::Ok);
1478 assert_eq!(mode, "lines:1-100");
1479 assert!(!degraded);
1480 }
1481
1482 #[test]
1483 fn verdict_block_degrades_map_to_signatures() {
1484 let (mode, degraded) = super::apply_verdict("map", DegradationVerdictV1::Block);
1485 assert_eq!(mode, "signatures");
1486 assert!(degraded);
1487 }
1488}
1489
1490#[cfg(test)]
1493#[path = "ctx_read_repo_param_tests.rs"]
1494mod repo_param_tests;