Skip to main content

lean_ctx/tools/ctx_read/
dispatch.rs

1use super::{
2    CrpMode, HookPoint, PluginManager, ReadMode, ReadOutput, ReadTuning, SessionCache,
3    count_tokens, dedup_hook, handle_with_options_inner, kernel, protocol,
4};
5
6/// Reads a file through the cache and applies the requested compression mode.
7pub fn handle(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
8    handle_with_options(cache, path, mode, false, crp_mode, None)
9}
10
11/// Like `handle`, but invalidates the cache first to force a fresh disk read.
12pub fn handle_fresh(cache: &mut SessionCache, path: &str, mode: &str, crp_mode: CrpMode) -> String {
13    handle_with_options(cache, path, mode, true, crp_mode, None)
14}
15
16/// Reads a file with task-aware filtering to prioritize task-relevant content.
17pub fn handle_with_task(
18    cache: &mut SessionCache,
19    path: &str,
20    mode: &str,
21    crp_mode: CrpMode,
22    task: Option<&str>,
23) -> String {
24    let mut result = handle_with_options(cache, path, mode, false, crp_mode, task);
25    kernel::enrich_with_kernel(&mut result, task);
26    result
27}
28
29/// Like `handle_with_task`, also returns the resolved mode name and pre-counted tokens.
30pub fn handle_with_task_resolved(
31    cache: &mut SessionCache,
32    path: &str,
33    mode: &str,
34    crp_mode: CrpMode,
35    task: Option<&str>,
36) -> ReadOutput {
37    handle_with_options_resolved(
38        cache,
39        path,
40        mode,
41        false,
42        crp_mode,
43        task,
44        ReadTuning::resolve(None, &[]),
45    )
46}
47
48/// Like [`handle_with_task_resolved`] but with an explicit per-call
49/// aggressiveness (the `ctx_read` `aggressiveness` arg, #714). `None` falls back
50/// to the `LEAN_CTX_AGGRESSIVENESS` env var / config field.
51pub fn handle_with_task_resolved_tuned(
52    cache: &mut SessionCache,
53    path: &str,
54    mode: &str,
55    crp_mode: CrpMode,
56    task: Option<&str>,
57    aggressiveness: Option<f64>,
58    protect: &[String],
59) -> ReadOutput {
60    handle_with_options_resolved(
61        cache,
62        path,
63        mode,
64        false,
65        crp_mode,
66        task,
67        ReadTuning::resolve(aggressiveness, protect),
68    )
69}
70
71/// Like [`handle_with_task_resolved_tuned`] but accepts pre-read file content,
72/// avoiding disk I/O under the cache write-lock (Two-Phase Read pattern, #1098).
73#[allow(clippy::too_many_arguments)]
74pub fn handle_with_preread(
75    cache: &mut SessionCache,
76    path: &str,
77    mode: &str,
78    fresh: bool,
79    crp_mode: CrpMode,
80    task: Option<&str>,
81    aggressiveness: Option<f64>,
82    protect: &[String],
83    preread: String,
84) -> ReadOutput {
85    handle_with_options_resolved_preread(
86        cache,
87        path,
88        mode,
89        fresh,
90        crp_mode,
91        task,
92        ReadTuning::resolve(aggressiveness, protect),
93        Some(preread),
94    )
95}
96
97/// Fresh read with task-aware filtering (invalidates cache first).
98pub fn handle_fresh_with_task(
99    cache: &mut SessionCache,
100    path: &str,
101    mode: &str,
102    crp_mode: CrpMode,
103    task: Option<&str>,
104) -> String {
105    handle_with_options(cache, path, mode, true, crp_mode, task)
106}
107
108/// Fresh read with task-aware filtering, also returns the resolved mode name and pre-counted tokens.
109pub fn handle_fresh_with_task_resolved(
110    cache: &mut SessionCache,
111    path: &str,
112    mode: &str,
113    crp_mode: CrpMode,
114    task: Option<&str>,
115) -> ReadOutput {
116    handle_with_options_resolved(
117        cache,
118        path,
119        mode,
120        true,
121        crp_mode,
122        task,
123        ReadTuning::resolve(None, &[]),
124    )
125}
126
127/// Fresh-read variant of [`handle_with_task_resolved_tuned`] (#714).
128pub fn handle_fresh_with_task_resolved_tuned(
129    cache: &mut SessionCache,
130    path: &str,
131    mode: &str,
132    crp_mode: CrpMode,
133    task: Option<&str>,
134    aggressiveness: Option<f64>,
135    protect: &[String],
136) -> ReadOutput {
137    handle_with_options_resolved(
138        cache,
139        path,
140        mode,
141        true,
142        crp_mode,
143        task,
144        ReadTuning::resolve(aggressiveness, protect),
145    )
146}
147
148fn handle_with_options(
149    cache: &mut SessionCache,
150    path: &str,
151    mode: &str,
152    fresh: bool,
153    crp_mode: CrpMode,
154    task: Option<&str>,
155) -> String {
156    handle_with_options_resolved(
157        cache,
158        path,
159        mode,
160        fresh,
161        crp_mode,
162        task,
163        ReadTuning::resolve(None, &[]),
164    )
165    .content
166}
167
168/// `LEAN_CTX_FORCE_FRESH=1` — an explicit operator override that always forces a
169/// cold full read, independent of conversation scoping.
170pub(crate) fn force_fresh_env() -> bool {
171    static FORCE_FRESH: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
172    *FORCE_FRESH.get_or_init(|| {
173        std::env::var("LEAN_CTX_FORCE_FRESH").is_ok_and(|v| v == "1" || v == "true")
174    })
175}
176
177/// Detects a subagent (forked agent) execution context.
178///
179/// A subagent must never be served a stub for content only the parent received.
180/// That used to be enforced by force-freshing *every* subagent read; with
181/// conversation scoping (#954/#955) the subagent instead runs under its own scope
182/// (`conversation::current_conversation_id` → `task:{id}` or `proc:{id}`), so
183/// the stub gate withholds cross-agent stubs precisely while restoring the
184/// subagent's *own* cheap re-reads. The blanket force-fresh is therefore kept
185/// only as the fallback when scoping is disabled (#956).
186///
187/// Checks `CURSOR_TASK_ID` (Cursor) and `CLAUDE_CODE_ENTRYPOINT=local-agent`
188/// (future Claude Code subagent marker). Current Claude Code is handled by
189/// per-process scoping in [`crate::core::conversation`] (#1292).
190pub(crate) fn is_subagent_context() -> bool {
191    static IS_SUBAGENT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
192    *IS_SUBAGENT.get_or_init(|| {
193        std::env::var("CURSOR_TASK_ID").is_ok_and(|v| !v.is_empty())
194            || std::env::var("CLAUDE_CODE_ENTRYPOINT")
195                .ok()
196                .as_deref()
197                .map(str::trim)
198                == Some("local-agent")
199    })
200}
201
202fn handle_with_options_resolved(
203    cache: &mut SessionCache,
204    path: &str,
205    mode: &str,
206    fresh: bool,
207    crp_mode: CrpMode,
208    task: Option<&str>,
209    tuning: ReadTuning<'_>,
210) -> ReadOutput {
211    handle_with_options_resolved_preread(cache, path, mode, fresh, crp_mode, task, tuning, None)
212}
213
214fn handle_with_options_resolved_preread(
215    cache: &mut SessionCache,
216    path: &str,
217    mode: &str,
218    fresh: bool,
219    crp_mode: CrpMode,
220    task: Option<&str>,
221    tuning: ReadTuning<'_>,
222    preread: Option<String>,
223) -> ReadOutput {
224    // #1292: Sub-agents have separate context windows and never received
225    // the parent's reads. Always force fresh regardless of scope state —
226    // correctness over cache savings for short-lived sub-agent contexts.
227    let effective_fresh = fresh || force_fresh_env() || is_subagent_context();
228
229    if PluginManager::has_listener("pre_read") {
230        PluginManager::fire_hook_background(HookPoint::PreRead {
231            path: path.to_string(),
232        });
233    }
234
235    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
236        bt.next_seq();
237    }
238    let mut result = handle_with_options_inner(
239        cache,
240        path,
241        mode,
242        effective_fresh,
243        crp_mode,
244        task,
245        tuning,
246        preread,
247    );
248
249    if let Some(entry) = cache.get_mut(path) {
250        entry.last_mode.clone_from(&result.resolved_mode);
251        // #841: a partial/filtered read means the model's most recent view is NOT
252        // the full content. Clear the delivery flag so a subsequent mode="full"
253        // re-delivers real content instead of the [unchanged] stub. Without this,
254        // a task→full sequence returns an empty stub because the flag was set by an
255        // earlier full delivery and never cleared by the intervening non-full read.
256        if !matches!(result.resolved_mode.as_str(), "full" | "full-compact") {
257            entry.full_content_delivered = false;
258        }
259    }
260
261    // SSOT via [`ReadMode`] (#528): lossy summaries may elide shared blocks.
262    let dedup_allowed = result
263        .resolved_mode
264        .parse::<ReadMode>()
265        .is_ok_and(|m| m.is_lossy_summary());
266    if dedup_allowed && let Some(deduped) = cache.apply_dedup(path, &result.content) {
267        let new_tokens = count_tokens(&deduped);
268        if new_tokens < result.output_tokens {
269            result.content = deduped;
270            result.output_tokens = new_tokens;
271        }
272    }
273
274    // R28: Kernel content dedup — detect re-reads of unchanged content.
275    if let Some(stub) = dedup_hook::maybe_dedup(path, &result.content, mode) {
276        let stub_tokens = count_tokens(&stub);
277        if stub_tokens < result.output_tokens {
278            result.content = stub;
279            result.output_tokens = stub_tokens;
280            result.is_cache_hit = true;
281        }
282    }
283
284    // R30: Feed bounce-tracker signal into adaptive compression bridge.
285    crate::core::context_kernel::adaptive_hook::update_from_bounce_tracker();
286    if let Ok(mut bt) = crate::core::bounce_tracker::global().lock() {
287        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
288        bt.record_read(
289            path,
290            &result.resolved_mode,
291            result.output_tokens,
292            original_tokens,
293        );
294
295        // Quality signals (#538): compressed reads count as clean until a
296        // bounce proves otherwise (the bounce signal outweighs 6:1); large
297        // full reads of never-bouncing extensions are wasted compression
298        // opportunities and push the learned threshold up.
299        // SSOT via [`ReadMode`] (#528): only verbatim `full` and the `diff`
300        // delta are uncompressed. A resolved window is always the canonical
301        // `lines:N-M` (parses to `Lines` ⇒ compressed); the default of `true`
302        // for the unreachable bare `"lines"` keeps prior behaviour everywhere a
303        // real resolved mode can occur.
304        let compressed = result
305            .resolved_mode
306            .parse::<ReadMode>()
307            .map_or(true, |m| m.counts_as_compressed());
308        if compressed {
309            crate::core::adaptive_thresholds::record_quality_signal(
310                path,
311                crate::core::threshold_learning::QualitySignal::CleanCompressed,
312            );
313        } else if result.resolved_mode == "full"
314            && result.output_tokens > 2000
315            && bt.bounce_rate_for_extension(path).unwrap_or(0.0) < 0.05
316        {
317            crate::core::adaptive_thresholds::record_quality_signal(
318                path,
319                crate::core::threshold_learning::QualitySignal::WastedFull,
320            );
321        }
322    }
323
324    // Plugin seam: emit the realized compression stats. Same zero-cost guard.
325    if PluginManager::has_listener("post_compress") {
326        let original_tokens = cache.get(path).map_or(0, |e| e.original_tokens);
327        PluginManager::fire_hook_background(HookPoint::PostCompress {
328            path: path.to_string(),
329            original_tokens,
330            compressed_tokens: result.output_tokens,
331        });
332    }
333
334    // Stigmergy (#540): deposit a Hot scent for this read in the background
335    // (the field file lock may briefly block; never stall the read path). The
336    // foreign-claim hint is intentionally NOT appended to the body: it carries a
337    // relative timestamp ("claimed Nm ago"), which would make the output a
338    // non-pure function of wall-clock time and defeat provider prompt caching
339    // (#498). The deposit remains so the field still reflects active work.
340    {
341        let self_agent = crate::core::scent_field::scent_agent_id();
342        let scent_path = crate::core::pathutil::normalize_tool_path(path);
343        std::thread::spawn(move || {
344            crate::core::scent_field::deposit(
345                self_agent,
346                crate::core::scent_field::ScentKind::Hot,
347                &scent_path,
348                0.3,
349            );
350        });
351    }
352
353    result
354}
355
356/// Attempt to serve a `mode="full"` cache hit (`[unchanged …]`) using only a
357/// shared borrow of the cache.
358///
359/// Returns `None` when the file is not cached, was modified on disk, full
360/// content was never delivered, or the cache policy forbids stubbing — in those
361/// cases the caller must fall back to the write path.
362///
363/// This is the read-locked fast path: it needs no `&mut SessionCache`, so the
364/// dominant "re-read an unchanged file" case proceeds under a shared lock and
365/// parallel reads of distinct files no longer serialize on a global write lock.
366pub fn try_stub_hit_readonly(cache: &SessionCache, path: &str) -> Option<ReadOutput> {
367    // Resolve the caller *fresh* (TTL-bypassed): the stub gate's concurrency
368    // detection must see a just-appeared second chat with zero lag, else a stub
369    // could leak across chats in the pre-detection window (#1042).
370    let current_conversation = crate::core::conversation::current_conversation_id_fresh();
371    try_stub_hit_readonly_scoped(cache, path, current_conversation.as_deref())
372}
373
374/// Conversation-scoped core of [`try_stub_hit_readonly`]. The current
375/// conversation id is injected (not read from the global resolver) so the
376/// conversation gate can be tested deterministically without global state.
377pub(crate) fn try_stub_hit_readonly_scoped(
378    cache: &SessionCache,
379    path: &str,
380    current_conversation: Option<&str>,
381) -> Option<ReadOutput> {
382    let no_deg = crate::core::config::Config::load().no_degrade_effective();
383    let prof = crate::core::profiles::active_profile();
384    let force_full = no_deg
385        || (prof.read.default_mode_effective() == "full"
386            && prof.compression.crp_mode_effective() == "off");
387    let policy_allows_stub =
388        crate::server::compaction_sync::effective_cache_policy() != "safe" && !force_full;
389    if !policy_allows_stub {
390        return None;
391    }
392
393    // Warm path: a live in-memory entry is the freshest source of truth.
394    if let Some(file_ref) = cache.get_file_ref_readonly(path) {
395        let (cached_mtime, cached_hash, line_count, delivered_conv) = {
396            let entry = cache.get(path)?;
397            (
398                entry.stored_mtime,
399                entry.hash.clone(),
400                entry.line_count,
401                entry.delivered_conversation.clone(),
402            )
403        };
404        if crate::core::cache::is_cache_entry_stale_verified(path, cached_mtime, &cached_hash)
405            || !cache.is_full_delivered(path)
406        {
407            return None;
408        }
409        // Conversation scoping (#954): only stub when THIS conversation received
410        // the content. A different (or unknown) conversation re-delivers in full
411        // rather than emit a misleading stub. `current == None` (hooks absent)
412        // preserves legacy process-scoped behavior, so single-chat hit rates are
413        // unchanged.
414        if !crate::core::conversation::conversation_allows_stub(
415            current_conversation,
416            delivered_conv.as_deref(),
417        ) {
418            crate::core::cache_telemetry::record_conversation_mismatch();
419            return None;
420        }
421        cache.record_cache_hit(path);
422        crate::core::telemetry::global_metrics().record_cache(true);
423        return Some(render_unchanged_stub(&file_ref, path, line_count));
424    }
425
426    // Cold fallback (#955): no live entry (e.g. after a daemon restart or idle
427    // clear). Serve the stub from the persisted index iff the file is unchanged
428    // AND the *same known* conversation is asking — a stricter gate than the warm
429    // path, because a cold stub crosses a process boundary (no "no context →
430    // legacy" escape; see `conversation_allows_cold_stub`).
431    let rec = crate::core::read_stub_index::lookup(path)?;
432    if crate::core::cache::is_cache_entry_stale_verified(path, rec.stored_mtime(), &rec.hash) {
433        return None;
434    }
435    if !crate::core::conversation::conversation_allows_cold_stub(
436        current_conversation,
437        rec.delivered_conversation.as_deref(),
438    ) {
439        crate::core::cache_telemetry::record_conversation_mismatch();
440        return None;
441    }
442    Some(render_unchanged_stub(&rec.file_ref, path, rec.line_count))
443}
444
445/// Renders the `[unchanged …]` stub body shared by the warm and cold stub paths.
446///
447/// #498 determinism: the stub is a pure function of (file_ref, path, line_count),
448/// so identical re-reads stay byte-stable and provider prompt caching applies.
449/// The `fresh=true` escape is a *static* suffix (no rotating proof lines or
450/// read-count notes), so a re-reader in non-meta mode still sees how to force the
451/// content (#513) without breaking byte-stability.
452fn render_unchanged_stub(file_ref: &str, path: &str, line_count: usize) -> ReadOutput {
453    let short = protocol::shorten_path(path);
454    let out = if crate::core::protocol::meta_visible() {
455        format!(
456            "{file_ref}={short} [unchanged {line_count}L]\nUnchanged on disk. Use fresh=true to force re-read.",
457        )
458    } else {
459        format!("{file_ref}={short} [unchanged {line_count}L · fresh=true to re-read]")
460    };
461    let out = crate::core::redaction::redact_text_if_enabled(&out);
462    let sent = count_tokens(&out);
463    ReadOutput {
464        content: out,
465        resolved_mode: "full".into(),
466        output_tokens: sent,
467        is_cache_hit: true,
468    }
469}
470
471/// Outcome of [`resolve_explicit_delta_mode`]: the (possibly rewritten) read
472/// mode plus an optional advisory note to surface to the agent.
473#[derive(Debug, Clone, PartialEq, Eq)]
474pub struct DeltaExplicitDecision {
475    /// The mode the read should proceed with (rewritten only when the feature
476    /// fires; otherwise the caller's mode, unchanged).
477    pub mode: String,
478    /// A byte-stable advisory appended to the read body when the mode was
479    /// rewritten to `diff`. `None` when nothing was rewritten or the collapse
480    /// was a silent `lines:`→`full` stub.
481    pub note: Option<String>,
482}
483
484/// Decide whether an **explicit** `full`/`lines:N-M` re-read of a session-cached
485/// file should be served as a delta instead of re-emitting content the model
486/// already holds (the `delta_explicit` opt-in; env `LCTX_DELTA_EXPLICIT`).
487///
488/// Returns the mode the read should proceed with:
489/// - **Changed on disk** (verified mtime+md5 stale) and full content is cached →
490///   `diff`, plus an advisory note. The diff carries exactly the new
491///   information in a fraction of the tokens.
492/// - **Unchanged** and the request is `lines:` of an already-fully-delivered
493///   file → `full`, so the read collapses to the ~15-token `[unchanged]` stub
494///   instead of re-extracting a window the model has seen.
495/// - Otherwise the caller's `mode` is returned untouched.
496///
497/// First reads (nothing cached) and `fresh=true` are never affected — the
498/// caller gates those before calling. Staleness uses the **verified** variant
499/// ([`crate::core::cache::is_cache_entry_stale_verified`]) so a same-second
500/// write on a coarse-granularity filesystem cannot be mistaken for "unchanged"
501/// and yield a misleading empty diff (#498 determinism).
502///
503/// Pure w.r.t. (cache, path, mode, enabled): no wall-clock, counters, or
504/// randomness enter the result, so identical inputs stay byte-stable.
505pub fn resolve_explicit_delta_mode(
506    cache: &SessionCache,
507    path: &str,
508    mode: &str,
509    explicit_mode: bool,
510    fresh: bool,
511    enabled: bool,
512) -> DeltaExplicitDecision {
513    let unchanged = DeltaExplicitDecision {
514        mode: mode.to_string(),
515        note: None,
516    };
517    if fresh
518        || !enabled
519        || !explicit_mode
520        || !(mode == "full" || mode == "full-compact" || mode.starts_with("lines:"))
521    {
522        return unchanged;
523    }
524    let Some(entry) = cache.get(path) else {
525        // First read this session — nothing to diff against.
526        return unchanged;
527    };
528    let stale =
529        crate::core::cache::is_cache_entry_stale_verified(path, entry.stored_mtime, &entry.hash);
530    if stale {
531        // Only divert to a diff when full content is actually cached: the diff
532        // base is that full content (see `handle_diff`), never a compressed
533        // view. Without it, `handle_diff` would have nothing to compare.
534        if entry.content().is_some() {
535            return DeltaExplicitDecision {
536                mode: "diff".to_string(),
537                note: Some(format!(
538                    "[delta-explicit] requested mode={mode} served as a diff: the file \
539                     changed since your last read and the diff is the new information. \
540                     Pass fresh=true if you need the full content re-emitted."
541                )),
542            };
543        }
544        return unchanged;
545    }
546    // Unchanged on disk: a `lines:` window of a file already delivered in full
547    // re-emits text the model holds — collapse to the full-mode stub
548    // (~15 tokens). A plain `full` re-read already hits that stub downstream.
549    if mode.starts_with("lines:") && cache.is_full_delivered(path) {
550        return DeltaExplicitDecision {
551            mode: "full".to_string(),
552            note: None,
553        };
554    }
555    unchanged
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561    use std::sync::atomic::Ordering;
562
563    #[test]
564    fn warm_stub_hit_records_central_telemetry() {
565        let dir = tempfile::tempdir().unwrap();
566        let file = dir.path().join("telemetry-hit.rs");
567        std::fs::write(&file, "fn telemetry_hit() {}\n").unwrap();
568        let path = file.to_string_lossy();
569        let mut cache = SessionCache::new();
570        cache.store(&path, "fn telemetry_hit() {}\n");
571        cache.mark_full_delivered(&path);
572
573        let metrics = crate::core::telemetry::global_metrics();
574        let before = metrics.cache_hits.load(Ordering::Relaxed);
575        let output = try_stub_hit_readonly_scoped(&cache, &path, None);
576        let after = metrics.cache_hits.load(Ordering::Relaxed);
577
578        assert!(output.is_some(), "warm re-read must use the stub cache");
579        assert!(
580            after > before,
581            "stub cache hit must increment central telemetry"
582        );
583    }
584}