lean_ctx_server/tools/registered/ctx_read.rs
1// SPDX-License-Identifier: Apache-2.0
2
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::sync::{Arc, Mutex};
5
6use rmcp::ErrorData;
7use rmcp::model::Tool;
8use serde_json::{Map, Value};
9
10use crate::server::tool_trait::{
11 McpTool, ToolContext, ToolOutput, get_bool, get_f64, get_int, get_str, get_str_array,
12 require_resolved_path,
13};
14use lean_ctx::core::cache::ReuseOutcome;
15
16/// Per-file lock that serializes concurrent reads of the same path.
17///
18/// When multiple subagents read sequentially through a shared set of files,
19/// they tend to hit the same path at the same time. Without per-file locking
20/// they all contend on the global cache write lock while doing redundant I/O.
21/// This lock ensures only one thread reads a given file from disk; the others
22/// wait cheaply on the per-file mutex, then hit the warm cache.
23///
24/// Backed by the shared `core::path_locks` registry so reads and edits of the
25/// same path coordinate through a single mutex (see issue #320).
26fn per_file_lock(path: &str) -> Arc<Mutex<()>> {
27 lean_ctx::core::path_locks::per_file_lock(path)
28}
29
30pub struct CtxReadTool;
31
32#[path = "ctx_read_delivery.rs"]
33mod delivery;
34use delivery::{
35 CacheSourceIdentity, PendingCacheDelivery, PendingCrossAgentDelivery, PendingReadDelivery,
36};
37
38pub(super) struct PreparedRead {
39 pub(super) output: ToolOutput,
40 delivery: Option<PendingReadDelivery>,
41}
42
43impl PreparedRead {
44 pub(super) fn commit(self, ctx: &ToolContext, record_agent_budget: bool) -> ToolOutput {
45 if let Some(delivery) = self.delivery {
46 delivery.commit(ctx, &self.output, record_agent_budget);
47 }
48 self.output
49 }
50}
51
52#[path = "ctx_read_dispatch.rs"]
53pub mod dispatch;
54
55impl CtxReadTool {
56 #[allow(clippy::unused_self)]
57 pub(super) fn handle_inner(
58 &self,
59 args: &Map<String, Value>,
60 ctx: &ToolContext,
61 path: &str,
62 engine_interface_v1: bool,
63 ) -> Result<ToolOutput, ErrorData> {
64 self.handle_inner_prepared(args, ctx, path, engine_interface_v1)
65 .map(|prepared| prepared.commit(ctx, true))
66 }
67
68 #[allow(clippy::unused_self)]
69 pub(super) fn handle_inner_prepared(
70 &self,
71 args: &Map<String, Value>,
72 ctx: &ToolContext,
73 path: &str,
74 engine_interface_v1: bool,
75 ) -> Result<PreparedRead, ErrorData> {
76 let session_lock = ctx
77 .session
78 .as_ref()
79 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
80 let cache_lock = ctx
81 .cache
82 .as_ref()
83 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
84
85 let current_task = {
86 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
87 let mut attempt = 0u32;
88 loop {
89 // #1018: use try_read_owned instead of Handle::block_on to avoid
90 // the async-runtime-saturation anti-pattern on Windows.
91 if let Ok(guard) = session_lock.clone().try_read_owned() {
92 break guard
93 .task
94 .as_ref()
95 .map(|t| (t.description.clone(), t.intent.clone()));
96 }
97 attempt += 1;
98 if std::time::Instant::now() >= deadline {
99 tracing::warn!(
100 "session read-lock timeout after {attempt} attempts in ctx_read for {path}"
101 );
102 break None;
103 }
104 std::thread::sleep(std::time::Duration::from_millis(25));
105 }
106 };
107 // #1590: `auto_infer_task` fabricates descriptions like
108 // "Working on /repo/src/printer (explore)" from touched-file patterns.
109 // That is fine as a telemetry hint, but its "keywords" (Working,
110 // explore, a directory path) carry no signal about a file's contents —
111 // filtering a file down to a quarter of its lines on that basis, or
112 // pinning it to `full` as an intent target, invents a task the caller
113 // never stated. Only an explicitly set task steers a read.
114 let task_is_inferred = current_task
115 .as_ref()
116 .is_some_and(|(_, intent)| !task_intent_steers_read(intent.as_deref()));
117 let current_task = current_task.map(|(description, _)| description);
118 let task_ref = if task_is_inferred {
119 None
120 } else {
121 current_task.as_deref()
122 };
123 // #513: `raw=true` is the intuitive "give me the exact bytes" escape an
124 // agent reaches for. Alias it to mode="raw" (verbatim, unframed) and
125 // force a fresh disk read below so a re-read never collapses to an
126 // `[unchanged]`/auto-delta stub. An explicit raw flag wins over `mode`.
127 let arg_raw = get_bool(args, "raw").unwrap_or(false);
128 let explicit_mode_arg = resolve_explicit_mode(arg_raw, get_str(args, "mode"));
129 let explicit_mode = explicit_mode_arg.is_some();
130 // #1209: a malformed line-range payload (e.g. `lines:44:48` with a colon
131 // instead of a dash) must fail loudly, not silently return an empty
132 // window on small files or bounce to a full-file dump on large ones.
133 // `lines:` and `anchored:` share `parse_line_range`, so validating both
134 // line-range modes at the boundary catches the typo for either. Other
135 // modes are left untouched: bare `density:` is a valid fallback and
136 // unknown keywords keep the historical warn-and-fall-back behaviour.
137 if let Some(ref requested) = explicit_mode_arg
138 && (requested.starts_with("lines:") || requested.starts_with("anchored:"))
139 && let Err(e) = requested.parse::<crate::tools::ctx_read::ReadMode>()
140 {
141 return Err(ErrorData::invalid_params(e.user_message(), None));
142 }
143 let configured_mode = (!explicit_mode)
144 .then(lean_ctx::core::auto_mode_resolver::configured_default_mode)
145 .flatten();
146 // No range requested: the outline rule decides between the outline of
147 // a large file and the whole small file (owner decision 2026-10-30).
148 let learned_mode = if !explicit_mode && configured_mode.is_none() {
149 if let Ok(cache) = cache_lock.try_read() {
150 Some(crate::tools::ctx_smart_read::select_unranged_mode(
151 &cache, path, task_ref,
152 ))
153 } else {
154 tracing::debug!(
155 "cache lock contested during auto-mode selection for {path}; \
156 resolving cold"
157 );
158 Some(crate::tools::ctx_smart_read::select_unranged_mode(
159 &lean_ctx::core::cache::SessionCache::new(),
160 path,
161 task_ref,
162 ))
163 }
164 } else {
165 None
166 };
167 let auto_or_no_mode =
168 explicit_mode_arg.as_deref().is_none_or(|m| m == "auto") && configured_mode.is_none();
169 let mut mode = lean_ctx::core::auto_mode_resolver::resolve_mode_precedence(
170 explicit_mode_arg,
171 configured_mode,
172 learned_mode,
173 "full",
174 );
175 let mut fresh = get_bool(args, "fresh").unwrap_or(false);
176 // Reacquire original bytes under current rules; an older compressed
177 // view can have omitted the classification marker that now blocks it.
178 if lean_ctx::core::policy::runtime::is_active() {
179 fresh = true;
180 }
181 // #513: a raw/verbatim request always reads from disk — the whole point
182 // is exact current bytes, never a cached stub or delta.
183 if arg_raw || engine_interface_v1 {
184 fresh = true;
185 }
186 let cache_policy = crate::server::compaction_sync::effective_cache_policy();
187 let cache_policy_bypassed = cache_policy == "off";
188 if cache_policy_bypassed {
189 fresh = true;
190 }
191 let aggressiveness =
192 lean_ctx::core::aggressiveness::effective(get_f64(args, "aggressiveness"));
193 let protect = get_str_array(args, "protect").unwrap_or_default();
194 // One-knob UX: when the caller sets aggressiveness without pinning a mode,
195 // route through the proven density path at the mapped target. An explicit
196 // mode (incl. entropy/task) instead has the knob tune it via ReadTuning.
197 if !explicit_mode && let Some(a) = aggressiveness {
198 // SSOT mode construction via the typed `ReadMode` (#528): the typed
199 // `Density` Display emits the same `density:0.NN` the pipeline parses.
200 mode = crate::tools::ctx_read::ReadMode::Density(
201 lean_ctx::core::aggressiveness::AggressivenessProfile::from_level(a).density_target,
202 )
203 .to_string();
204 }
205 // `start_line` (and its `offset`/`limit` aliases) can pin a line window.
206 // The resolution lives in `apply_line_window`/`resolve_line_window` so
207 // the runtime path and the unit tests share one implementation and can
208 // never drift (GitHub #432 aliases, #259 explicit-mode, #253 line-1).
209 apply_line_window(
210 &mut mode,
211 &mut fresh,
212 explicit_mode,
213 get_int(args, "start_line"),
214 get_int(args, "offset"),
215 get_int(args, "limit"),
216 );
217
218 // A concrete per-call request outranks saved views and adaptive heuristics.
219 // `auto` explicitly delegates the choice; mandatory admission still runs.
220 let pinned_mode = explicit_mode && mode != "auto";
221 if pinned_mode
222 && (matches!(mode.as_str(), "raw" | "full" | "full-compact")
223 || mode.starts_with("lines:")
224 || mode.starts_with("anchored"))
225 {
226 // Exact/full requests must not collapse to a cache stub or a delta,
227 // including when the older delta_explicit preference is enabled.
228 fresh = true;
229 }
230
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 = if pinned_mode {
236 crate::server::context_gate::pre_dispatch_pinned_read_for_agent(
237 path,
238 &mode,
239 resolved_agent_id.as_deref(),
240 )
241 } else {
242 crate::server::context_gate::pre_dispatch_read_for_agent(
243 path,
244 &mode,
245 task_ref,
246 Some(&ctx.project_root),
247 resolved_agent_id.as_deref(),
248 fresh,
249 )
250 };
251 let mut engine_policy_admission = engine::admission_or_reject(
252 engine_interface_v1,
253 &gate_result,
254 &mode,
255 &ctx.project_root,
256 path,
257 )?;
258 let budget_warning = gate_result.budget_warning.clone();
259 // A read without a range (no mode, or `auto`, no window, no density
260 // knob) is answered by the outline rule; only a view the user saved
261 // for this file (overlay) still replaces it, the gate's heuristics do not.
262 let unranged = aggressiveness.is_none()
263 && auto_or_no_mode
264 && !mode.starts_with("lines:")
265 && !mode.starts_with("anchored");
266 let user_view = matches!(
267 gate_result.reason,
268 Some("overlay-set-view" | "pinned" | "excluded")
269 );
270 // #513: an explicit raw/verbatim request is never silently downgraded by
271 // the budget gate — the caller asked for exact bytes.
272 let mut mode_override_note: Option<String> = None;
273 if mode != "raw"
274 && (!unranged || user_view)
275 && let Some(overridden) = gate_result.overridden_mode
276 {
277 if explicit_mode {
278 let reason = gate_result.reason.unwrap_or("context-gate");
279 // #1588: an override the caller cannot escape is a dead end.
280 // Name the escape hatch for the two heuristics that honour it.
281 let escape = if matches!(
282 reason,
283 "bounce-prevention" | "intent-target" | "learned-full"
284 ) {
285 ", pass fresh=true to keep the requested mode"
286 } else {
287 ""
288 };
289 mode_override_note = Some(format!(
290 "[mode overridden: {mode} -> {overridden}, reason={reason}{escape}]"
291 ));
292 }
293 mode = overridden;
294 }
295
296 let (instruction_mode, instruction_mode_note) = resolve_instruction_file_mode(path, &mode);
297 let mut mode = instruction_mode;
298 engine::require_aggressive(
299 engine_interface_v1,
300 &mode,
301 &ctx.project_root,
302 path,
303 &mut engine_policy_admission,
304 )?;
305
306 // Delta-aware explicit re-reads (opt-in: config `delta_explicit`, env
307 // LCTX_DELTA_EXPLICIT). Re-requesting full/lines:N-M content for a file
308 // this session already read re-emits content the model already holds;
309 // when the file changed on disk, a diff carries the same information in
310 // a fraction of the tokens, and an unchanged lines: request of a
311 // fully-delivered file collapses to the full-mode stub. The decision is
312 // a pure function of (cache, path, mode) — see
313 // `ctx_read::resolve_explicit_delta_mode`. First reads are unaffected;
314 // fresh=true always bypasses. Runs BEFORE the lines:→fresh guard below
315 // so a changed-file lines: re-read can still be diverted to a diff.
316 let mut delta_explicit_note: Option<String> = None;
317 if !fresh
318 && explicit_mode
319 && (mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
320 && lean_ctx::core::config::Config::load().delta_explicit_effective()
321 && let Ok(cache) = cache_lock.try_read()
322 {
323 let decision = crate::tools::ctx_read::resolve_explicit_delta_mode(
324 &cache,
325 path,
326 &mode,
327 explicit_mode,
328 fresh,
329 true,
330 );
331 mode = decision.mode;
332 delta_explicit_note = decision.note;
333 }
334
335 if mode.starts_with("lines:") {
336 fresh = true;
337 }
338
339 engine::reject_non_text_extension(
340 engine_interface_v1,
341 &ctx.project_root,
342 path,
343 &mut engine_policy_admission,
344 )?;
345 // A directory has no text to read; failing with the OS error ("Is a
346 // directory") left the agent guessing. Answer with its entries, as
347 // ctx_tree would, so the next call can name a file.
348 if !engine_interface_v1 && std::path::Path::new(path).is_dir() {
349 let (tree, _) = crate::tools::ctx_tree::handle(path, 1, false, true);
350 if tree.starts_with("ERROR:") {
351 return Err(ErrorData::invalid_params(tree, None));
352 }
353 return Ok(PreparedRead {
354 output: ToolOutput::simple(format!(
355 "{path} is a directory — pass one of its files to ctx_read:\n{tree}"
356 )),
357 delivery: None,
358 });
359 }
360 if lean_ctx::core::binary_detect::is_llm_viewable_image(path) {
361 // Text detectors cannot inspect pixels: the gateway says so, and
362 // withholds the image where every detector is mandatory.
363 let bytes = std::fs::metadata(path).map_or(0, |meta| meta.len());
364 let note = lean_ctx::core::context_admission::admit_media(path, bytes)
365 .map_err(|error| ErrorData::invalid_params(error.to_string(), None))?;
366 return read_image_file(path).map(|mut output| {
367 if let (Some(note), Some(blocks)) = (note, output.content_blocks.as_mut()) {
368 blocks.push(rmcp::model::ContentBlock::text(note));
369 }
370 PreparedRead {
371 output,
372 delivery: None,
373 }
374 });
375 }
376 if !engine_interface_v1 && lean_ctx::core::binary_detect::is_binary_file(path) {
377 let msg = lean_ctx::core::binary_detect::binary_file_message(path);
378 return Err(ErrorData::invalid_params(msg, None));
379 }
380 if !engine_interface_v1 {
381 let cap = lean_ctx::core::limits::max_read_bytes() as u64;
382 if let Ok(meta) = std::fs::metadata(path)
383 && meta.len() > cap
384 {
385 let msg = format!(
386 "File too large ({} bytes, limit {} bytes via LCTX_MAX_READ_BYTES). \
387 Use mode=\"lines:1-100\" or start_line+limit for partial reads, \
388 mode=\"anchored\" with start_line+limit for edit-ready windows, \
389 or increase the limit.",
390 meta.len(),
391 cap
392 );
393 return Err(ErrorData::invalid_params(msg, None));
394 }
395 }
396
397 // Compaction-aware: if host compacted since last check, reset delivery flags
398 // so post-compaction reads deliver full content instead of stubs.
399 if !fresh
400 && let Ok(data_dir) = lean_ctx::core::data_dir::lean_ctx_data_dir()
401 && let Ok(mut cache) = cache_lock.try_write()
402 {
403 crate::server::compaction_sync::sync_if_compacted(&mut cache, &data_dir);
404 }
405
406 // Fast path: if both per-file lock and cache write-lock are immediately
407 // available, execute inline without spawning a thread. This avoids thread +
408 // channel overhead for the ~90% of calls that are cache hits.
409 let read_timeout = std::time::Duration::from_secs(30);
410 let cancelled = Arc::new(AtomicBool::new(false));
411 let engine_snapshot = engine::SourceSnapshot::default();
412 let pending_cache = Arc::new(Mutex::new(PendingCacheDelivery::default()));
413 // Hash once for cross-agent delivery (avoids re-reading on record).
414 let delivery_metadata = (!engine_interface_v1
415 && lean_ctx::core::config::Config::load()
416 .ocla
417 .delivery_enabled())
418 .then(|| crate::tools::ctx_read::file_blake3_prefix(path))
419 .flatten();
420 let (output, resolved_mode, original, is_cache_hit, file_ref, _cache_stats, reuse_outcome) = {
421 let crp_mode = ctx.crp_mode;
422 let fast_result = 'fast: {
423 let file_lock = per_file_lock(path);
424 let Some(_file_guard) = file_lock.try_lock().ok() else {
425 break 'fast None;
426 };
427
428 // Phase 1 (shared lock): the dominant case is re-reading an
429 // unchanged file. Serve the `[unchanged]` stub under a *read* lock
430 // so parallel reads of distinct files run concurrently instead of
431 // serializing on the global write lock. `auto` is included because
432 // a warm `auto` re-read of a fully-delivered file resolves to a
433 // full cache-hit; `try_stub_hit_readonly` self-guards (returns None
434 // unless full content was delivered and the file is unchanged), so a
435 // first or compressed-only `auto` read still falls through to
436 // Phase 2. When aggressiveness is set `mode` was already rewritten
437 // to `density:` upstream, so it never reaches this `auto` branch.
438 if !fresh
439 && (mode == "full" || mode == "full-compact" || mode == "auto")
440 && let Ok(cache) = cache_lock.try_read()
441 && let Some(read_output) =
442 crate::tools::ctx_read::try_stub_hit_readonly(&cache, path)
443 {
444 let hit = read_output.is_cache_hit;
445 let content = read_output.content;
446 let rmode = read_output.resolved_mode;
447 let orig = cache.get(path).map_or(0, |e| e.original_tokens);
448 let fref = cache.file_ref_map().get(path).cloned();
449 let stats = cache.get_stats();
450 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
451 break 'fast Some((
452 content,
453 rmode,
454 orig,
455 hit,
456 fref,
457 stats_snapshot,
458 ReuseOutcome::UnchangedStub,
459 ));
460 }
461
462 // Misses use the slow path's double-check sequence: it does a
463 // second cache check, computes without the global lock, then
464 // briefly takes a write lock to store the result.
465 None
466 };
467
468 if let Some(result) = fast_result {
469 result
470 } else {
471 let cache_lock = cache_lock.clone();
472 let mode = mode.clone();
473 let task_owned = current_task.clone();
474 let protect_owned = protect.clone();
475 let path_owned = path.to_string();
476 let project_root_owned = ctx.project_root.clone();
477 let cancel_flag = cancelled.clone();
478 let snapshot_worker = engine_snapshot.clone();
479 let pending_cache_worker = pending_cache.clone();
480 let (tx, rx) = std::sync::mpsc::sync_channel(1);
481 lean_ctx::core::task_spine::TaskSpine::spawn_thread(move || {
482 let file_lock = per_file_lock(&path_owned);
483
484 // The per-file lock only deduplicates concurrent reads of
485 // one file. When an earlier read holds it past the
486 // deadline (an abandoned read of a huge file, a slow
487 // mount), this read proceeds without it: duplicate work
488 // beats failing the call — the cache store below has its
489 // own lock, and "retry in a moment" met the same holder.
490 let _file_guard = {
491 let deadline =
492 std::time::Instant::now() + std::time::Duration::from_secs(10);
493 loop {
494 if cancel_flag.load(Ordering::Relaxed) {
495 return;
496 }
497 if let Ok(guard) = file_lock.try_lock() {
498 break Some(guard);
499 }
500 if std::time::Instant::now() >= deadline {
501 tracing::warn!(
502 "ctx_read: per-file lock still held after 10s for \
503 {path_owned}, reading without it"
504 );
505 break None;
506 }
507 std::thread::sleep(std::time::Duration::from_millis(50));
508 }
509 };
510
511 if cancel_flag.load(Ordering::Relaxed) {
512 return;
513 }
514
515 // ── Two-Phase Read (#1098) ──────────────────────────
516 //
517 // Phase 1 (read lock): try the [unchanged] stub — this is the
518 // ~70% case (repeated reads of unchanged files). Previously
519 // missing in the slow path, forcing every slow-path call into
520 // the expensive write-lock branch.
521 if !fresh
522 && (mode == "full" || mode == "full-compact" || mode == "auto")
523 && let Ok(cache) = cache_lock.try_read()
524 && let Some(read_output) =
525 crate::tools::ctx_read::try_stub_hit_readonly(&cache, &path_owned)
526 {
527 let content = read_output.content;
528 let rmode = read_output.resolved_mode;
529 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
530 let hit = true;
531 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
532 let stats = cache.get_stats();
533 let stats_snapshot = (stats.total_reads(), stats.cache_hits());
534 let _ = tx.send((
535 content,
536 rmode,
537 orig,
538 hit,
539 fref,
540 stats_snapshot,
541 ReuseOutcome::UnchangedStub,
542 ));
543 return;
544 }
545
546 // The session-local stub is checked first so the current
547 // agent's own delivery always wins. On a miss, a verified
548 // cross-agent delivery can avoid the disk read below.
549 if !crate::tools::ctx_read::effective_fresh_for_delivery(fresh)
550 && let Some(fp) = delivery_metadata
551 && let Some(read_output) = crate::tools::ctx_read::try_cross_agent_stub(
552 &path_owned,
553 &mode,
554 fp.hash,
555 fp.mtime,
556 crp_mode,
557 crate::tools::ctx_read::ReadTuning::resolve(
558 aggressiveness,
559 &protect_owned,
560 ),
561 )
562 {
563 let _ = tx.send((
564 read_output.content,
565 read_output.resolved_mode,
566 0,
567 read_output.is_cache_hit,
568 None,
569 (0, 0),
570 ReuseOutcome::CrossFileRef,
571 ));
572 return;
573 }
574
575 // Phase 2a: disk I/O under per-file lock but WITHOUT cache lock.
576 let preread = match engine::read_source(
577 engine_interface_v1,
578 &path_owned,
579 &project_root_owned,
580 &snapshot_worker,
581 ) {
582 Ok(c) => Some(c),
583 Err(e) => {
584 tracing::warn!("ctx_read: cannot read {path_owned}: {e}");
585 if engine_interface_v1 {
586 let _ = tx.send((
587 "Engine v1 source read failed".to_owned(),
588 "error".into(),
589 0,
590 false,
591 None,
592 (0, 0),
593 ReuseOutcome::Cold,
594 ));
595 return;
596 }
597 None
598 }
599 };
600
601 if cancel_flag.load(Ordering::Relaxed) {
602 return;
603 }
604
605 // ── Phase 2b: Three-sub-phase read (#807) ──────────
606 //
607 // Previously held the global cache write-lock for the
608 // entire computation (tree-sitter, entropy compression).
609 // For large files this caused 30+ second lock holds and
610 // cascading timeouts for all concurrent tool calls.
611 //
612 // New: prepare (brief lock) → compute (no lock) → store (brief lock).
613
614 // #1590: an inferred task never steers a lossy view.
615 let task_ref = if task_is_inferred {
616 None
617 } else {
618 task_owned.as_deref()
619 };
620 let tuning =
621 crate::tools::ctx_read::ReadTuning::resolve(aggressiveness, &protect_owned);
622
623 // Helper: acquire write lock with deadline. `None` means the
624 // lock stayed contended past the deadline; callers degrade
625 // to an uncached read instead of failing it (#1925).
626 macro_rules! acquire_write {
627 ($deadline_secs:expr, $label:expr) => {{
628 let deadline =
629 std::time::Instant::now() + cache_lock_deadline($deadline_secs);
630 loop {
631 if cancel_flag.load(Ordering::Relaxed) {
632 return;
633 }
634 if let Ok(guard) = cache_lock.try_write() {
635 break Some(guard);
636 }
637 if std::time::Instant::now() >= deadline {
638 tracing::warn!(
639 "ctx_read: cache write-lock timeout ({}) for {path_owned}, \
640 serving uncached",
641 $label,
642 );
643 break None;
644 }
645 std::thread::sleep(std::time::Duration::from_millis(50));
646 }
647 }};
648 }
649
650 // Work that needs no cache state runs before the write lock
651 // (#1925). The first `count_tokens` call builds the BPE tables
652 // (seconds in debug builds); done under the lock it starved
653 // every concurrent read into the deadline. Counting the
654 // preread here also turns the count inside `cache.store()`
655 // into a token-cache hit.
656 let preread_tokens =
657 preread.as_deref().map(lean_ctx::core::tokens::count_tokens);
658 let mode_eff = if mode != "raw"
659 && !mode.starts_with("lines:")
660 && lean_ctx::core::config::Config::load()
661 .proxy
662 .is_path_compress_protected(&path_owned)
663 {
664 "full".to_string()
665 } else {
666 mode.clone()
667 };
668
669 // 2b-i: Brief write lock — prepare cache state, resolve
670 // mode, check for hits. Sub-millisecond: HashMap lookups,
671 // staleness checks, raw-content storage for new files.
672 let (outcome, source_identity) = 'prepare: {
673 let Some(mut cache) = acquire_write!(10, "prepare 10s") else {
674 match prepare_uncached(
675 preread,
676 preread_tokens,
677 &path_owned,
678 &mode_eff,
679 &tuning,
680 task_ref,
681 ) {
682 Ok(uncached) => break 'prepare (uncached, None),
683 Err(msg) => {
684 let _ = tx.send((
685 msg,
686 "error".into(),
687 0,
688 false,
689 None,
690 (0, 0),
691 ReuseOutcome::Cold,
692 ));
693 return;
694 }
695 }
696 };
697 let mut reuse_outcome = if cache_policy_bypassed {
698 ReuseOutcome::PolicyBypass
699 } else if fresh {
700 ReuseOutcome::FreshBypass
701 } else {
702 ReuseOutcome::Cold
703 };
704
705 let file_ref = cache.get_file_ref(&path_owned);
706
707 let effective_fresh = fresh
708 || crate::tools::ctx_read::force_fresh_env()
709 || (crate::tools::ctx_read::is_subagent_context()
710 && !lean_ctx::core::conversation::scope_enabled());
711
712 if effective_fresh {
713 cache.invalidate(&path_owned);
714 }
715
716 if !effective_fresh {
717 // #1584: `diff` is the one mode for which a stale
718 // entry is the *point* — it holds the baseline the
719 // delta is computed against. Dropping it here left
720 // `handle_diff` with nothing to compare and made
721 // every post-edit diff answer "no cached version".
722 // Every other mode still refuses to serve content
723 // that no longer matches the file on disk.
724 let stale = mode_eff != "diff"
725 && cache.get(&path_owned).is_some_and(|e| {
726 lean_ctx::core::cache::is_cache_entry_stale_verified(
727 &path_owned,
728 e.stored_mtime,
729 &e.hash,
730 )
731 });
732 if stale {
733 cache.invalidate(&path_owned);
734 reuse_outcome = ReuseOutcome::Stale;
735 }
736 }
737
738 let snap = cache
739 .get(&path_owned)
740 .map(|e| (e.original_tokens, e.content()));
741
742 // #1584: `diff` is a delta view, not a whole-file
743 // render. It needs the cached baseline and has no arm in
744 // `process_mode_tuned`, so letting it fall through to the
745 // generic compute path made the MCP handler answer
746 // "[WARNING: unknown mode 'diff']" and return the entire
747 // file — while the CLI path handled it correctly. Both
748 // paths now share `handle_diff`.
749 let outcome = if mode_eff == "diff" {
750 // A file that cannot be read is this read's typed
751 // failure (`"error"`, answered as a tool error
752 // below) with the same text — never a `diff` reply.
753 let (out, baseline, diff_mode) = if fresh {
754 let warning = "[warning] fresh+diff is redundant — fresh invalidates the cache, so no baseline is left to diff against. Use mode=full with fresh=true instead.";
755 (warning.to_string(), None, "diff")
756 } else {
757 match crate::tools::ctx_read::prepare_diff(
758 &cache,
759 &path_owned,
760 &file_ref,
761 ) {
762 Ok((out, _sent, baseline)) => (out, baseline, "diff"),
763 Err(failure) => (failure, None, "error"),
764 }
765 };
766 let out = lean_ctx::core::redaction::redact_text_if_enabled(&out);
767 let orig = baseline.as_deref().map_or_else(
768 || cache.get(&path_owned).map_or(0, |e| e.original_tokens),
769 lean_ctx::core::tokens::count_tokens,
770 );
771 pending_cache_worker
772 .lock()
773 .unwrap_or_else(std::sync::PoisonError::into_inner)
774 .diff_baseline = baseline;
775 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
776 let s = cache.get_stats();
777 PrepareOutcome::Hit(
778 out,
779 diff_mode.to_string(),
780 orig,
781 false,
782 fref,
783 (s.total_reads(), s.cache_hits()),
784 reuse_outcome,
785 )
786 } else if let Some((orig_tok, content_opt)) = snap {
787 let resolved = if mode_eff == "auto" {
788 tuning.auto_density_mode().unwrap_or_else(|| {
789 crate::tools::ctx_read::resolve_unranged_auto_mode(
790 Some(&cache),
791 &path_owned,
792 orig_tok,
793 cache.get(&path_owned).map_or(0, |e| e.line_count),
794 task_ref,
795 )
796 })
797 } else {
798 mode_eff
799 };
800
801 if (resolved == "full" || resolved == "full-compact")
802 && let Some(out) = crate::tools::ctx_read::try_stub_hit_readonly(
803 &cache,
804 &path_owned,
805 )
806 {
807 let orig = cache.get(&path_owned).map_or(0, |e| e.original_tokens);
808 let fref = cache.file_ref_map().get(path_owned.as_str()).cloned();
809 let s = cache.get_stats();
810 PrepareOutcome::Hit(
811 out.content,
812 out.resolved_mode,
813 orig,
814 true,
815 fref,
816 (s.total_reads(), s.cache_hits()),
817 ReuseOutcome::UnchangedStub,
818 )
819 } else if crate::tools::ctx_read::is_cacheable_mode(&resolved) {
820 let ck = crate::tools::ctx_read::request_scoped_key(
821 crate::tools::ctx_read::compressed_cache_key(
822 &resolved,
823 crp_mode,
824 task_ref,
825 tuning.aggressiveness,
826 tuning.protect,
827 ),
828 mode == "auto",
829 );
830 if let Some(hit) = cache.get_compressed(&path_owned, &ck).cloned() {
831 lean_ctx::core::auto_mode_resolver::count_source(
832 "compressed_cache_hit",
833 );
834 let hit =
835 lean_ctx::core::redaction::redact_text_if_enabled(&hit);
836 let orig =
837 cache.get(&path_owned).map_or(0, |e| e.original_tokens);
838 let fref =
839 cache.file_ref_map().get(path_owned.as_str()).cloned();
840 let s = cache.get_stats();
841 PrepareOutcome::Hit(
842 hit,
843 resolved,
844 orig,
845 true,
846 fref,
847 (s.total_reads(), s.cache_hits()),
848 ReuseOutcome::RenderCacheHit,
849 )
850 } else {
851 if reuse_outcome == ReuseOutcome::Cold
852 && cache.was_compressed_variant_evicted(&path_owned, &ck)
853 {
854 reuse_outcome = ReuseOutcome::VariantEvicted;
855 }
856 let c = content_opt
857 .or_else(|| preread.as_deref().map(String::from));
858 PrepareOutcome::Compute {
859 file_ref,
860 resolved_mode: resolved,
861 content: c.unwrap_or_default(),
862 original_tokens: orig_tok,
863 reuse_outcome,
864 cacheable: true,
865 }
866 }
867 } else {
868 let c =
869 content_opt.or_else(|| preread.as_deref().map(String::from));
870 PrepareOutcome::Compute {
871 file_ref,
872 resolved_mode: resolved,
873 content: c.unwrap_or_default(),
874 original_tokens: orig_tok,
875 reuse_outcome,
876 cacheable: true,
877 }
878 }
879 } else {
880 let raw = match preread {
881 Some(c) if !c.is_empty() => c,
882 _ => match crate::tools::ctx_read::read_file_lossy(&path_owned) {
883 Ok(c) if !c.is_empty() => c,
884 Ok(_) => {
885 tracing::debug!(
886 "ctx_read: skipping cache for empty content: {path_owned}"
887 );
888 let _ = tx.send((
889 empty_file_notice(&path_owned),
890 "full".into(),
891 0,
892 false,
893 None,
894 (0, 0),
895 ReuseOutcome::Cold,
896 ));
897 return;
898 }
899 Err(e) => {
900 tracing::debug!(
901 "ctx_read: skipping cache for empty content: {path_owned}"
902 );
903 let _ = tx.send((
904 format!("Cannot read file: {path_owned}: {e}"),
905 "error".into(),
906 0,
907 false,
908 None,
909 (0, 0),
910 ReuseOutcome::Cold,
911 ));
912 return;
913 }
914 },
915 };
916 let sr = cache.store(&path_owned, &raw);
917 let resolved = if mode_eff == "auto" {
918 tuning.auto_density_mode().unwrap_or_else(|| {
919 crate::tools::ctx_read::resolve_unranged_auto_mode(
920 None,
921 &path_owned,
922 sr.original_tokens,
923 sr.line_count,
924 task_ref,
925 )
926 })
927 } else {
928 mode_eff
929 };
930 PrepareOutcome::Compute {
931 file_ref,
932 resolved_mode: resolved,
933 content: raw,
934 original_tokens: sr.original_tokens,
935 reuse_outcome,
936 cacheable: true,
937 }
938 };
939 let identity = cache.get(&path_owned).map(CacheSourceIdentity::from_entry);
940 (outcome, identity)
941 }; // write lock released
942
943 if let PrepareOutcome::Hit(c, rm, orig, hit, fref, ss, reuse_outcome) = outcome
944 {
945 // Update last_mode for compressed-cache hits so the auto-mode
946 // resolver can reuse this mode on future re-reads (#E26).
947 let mut pending = pending_cache_worker
948 .lock()
949 .unwrap_or_else(std::sync::PoisonError::into_inner);
950 let diff_baseline = pending.diff_baseline.take();
951 *pending = PendingCacheDelivery {
952 identity: source_identity,
953 last_mode: Some(rm.clone()),
954 diff_baseline,
955 ..PendingCacheDelivery::default()
956 };
957 let _ = tx.send((c, rm, orig, hit, fref, ss, reuse_outcome));
958 return;
959 }
960 let PrepareOutcome::Compute {
961 file_ref,
962 resolved_mode,
963 content: compute_content,
964 original_tokens,
965 reuse_outcome,
966 cacheable,
967 } = outcome
968 else {
969 unreachable!()
970 };
971
972 if cancel_flag.load(Ordering::Relaxed) {
973 return;
974 }
975
976 // 2b-ii: Heavy computation WITHOUT cache lock.
977 // Tree-sitter, entropy compression, mode rendering all
978 // run under the per-file mutex only (serializes same-file
979 // reads, but does not block other files or tool calls).
980 let short = lean_ctx::core::protocol::shorten_path(&path_owned);
981 let ext_s = std::path::Path::new(&*path_owned)
982 .extension()
983 .and_then(|e| e.to_str())
984 .unwrap_or("");
985
986 let source_hash = blake3::hash(compute_content.as_bytes());
987 let mut source_fingerprint = [0u8; 12];
988 source_fingerprint.copy_from_slice(&source_hash.as_bytes()[..12]);
989 let (mut computed, rmode) = if resolved_mode == "full"
990 || resolved_mode == "full-compact"
991 {
992 if resolved_mode == "full-compact" {
993 let (out, _) = crate::tools::ctx_read::format_full_compact_output(
994 &compute_content,
995 );
996 (out, "full-compact".to_string())
997 } else {
998 let lc = compute_content.lines().count();
999 let (out, _) = crate::tools::ctx_read::format_full_output(
1000 &file_ref,
1001 &short,
1002 ext_s,
1003 &compute_content,
1004 original_tokens,
1005 lc,
1006 task_ref,
1007 );
1008 let ft = lean_ctx::core::tokens::count_tokens(&out);
1009 let out = crate::tools::ctx_read::cap_to_raw(
1010 out,
1011 ft,
1012 &compute_content,
1013 original_tokens,
1014 "full",
1015 );
1016 (out, "full".to_string())
1017 }
1018 } else {
1019 // #1910: a fallback inside an `auto` read returns the
1020 // bare file, bannerless.
1021 let _auto_guard = (mode == "auto")
1022 .then(crate::tools::ctx_read::render::AutoRequestGuard::new);
1023 let (out, _) = crate::tools::ctx_read::process_mode_tuned(
1024 &compute_content,
1025 &resolved_mode,
1026 &file_ref,
1027 &short,
1028 ext_s,
1029 original_tokens,
1030 crp_mode,
1031 &path_owned,
1032 task_ref,
1033 tuning,
1034 );
1035 let out = if crate::tools::ctx_read::mode_allows_raw_cap(&resolved_mode) {
1036 let ft = lean_ctx::core::tokens::count_tokens(&out);
1037 crate::tools::ctx_read::cap_to_raw(
1038 out,
1039 ft,
1040 &compute_content,
1041 original_tokens,
1042 &resolved_mode,
1043 )
1044 } else {
1045 out
1046 };
1047 (out, resolved_mode)
1048 };
1049
1050 computed = lean_ctx::core::redaction::redact_text_if_enabled(&computed);
1051 if let Some(note) =
1052 lean_ctx::core::context_admission::note_for(&compute_content, &computed)
1053 {
1054 computed.push('\n');
1055 computed.push_str(¬e);
1056 }
1057
1058 if cancel_flag.load(Ordering::Relaxed) {
1059 return;
1060 }
1061
1062 // Warming source bytes is acquisition; variants/full flags imply
1063 // delivery and are committed only if this result is included.
1064 // An uncached fallback (`F?` ref, #1925) never feeds the cache.
1065 let compressed_variant = (cacheable
1066 && crate::tools::ctx_read::is_cacheable_mode(&rmode))
1067 .then(|| {
1068 // #1910: an `auto` request's fallback is bannerless, an
1069 // explicit one is not — they must never share a variant.
1070 let key = crate::tools::ctx_read::request_scoped_key(
1071 crate::tools::ctx_read::compressed_cache_key(
1072 &rmode,
1073 crp_mode,
1074 task_ref,
1075 tuning.aggressiveness,
1076 tuning.protect,
1077 ),
1078 mode == "auto",
1079 );
1080 (key, computed.clone())
1081 });
1082 *pending_cache_worker
1083 .lock()
1084 .unwrap_or_else(std::sync::PoisonError::into_inner) =
1085 PendingCacheDelivery {
1086 identity: source_identity,
1087 compressed_variant,
1088 // A read the turn budget cuts is not a full delivery (#2172).
1089 full_content: cacheable
1090 && matches!(rmode.as_str(), "full" | "full-compact")
1091 && !crate::tools::ctx_read::dedup_hook::exceeds_turn_budget(
1092 &computed,
1093 ),
1094 source_fingerprint: Some(source_fingerprint),
1095 last_mode: cacheable.then(|| rmode.clone()),
1096 record_bounce_read: true,
1097 diff_baseline: None,
1098 };
1099 let _ = tx.send((
1100 computed,
1101 rmode,
1102 original_tokens,
1103 false,
1104 Some(file_ref),
1105 (0, 0),
1106 reuse_outcome,
1107 ));
1108 });
1109 if let Ok(result) = rx.recv_timeout(read_timeout) {
1110 result
1111 } else {
1112 cancelled.store(true, Ordering::Relaxed);
1113 tracing::error!("ctx_read timed out after {read_timeout:?} for {path}");
1114 let msg = format!(
1115 "ERROR: ctx_read timed out after {}s reading {path}. \
1116 The file may be very large or a blocking I/O issue occurred. \
1117 Try mode=\"lines:1-100\" for a partial read.",
1118 read_timeout.as_secs()
1119 );
1120 return Err(ErrorData::internal_error(msg, None));
1121 }
1122 } // end else (slow path)
1123 };
1124
1125 engine::reject_read_failure_if_enabled(
1126 engine_interface_v1,
1127 &resolved_mode,
1128 &ctx.project_root,
1129 path,
1130 &mut engine_policy_admission,
1131 )?;
1132 engine::require_aggressive(
1133 engine_interface_v1,
1134 &resolved_mode,
1135 &ctx.project_root,
1136 path,
1137 &mut engine_policy_admission,
1138 )?;
1139 if resolved_mode == "error" {
1140 return Err(ErrorData::invalid_params(output, None));
1141 }
1142
1143 let mut output = output;
1144 // Raw and receipt-backed contracts retain their exact payload. Only a
1145 // newly acquired source can produce a current optional Pro hint.
1146 let code_hint = if !engine_interface_v1 && resolved_mode != "raw" && !is_cache_hit {
1147 engine_snapshot.code_hint(path)
1148 } else {
1149 None
1150 };
1151 let engine_warning = engine_snapshot.record_if_enabled(
1152 &ctx.project_root,
1153 engine_policy_admission,
1154 &mut output,
1155 );
1156 let output_tokens = lean_ctx::core::tokens::count_tokens(&output);
1157
1158 let cache_delivery = std::mem::take(
1159 &mut *pending_cache
1160 .lock()
1161 .unwrap_or_else(std::sync::PoisonError::into_inner),
1162 );
1163 // A pre-read disk hash may describe older bytes. Relay only when it
1164 // agrees with the source that actually supplied this rendered result.
1165 let cross_agent = if is_cache_hit {
1166 None
1167 } else {
1168 delivery_metadata
1169 .filter(|fp| cache_delivery.source_fingerprint.as_ref() == Some(&fp.hash))
1170 .map(|fp| {
1171 let relay_key = (matches!(resolved_mode.as_str(), "map" | "signatures")
1172 && output.len() <= 8192)
1173 .then(|| {
1174 crate::tools::ctx_read::compressed_cache_key(
1175 &resolved_mode,
1176 ctx.crp_mode,
1177 None,
1178 aggressiveness,
1179 &protect,
1180 )
1181 });
1182 PendingCrossAgentDelivery {
1183 hash: fp.hash,
1184 mtime: fp.mtime,
1185 line_count: fp.line_count,
1186 output_tokens,
1187 output: output.clone(),
1188 relay_key,
1189 }
1190 })
1191 };
1192
1193 // #1098: graph-related hints (callers/callees) are now computed AFTER the
1194 // cache lock is released. They involve SQLite queries (~50-200ms) that
1195 // previously blocked all parallel reads while holding the write lock.
1196 let graph_hint = if !is_cache_hit
1197 && !resolved_mode.starts_with("lines:")
1198 && lean_ctx::core::profiles::active_profile()
1199 .output_hints
1200 .related_hint()
1201 {
1202 crate::tools::ctx_read::graph_related_hint(path)
1203 } else {
1204 None
1205 };
1206
1207 // Cross-source hints: gated by profile `cross_source_hint` (default on).
1208 // When enabled, appends issue/PR/schema references from the property
1209 // graph. Skipped when graph.db doesn't exist (#682).
1210 // Legacy graph edges carry no source authority for protected reuse.
1211 // A project linked to an emspace space is indexed in the background
1212 // (detached, never waited for), so its page hints need no provider call.
1213 let hints_on = lean_ctx::core::profiles::active_profile()
1214 .output_hints
1215 .cross_source_hint()
1216 && !lean_ctx::core::policy::runtime::is_active();
1217 if hints_on && lean_ctx::core::providers::emspace::is_linked(&ctx.project_root) {
1218 lean_ctx::core::providers::emspace::refresh_index_if_due(&ctx.project_root);
1219 }
1220 let hints_suffix = if hints_on {
1221 let graph_db =
1222 lean_ctx::core::property_graph::graph_dir(&ctx.project_root).join("graph.db");
1223 let graph = graph_db
1224 .exists()
1225 .then(|| lean_ctx::core::property_graph::CodeGraph::open(&ctx.project_root))
1226 .transpose()
1227 .ok()
1228 .flatten();
1229 graph.map_or_else(String::new, |graph| {
1230 let edges = graph.all_cross_source_edges();
1231 if edges.is_empty() {
1232 String::new()
1233 } else {
1234 let ranges = scoped_read_ranges(&resolved_mode);
1235 let relative_path =
1236 lean_ctx::core::graph_index::graph_relative_key(path, &ctx.project_root);
1237 let hints = lean_ctx::core::cross_source_hints::hints_for_file_matching(
1238 path,
1239 &edges,
1240 &ctx.project_root,
1241 |hint| {
1242 // The far end of the edge is asked again (G04);
1243 // the near end is the file this call just read.
1244 graph.serves_file(&hint.source_uri)
1245 && ranges.as_ref().is_none_or(|ranges| {
1246 hint_intersects_ranges(hint, ranges, &graph, &relative_path)
1247 })
1248 },
1249 );
1250 admitted_hints(&hints)
1251 }
1252 })
1253 } else {
1254 String::new()
1255 };
1256 // pz21 §1.8: a file this session downloaded through a matched
1257 // command is outside text, whatever its path.
1258 note_downloaded_read(path, &ctx.project_root);
1259
1260 // Rule injection (#1325): discover and append rules scoped to this file
1261 // path (CLAUDE.md, AGENTS.md, .cursor/rules, .claude/rules) so the agent
1262 // receives the same context it would get from the native Read tool.
1263 let rules_suffix = {
1264 let client_id = ctx
1265 .client_name
1266 .as_ref()
1267 .map(|c| c.blocking_read().clone())
1268 .unwrap_or_default();
1269 lean_ctx::core::rule_discovery::rules_suffix_for_read(
1270 path,
1271 &ctx.project_root,
1272 &client_id,
1273 )
1274 };
1275 let mut warnings: Vec<&str> = engine_warning.as_deref().into_iter().collect();
1276 if let Some(ref w) = budget_warning {
1277 warnings.push(w.as_str());
1278 }
1279 if let Some(ref w) = delta_explicit_note {
1280 warnings.push(w.as_str());
1281 }
1282 if let Some(ref w) = mode_override_note {
1283 warnings.push(w.as_str());
1284 }
1285 if let Some(ref w) = instruction_mode_note {
1286 warnings.push(w.as_str());
1287 }
1288 let file_summary = extract_file_summary(&output, path);
1289 let graph_suffix = graph_hint.map(|h| format!("\n{h}")).unwrap_or_default();
1290 // #977: notices (mode override, budget, delta) go BEFORE the
1291 // payload so client-side truncation of large outputs cannot hide them.
1292 let final_output = if !warnings.is_empty() {
1293 format!(
1294 "{}\n\n{output}{hints_suffix}{graph_suffix}{rules_suffix}",
1295 warnings.join("\n")
1296 )
1297 } else if hints_suffix.is_empty() && graph_suffix.is_empty() && rules_suffix.is_empty() {
1298 output
1299 } else {
1300 format!("{output}{hints_suffix}{graph_suffix}{rules_suffix}")
1301 };
1302 // Source lineage of this reply (G04). The requested file is published
1303 // even when the answer came from a cache or a stub. Everything the
1304 // reply carries from somewhere ELSE — graph neighbours, cross-source
1305 // hints, proactively expanded content — comes from stores whose
1306 // sources are not bound here: the call's source set is then not
1307 // complete and is recorded as such, never as "this file only".
1308 // Rule files are not in that list: `rule_discovery` appends a rule
1309 // only as the admitted rooted reader delivers it, and that reader
1310 // publishes the rule file into this call's source set (or marks the
1311 // set unbound itself when it cannot bind the file).
1312 crate::tools::ctx_read::publish_requested_source(path, &ctx.project_root, "ctx_read");
1313 let assembled_len = final_output.len();
1314 let foreign_content = !hints_suffix.is_empty() || !graph_suffix.is_empty();
1315
1316 // Proactive context: gated by profile `proactive_context` (default off).
1317 // When enabled, auto-expands previously compressed content that is
1318 // keyword-relevant to the current read (up to 2000 tokens).
1319 let final_output = if lean_ctx::core::profiles::active_profile()
1320 .output_hints
1321 .proactive_context()
1322 {
1323 let proactive_query = format!(
1324 "ctx_read path={path} mode={resolved_mode} task={}",
1325 task_ref.unwrap_or_default()
1326 );
1327 if let Some(block) = lean_ctx::core::relevance_tracker::proactive_context_for_path(
1328 &proactive_query,
1329 path,
1330 ) {
1331 format!("{final_output}{block}")
1332 } else {
1333 final_output
1334 }
1335 } else {
1336 final_output
1337 };
1338 if foreign_content || final_output.len() != assembled_len {
1339 lean_ctx::core::archive::authority::mark_unbound();
1340 }
1341
1342 // Keep paid annotations outside the pending source/relay cache payload;
1343 // an expired license must never recover an old paid analysis as fresh.
1344 let final_output = match code_hint {
1345 Some(note) => format!("{final_output}\n\n{note}"),
1346 None => final_output,
1347 };
1348
1349 // Monotonic guard (#1326): re-count tokens on the fully assembled output
1350 // (including hints, warnings, proactive context) and verify the compressed
1351 // result is actually smaller than the original. If annotations inflated the
1352 // output beyond the raw baseline, report zero savings so the ledger stays
1353 // honest. We keep the compressed form (it may still be more useful than raw)
1354 // but correct the accounting.
1355 let final_tokens = lean_ctx::core::tokens::count_tokens(&final_output);
1356 let verified_saved = original.saturating_sub(final_tokens);
1357
1358 let delivery = PendingReadDelivery {
1359 path: path.to_owned(),
1360 cache_delivery,
1361 cross_agent,
1362 file_ref,
1363 file_summary,
1364 resolved_mode: resolved_mode.clone(),
1365 original_tokens: original,
1366 reuse_outcome,
1367 is_cache_hit,
1368 agent_id: resolved_agent_id,
1369 };
1370 Ok(PreparedRead {
1371 delivery: Some(delivery),
1372 output: ToolOutput {
1373 text: final_output,
1374 original_tokens: original,
1375 saved_tokens: verified_saved,
1376 mode: Some(resolved_mode),
1377 path: Some(path.to_string()),
1378 changed: false,
1379 shell_outcome: None,
1380 content_blocks: None,
1381 },
1382 })
1383 }
1384}
1385
1386#[path = "ctx_read_engine.rs"]
1387mod engine;
1388// #660 LOC gate: the MCP tool definition lives in its own module.
1389#[path = "ctx_read_image.rs"]
1390mod image;
1391#[path = "ctx_read_schema.rs"]
1392mod schema;
1393#[path = "ctx_read_symbol.rs"]
1394mod symbol;
1395use image::read_image_file;
1396// #660 LOC gate: leaf helpers live in their own module.
1397#[path = "ctx_read_helpers.rs"]
1398mod helpers;
1399pub(crate) use helpers::task_intent_steers_read;
1400use helpers::{extract_file_summary, record_attribution_result};
1401
1402// #660 LOC gate: cache-lock deadline and the uncached fallback (#1925).
1403#[path = "ctx_read_uncached.rs"]
1404mod uncached;
1405use uncached::{PrepareOutcome, cache_lock_deadline, empty_file_notice, prepare_uncached};
1406
1407#[path = "ctx_read_window.rs"]
1408mod window;
1409// #1993: the batch read resolves `raw=true` the same way.
1410pub(super) use window::resolve_explicit_mode;
1411#[allow(unused_imports)]
1412// lines_mode + resolve_line_window used in #[cfg(test)] ctx_read_inline_tests
1413use window::{
1414 apply_line_window, hint_intersects_ranges, lines_mode, resolve_instruction_file_mode,
1415 resolve_line_window, resolve_raw_alias, scoped_read_ranges,
1416};
1417
1418// #660 LOC gate: inline tests split out to keep this file under the line cap.
1419#[cfg(test)]
1420#[path = "ctx_read_inline_tests.rs"]
1421mod tests;
1422
1423#[cfg(test)]
1424#[path = "ctx_read_security_tests.rs"]
1425mod security_tests;
1426
1427#[cfg(test)]
1428#[path = "ctx_read_gateway_tests.rs"]
1429mod gateway_tests;
1430
1431// #660 LOC gate: repo-param tests split out to keep this file under the line
1432// cap — see `ctx_read_repo_param_tests.rs`.
1433
1434#[cfg(test)]
1435#[path = "ctx_read_repo_param_tests.rs"]
1436mod repo_param_tests;
1437
1438#[cfg(test)]
1439#[path = "ctx_read_override_tests.rs"]
1440mod override_tests;
1441
1442#[path = "ctx_read_origin.rs"]
1443mod origin_parts;
1444use origin_parts::{admitted_hints, note_downloaded_read};