lean_ctx/tools/registered/
ctx_multi_read.rs1use rmcp::ErrorData;
4use rmcp::model::Tool;
5use serde_json::{Map, Value, json};
6
7use crate::server::tool_trait::{
8 McpTool, ToolContext, ToolOutput, get_bool, get_str, get_str_array,
9};
10use crate::tool_defs::tool_def;
11
12pub struct CtxMultiReadTool;
13
14impl McpTool for CtxMultiReadTool {
15 fn name(&self) -> &'static str {
16 "ctx_multi_read"
17 }
18
19 fn tool_def(&self) -> Tool {
20 tool_def(
21 "ctx_multi_read",
22 "DEPRECATED → use ctx_read with paths=['a.rs','b.rs']. Folded into ctx_read\n\
23 (#509); hidden from tools/list, still callable for one release.",
24 json!({
25 "type": "object",
26 "properties": {
27 "paths": {
28 "type": "array",
29 "items": { "type": "string" },
30 "description": "Paths to batch-read, in order"
31 },
32 "mode": {
33 "type": "string",
34 "default": "auto",
35 "description": "auto|full|raw|signatures|map (same as ctx_read)"
36 },
37 "fresh": {
38 "type": "boolean",
39 "description": "Bypass cache, full re-read"
40 }
41 },
42 "required": ["paths"]
43 }),
44 )
45 }
46
47 fn handle(
48 &self,
49 args: &Map<String, Value>,
50 ctx: &ToolContext,
51 ) -> Result<ToolOutput, ErrorData> {
52 batch_read(args, ctx)
53 }
54}
55
56pub(crate) fn batch_read(
63 args: &Map<String, Value>,
64 ctx: &ToolContext,
65) -> Result<ToolOutput, ErrorData> {
66 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle_inner(args, ctx))) {
67 Ok(result) => result,
68 Err(_) => Err(ErrorData::internal_error(
69 "ctx_multi_read panicked while processing the batch. This is a bug — please report it.",
70 None,
71 )),
72 }
73}
74
75fn handle_inner(args: &Map<String, Value>, ctx: &ToolContext) -> Result<ToolOutput, ErrorData> {
76 let raw_paths = get_str_array(args, "paths")
77 .ok_or_else(|| ErrorData::invalid_params("paths array is required", None))?;
78
79 let session_lock = ctx
80 .session
81 .as_ref()
82 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
83 let cache_lock = ctx
84 .cache
85 .as_ref()
86 .ok_or_else(|| ErrorData::internal_error("cache not available", None))?;
87
88 let cap = crate::core::limits::max_read_bytes() as u64;
89
90 let (paths, current_task) = {
96 let Some(session) =
97 crate::server::bounded_lock::read(session_lock, "ctx_multi_read:session")
98 else {
99 return Err(ErrorData::internal_error(
100 "session read-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
101 None,
102 ));
103 };
104 let mut paths = Vec::with_capacity(raw_paths.len());
105 for p in &raw_paths {
106 let resolved = super::resolve_path_sync(&session, p)
107 .map_err(|e| ErrorData::invalid_params(e, None))?;
108 if crate::core::binary_detect::is_binary_file(&resolved) {
109 continue;
110 }
111 if let Ok(meta) = std::fs::metadata(&resolved)
112 && meta.len() > cap
113 {
114 continue;
115 }
116 paths.push(resolved);
117 }
118 let current_task = session.task.as_ref().map(|t| t.description.clone());
119 (paths, current_task)
120 };
121
122 if paths.is_empty() {
123 return Err(ErrorData::invalid_params(
124 "all paths are binary or exceed the size limit",
125 None,
126 ));
127 }
128
129 let explicit_mode = get_str(args, "mode");
132 let configured_mode = explicit_mode
133 .is_none()
134 .then(crate::core::auto_mode_resolver::configured_default_mode)
135 .flatten();
136 let mode = crate::core::auto_mode_resolver::resolve_mode_precedence(
137 explicit_mode,
138 configured_mode,
139 Some("auto".to_string()),
140 "full",
141 );
142 let fresh = get_bool(args, "fresh").unwrap_or(false);
143
144 let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "ctx_multi_read:cache")
148 else {
149 return Err(ErrorData::internal_error(
150 "cache write-lock timeout in ctx_multi_read — another tool may be holding it. Retry in a moment.",
151 None,
152 ));
153 };
154 let output = crate::tools::ctx_multi_read::handle_with_task_fresh_result(
155 &mut cache,
156 &paths,
157 &mode,
158 fresh,
159 ctx.crp_mode,
160 current_task.as_deref(),
161 );
162 let total_original = output.original_tokens;
163 let tokens = crate::core::tokens::count_tokens(&output.text);
164 drop(cache);
165
166 Ok(ToolOutput {
167 text: output.text,
168 original_tokens: total_original,
169 saved_tokens: total_original.saturating_sub(tokens),
170 mode: Some(mode),
171 path: None,
172 changed: false,
173 shell_outcome: None,
174 content_blocks: None,
175 })
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use std::sync::Arc;
182 use std::time::Duration;
183 use tokio::sync::RwLock;
184
185 use crate::core::cache::SessionCache;
186 use crate::core::session::SessionState;
187 use crate::tools::CrpMode;
188
189 fn ctx_with(
190 cache: Arc<RwLock<SessionCache>>,
191 session: Arc<RwLock<SessionState>>,
192 project_root: &str,
193 ) -> ToolContext {
194 ToolContext {
195 project_root: project_root.to_string(),
196 extra_roots: Vec::new(),
197 minimal: false,
198 resolved_paths: std::collections::HashMap::new(),
199 crp_mode: CrpMode::Off,
200 cache: Some(cache),
201 session: Some(session),
202 tool_calls: None,
203 agent_id: None,
204 workflow: None,
205 ledger: None,
206 client_name: None,
207 client_role: None,
208 shell_access: None,
209 pipeline_stats: None,
210 call_count: None,
211 autonomy: None,
212 pressure_snapshot: None,
213 path_errors: std::collections::HashMap::new(),
214 bm25_cache: None,
215 progress_sender: None,
216 }
217 }
218
219 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
231 async fn concurrent_multi_read_does_not_hang() {
232 let dir = tempfile::tempdir().unwrap();
233 let mut paths = Vec::new();
234 for i in 0..6 {
235 let p = dir.path().join(format!("file_{i}.rs"));
236 std::fs::write(&p, format!("fn f{i}() {{ let _ = {i}; }}\n")).unwrap();
237 paths.push(p.to_string_lossy().to_string());
238 }
239 let root = dir.path().to_string_lossy().to_string();
240
241 let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
242 let session = {
243 let mut s = SessionState::new();
244 s.project_root = Some(root.clone());
245 Arc::new(RwLock::new(s))
246 };
247
248 let mut handles = Vec::new();
249 for _ in 0..8 {
250 let cache = cache.clone();
251 let session = session.clone();
252 let paths = paths.clone();
253 let root = root.clone();
254 handles.push(tokio::spawn(async move {
255 let ctx = ctx_with(cache, session, &root);
256 let args = json!({ "paths": paths, "mode": "full" })
257 .as_object()
258 .unwrap()
259 .clone();
260 tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
261 }));
262 }
263
264 for h in handles {
265 let joined = tokio::time::timeout(Duration::from_secs(20), h)
266 .await
267 .expect("ctx_multi_read hung (>20s) — nested block_in_place regression?")
268 .expect("spawned task panicked");
269 let out = joined.expect("ctx_multi_read returned an error");
270 assert!(
271 out.text.contains("Read 6 files"),
272 "unexpected output: {}",
273 out.text
274 );
275 }
276 }
277
278 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
282 async fn ctx_read_with_paths_delegates_to_batch_read() {
283 use crate::tools::registered::ctx_read::CtxReadTool;
284
285 let dir = tempfile::tempdir().unwrap();
286 let mut paths = Vec::new();
287 for i in 0..3 {
288 let p = dir.path().join(format!("f{i}.rs"));
289 std::fs::write(&p, format!("fn f{i}() {{ let _ = {i}; }}\n")).unwrap();
290 paths.push(p.to_string_lossy().to_string());
291 }
292 let root = dir.path().to_string_lossy().to_string();
293
294 let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
295 let session = {
296 let mut s = SessionState::new();
297 s.project_root = Some(root.clone());
298 Arc::new(RwLock::new(s))
299 };
300 let ctx = ctx_with(cache, session, &root);
301 let args = json!({ "paths": paths, "mode": "full" })
302 .as_object()
303 .unwrap()
304 .clone();
305
306 let out = tokio::task::block_in_place(|| CtxReadTool.handle(&args, &ctx))
307 .expect("ctx_read(paths) returned an error");
308 assert!(
309 out.text.contains("Read 3 files"),
310 "ctx_read(paths) must batch-read like ctx_multi_read, got: {}",
311 out.text
312 );
313 }
314
315 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
321 async fn omitting_mode_uses_profile_default_not_forced_full() {
322 let dir = tempfile::tempdir().unwrap();
323 let p = dir.path().join("lib.rs");
324 std::fs::write(&p, "fn a() {}\nfn b() {}\n").unwrap();
325 let root = dir.path().to_string_lossy().to_string();
326
327 let cache: Arc<RwLock<SessionCache>> = Arc::new(RwLock::new(SessionCache::new()));
328 let session = {
329 let mut s = SessionState::new();
330 s.project_root = Some(root.clone());
331 Arc::new(RwLock::new(s))
332 };
333 let ctx = ctx_with(cache, session, &root);
334 let args = json!({ "paths": [p.to_string_lossy()] })
335 .as_object()
336 .unwrap()
337 .clone();
338
339 let out = tokio::task::block_in_place(|| CtxMultiReadTool.handle(&args, &ctx))
340 .expect("ctx_multi_read returned an error");
341
342 let expected = crate::core::profiles::active_profile()
343 .read
344 .default_mode_effective()
345 .to_string();
346 assert_eq!(
347 out.mode,
348 Some(expected),
349 "omitting mode must use the profile default, not a forced override (#421)"
350 );
351 }
352}