lean_ctx/tools/registered/
ctx_multi_read.rs1use 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 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 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 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 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 #[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 #[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}