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