Skip to main content

bamboo_tools/tools/
read.rs

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