Skip to main content

lean_ctx/tools/
ctx_multi_read.rs

1use crate::core::cache::SessionCache;
2use crate::core::heatmap;
3use crate::core::ocla::cache_types::{CacheKeyBuilder, FileReadKey};
4use crate::core::tokens::count_tokens;
5use crate::tools::CrpMode;
6use crate::tools::ctx_read;
7
8pub fn handle(cache: &mut SessionCache, paths: &[String], mode: &str, crp_mode: CrpMode) -> String {
9    handle_with_task(cache, paths, mode, crp_mode, None)
10}
11
12pub fn handle_with_task(
13    cache: &mut SessionCache,
14    paths: &[String],
15    mode: &str,
16    crp_mode: CrpMode,
17    task: Option<&str>,
18) -> String {
19    handle_with_task_fresh(cache, paths, mode, false, crp_mode, task)
20}
21
22const DEFAULT_MAX_MULTI_READ_BYTES: usize = 512 * 1024;
23
24fn max_multi_read_bytes() -> usize {
25    std::env::var("LCTX_MAX_MULTI_READ_BYTES")
26        .ok()
27        .and_then(|v| v.parse().ok())
28        .unwrap_or(DEFAULT_MAX_MULTI_READ_BYTES)
29}
30
31pub fn handle_with_task_fresh(
32    cache: &mut SessionCache,
33    paths: &[String],
34    mode: &str,
35    fresh: bool,
36    crp_mode: CrpMode,
37    task: Option<&str>,
38) -> String {
39    handle_with_task_fresh_result(cache, paths, mode, fresh, crp_mode, task).text
40}
41
42/// Batch-read result with the aggregate baseline across local and cross-agent hits.
43pub struct MultiReadResult {
44    pub text: String,
45    pub original_tokens: usize,
46}
47
48pub fn handle_with_task_fresh_result(
49    cache: &mut SessionCache,
50    paths: &[String],
51    mode: &str,
52    fresh: bool,
53    crp_mode: CrpMode,
54    task: Option<&str>,
55) -> MultiReadResult {
56    let n = paths.len();
57    if n == 0 {
58        return MultiReadResult {
59            text: "Read 0 files | 0 tokens saved".to_string(),
60            original_tokens: 0,
61        };
62    }
63
64    let max_bytes = max_multi_read_bytes();
65    let mut sections: Vec<String> = Vec::with_capacity(n);
66    let mut total_saved: usize = 0;
67    let mut total_original: usize = 0;
68    let mut accumulated_bytes: usize = 0;
69    let mut files_read = 0usize;
70    let mut truncated = false;
71
72    for path in paths {
73        let effective_mode = if ctx_read::is_instruction_file(path) {
74            "full"
75        } else {
76            mode
77        };
78        let cache_key = file_read_cache_key(path, effective_mode, crp_mode, task);
79        let cross_agent = (!fresh)
80            .then(|| {
81                crate::core::ocla::cache_delivery::check(
82                    &cache_key.cache_key(),
83                    &cache_key.validator(),
84                    "ctx_multi_read",
85                )
86            })
87            .flatten();
88        let (chunk, cross_agent_original) = if let Some(entry) = cross_agent {
89            (
90                crate::core::ocla::cache_delivery::stub(&entry, "file read"),
91                Some(entry.token_count as usize),
92            )
93        } else {
94            let chunk = if fresh {
95                ctx_read::handle_fresh_with_task(cache, path, effective_mode, crp_mode, task)
96            } else {
97                ctx_read::handle_with_task(cache, path, effective_mode, crp_mode, task)
98            };
99            if !chunk.contains("[cross-agent") {
100                crate::core::ocla::cache_delivery::record(
101                    cache_key.cache_key(),
102                    crate::core::ocla::cache_types::DeliveryKind::FileRead,
103                    cache_key.validator(),
104                    Some(cache_key.path.clone()),
105                    &chunk,
106                    "ctx_multi_read",
107                );
108            }
109            (chunk, None)
110        };
111        let original = cross_agent_original
112            .or_else(|| cache.get(path).map(|entry| entry.original_tokens))
113            .unwrap_or(0);
114        let sent = count_tokens(&chunk);
115        heatmap::record_file_access(path, original, original.saturating_sub(sent));
116        // Verified ledger (#685): model-correct counts. The default O200kBase model
117        // reuses the o200k counts above (same BPE + cache key → zero extra work); a
118        // resolved Claude/Gemini/Llama model re-tokenizes the raw source (from the
119        // cache) and the sent chunk so savings match the provider's billing units.
120        {
121            use crate::core::savings_ledger as ledger;
122            let (lbase, lsaved) =
123                if ledger::ledger_family() == crate::core::tokens::TokenizerFamily::O200kBase {
124                    (original, original.saturating_sub(sent))
125                } else if let Some(raw) = cache
126                    .get(path)
127                    .and_then(crate::core::cache::CacheEntry::content)
128                {
129                    let lo = ledger::count_for_ledger(&raw);
130                    (lo, lo.saturating_sub(ledger::count_for_ledger(&chunk)))
131                } else {
132                    (original, original.saturating_sub(sent))
133                };
134            ledger::record_read_event(lbase, lsaved, None, None);
135        }
136        total_original = total_original.saturating_add(original);
137        total_saved = total_saved.saturating_add(original.saturating_sub(sent));
138
139        let chunk_bytes = chunk.len();
140        if accumulated_bytes > 0 && accumulated_bytes + chunk_bytes > max_bytes {
141            truncated = true;
142            break;
143        }
144        accumulated_bytes += chunk_bytes;
145        sections.push(chunk);
146        files_read += 1;
147    }
148
149    let body = sections.join("\n---\n");
150    let summary = if truncated {
151        let skipped = n - files_read;
152        format!(
153            "Read {files_read}/{n} files | {total_saved} tokens saved\n\
154             ⚠ Output capped at {max_bytes} bytes (LCTX_MAX_MULTI_READ_BYTES). \
155             {skipped} file(s) skipped. Use individual ctx_read calls for remaining files."
156        )
157    } else if total_saved > 0 {
158        format!("Read {n} files | {total_saved} tokens saved")
159    } else {
160        format!("Read {n} files")
161    };
162    MultiReadResult {
163        text: format!("{body}\n---\n{summary}"),
164        original_tokens: total_original,
165    }
166}
167
168fn file_read_cache_key(
169    path: &str,
170    mode: &str,
171    crp_mode: CrpMode,
172    task: Option<&str>,
173) -> FileReadKey {
174    let canonical = crate::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(path));
175    let mtime_ns = std::fs::metadata(&canonical)
176        .ok()
177        .and_then(|metadata| metadata.modified().ok())
178        .and_then(|mtime| mtime.duration_since(std::time::UNIX_EPOCH).ok())
179        .map_or(0, |duration| duration.as_nanos());
180    FileReadKey {
181        path: canonical.to_string_lossy().into_owned(),
182        mtime_ns,
183        mode: mode.into(),
184        crp_mode: format!("{crp_mode:?}").to_ascii_lowercase(),
185        task_digest: blake3::hash(task.unwrap_or_default().as_bytes())
186            .to_hex()
187            .to_string(),
188        policy_rev: env!("CARGO_PKG_VERSION").into(),
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn multi_read_deduplicates_each_file_with_cross_agent_references() {
198        let directory = tempfile::tempdir().unwrap();
199        let first = directory.path().join("first.rs");
200        let second = directory.path().join("second.rs");
201        std::fs::write(&first, "fn first_probe() {}\n").unwrap();
202        std::fs::write(&second, "fn second_probe() {}\n").unwrap();
203        let paths = vec![
204            first.to_string_lossy().into_owned(),
205            second.to_string_lossy().into_owned(),
206        ];
207
208        let mut local = SessionCache::new();
209        let initial =
210            handle_with_task_fresh_result(&mut local, &paths, "full", false, CrpMode::Off, None);
211        assert!(initial.text.contains("first_probe"));
212
213        let mut another_agent = SessionCache::new();
214        let repeated = handle_with_task_fresh_result(
215            &mut another_agent,
216            &paths,
217            "full",
218            false,
219            CrpMode::Off,
220            None,
221        );
222        assert_eq!(repeated.text.matches("[cross-agent cache").count(), 2);
223        assert!(repeated.original_tokens > 0);
224    }
225}