Skip to main content

lean_ctx/tools/registered/
ctx_read.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Mutex};
3
4use rmcp::ErrorData;
5use rmcp::model::{ContentBlock, 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
14/// Per-file lock that serializes concurrent reads of the same path.
15///
16/// When multiple subagents read sequentially through a shared set of files,
17/// they tend to hit the same path at the same time. Without per-file locking
18/// they all contend on the global cache write lock while doing redundant I/O.
19/// This lock ensures only one thread reads a given file from disk; the others
20/// wait cheaply on the per-file mutex, then hit the warm cache.
21///
22/// Backed by the shared `core::path_locks` registry so reads and edits of the
23/// same path coordinate through a single mutex (see issue #320).
24fn 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 recommended — choose by intent (see `mode` below); defaults to auto when omitted.\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": "Recommended (defaults to auto). 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        // #509: ctx_read absorbs multi-file batch reads (supersedes ctx_multi_read).
69        // A non-empty `paths` array routes to the one shared batch implementation.
70        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 deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
131            let mut attempt = 0u32;
132            loop {
133                // #1018: use try_read_owned instead of Handle::block_on to avoid
134                // the async-runtime-saturation anti-pattern on Windows.
135                if let Ok(guard) = session_lock.clone().try_read_owned() {
136                    break guard.task.as_ref().map(|t| t.description.clone());
137                }
138                attempt += 1;
139                if std::time::Instant::now() >= deadline {
140                    tracing::warn!(
141                        "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
142                    );
143                    break None;
144                }
145                std::thread::sleep(std::time::Duration::from_millis(25));
146            }
147        };
148        let task_ref = current_task.as_deref();
149        // #513: `raw=true` is the intuitive "give me the exact bytes" escape an
150        // agent reaches for. Alias it to mode="raw" (verbatim, unframed) and
151        // force a fresh disk read below so a re-read never collapses to an
152        // `[unchanged]`/auto-delta stub. An explicit raw flag wins over `mode`.
153        let arg_raw = get_bool(args, "raw").unwrap_or(false);
154        let explicit_mode_arg = resolve_raw_alias(arg_raw, get_str(args, "mode"));
155        let explicit_mode = explicit_mode_arg.is_some();
156        // #1209: a malformed line-range payload (e.g. `lines:44:48` with a colon
157        // instead of a dash) must fail loudly, not silently return an empty
158        // window on small files or bounce to a full-file dump on large ones.
159        // `lines:` and `anchored:` share `parse_line_range`, so validating both
160        // line-range modes at the boundary catches the typo for either. Other
161        // modes are left untouched: bare `density:` is a valid fallback and
162        // unknown keywords keep the historical warn-and-fall-back behaviour.
163        if let Some(ref requested) = explicit_mode_arg
164            && (requested.starts_with("lines:") || requested.starts_with("anchored:"))
165            && let Err(e) = requested.parse::<crate::tools::ctx_read::ReadMode>()
166        {
167            return Err(ErrorData::invalid_params(e.user_message(), None));
168        }
169        let configured_mode = (!explicit_mode)
170            .then(crate::core::auto_mode_resolver::configured_default_mode)
171            .flatten();
172        let learned_mode = if !explicit_mode && configured_mode.is_none() {
173            if let Ok(cache) = cache_lock.try_read() {
174                Some(crate::tools::ctx_smart_read::select_mode_with_task(
175                    &cache, path, task_ref,
176                ))
177            } else {
178                tracing::debug!(
179                    "cache lock contested during auto-mode selection for {path}; \
180                     falling back to full"
181                );
182                None
183            }
184        } else {
185            None
186        };
187        let mut mode = crate::core::auto_mode_resolver::resolve_mode_precedence(
188            explicit_mode_arg,
189            configured_mode,
190            learned_mode,
191            "full",
192        );
193        let mut fresh = get_bool(args, "fresh").unwrap_or(false);
194        // #513: a raw/verbatim request always reads from disk — the whole point
195        // is exact current bytes, never a cached stub or delta.
196        if arg_raw {
197            fresh = true;
198        }
199        let cache_policy = crate::server::compaction_sync::effective_cache_policy();
200        if cache_policy == "off" {
201            fresh = true;
202        }
203        let aggressiveness =
204            crate::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
205        let protect = get_str_array(args, "protect").unwrap_or_default();
206        // One-knob UX: when the caller sets aggressiveness without pinning a mode,
207        // route through the proven density path at the mapped target. An explicit
208        // mode (incl. entropy/task) instead has the knob tune it via ReadTuning.
209        if !explicit_mode && let Some(a) = aggressiveness {
210            // SSOT mode construction via the typed `ReadMode` (#528): the typed
211            // `Density` Display emits the same `density:0.NN` the pipeline parses.
212            mode = crate::tools::ctx_read::ReadMode::Density(
213                crate::core::aggressiveness::AggressivenessProfile::from_level(a).density_target,
214            )
215            .to_string();
216        }
217        // `start_line` (and its `offset`/`limit` aliases) can pin a line window.
218        // The resolution lives in `apply_line_window`/`resolve_line_window` so
219        // the runtime path and the unit tests share one implementation and can
220        // never drift (GitHub #432 aliases, #259 explicit-mode, #253 line-1).
221        apply_line_window(
222            &mut mode,
223            &mut fresh,
224            explicit_mode,
225            get_int(args, "start_line"),
226            get_int(args, "offset"),
227            get_int(args, "limit"),
228        );
229
230        let pressure_action = ctx.pressure_snapshot.as_ref().map(|p| &p.recommendation);
231        let resolved_agent_id = ctx.agent_id.as_ref().and_then(|a| match a.try_read() {
232            Ok(guard) => guard.clone(),
233            Err(_) => None,
234        });
235        let gate_result = crate::server::context_gate::pre_dispatch_read_for_agent(
236            path,
237            &mode,
238            task_ref,
239            Some(&ctx.project_root),
240            pressure_action,
241            resolved_agent_id.as_deref(),
242        );
243        if gate_result.budget_blocked {
244            let msg = gate_result
245                .budget_warning
246                .unwrap_or_else(|| "Agent token budget exceeded".to_string());
247            return Err(ErrorData::invalid_params(msg, None));
248        }
249        let budget_warning = gate_result.budget_warning.clone();
250        // #513: an explicit raw/verbatim request is never silently downgraded by
251        // the budget gate — the caller asked for exact bytes.
252        let mut mode_override_note: Option<String> = None;
253        if mode != "raw"
254            && let Some(overridden) = gate_result.overridden_mode
255        {
256            if explicit_mode {
257                let reason = gate_result.reason.unwrap_or("context-gate");
258                mode_override_note = Some(format!(
259                    "[mode overridden: {mode} -> {overridden}, reason={reason}]"
260                ));
261            }
262            mode = overridden;
263        }
264
265        let (instruction_mode, instruction_mode_note) = resolve_instruction_file_mode(path, &mode);
266        let (mut mode, degrade_warning) =
267            if instruction_mode_note.is_some() || instruction_mode != mode {
268                (instruction_mode, None)
269            } else if mode == "raw" || mode.starts_with("anchored") || mode.starts_with("lines:") {
270                // #513: raw bypasses context-pressure degradation (which would
271                // otherwise downgrade to signatures under Block), exactly like
272                // instruction files — explicit lossless modes mean lossless.
273                (mode, None)
274            } else {
275                auto_degrade_read_mode(&mode)
276            };
277
278        // Delta-aware explicit re-reads (opt-in: config `delta_explicit`, env
279        // LCTX_DELTA_EXPLICIT). Re-requesting full/lines:N-M content for a file
280        // this session already read re-emits content the model already holds;
281        // when the file changed on disk, a diff carries the same information in
282        // a fraction of the tokens, and an unchanged lines: request of a
283        // fully-delivered file collapses to the full-mode stub. The decision is
284        // a pure function of (cache, path, mode) — see
285        // `ctx_read::resolve_explicit_delta_mode`. First reads are unaffected;
286        // fresh=true always bypasses. Runs BEFORE the lines:→fresh guard below
287        // so a changed-file lines: re-read can still be diverted to a diff.
288        let mut delta_explicit_note: Option<String> = None;
289        if !fresh
290            && explicit_mode
291            && (mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
292            && crate::core::config::Config::load().delta_explicit_effective()
293            && let Ok(cache) = cache_lock.try_read()
294        {
295            let decision = crate::tools::ctx_read::resolve_explicit_delta_mode(
296                &cache,
297                path,
298                &mode,
299                explicit_mode,
300                fresh,
301                true,
302            );
303            mode = decision.mode;
304            delta_explicit_note = decision.note;
305        }
306
307        if mode.starts_with("lines:") {
308            fresh = true;
309        }
310
311        if crate::core::binary_detect::is_llm_viewable_image(path) {
312            return read_image_file(path);
313        }
314        if crate::core::binary_detect::is_binary_file(path) {
315            let msg = crate::core::binary_detect::binary_file_message(path);
316            return Err(ErrorData::invalid_params(msg, None));
317        }
318        {
319            let cap = crate::core::limits::max_read_bytes() as u64;
320            if let Ok(meta) = std::fs::metadata(path)
321                && meta.len() > cap
322            {
323                let msg = format!(
324                    "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
325                     Use mode=\"lines:1-100\" or start_line+limit for partial reads, \
326                     mode=\"anchored\" with start_line+limit for edit-ready windows, \
327                     or increase the limit.",
328                    meta.len(),
329                    cap
330                );
331                return Err(ErrorData::invalid_params(msg, None));
332            }
333        }
334
335        // Compaction-aware: if host compacted since last check, reset delivery flags
336        // so post-compaction reads deliver full content instead of stubs.
337        if !fresh
338            && let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir()
339            && let Ok(mut cache) = cache_lock.try_write()
340        {
341            crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
342        }
343
344        // Fast path: if both per-file lock and cache write-lock are immediately
345        // available, execute inline without spawning a thread. This avoids thread +
346        // channel overhead for the ~90% of calls that are cache hits.
347        let read_timeout = std::time::Duration::from_secs(30);
348        let cancelled = Arc::new(AtomicBool::new(false));
349        // Hash once for cross-agent delivery (avoids re-reading on record).
350        let delivery_metadata = crate::core::config::Config::load()
351            .ocla
352            .delivery_enabled()
353            .then(|| crate::tools::ctx_read::file_blake3_prefix(path))
354            .flatten();
355        let (output, resolved_mode, original, is_cache_hit, file_ref, cache_stats) = {
356            let crp_mode = ctx.crp_mode;
357            let fast_result = 'fast: {
358                let file_lock = per_file_lock(path);
359                let Some(_file_guard) = file_lock.try_lock().ok() else {
360                    break 'fast None;
361                };
362
363                // Phase 1 (shared lock): the dominant case is re-reading an
364                // unchanged file. Serve the `[unchanged]` stub under a *read* lock
365                // so parallel reads of distinct files run concurrently instead of
366                // serializing on the global write lock. `auto` is included because
367                // a warm `auto` re-read of a fully-delivered file resolves to a
368                // full cache-hit; `try_stub_hit_readonly` self-guards (returns None
369                // unless full content was delivered and the file is unchanged), so a
370                // first or compressed-only `auto` read still falls through to
371                // Phase 2. When aggressiveness is set `mode` was already rewritten
372                // to `density:` upstream, so it never reaches this `auto` branch.
373                if !fresh
374                    && (mode == "full" || mode == "full-compact" || mode == "auto")
375                    && let Ok(cache) = cache_lock.try_read()
376                    && let Some(read_output) =
377                        crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
378                {
379                    let hit = read_output.is_cache_hit;
380                    let content = read_output.content;
381                    let rmode = read_output.resolved_mode;
382                    let orig = cache.get(path).map_or(0, |e| e.original_tokens);
383                    let fref = cache.file_ref_map().get(path).cloned();
384                    let stats = cache.get_stats();
385                    let stats_snapshot = (stats.total_reads(), stats.cache_hits());
386                    break 'fast Some((content, rmode, orig, hit, fref, stats_snapshot));
387                }
388
389                // Misses use the slow path's double-check sequence: it does a
390                // second cache check, computes without the global lock, then
391                // briefly takes a write lock to store the result.
392                None
393            };
394
395            if let Some(result) = fast_result {
396                result
397            } else {
398                let cache_lock = cache_lock.clone();
399                let mode = mode.clone();
400                let task_owned = current_task.clone();
401                let protect_owned = protect.clone();
402                let path_owned = path.to_string();
403                let cancel_flag = cancelled.clone();
404                let (tx, rx) = std::sync::mpsc::sync_channel(1);
405                std::thread::spawn(move || {
406                    let file_lock = per_file_lock(&path_owned);
407
408                    let _file_guard = {
409                        let deadline =
410                            std::time::Instant::now() + std::time::Duration::from_secs(25);
411                        loop {
412                            if cancel_flag.load(Ordering::Relaxed) {
413                                return;
414                            }
415                            if let Ok(guard) = file_lock.try_lock() {
416                                break guard;
417                            }
418                            if std::time::Instant::now() >= deadline {
419                                tracing::error!(
420                                    "ctx_read: per-file lock timeout after 25s for {path_owned}"
421                                );
422                                let _ = tx.send((
423                                    format!("per-file lock contention for {path_owned} — retry in a moment"),
424                                    "error".to_string(), 0, false, None, (0, 0),
425                                ));
426                                return;
427                            }
428                            std::thread::sleep(std::time::Duration::from_millis(50));
429                        }
430                    };
431
432                    if cancel_flag.load(Ordering::Relaxed) {
433                        return;
434                    }
435
436                    // ── Two-Phase Read (#1098) ──────────────────────────
437                    //
438                    // Phase 1 (read lock): try the [unchanged] stub — this is the
439                    // ~70% case (repeated reads of unchanged files). Previously
440                    // missing in the slow path, forcing every slow-path call into
441                    // the expensive write-lock branch.
442                    if !fresh
443                        && (mode == "full" || mode == "full-compact" || mode == "auto")
444                        && let Ok(cache) = cache_lock.try_read()
445                        && let Some(read_output) =
446                            crate::tools::ctx_read::try_stub_hit_readonly(&cache, &path_owned)
447                    {
448                        let content = read_output.content;
449                        let rmode = read_output.resolved_mode;
450                        let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
451                        let hit = true;
452                        let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
453                        let stats = cache.get_stats();
454                        let stats_snapshot = (stats.total_reads(), stats.cache_hits());
455                        let _ = tx.send((content, rmode, orig, hit, fref, stats_snapshot));
456                        return;
457                    }
458
459                    // The session-local stub is checked first so the current
460                    // agent's own delivery always wins. On a miss, a verified
461                    // cross-agent delivery can avoid the disk read below.
462                    if !crate::tools::ctx_read::effective_fresh_for_delivery(fresh)
463                        && let Some((hash, mtime)) = delivery_metadata
464                        && let Some(read_output) = crate::tools::ctx_read::try_cross_agent_stub(
465                            &path_owned,
466                            &mode,
467                            hash,
468                            mtime,
469                        )
470                    {
471                        let _ = tx.send((
472                            read_output.content,
473                            read_output.resolved_mode,
474                            0,
475                            read_output.is_cache_hit,
476                            None,
477                            (0, 0),
478                        ));
479                        return;
480                    }
481
482                    // Phase 2a: disk I/O under per-file lock but WITHOUT cache lock.
483                    let preread = match crate::tools::ctx_read::read_file_lossy(&path_owned) {
484                        Ok(c) => Some(c),
485                        Err(e) => {
486                            tracing::warn!("ctx_read: cannot read {path_owned}: {e}");
487                            None
488                        }
489                    };
490
491                    if cancel_flag.load(Ordering::Relaxed) {
492                        return;
493                    }
494
495                    // ── Phase 2b: Three-sub-phase read (#807) ──────────
496                    //
497                    // Previously held the global cache write-lock for the
498                    // entire computation (tree-sitter, entropy compression).
499                    // For large files this caused 30+ second lock holds and
500                    // cascading timeouts for all concurrent tool calls.
501                    //
502                    // New: prepare (brief lock) → compute (no lock) → store (brief lock).
503
504                    let task_ref = task_owned.as_deref();
505                    let tuning =
506                        crate::tools::ctx_read::ReadTuning::resolve(aggressiveness, &protect_owned);
507
508                    // Helper: acquire write lock with deadline.
509                    macro_rules! acquire_write {
510                        ($deadline_secs:expr, $label:expr) => {{
511                            let deadline = std::time::Instant::now()
512                                + std::time::Duration::from_secs($deadline_secs);
513                            loop {
514                                if cancel_flag.load(Ordering::Relaxed) {
515                                    return;
516                                }
517                                if let Ok(guard) = cache_lock.try_write() {
518                                    break guard;
519                                }
520                                if std::time::Instant::now() >= deadline {
521                                    tracing::error!(
522                                        "ctx_read: cache write-lock timeout ({}) for {path_owned}",
523                                        $label,
524                                    );
525                                    let _ = tx.send((
526                                        format!(
527                                            "cache lock contention for {path_owned} — retry in a moment"
528                                        ),
529                                        "error".into(),
530                                        0,
531                                        false,
532                                        None,
533                                        (0, 0),
534                                    ));
535                                    return;
536                                }
537                                std::thread::sleep(std::time::Duration::from_millis(50));
538                            }
539                        }};
540                    }
541
542                    // 2b-i: Brief write lock — prepare cache state, resolve
543                    // mode, check for hits. Sub-millisecond: HashMap lookups,
544                    // staleness checks, raw-content storage for new files.
545                    #[allow(clippy::large_enum_variant)]
546                    enum PrepareOutcome {
547                        Hit(String, String, usize, bool, Option<String>, (u64, u64)),
548                        Compute {
549                            file_ref: String,
550                            resolved_mode: String,
551                            content: String,
552                            original_tokens: usize,
553                        },
554                    }
555
556                    let outcome = {
557                        let mut cache = acquire_write!(10, "prepare 10s");
558
559                        if crate::core::plugins::PluginManager::has_listener("pre_read") {
560                            crate::core::plugins::PluginManager::fire_hook_background(
561                                crate::core::plugins::executor::HookPoint::PreRead {
562                                    path: path_owned.clone(),
563                                },
564                            );
565                        }
566                        if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
567                            bt.next_seq();
568                        }
569
570                        let file_ref = cache.get_file_ref(&path_owned);
571
572                        let effective_fresh = fresh
573                            || crate::tools::ctx_read::force_fresh_env()
574                            || (crate::tools::ctx_read::is_subagent_context()
575                                && !crate::core::conversation::scope_enabled());
576
577                        let mode_eff = if mode != "raw"
578                            && !mode.starts_with("lines:")
579                            && crate::core::config::Config::load()
580                                .proxy
581                                .is_path_compress_protected(&path_owned)
582                        {
583                            "full".to_string()
584                        } else {
585                            mode.clone()
586                        };
587
588                        if effective_fresh {
589                            cache.invalidate(&path_owned);
590                        }
591
592                        if !effective_fresh {
593                            let stale = cache.get(&path_owned).is_some_and(|e| {
594                                crate::core::cache::is_cache_entry_stale_verified(
595                                    &path_owned,
596                                    e.stored_mtime,
597                                    &e.hash,
598                                )
599                            });
600                            if stale {
601                                cache.invalidate(&path_owned);
602                            }
603                        }
604
605                        let snap = cache
606                            .get(&path_owned)
607                            .map(|e| (e.original_tokens, e.content()));
608
609                        if let Some((orig_tok, content_opt)) = snap {
610                            let resolved = if mode_eff == "auto" {
611                                tuning.auto_density_mode().unwrap_or_else(|| {
612                                    crate::tools::ctx_read::resolve_auto_mode(
613                                        Some(&cache),
614                                        &path_owned,
615                                        orig_tok,
616                                        None,
617                                        task_ref,
618                                    )
619                                })
620                            } else {
621                                mode_eff
622                            };
623
624                            if (resolved == "full" || resolved == "full-compact")
625                                && let Some(out) = crate::tools::ctx_read::try_stub_hit_readonly(
626                                    &cache,
627                                    &path_owned,
628                                )
629                            {
630                                let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
631                                let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
632                                let s = cache.get_stats();
633                                PrepareOutcome::Hit(
634                                    out.content,
635                                    out.resolved_mode,
636                                    orig,
637                                    true,
638                                    fref,
639                                    (s.total_reads(), s.cache_hits()),
640                                )
641                            } else if crate::tools::ctx_read::is_cacheable_mode(&resolved) {
642                                let ck = crate::tools::ctx_read::compressed_cache_key(
643                                    &resolved,
644                                    crp_mode,
645                                    task_ref,
646                                    tuning.aggressiveness,
647                                    tuning.protect,
648                                );
649                                if let Some(hit) = cache.get_compressed(&path_owned, &ck).cloned() {
650                                    crate::core::auto_mode_resolver::count_source(
651                                        "compressed_cache_hit",
652                                    );
653                                    let hit = crate::core::redaction::redact_text_if_enabled(&hit);
654                                    let orig =
655                                        cache.get(&path_owned).map_or(0, |e| e.original_tokens);
656                                    let fref =
657                                        cache.file_ref_map().get(path_owned.as_str()).cloned();
658                                    let s = cache.get_stats();
659                                    PrepareOutcome::Hit(
660                                        hit,
661                                        resolved,
662                                        orig,
663                                        true,
664                                        fref,
665                                        (s.total_reads(), s.cache_hits()),
666                                    )
667                                } else {
668                                    let c = content_opt
669                                        .or_else(|| preread.as_deref().map(String::from));
670                                    PrepareOutcome::Compute {
671                                        file_ref,
672                                        resolved_mode: resolved,
673                                        content: c.unwrap_or_default(),
674                                        original_tokens: orig_tok,
675                                    }
676                                }
677                            } else {
678                                let c =
679                                    content_opt.or_else(|| preread.as_deref().map(String::from));
680                                PrepareOutcome::Compute {
681                                    file_ref,
682                                    resolved_mode: resolved,
683                                    content: c.unwrap_or_default(),
684                                    original_tokens: orig_tok,
685                                }
686                            }
687                        } else {
688                            let raw = match preread {
689                                Some(c) if !c.is_empty() => c,
690                                _ => match crate::tools::ctx_read::read_file_lossy(&path_owned) {
691                                    Ok(c) if !c.is_empty() => c,
692                                    Ok(_) => {
693                                        tracing::debug!(
694                                            "ctx_read: skipping cache for empty content: {path_owned}"
695                                        );
696                                        let _ = tx.send((
697                                            format!("File is empty: {path_owned}"),
698                                            "error".into(),
699                                            0,
700                                            false,
701                                            None,
702                                            (0, 0),
703                                        ));
704                                        return;
705                                    }
706                                    Err(e) => {
707                                        tracing::debug!(
708                                            "ctx_read: skipping cache for empty content: {path_owned}"
709                                        );
710                                        let _ = tx.send((
711                                            format!("Cannot read file: {path_owned}: {e}"),
712                                            "error".into(),
713                                            0,
714                                            false,
715                                            None,
716                                            (0, 0),
717                                        ));
718                                        return;
719                                    }
720                                },
721                            };
722                            let sr = cache.store(&path_owned, &raw);
723                            let resolved = if mode_eff == "auto" {
724                                tuning.auto_density_mode().unwrap_or_else(|| {
725                                    crate::tools::ctx_read::resolve_auto_mode(
726                                        None,
727                                        &path_owned,
728                                        sr.original_tokens,
729                                        Some(sr.line_count),
730                                        task_ref,
731                                    )
732                                })
733                            } else {
734                                mode_eff
735                            };
736                            PrepareOutcome::Compute {
737                                file_ref,
738                                resolved_mode: resolved,
739                                content: raw,
740                                original_tokens: sr.original_tokens,
741                            }
742                        }
743                    }; // write lock released
744
745                    if let PrepareOutcome::Hit(c, rm, orig, hit, fref, ss) = outcome {
746                        // Update last_mode for compressed-cache hits so the auto-mode
747                        // resolver can reuse this mode on future re-reads (#E26).
748                        let mut cache = acquire_write!(10, "hit last_mode 10s");
749                        if let Some(entry) = cache.get_mut(&path_owned) {
750                            entry.last_mode.clone_from(&rm);
751                        }
752                        let _ = tx.send((c, rm, orig, hit, fref, ss));
753                        return;
754                    }
755                    let PrepareOutcome::Compute {
756                        file_ref,
757                        resolved_mode,
758                        content: compute_content,
759                        original_tokens,
760                    } = outcome
761                    else {
762                        unreachable!()
763                    };
764
765                    if cancel_flag.load(Ordering::Relaxed) {
766                        return;
767                    }
768
769                    // 2b-ii: Heavy computation WITHOUT cache lock.
770                    // Tree-sitter, entropy compression, mode rendering all
771                    // run under the per-file mutex only (serializes same-file
772                    // reads, but does not block other files or tool calls).
773                    let short = crate::core::protocol::shorten_path(&path_owned);
774                    let ext_s = std::path::Path::new(&*path_owned)
775                        .extension()
776                        .and_then(|e| e.to_str())
777                        .unwrap_or("");
778
779                    let (mut computed, rmode) = if resolved_mode == "full"
780                        || resolved_mode == "full-compact"
781                    {
782                        if resolved_mode == "full-compact" {
783                            let (out, _) = crate::tools::ctx_read::format_full_compact_output(
784                                &compute_content,
785                            );
786                            (out, "full-compact".to_string())
787                        } else {
788                            let lc = compute_content.lines().count();
789                            let (out, _) = crate::tools::ctx_read::format_full_output(
790                                &file_ref,
791                                &short,
792                                ext_s,
793                                &compute_content,
794                                original_tokens,
795                                lc,
796                                task_ref,
797                            );
798                            let ft = crate::core::tokens::count_tokens(&out);
799                            let out = crate::tools::ctx_read::cap_to_raw(
800                                out,
801                                ft,
802                                &compute_content,
803                                original_tokens,
804                            );
805                            (out, "full".to_string())
806                        }
807                    } else {
808                        let (out, _) = crate::tools::ctx_read::process_mode_tuned(
809                            &compute_content,
810                            &resolved_mode,
811                            &file_ref,
812                            &short,
813                            ext_s,
814                            original_tokens,
815                            crp_mode,
816                            &path_owned,
817                            task_ref,
818                            tuning,
819                        );
820                        let out = if crate::tools::ctx_read::mode_allows_raw_cap(&resolved_mode) {
821                            let ft = crate::core::tokens::count_tokens(&out);
822                            crate::tools::ctx_read::cap_to_raw(
823                                out,
824                                ft,
825                                &compute_content,
826                                original_tokens,
827                            )
828                        } else {
829                            out
830                        };
831                        (out, resolved_mode)
832                    };
833
834                    computed = crate::core::redaction::redact_text_if_enabled(&computed);
835
836                    if cancel_flag.load(Ordering::Relaxed) {
837                        return;
838                    }
839
840                    // 2b-iii: Brief write lock — store result + metadata.
841                    // Sub-millisecond: HashMap insert + stats snapshot.
842                    // Graceful degradation: if the lock cannot be acquired
843                    // within 5s, return the result without caching it.
844                    {
845                        let deadline =
846                            std::time::Instant::now() + std::time::Duration::from_secs(5);
847                        let cache_guard = loop {
848                            if cancel_flag.load(Ordering::Relaxed) {
849                                return;
850                            }
851                            if let Ok(g) = cache_lock.try_write() {
852                                break Some(g);
853                            }
854                            if std::time::Instant::now() >= deadline {
855                                tracing::warn!(
856                                    "ctx_read: store-lock timeout (5s) for {path_owned},                                      returning without caching"
857                                );
858                                break None;
859                            }
860                            std::thread::sleep(std::time::Duration::from_millis(50));
861                        };
862
863                        if let Some(mut cache) = cache_guard {
864                            if crate::tools::ctx_read::is_cacheable_mode(&rmode) {
865                                let ck = crate::tools::ctx_read::compressed_cache_key(
866                                    &rmode,
867                                    crp_mode,
868                                    task_ref,
869                                    tuning.aggressiveness,
870                                    tuning.protect,
871                                );
872                                cache.set_compressed(&path_owned, &ck, computed.clone());
873                            }
874                            if rmode == "full" || rmode == "full-compact" {
875                                cache.mark_full_delivered(&path_owned);
876                            }
877                            if let Some(entry) = cache.get_mut(&path_owned) {
878                                entry.last_mode.clone_from(&rmode);
879                            }
880                            if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
881                                bt.record_read(
882                                    &path_owned,
883                                    &rmode,
884                                    crate::core::tokens::count_tokens(&computed),
885                                    original_tokens,
886                                );
887                            }
888                            let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
889                            let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
890                            let s = cache.get_stats();
891                            let _ = tx.send((
892                                computed,
893                                rmode,
894                                orig,
895                                false,
896                                fref,
897                                (s.total_reads(), s.cache_hits()),
898                            ));
899                        } else {
900                            let _ =
901                                tx.send((computed, rmode, original_tokens, false, None, (0, 0)));
902                        }
903                    }
904                });
905                if let Ok(result) = rx.recv_timeout(read_timeout) {
906                    result
907                } else {
908                    cancelled.store(true, Ordering::Relaxed);
909                    tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
910                    let msg = format!(
911                        "ERROR: ctx_read timed out after {}s reading {path}. \
912                     The file may be very large or a blocking I/O issue occurred. \
913                     Try mode=\"lines:1-100\" for a partial read.",
914                        read_timeout.as_secs()
915                    );
916                    return Err(ErrorData::internal_error(msg, None));
917                }
918            } // end else (slow path)
919        };
920
921        if resolved_mode == "error" {
922            return Err(ErrorData::invalid_params(output, None));
923        }
924
925        let output_tokens = crate::core::tokens::count_tokens(&output);
926        let saved = original.saturating_sub(output_tokens);
927
928        if !is_cache_hit {
929            if let Some((hash, mtime)) = delivery_metadata {
930                crate::tools::ctx_read::record_cross_agent_delivery(
931                    path,
932                    hash,
933                    mtime,
934                    0,
935                    output_tokens,
936                    None,
937                    None,
938                );
939            }
940        }
941
942        // Session updates (bounded lock — 10s timeout, read already succeeded)
943        let mut ensured_root: Option<String> = None;
944        let mut traversal_working_set: Vec<String> = Vec::new();
945        let mut prefetch_paths: Vec<String> = Vec::new();
946        let project_root_snapshot;
947        {
948            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
949            let session_guard = loop {
950                if let Ok(g) = session_lock.clone().try_write_owned() {
951                    break Some(g);
952                }
953                if std::time::Instant::now() >= deadline {
954                    break None;
955                }
956                std::thread::sleep(std::time::Duration::from_millis(25));
957            };
958            if let Some(mut session) = session_guard {
959                session.touch_file(path, file_ref.as_deref(), &resolved_mode, original);
960                prefetch_paths = session.prefetch_predictions(3);
961                // Capture the recent working set (under the lock) so the
962                // background thread can record a traversal/co-access edge (#289).
963                traversal_working_set =
964                    crate::core::tool_lifecycle::recent_working_set(&session, path);
965                let file_summary = extract_file_summary(&output, path);
966                if !file_summary.is_empty() {
967                    session.set_file_summary(path, &file_summary);
968                }
969                if is_cache_hit {
970                    session.record_cache_hit();
971                }
972                if session.active_structured_intent.is_none() && session.files_touched.len() >= 2 {
973                    let touched: Vec<String> = session
974                        .files_touched
975                        .iter()
976                        .map(|f| f.path.clone())
977                        .collect();
978                    let inferred =
979                        crate::core::intent_engine::StructuredIntent::from_file_patterns(&touched);
980                    if inferred.confidence >= 0.4 {
981                        session.active_structured_intent = Some(inferred);
982                    }
983                }
984                if session.task.is_none() && session.stats.files_read % 5 == 0 {
985                    session.auto_infer_task();
986                }
987                let root_missing = session
988                    .project_root
989                    .as_deref()
990                    .is_none_or(|r| r.trim().is_empty());
991                if root_missing && let Some(root) = crate::core::protocol::detect_project_root(path)
992                {
993                    session.project_root = Some(root.clone());
994                    ensured_root = Some(root);
995                }
996                project_root_snapshot = session
997                    .project_root
998                    .clone()
999                    .unwrap_or_else(|| ".".to_string());
1000            } else {
1001                tracing::warn!(
1002                    "session write-lock timeout (5s) in ctx_read post-update for {path}"
1003                );
1004                project_root_snapshot = ctx.project_root.clone();
1005            }
1006        }
1007        if let Some(root) = ensured_root.as_deref() {
1008            crate::core::index_orchestrator::ensure_all_background(root);
1009        }
1010
1011        if !prefetch_paths.is_empty() {
1012            crate::core::context_prefetch::warm_predictions(&prefetch_paths, Some(&cache_lock));
1013        }
1014
1015        // Telemetry + learning are pure side-effects that never influence this
1016        // response, yet they did synchronous disk I/O on every read (heatmap
1017        // append, ModePredictor load+save, FeedbackStore load). Push them off
1018        // the hot path so reads — especially cache-hit stubs — return without
1019        // waiting on disk (#149).
1020        {
1021            let path_bg = path.to_string();
1022            let resolved_mode_bg = resolved_mode.clone();
1023            let project_root_bg = project_root_snapshot.clone();
1024            let (turns, hits) = cache_stats;
1025            // #685: model-correct verified-ledger inputs, computed off the hot path.
1026            // The default O200kBase model reuses the o200k `original`/`saved` below
1027            // (byte-identical, no clone). Only a resolved Claude/Gemini/Llama model
1028            // carries the cache handle + output so the bg thread can re-tokenize the
1029            // raw source and the sent output in the family the provider actually bills.
1030            let ledger_cache = (crate::core::savings_ledger::ledger_family()
1031                != crate::core::tokens::TokenizerFamily::O200kBase)
1032                .then(|| cache_lock.clone());
1033            let ledger_output = ledger_cache.as_ref().map(|_| output.clone());
1034            std::thread::spawn(move || {
1035                // A panic in telemetry must not poison locks or leave a zombie thread;
1036                // it never affects the already-returned read response.
1037                let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
1038                    crate::core::heatmap::record_file_access(&path_bg, original, saved);
1039
1040                    // #685: verified savings ledger, decoupled from the heatmap so it
1041                    // can denominate in the active model's tokenizer family. O200kBase
1042                    // reuses the o200k counts; other families re-tokenize raw (cache)
1043                    // + output. A cache miss falls back to o200k (conservative).
1044                    {
1045                        use crate::core::savings_ledger as ledger;
1046                        let (lbase, lsaved) = match (&ledger_cache, &ledger_output) {
1047                            (Some(cl), Some(out)) => match cl.try_read().ok().and_then(|c| {
1048                                c.get(&path_bg)
1049                                    .and_then(crate::core::cache::CacheEntry::content)
1050                            }) {
1051                                Some(raw) => {
1052                                    let lo = ledger::count_for_ledger(&raw);
1053                                    (lo, lo.saturating_sub(ledger::count_for_ledger(out)))
1054                                }
1055                                None => (original, saved),
1056                            },
1057                            _ => (original, saved),
1058                        };
1059                        ledger::record_read_event(lbase, lsaved, None, None);
1060                    }
1061
1062                    // Traversal/co-access edge: this read fired together with the
1063                    // recent working set captured under the session lock (#289).
1064                    if let Some(root) =
1065                        crate::core::tool_lifecycle::usable_root(Some(project_root_bg.as_str()))
1066                    {
1067                        crate::core::cooccurrence::record_focus_access(
1068                            root,
1069                            &path_bg,
1070                            &traversal_working_set,
1071                        );
1072                    }
1073                    let sig =
1074                        crate::core::mode_predictor::FileSignature::from_path(&path_bg, original);
1075                    let density = if output_tokens > 0 {
1076                        original as f64 / output_tokens as f64
1077                    } else {
1078                        1.0
1079                    };
1080                    let outcome = crate::core::mode_predictor::ModeOutcome {
1081                        mode: resolved_mode_bg,
1082                        tokens_in: original,
1083                        tokens_out: output_tokens,
1084                        density: density.min(1.0),
1085                    };
1086                    let mut predictor = crate::core::mode_predictor::ModePredictor::new();
1087                    predictor.set_project_root(&project_root_bg);
1088                    predictor.record(sig, outcome);
1089                    predictor.save();
1090
1091                    let ext = std::path::Path::new(&path_bg)
1092                        .extension()
1093                        .and_then(|e| e.to_str())
1094                        .unwrap_or("")
1095                        .to_string();
1096                    let thresholds =
1097                        crate::core::adaptive_thresholds::thresholds_for_path(&path_bg);
1098                    let feedback_outcome = crate::core::feedback::CompressionOutcome {
1099                        session_id: format!("{}", std::process::id()),
1100                        language: ext,
1101                        entropy_threshold: thresholds.bpe_entropy,
1102                        jaccard_threshold: thresholds.jaccard,
1103                        total_turns: turns as u32,
1104                        tokens_saved: saved as u64,
1105                        tokens_original: original as u64,
1106                        cache_hits: hits as u32,
1107                        total_reads: turns as u32,
1108                        // Real behavioral signal instead of a hardcoded success
1109                        // (#593): a compressed read only counts as task-completing
1110                        // when this extension is not in a high-bounce state —
1111                        // compression that keeps forcing full re-reads is not
1112                        // "completing" anything. Unknown (too few reads) stays
1113                        // optimistic so the cold start is unchanged. 0.30 mirrors
1114                        // bounce_tracker::BOUNCE_RATE_THRESHOLD.
1115                        task_completed: crate::core::bounce_tracker::global()
1116                            .lock()
1117                            .ok()
1118                            .and_then(|bt| bt.bounce_rate_for_extension(&path_bg))
1119                            .is_none_or(|rate| rate < 0.30),
1120                        timestamp: chrono::Local::now().to_rfc3339(),
1121                    };
1122                    let mut store = crate::core::feedback::FeedbackStore::load();
1123                    store.project_root = Some(project_root_bg);
1124                    store.record_outcome(feedback_outcome);
1125                }));
1126            });
1127        }
1128        if let Some(aid) = resolved_agent_id.as_deref() {
1129            crate::core::agent_budget::record_consumption(aid, output_tokens);
1130        }
1131
1132        // #1098: graph-related hints (callers/callees) are now computed AFTER the
1133        // cache lock is released. They involve SQLite queries (~50-200ms) that
1134        // previously blocked all parallel reads while holding the write lock.
1135        let graph_hint = if !is_cache_hit
1136            && !resolved_mode.starts_with("lines:")
1137            && crate::core::profiles::active_profile()
1138                .output_hints
1139                .related_hint()
1140        {
1141            crate::tools::ctx_read::graph_related_hint(path)
1142        } else {
1143            None
1144        };
1145
1146        // Cross-source hints: gated by profile `cross_source_hint` (default off).
1147        // When enabled, appends issue/PR/schema references from the property
1148        // graph. Skipped when graph.db doesn't exist (#682).
1149        let hints_suffix = if crate::core::profiles::active_profile()
1150            .output_hints
1151            .cross_source_hint()
1152        {
1153            let graph_db =
1154                crate::core::property_graph::graph_dir(&ctx.project_root).join("graph.db");
1155            let graph = graph_db
1156                .exists()
1157                .then(|| crate::core::property_graph::CodeGraph::open(&ctx.project_root))
1158                .transpose()
1159                .ok()
1160                .flatten();
1161            graph.map_or_else(String::new, |graph| {
1162                let edges = graph.all_cross_source_edges();
1163                if edges.is_empty() {
1164                    String::new()
1165                } else {
1166                    let ranges = scoped_read_ranges(&resolved_mode);
1167                    let relative_path =
1168                        crate::core::graph_index::graph_relative_key(path, &ctx.project_root);
1169                    let hints = crate::core::cross_source_hints::hints_for_file_matching(
1170                        path,
1171                        &edges,
1172                        &ctx.project_root,
1173                        |hint| {
1174                            ranges.as_ref().is_none_or(|ranges| {
1175                                hint_intersects_ranges(hint, ranges, &graph, &relative_path)
1176                            })
1177                        },
1178                    );
1179                    crate::core::cross_source_hints::format_hints(&hints)
1180                }
1181            })
1182        } else {
1183            String::new()
1184        };
1185
1186        // Rule injection (#1325): discover and append rules scoped to this file
1187        // path (CLAUDE.md, AGENTS.md, .cursor/rules, .claude/rules) so the agent
1188        // receives the same context it would get from the native Read tool.
1189        let rules_suffix = {
1190            let client_id = ctx
1191                .client_name
1192                .as_ref()
1193                .map(|c| c.blocking_read().clone())
1194                .unwrap_or_default();
1195            crate::core::rule_discovery::rules_suffix_for_read(path, &ctx.project_root, &client_id)
1196        };
1197        let mut warnings = Vec::new();
1198        if let Some(ref w) = budget_warning {
1199            warnings.push(w.as_str());
1200        }
1201        if let Some(ref w) = degrade_warning {
1202            warnings.push(w.as_str());
1203        }
1204        if let Some(ref w) = delta_explicit_note {
1205            warnings.push(w.as_str());
1206        }
1207        if let Some(ref w) = mode_override_note {
1208            warnings.push(w.as_str());
1209        }
1210        if let Some(ref w) = instruction_mode_note {
1211            warnings.push(w.as_str());
1212        }
1213        let graph_suffix = graph_hint.map(|h| format!("\n{h}")).unwrap_or_default();
1214        // #977: notices (mode override, budget, degradation, delta) go BEFORE the
1215        // payload so client-side truncation of large outputs cannot hide them.
1216        let final_output = if !warnings.is_empty() {
1217            format!(
1218                "{}\n\n{output}{hints_suffix}{graph_suffix}{rules_suffix}",
1219                warnings.join("\n")
1220            )
1221        } else if hints_suffix.is_empty() && graph_suffix.is_empty() && rules_suffix.is_empty() {
1222            output
1223        } else {
1224            format!("{output}{hints_suffix}{graph_suffix}{rules_suffix}")
1225        };
1226        // Proactive context: gated by profile `proactive_context` (default off).
1227        // When enabled, auto-expands previously compressed content that is
1228        // keyword-relevant to the current read (up to 2000 tokens).
1229        let final_output = if crate::core::profiles::active_profile()
1230            .output_hints
1231            .proactive_context()
1232        {
1233            let proactive_query = format!(
1234                "ctx_read path={path} mode={resolved_mode} task={}",
1235                task_ref.unwrap_or_default()
1236            );
1237            if let Some(block) =
1238                crate::core::relevance_tracker::proactive_context_for_path(&proactive_query, path)
1239            {
1240                format!("{final_output}{block}")
1241            } else {
1242                final_output
1243            }
1244        } else {
1245            final_output
1246        };
1247
1248        // Monotonic guard (#1326): re-count tokens on the fully assembled output
1249        // (including hints, warnings, proactive context) and verify the compressed
1250        // result is actually smaller than the original. If annotations inflated the
1251        // output beyond the raw baseline, report zero savings so the ledger stays
1252        // honest. We keep the compressed form (it may still be more useful than raw)
1253        // but correct the accounting.
1254        let final_tokens = crate::core::tokens::count_tokens(&final_output);
1255        let verified_saved = original.saturating_sub(final_tokens);
1256
1257        Ok(ToolOutput {
1258            text: final_output,
1259            original_tokens: original,
1260            saved_tokens: verified_saved,
1261            mode: Some(resolved_mode),
1262            path: Some(path.to_string()),
1263            changed: false,
1264            shell_outcome: None,
1265            content_blocks: None,
1266        })
1267    }
1268}
1269
1270#[path = "ctx_read_window.rs"]
1271mod window;
1272#[allow(unused_imports)]
1273// lines_mode + resolve_line_window used in #[cfg(test)] ctx_read_inline_tests
1274use window::{
1275    apply_line_window, hint_intersects_ranges, lines_mode, resolve_instruction_file_mode,
1276    resolve_line_window, resolve_raw_alias, scoped_read_ranges,
1277};
1278
1279fn apply_verdict(
1280    mode: &str,
1281    verdict: crate::core::degradation_policy::DegradationVerdictV1,
1282) -> (String, bool) {
1283    use crate::core::degradation_policy::DegradationVerdictV1;
1284    match verdict {
1285        DegradationVerdictV1::Ok => (mode.to_string(), false),
1286        DegradationVerdictV1::Warn => match mode {
1287            "full" => ("map".to_string(), true),
1288            other => (other.to_string(), false),
1289        },
1290        DegradationVerdictV1::Throttle => match mode {
1291            "full" | "map" => ("signatures".to_string(), true),
1292            other => (other.to_string(), false),
1293        },
1294        DegradationVerdictV1::Block => {
1295            if mode == "signatures" {
1296                ("signatures".to_string(), false)
1297            } else {
1298                ("signatures".to_string(), true)
1299            }
1300        }
1301    }
1302}
1303
1304fn auto_degrade_read_mode(mode: &str) -> (String, Option<String>) {
1305    if crate::core::config::Config::load().no_degrade_effective() {
1306        return (mode.to_string(), None);
1307    }
1308    let profile = crate::core::profiles::active_profile();
1309    if !profile.degradation.enforce_effective() {
1310        return (mode.to_string(), None);
1311    }
1312    let policy = crate::core::degradation_policy::evaluate_v1_for_tool("ctx_read", None);
1313    let (new_mode, degraded) = apply_verdict(mode, policy.decision.verdict);
1314    let warning = if degraded {
1315        Some(format!(
1316            "⚠ Context pressure: mode={mode} was downgraded to mode={new_mode} \
1317             (verdict: {:?}). Use start_line=1 to bypass, or run ctx_compress to free budget.",
1318            policy.decision.verdict
1319        ))
1320    } else {
1321        None
1322    };
1323    (new_mode, warning)
1324}
1325
1326fn extract_file_summary(output: &str, path: &str) -> String {
1327    let hint = crate::core::auto_findings::extract_content_hint(output);
1328    if !hint.is_empty() {
1329        return hint;
1330    }
1331    let ext = std::path::Path::new(path)
1332        .extension()
1333        .and_then(|e| e.to_str())
1334        .unwrap_or("");
1335    let line_count = output.lines().count();
1336    if line_count > 5 {
1337        format!("{ext} file, {line_count} lines")
1338    } else {
1339        String::new()
1340    }
1341}
1342
1343// #660 LOC gate: inline tests split out to keep this file under the line cap.
1344#[cfg(test)]
1345#[path = "ctx_read_inline_tests.rs"]
1346mod tests;
1347
1348// #660 LOC gate: repo-param tests split out to keep this file under the line
1349// cap — see `ctx_read_repo_param_tests.rs`.
1350
1351/// Read an image file and return it as MCP ContentBlock::Image for visual LLM processing.
1352fn read_image_file(path: &str) -> Result<ToolOutput, ErrorData> {
1353    use crate::core::binary_detect::{IMAGE_MAX_BYTES, image_mime_type};
1354    use base64::Engine;
1355
1356    let metadata = std::fs::metadata(path)
1357        .map_err(|e| ErrorData::invalid_params(format!("Cannot read image: {e}"), None))?;
1358
1359    if metadata.len() > IMAGE_MAX_BYTES {
1360        return Err(ErrorData::invalid_params(
1361            format!(
1362                "Image too large ({:.1} MB, limit {:.0} MB). Resize or use a smaller image.",
1363                metadata.len() as f64 / 1024.0 / 1024.0,
1364                IMAGE_MAX_BYTES as f64 / 1024.0 / 1024.0,
1365            ),
1366            None,
1367        ));
1368    }
1369
1370    let mime_type = image_mime_type(path)
1371        .ok_or_else(|| ErrorData::invalid_params("Unsupported image format".to_string(), None))?;
1372
1373    let bytes = std::fs::read(path)
1374        .map_err(|e| ErrorData::invalid_params(format!("Cannot read image: {e}"), None))?;
1375
1376    let base64_data = base64::prelude::BASE64_STANDARD.encode(&bytes);
1377    let short_name = std::path::Path::new(path)
1378        .file_name()
1379        .and_then(|n| n.to_str())
1380        .unwrap_or(path);
1381
1382    let text_block = ContentBlock::text(format!(
1383        "[Image: {} ({} KB, {})]",
1384        short_name,
1385        bytes.len() / 1024,
1386        mime_type
1387    ));
1388    let image_block = ContentBlock::image(base64_data, mime_type);
1389
1390    Ok(ToolOutput::image(
1391        vec![text_block, image_block],
1392        path.to_string(),
1393    ))
1394}
1395
1396#[cfg(test)]
1397#[path = "ctx_read_repo_param_tests.rs"]
1398mod repo_param_tests;