Skip to main content

lean_ctx/tools/registered/
ctx_multi_read.rs

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