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