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