1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde::Deserialize;
4use std::path::Path;
5
6use super::parameter_schema;
7use super::read_tracker::{self, MAX_TRACKED_FILE_SIZE};
8
9const BLOCKED_DEVICE_PATHS: &[&str] = &[
10 "/dev/zero",
11 "/dev/random",
12 "/dev/urandom",
13 "/dev/full",
14 "/dev/stdin",
15 "/dev/tty",
16 "/dev/console",
17 "/dev/stdout",
18 "/dev/stderr",
19 "/dev/fd/0",
20 "/dev/fd/1",
21 "/dev/fd/2",
22];
23
24#[derive(Debug, Deserialize, schemars::JsonSchema)]
25#[schemars(deny_unknown_fields)]
26struct ReadArgs {
27 file_path: String,
29 #[serde(default)]
31 #[schemars(
32 schema_with = "parameter_schema::number",
33 skip_serializing_if = "Option::is_none"
34 )]
35 offset: Option<usize>,
36 #[serde(default)]
38 #[schemars(
39 schema_with = "parameter_schema::number",
40 skip_serializing_if = "Option::is_none"
41 )]
42 limit: Option<usize>,
43}
44
45pub struct ReadTool;
46
47impl ReadTool {
48 pub fn new() -> Self {
49 Self
50 }
51
52 fn is_blocked_device_path(path: &Path) -> bool {
53 let display = path.to_string_lossy();
54 if BLOCKED_DEVICE_PATHS
55 .iter()
56 .any(|blocked| display == *blocked)
57 {
58 return true;
59 }
60
61 display.starts_with("/proc/")
62 && (display.ends_with("/fd/0")
63 || display.ends_with("/fd/1")
64 || display.ends_with("/fd/2"))
65 }
66}
67
68impl Default for ReadTool {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74fn slice_bounds(total: usize, offset: usize, limit: Option<usize>) -> (usize, usize) {
75 let start = offset.min(total);
76 let end = limit
77 .map(|value| start.saturating_add(value).min(total))
78 .unwrap_or(total);
79 (start, end)
80}
81
82fn continuation_hint(
83 noun: &str,
84 start: usize,
85 end: usize,
86 total: usize,
87 limit: Option<usize>,
88) -> Option<String> {
89 if end >= total {
90 return None;
91 }
92
93 let shown = end.saturating_sub(start);
94 let limit_fragment = match limit {
95 Some(value) => format!(", limit={value}"),
96 None => String::new(),
97 };
98
99 if shown == 0 {
100 return Some(format!(
101 "[TRUNCATED] No {noun} returned. Continue with offset={end}{limit_fragment}"
102 ));
103 }
104
105 Some(format!(
106 "[TRUNCATED] Showing {noun} {first}-{end} of {total}. Continue with offset={end}{limit_fragment}",
107 first = start + 1
108 ))
109}
110
111fn render_file_with_line_numbers(content: &str, offset: usize, limit: Option<usize>) -> String {
112 let lines: Vec<&str> = content.lines().collect();
113 let (start, end) = slice_bounds(lines.len(), offset, limit);
114
115 let mut rendered = lines[start..end]
116 .iter()
117 .enumerate()
118 .map(|(idx, line)| format!("{:>6}\t{}", start + idx + 1, line))
119 .collect::<Vec<_>>()
120 .join("\n");
121
122 if let Some(hint) = continuation_hint("lines", start, end, lines.len(), limit) {
123 if !rendered.is_empty() {
124 rendered.push('\n');
125 }
126 rendered.push_str(&hint);
127 }
128
129 rendered
130}
131
132fn render_directory_entries(entries: &[String], offset: usize, limit: Option<usize>) -> String {
133 let (start, end) = slice_bounds(entries.len(), offset, limit);
134 let mut rendered = entries[start..end]
135 .iter()
136 .enumerate()
137 .map(|(idx, entry)| format!("{:>6}\t{}", start + idx + 1, entry))
138 .collect::<Vec<_>>()
139 .join("\n");
140
141 if let Some(hint) = continuation_hint("entries", start, end, entries.len(), limit) {
142 if !rendered.is_empty() {
143 rendered.push('\n');
144 }
145 rendered.push_str(&hint);
146 }
147
148 rendered
149}
150
151#[async_trait]
152impl Tool for ReadTool {
153 fn name(&self) -> &str {
154 "Read"
155 }
156
157 fn description(&self) -> &str {
158 "Read a local file or directory with line-numbered output (supports offset/limit). Use this before Edit/Write on existing files. Safe for text files and directories; binary files are omitted and blocking device paths are rejected."
159 }
160
161 fn classify(&self, _args: &serde_json::Value) -> ToolClass {
162 ToolClass::READONLY_PARALLEL
163 }
164
165 fn parameters_schema(&self) -> serde_json::Value {
166 parameter_schema::for_arguments::<ReadArgs>()
167 }
168
169 async fn invoke(
170 &self,
171 args: serde_json::Value,
172 ctx: ToolCtx,
173 ) -> Result<ToolOutcome, ToolError> {
174 let parsed: ReadArgs = serde_json::from_value(args)
175 .map_err(|e| ToolError::InvalidArguments(format!("Invalid Read args: {}", e)))?;
176
177 let path = Path::new(parsed.file_path.trim());
178 if !path.is_absolute() {
179 return Err(ToolError::InvalidArguments(
180 "file_path must be an absolute path".to_string(),
181 ));
182 }
183 if Self::is_blocked_device_path(path) {
184 return Err(ToolError::InvalidArguments(format!(
185 "Refusing to read blocking or unbounded device path: {}",
186 path.display()
187 )));
188 }
189
190 let metadata = tokio::fs::metadata(path)
191 .await
192 .map_err(|e| ToolError::Execution(format!("Failed to read path: {}", e)))?;
193
194 if metadata.is_dir() {
195 let mut dir = tokio::fs::read_dir(path)
196 .await
197 .map_err(|e| ToolError::Execution(format!("Failed to read directory: {}", e)))?;
198 let mut entries = Vec::new();
199 while let Some(entry) = dir
200 .next_entry()
201 .await
202 .map_err(|e| ToolError::Execution(format!("Failed to iterate directory: {}", e)))?
203 {
204 let mut name = entry.file_name().to_string_lossy().to_string();
205 if entry
206 .file_type()
207 .await
208 .map_err(|e| ToolError::Execution(format!("Failed to inspect entry: {}", e)))?
209 .is_dir()
210 {
211 name.push('/');
212 }
213 entries.push(name);
214 }
215 entries.sort();
216
217 let rendered =
218 render_directory_entries(&entries, parsed.offset.unwrap_or(0), parsed.limit);
219 return Ok(ToolOutcome::Completed(ToolResult {
220 success: true,
221 result: rendered,
222 display_preference: Some("Collapsible".to_string()),
223 images: Vec::new(),
224 }));
225 }
226
227 if metadata.len() > MAX_TRACKED_FILE_SIZE {
228 return Err(ToolError::Execution(format!(
229 "File is {} bytes, which exceeds the maximum readable size of {} bytes ({} MB). \
230 Use Grep to search within this file instead.",
231 metadata.len(),
232 MAX_TRACKED_FILE_SIZE,
233 MAX_TRACKED_FILE_SIZE / 1024 / 1024
234 )));
235 }
236
237 let stable = read_tracker::stable_read(parsed.file_path.trim())
238 .await
239 .map_err(|e| ToolError::Execution(format!("Failed to read stable file: {}", e)))?;
240
241 if stable.bytes().len() as u64 > MAX_TRACKED_FILE_SIZE {
242 return Err(ToolError::Execution(format!(
243 "File is {} bytes, which exceeds the maximum readable size of {} bytes ({} MB). \
244 Use Grep to search within this file instead.",
245 stable.bytes().len(),
246 MAX_TRACKED_FILE_SIZE,
247 MAX_TRACKED_FILE_SIZE / 1024 / 1024
248 )));
249 }
250
251 if let Some(session_id) = ctx.session_id() {
252 read_tracker::mark_stable_read(session_id, parsed.file_path.trim(), &stable).await;
253 }
254
255 let bytes = stable.bytes();
256
257 if bytes.contains(&0) {
258 return Ok(ToolOutcome::Completed(ToolResult {
259 success: true,
260 result: "[Binary file omitted]".to_string(),
261 display_preference: Some("Collapsible".to_string()),
262 images: Vec::new(),
263 }));
264 }
265
266 let content = String::from_utf8_lossy(bytes).to_string();
267 let rendered =
268 render_file_with_line_numbers(&content, parsed.offset.unwrap_or(0), parsed.limit);
269
270 Ok(ToolOutcome::Completed(ToolResult {
271 success: true,
272 result: rendered,
273 display_preference: Some("Collapsible".to_string()),
274 images: Vec::new(),
275 }))
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::tools::WriteTool;
283 use serde_json::json;
284
285 #[test]
286 fn read_args_preserve_optional_defaults_and_unknown_fields() {
287 for args in [
288 json!({"file_path": "/tmp/read.txt"}),
289 json!({
290 "file_path": "/tmp/read.txt",
291 "offset": null,
292 "limit": null,
293 "unknown": true
294 }),
295 ] {
296 let parsed: ReadArgs = serde_json::from_value(args).unwrap();
297 assert_eq!(parsed.file_path, "/tmp/read.txt");
298 assert_eq!(parsed.offset, None);
299 assert_eq!(parsed.limit, None);
300 }
301 }
302
303 #[test]
304 fn read_args_preserve_invalid_argument_rejection() {
305 for field in ["offset", "limit"] {
306 for invalid in [json!(-1), json!(1.5), json!("1"), json!(true)] {
307 let mut args = json!({"file_path": "/tmp/read.txt"});
308 args[field] = invalid;
309 assert!(serde_json::from_value::<ReadArgs>(args).is_err());
310 }
311 }
312 for args in [json!({}), json!({"file_path": null})] {
313 assert!(serde_json::from_value::<ReadArgs>(args).is_err());
314 }
315 }
316
317 #[tokio::test]
318 async fn binary_read_still_marks_file_as_read_for_session_write_gate() {
319 let file = tempfile::NamedTempFile::new().unwrap();
320 tokio::fs::write(file.path(), vec![0_u8, 1, 2, 3])
321 .await
322 .unwrap();
323 let file_path = file.path().to_string_lossy().to_string();
324 let make_ctx = || ToolCtx {
325 executing_caller: None,
326 executing_supervisor: None,
327 session_id: Some(std::sync::Arc::from("session_binary_read")),
328 tool_call_id: std::sync::Arc::from("call_1"),
329 event_tx: None,
330 available_tool_schemas: std::sync::Arc::from(Vec::new()),
331 bypass_permissions: false,
332 auto_approve_permissions: false,
333 plan_read_only: false,
334 can_async_resume: false,
335 async_completion_sink: None,
336 bash_completion_sink: None,
337 };
338
339 let read_tool = ReadTool::new();
340 let read_out = read_tool
341 .invoke(json!({ "file_path": file_path }), make_ctx())
342 .await
343 .unwrap();
344 let ToolOutcome::Completed(read_result) = read_out else {
345 panic!("expected Completed")
346 };
347 assert!(read_result.success);
348 assert!(read_result.result.contains("Binary file omitted"));
349
350 let write_tool = WriteTool::new();
351 let write_out = write_tool
352 .invoke(
353 json!({
354 "file_path": file.path(),
355 "content": "now text"
356 }),
357 make_ctx(),
358 )
359 .await
360 .unwrap();
361 let ToolOutcome::Completed(write_result) = write_out else {
362 panic!("expected Completed")
363 };
364 assert!(write_result.success);
365 }
366
367 #[tokio::test]
368 async fn read_directory_supports_offset_limit_and_marks_subdirs() {
369 let dir = tempfile::tempdir().unwrap();
370 tokio::fs::create_dir_all(dir.path().join("b-dir"))
371 .await
372 .unwrap();
373 tokio::fs::write(dir.path().join("a.txt"), "a")
374 .await
375 .unwrap();
376 tokio::fs::write(dir.path().join("c.txt"), "c")
377 .await
378 .unwrap();
379
380 let tool = ReadTool::new();
381 let out = tool
382 .invoke(
383 json!({
384 "file_path": dir.path(),
385 "offset": 1,
386 "limit": 1
387 }),
388 ToolCtx::none("t"),
389 )
390 .await
391 .unwrap();
392 let ToolOutcome::Completed(result) = out else {
393 panic!("expected Completed")
394 };
395
396 assert!(result.success);
397 assert!(result.result.contains("b-dir/"));
398 assert!(result.result.contains("TRUNCATED"));
399 }
400
401 #[tokio::test]
402 async fn read_file_adds_continuation_hint_when_truncated() {
403 let file = tempfile::NamedTempFile::new().unwrap();
404 tokio::fs::write(file.path(), "l1\nl2\nl3\n").await.unwrap();
405
406 let tool = ReadTool::new();
407 let out = tool
408 .invoke(
409 json!({
410 "file_path": file.path(),
411 "offset": 0,
412 "limit": 1
413 }),
414 ToolCtx::none("t"),
415 )
416 .await
417 .unwrap();
418 let ToolOutcome::Completed(result) = out else {
419 panic!("expected Completed")
420 };
421
422 assert!(result.success);
423 assert!(result.result.contains("l1"));
424 assert!(result.result.contains("Continue with offset=1"));
425 }
426
427 #[tokio::test]
428 async fn read_rejects_blocking_device_paths() {
429 let tool = ReadTool::new();
430 let result = tool
431 .invoke(
432 json!({
433 "file_path": "/dev/stdin"
434 }),
435 ToolCtx::none("t"),
436 )
437 .await;
438
439 let error = result.expect_err("device path should be rejected");
440 assert!(matches!(error, ToolError::InvalidArguments(_)));
441 assert!(error
442 .to_string()
443 .contains("Refusing to read blocking or unbounded device path"));
444 }
445}