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 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    /// The absolute path to the file or directory to read
28    file_path: String,
29    /// The line offset to start reading from. Omit when you want the full file or directory listing.
30    #[serde(default)]
31    #[schemars(
32        schema_with = "parameter_schema::number",
33        skip_serializing_if = "Option::is_none"
34    )]
35    offset: Option<usize>,
36    /// The maximum number of lines or directory entries to read. Omit for the full result when safe.
37    #[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_supervisor: None,
326            session_id: Some(std::sync::Arc::from("session_binary_read")),
327            tool_call_id: std::sync::Arc::from("call_1"),
328            event_tx: None,
329            available_tool_schemas: std::sync::Arc::from(Vec::new()),
330            bypass_permissions: false,
331            auto_approve_permissions: false,
332            plan_read_only: false,
333            can_async_resume: false,
334            async_completion_sink: None,
335            bash_completion_sink: None,
336        };
337
338        let read_tool = ReadTool::new();
339        let read_out = read_tool
340            .invoke(json!({ "file_path": file_path }), make_ctx())
341            .await
342            .unwrap();
343        let ToolOutcome::Completed(read_result) = read_out else {
344            panic!("expected Completed")
345        };
346        assert!(read_result.success);
347        assert!(read_result.result.contains("Binary file omitted"));
348
349        let write_tool = WriteTool::new();
350        let write_out = write_tool
351            .invoke(
352                json!({
353                    "file_path": file.path(),
354                    "content": "now text"
355                }),
356                make_ctx(),
357            )
358            .await
359            .unwrap();
360        let ToolOutcome::Completed(write_result) = write_out else {
361            panic!("expected Completed")
362        };
363        assert!(write_result.success);
364    }
365
366    #[tokio::test]
367    async fn read_directory_supports_offset_limit_and_marks_subdirs() {
368        let dir = tempfile::tempdir().unwrap();
369        tokio::fs::create_dir_all(dir.path().join("b-dir"))
370            .await
371            .unwrap();
372        tokio::fs::write(dir.path().join("a.txt"), "a")
373            .await
374            .unwrap();
375        tokio::fs::write(dir.path().join("c.txt"), "c")
376            .await
377            .unwrap();
378
379        let tool = ReadTool::new();
380        let out = tool
381            .invoke(
382                json!({
383                    "file_path": dir.path(),
384                    "offset": 1,
385                    "limit": 1
386                }),
387                ToolCtx::none("t"),
388            )
389            .await
390            .unwrap();
391        let ToolOutcome::Completed(result) = out else {
392            panic!("expected Completed")
393        };
394
395        assert!(result.success);
396        assert!(result.result.contains("b-dir/"));
397        assert!(result.result.contains("TRUNCATED"));
398    }
399
400    #[tokio::test]
401    async fn read_file_adds_continuation_hint_when_truncated() {
402        let file = tempfile::NamedTempFile::new().unwrap();
403        tokio::fs::write(file.path(), "l1\nl2\nl3\n").await.unwrap();
404
405        let tool = ReadTool::new();
406        let out = tool
407            .invoke(
408                json!({
409                    "file_path": file.path(),
410                    "offset": 0,
411                    "limit": 1
412                }),
413                ToolCtx::none("t"),
414            )
415            .await
416            .unwrap();
417        let ToolOutcome::Completed(result) = out else {
418            panic!("expected Completed")
419        };
420
421        assert!(result.success);
422        assert!(result.result.contains("l1"));
423        assert!(result.result.contains("Continue with offset=1"));
424    }
425
426    #[tokio::test]
427    async fn read_rejects_blocking_device_paths() {
428        let tool = ReadTool::new();
429        let result = tool
430            .invoke(
431                json!({
432                    "file_path": "/dev/stdin"
433                }),
434                ToolCtx::none("t"),
435            )
436            .await;
437
438        let error = result.expect_err("device path should be rejected");
439        assert!(matches!(error, ToolError::InvalidArguments(_)));
440        assert!(error
441            .to_string()
442            .contains("Refusing to read blocking or unbounded device path"));
443    }
444}