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;
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; // 10 MB
25
26#[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            auto_approve_permissions: false,
295            plan_read_only: false,
296            can_async_resume: false,
297            async_completion_sink: None,
298            bash_completion_sink: None,
299        };
300
301        let read_tool = ReadTool::new();
302        let read_out = read_tool
303            .invoke(json!({ "file_path": file_path }), make_ctx())
304            .await
305            .unwrap();
306        let ToolOutcome::Completed(read_result) = read_out else {
307            panic!("expected Completed")
308        };
309        assert!(read_result.success);
310        assert!(read_result.result.contains("Binary file omitted"));
311
312        let write_tool = WriteTool::new();
313        let write_out = write_tool
314            .invoke(
315                json!({
316                    "file_path": file.path(),
317                    "content": "now text"
318                }),
319                make_ctx(),
320            )
321            .await
322            .unwrap();
323        let ToolOutcome::Completed(write_result) = write_out else {
324            panic!("expected Completed")
325        };
326        assert!(write_result.success);
327    }
328
329    #[tokio::test]
330    async fn read_directory_supports_offset_limit_and_marks_subdirs() {
331        let dir = tempfile::tempdir().unwrap();
332        tokio::fs::create_dir_all(dir.path().join("b-dir"))
333            .await
334            .unwrap();
335        tokio::fs::write(dir.path().join("a.txt"), "a")
336            .await
337            .unwrap();
338        tokio::fs::write(dir.path().join("c.txt"), "c")
339            .await
340            .unwrap();
341
342        let tool = ReadTool::new();
343        let out = tool
344            .invoke(
345                json!({
346                    "file_path": dir.path(),
347                    "offset": 1,
348                    "limit": 1
349                }),
350                ToolCtx::none("t"),
351            )
352            .await
353            .unwrap();
354        let ToolOutcome::Completed(result) = out else {
355            panic!("expected Completed")
356        };
357
358        assert!(result.success);
359        assert!(result.result.contains("b-dir/"));
360        assert!(result.result.contains("TRUNCATED"));
361    }
362
363    #[tokio::test]
364    async fn read_file_adds_continuation_hint_when_truncated() {
365        let file = tempfile::NamedTempFile::new().unwrap();
366        tokio::fs::write(file.path(), "l1\nl2\nl3\n").await.unwrap();
367
368        let tool = ReadTool::new();
369        let out = tool
370            .invoke(
371                json!({
372                    "file_path": file.path(),
373                    "offset": 0,
374                    "limit": 1
375                }),
376                ToolCtx::none("t"),
377            )
378            .await
379            .unwrap();
380        let ToolOutcome::Completed(result) = out else {
381            panic!("expected Completed")
382        };
383
384        assert!(result.success);
385        assert!(result.result.contains("l1"));
386        assert!(result.result.contains("Continue with offset=1"));
387    }
388
389    #[tokio::test]
390    async fn read_rejects_blocking_device_paths() {
391        let tool = ReadTool::new();
392        let result = tool
393            .invoke(
394                json!({
395                    "file_path": "/dev/stdin"
396                }),
397                ToolCtx::none("t"),
398            )
399            .await;
400
401        let error = result.expect_err("device path should be rejected");
402        assert!(matches!(error, ToolError::InvalidArguments(_)));
403        assert!(error
404            .to_string()
405            .contains("Refusing to read blocking or unbounded device path"));
406    }
407}