Skip to main content

lean_ctx/tools/registered/
ctx_multi_read.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ToolContext, ToolOutput, get_bool, get_str, get_str_array,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxMultiReadTool;
11
12impl McpTool for CtxMultiReadTool {
13    fn name(&self) -> &'static str {
14        "ctx_multi_read"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_multi_read",
20            "Batch read files in one call. Same modes as ctx_read.",
21            json!({
22                "type": "object",
23                "properties": {
24                    "paths": {
25                        "type": "array",
26                        "items": { "type": "string" },
27                        "description": "Absolute file paths to read, in order"
28                    },
29                    "mode": {
30                        "type": "string",
31                        "default": "auto",
32                        "description": "Compression mode (default: auto — optimal per file, like ctx_read). Same modes as ctx_read (auto, full, raw, map, signatures, diff, aggressive, entropy, task, reference, lines:N-M). Use 'full' only when batch-editing; 'raw' for zero-overhead output."
33                    },
34                    "fresh": {
35                        "type": "boolean",
36                        "description": "Bypass cache and force a full re-read for all paths. Use when running as a subagent that may not have the parent's context."
37                    }
38                },
39                "required": ["paths"]
40            }),
41        )
42    }
43
44    fn handle(
45        &self,
46        args: &Map<String, Value>,
47        ctx: &ToolContext,
48    ) -> Result<ToolOutput, ErrorData> {
49        // Panic guard (mirrors ctx_read): a panic in tree-sitter / compression must
50        // never unwind through the dispatch `block_in_place` and kill the MCP server.
51        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
52            self.handle_inner(args, ctx)
53        })) {
54            Ok(result) => result,
55            Err(_) => Err(ErrorData::internal_error(
56                "ctx_multi_read panicked while processing the batch. This is a bug — please report it.",
57                None,
58            )),
59        }
60    }
61}
62
63impl CtxMultiReadTool {
64    #[allow(clippy::unused_self)]
65    fn handle_inner(
66        &self,
67        args: &Map<String, Value>,
68        ctx: &ToolContext,
69    ) -> Result<ToolOutput, ErrorData> {
70        let raw_paths = get_str_array(args, "paths")
71            .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
72
73        let session_lock = ctx
74            .session
75            .as_ref()
76            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
77        let cache_lock = ctx
78            .cache
79            .as_ref()
80            .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
81
82        let cap = crate::core::limits::max_read_bytes() as u64;
83
84        // Resolve + filter paths and capture the active task under one short read lock.
85        // `bounded_lock` uses `Handle::block_on` directly — NOT a nested
86        // `block_in_place` — because the dispatch layer already wraps this handler in
87        // `block_in_place`. The previous nested `block_in_place` calls could exhaust the
88        // 32-thread blocking pool under concurrent reads and freeze the server (#271).
89        let (paths, current_task) = {
90            let Some(session) =
91                crate::server::bounded_lock::read(session_lock, "ctx_multi_read:session")
92            else {
93                return Err(ErrorData::internal_error(
94                    "session read-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
95                    None,
96                ));
97            };
98            let mut paths = Vec::with_capacity(raw_paths.len());
99            for p in &raw_paths {
100                let resolved = super::resolve_path_sync(&session, p)
101                    .map_err(|e| ErrorData::invalid_params(e, None))?;
102                if crate::core::binary_detect::is_binary_file(&resolved) {
103                    continue;
104                }
105                if let Ok(meta) = std::fs::metadata(&resolved)
106                    && meta.len() > cap
107                {
108                    continue;
109                }
110                paths.push(resolved);
111            }
112            let current_task = session.task.as_ref().map(|t| t.description.clone());
113            (paths, current_task)
114        };
115
116        if paths.is_empty() {
117            return Err(ErrorData::invalid_params(
118                "all paths are binary or exceed the size limit",
119                None,
120            ));
121        }
122
123        // Default to the profile's read mode (auto) and let ctx_read resolve the
124        // optimal mode per file. Previously this forced auto→full, which is exactly
125        // the "everything comes back as full" complaint (#421): batch reads must
126        // honour auto like single ctx_read does.
127        let mode = get_str(args, "mode").unwrap_or_else(|| {
128            crate::core::profiles::active_profile()
129                .read
130                .default_mode_effective()
131                .to_string()
132        });
133        let fresh = get_bool(args, "fresh").unwrap_or(false);
134
135        // Batch read under one bounded write lock. `bounded_lock` guarantees we never
136        // block the runtime indefinitely and degrade gracefully on contention instead
137        // of hanging; ctx_read's own fast/slow path tolerates this lock being held.
138        let Some(mut cache) =
139            crate::server::bounded_lock::write(cache_lock, "ctx_multi_read:cache")
140        else {
141            return Err(ErrorData::internal_error(
142                "cache write-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
143                None,
144            ));
145        };
146        let output = crate::tools::ctx_multi_read::handle_with_task_fresh(
147            &mut cache,
148            &paths,
149            &mode,
150            fresh,
151            ctx.crp_mode,
152            current_task.as_deref(),
153        );
154        let mut total_original: usize = 0;
155        for path in &paths {
156            total_original =
157                total_original.saturating_add(cache.get(path).map_or(0, |e| e.original_tokens));
158        }
159        let tokens = crate::core::tokens::count_tokens(&output);
160        drop(cache);
161
162        Ok(ToolOutput {
163            text: output,
164            original_tokens: total_original,
165            saved_tokens: total_original.saturating_sub(tokens),
166            mode: Some(mode),
167            path: None,
168            changed: false,
169            shell_outcome: None,
170        })
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use std::sync::Arc;
178    use std::time::Duration;
179    use tokio::sync::RwLock;
180
181    use crate::core::cache::SessionCache;
182    use crate::core::session::SessionState;
183    use crate::tools::CrpMode;
184
185    fn ctx_with(
186        cache: Arc<RwLock<SessionCache>>,
187        session: Arc<RwLock<SessionState>>,
188        project_root: &str,
189    ) -> ToolContext {
190        ToolContext {
191            project_root: project_root.to_string(),
192            extra_roots: Vec::new(),
193            minimal: false,
194            resolved_paths: std::collections::HashMap::new(),
195            crp_mode: CrpMode::Off,
196            cache: Some(cache),
197            session: Some(session),
198            tool_calls: None,
199            agent_id: None,
200            workflow: None,
201            ledger: None,
202            client_name: None,
203            pipeline_stats: None,
204            call_count: None,
205            autonomy: None,
206            pressure_snapshot: None,
207            path_errors: std::collections::HashMap::new(),
208            bm25_cache: None,
209            progress_sender: None,
210        }
211    }
212
213    /// Regression for #271 (crash vector 11): under concurrent load,
214    /// `ctx_multi_read` must not hang. The handler runs inside the dispatch
215    /// layer's `block_in_place`, so it must acquire its session/cache locks
216    /// via `Handle::block_on` WITHOUT nesting another `block_in_place` —
217    /// nesting consumes extra blocking-pool threads and, under load, exhausts
218    /// the pool, hanging the call (no JSON-RPC response → client "invoke"
219    /// error).
220    ///
221    /// With only 2 worker threads and 8 concurrent batch reads, a nested
222    /// `block_in_place` regression would deadlock the pool and trip the 20s
223    /// timeout below.
224    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
225    async fn concurrent_multi_read_does_not_hang() {
226        let dir = tempfile::tempdir().unwrap();
227        let mut paths = Vec::new();
228        for i in 0..6 {
229            let p = dir.path().join(format!("file_{i}.rs"));
230            std::fs::write(&p, format!("fn f{i}() {{ let _ = {i}; }}\n")).unwrap();
231            paths.push(p.to_string_lossy().to_string());
232        }
233        let root = dir.path().to_string_lossy().to_string();
234
235        let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
236        let session = {
237            let mut s = SessionState::new();
238            s.project_root = Some(root.clone());
239            Arc::new(RwLock::new(s))
240        };
241
242        let mut handles = Vec::new();
243        for _ in 0..8 {
244            let cache = cache.clone();
245            let session = session.clone();
246            let paths = paths.clone();
247            let root = root.clone();
248            handles.push(tokio::spawn(async move {
249                let ctx = ctx_with(cache, session, &root);
250                let args = json!({ "paths": paths, "mode": "full" })
251                    .as_object()
252                    .unwrap()
253                    .clone();
254                tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
255            }));
256        }
257
258        for h in handles {
259            let joined = tokio::time::timeout(Duration::from_secs(20), h)
260                .await
261                .expect("ctx_multi_read hung (>20s) — nested block_in_place regression?")
262                .expect("spawned task panicked");
263            let out = joined.expect("ctx_multi_read returned an error");
264            assert!(
265                out.text.contains("Read 6 files"),
266                "unexpected output: {}",
267                out.text
268            );
269        }
270    }
271
272    /// #421: `ctx_multi_read` used to force `auto`→`full`, so omitting `mode`
273    /// over-expanded every file regardless of the active profile. With no `mode`
274    /// arg the handler must fall back to the profile's effective read mode
275    /// (`auto` by default) and pass it through to `ctx_read` — never silently
276    /// rewrite it to `full`.
277    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
278    async fn omitting_mode_uses_profile_default_not_forced_full() {
279        let dir = tempfile::tempdir().unwrap();
280        let p = dir.path().join("lib.rs");
281        std::fs::write(&p, "fn a() {}\nfn b() {}\n").unwrap();
282        let root = dir.path().to_string_lossy().to_string();
283
284        let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
285        let session = {
286            let mut s = SessionState::new();
287            s.project_root = Some(root.clone());
288            Arc::new(RwLock::new(s))
289        };
290        let ctx = ctx_with(cache, session, &root);
291        let args = json!({ "paths": [p.to_string_lossy()] })
292            .as_object()
293            .unwrap()
294            .clone();
295
296        let out = tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
297            .expect("ctx_multi_read returned an error");
298
299        let expected = crate::core::profiles::active_profile()
300            .read
301            .default_mode_effective()
302            .to_string();
303        assert_eq!(
304            out.mode,
305            Some(expected),
306            "omitting mode must use the profile default, not a forced override (#421)"
307        );
308    }
309}