Skip to main content

agent_sdk/primitive_tools/
read.rs

1use crate::llm::ContentSource;
2use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use serde::Deserialize;
5use serde_json::{Value, json};
6use std::sync::Arc;
7
8use super::PrimitiveToolContext;
9
10/// Maximum characters per line before truncation.
11const MAX_LINE_LENGTH: usize = 500;
12
13/// Default maximum number of lines to return.
14const DEFAULT_LIMIT: usize = 2000;
15
16pub struct ReadTool<E: Environment> {
17    ctx: PrimitiveToolContext<E>,
18}
19
20impl<E: Environment> ReadTool<E> {
21    #[must_use]
22    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
23        Self {
24            ctx: PrimitiveToolContext::new(environment, capabilities),
25        }
26    }
27}
28
29#[derive(Debug, Deserialize)]
30struct ReadInput {
31    #[serde(alias = "file_path")]
32    path: String,
33    /// 1-indexed line number to start reading from; defaults to 1.
34    #[serde(
35        default = "defaults::offset",
36        deserialize_with = "super::deserialize_usize_from_string_or_int"
37    )]
38    offset: usize,
39    /// Maximum number of lines to return; defaults to 2000.
40    #[serde(
41        default = "defaults::limit",
42        deserialize_with = "super::deserialize_usize_from_string_or_int"
43    )]
44    limit: usize,
45}
46
47mod defaults {
48    pub const fn offset() -> usize {
49        1
50    }
51    pub const fn limit() -> usize {
52        super::DEFAULT_LIMIT
53    }
54}
55
56impl<E: Environment + 'static> Tool<()> for ReadTool<E> {
57    type Name = PrimitiveToolName;
58
59    fn name(&self) -> PrimitiveToolName {
60        PrimitiveToolName::Read
61    }
62
63    fn display_name(&self) -> &'static str {
64        "Read File"
65    }
66
67    fn description(&self) -> &'static str {
68        "Read text files with 1-indexed line numbers. Also supports images (PNG/JPEG/GIF/WebP) and PDF documents."
69    }
70
71    fn tier(&self) -> ToolTier {
72        ToolTier::Observe
73    }
74
75    fn input_schema(&self) -> Value {
76        json!({
77            "type": "object",
78            "properties": {
79                "path": {
80                    "type": "string",
81                    "description": "Path to the file to read"
82                },
83                "offset": {
84                    "anyOf": [
85                        {"type": "integer"},
86                        {"type": "string", "pattern": "^[0-9]+$"}
87                    ],
88                    "description": "Line number to start from (1-based). Accepts either an integer or a numeric string. Default: 1"
89                },
90                "limit": {
91                    "anyOf": [
92                        {"type": "integer"},
93                        {"type": "string", "pattern": "^[0-9]+$"}
94                    ],
95                    "description": "Maximum number of lines to return. Accepts either an integer or a numeric string. Default: 2000"
96                }
97            },
98            "required": ["path"]
99        })
100    }
101
102    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
103        let input: ReadInput = serde_json::from_value(input.clone())
104            .with_context(|| format!("Invalid input for read tool: {input}"))?;
105
106        if input.offset == 0 {
107            return Ok(ToolResult::error("offset must be a 1-indexed line number"));
108        }
109
110        if input.limit == 0 {
111            return Ok(ToolResult::error("limit must be greater than zero"));
112        }
113
114        let path = self.ctx.environment.resolve_path(&input.path);
115
116        if let Err(reason) = self.ctx.capabilities.check_read(&path) {
117            return Ok(ToolResult::error(format!(
118                "Permission denied: cannot read '{path}': {reason}"
119            )));
120        }
121
122        let exists = self
123            .ctx
124            .environment
125            .exists(&path)
126            .await
127            .context("Failed to check file existence")?;
128
129        if !exists {
130            return Ok(ToolResult::error(format!("File not found: '{path}'")));
131        }
132
133        let is_dir = self
134            .ctx
135            .environment
136            .is_dir(&path)
137            .await
138            .context("Failed to check if path is directory")?;
139
140        if is_dir {
141            return Ok(ToolResult::error(format!(
142                "'{path}' is a directory, not a file"
143            )));
144        }
145
146        let bytes = self
147            .ctx
148            .environment
149            .read_file_bytes(&path)
150            .await
151            .context("Failed to read file")?;
152
153        // Handle images and PDFs as document attachments (like codex-rs view_image).
154        if let Some(media_type) = detect_media_type(&path) {
155            let encoded = base64_encode(&bytes);
156            return Ok(
157                ToolResult::success(format!("Read {media_type} file: '{path}'"))
158                    .with_documents(vec![ContentSource::new(media_type, encoded)]),
159            );
160        }
161
162        // Text files: lossy UTF-8, line numbers, truncation.
163        let content = String::from_utf8_lossy(&bytes);
164        let collected = read_lines(&content, input.offset, input.limit);
165
166        if collected.is_empty() {
167            return Ok(ToolResult::error("offset exceeds file length"));
168        }
169
170        Ok(ToolResult::success(collected.join("\n")))
171    }
172}
173
174fn read_lines(content: &str, offset: usize, limit: usize) -> Vec<String> {
175    let mut collected = Vec::new();
176    let mut line_number = 0usize;
177
178    for raw_line in content.split('\n') {
179        line_number += 1;
180
181        if line_number < offset {
182            continue;
183        }
184
185        if collected.len() >= limit {
186            break;
187        }
188
189        // Strip trailing \r for CRLF files
190        let line = raw_line.strip_suffix('\r').unwrap_or(raw_line);
191        let display = truncate_line(line);
192        collected.push(format!("L{line_number}: {display}"));
193    }
194
195    collected
196}
197
198fn truncate_line(line: &str) -> &str {
199    if line.len() <= MAX_LINE_LENGTH {
200        line
201    } else {
202        // Find the nearest char boundary at or before MAX_LINE_LENGTH
203        let mut end = MAX_LINE_LENGTH;
204        while end > 0 && !line.is_char_boundary(end) {
205            end -= 1;
206        }
207        &line[..end]
208    }
209}
210
211/// Detect supported binary media types by file extension.
212fn detect_media_type(path: &str) -> Option<&'static str> {
213    let ext = std::path::Path::new(path).extension()?.to_ascii_lowercase();
214
215    match ext.to_str()? {
216        "png" => Some("image/png"),
217        "jpg" | "jpeg" => Some("image/jpeg"),
218        "gif" => Some("image/gif"),
219        "webp" => Some("image/webp"),
220        "pdf" => Some("application/pdf"),
221        _ => None,
222    }
223}
224
225fn base64_encode(data: &[u8]) -> String {
226    use base64::Engine;
227    base64::engine::general_purpose::STANDARD.encode(data)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::{AgentCapabilities, InMemoryFileSystem};
234
235    fn create_test_tool(
236        fs: Arc<InMemoryFileSystem>,
237        capabilities: AgentCapabilities,
238    ) -> ReadTool<InMemoryFileSystem> {
239        ReadTool::new(fs, capabilities)
240    }
241
242    fn tool_ctx() -> ToolContext<()> {
243        ToolContext::new(())
244    }
245
246    #[tokio::test]
247    async fn reads_entire_file() -> anyhow::Result<()> {
248        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
249        fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
250
251        let tool = create_test_tool(fs, AgentCapabilities::full_access());
252        let result = tool
253            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
254            .await?;
255
256        assert!(result.success);
257        assert_eq!(result.output, "L1: alpha\nL2: beta\nL3: gamma");
258        Ok(())
259    }
260
261    #[tokio::test]
262    async fn reads_with_offset() -> anyhow::Result<()> {
263        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
264        fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
265
266        let tool = create_test_tool(fs, AgentCapabilities::full_access());
267        let result = tool
268            .execute(
269                &tool_ctx(),
270                json!({"path": "/workspace/test.txt", "offset": 2}),
271            )
272            .await?;
273
274        assert!(result.success);
275        assert_eq!(result.output, "L2: beta\nL3: gamma");
276        Ok(())
277    }
278
279    #[tokio::test]
280    async fn reads_with_limit() -> anyhow::Result<()> {
281        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
282        fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
283
284        let tool = create_test_tool(fs, AgentCapabilities::full_access());
285        let result = tool
286            .execute(
287                &tool_ctx(),
288                json!({"path": "/workspace/test.txt", "limit": 2}),
289            )
290            .await?;
291
292        assert!(result.success);
293        assert_eq!(result.output, "L1: alpha\nL2: beta");
294        Ok(())
295    }
296
297    #[tokio::test]
298    async fn reads_with_offset_and_limit() -> anyhow::Result<()> {
299        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
300        fs.write_file("test.txt", "alpha\nbeta\ngamma\ndelta\nepsilon")
301            .await?;
302
303        let tool = create_test_tool(fs, AgentCapabilities::full_access());
304        let result = tool
305            .execute(
306                &tool_ctx(),
307                json!({"path": "/workspace/test.txt", "offset": 2, "limit": 2}),
308            )
309            .await?;
310
311        assert!(result.success);
312        assert_eq!(result.output, "L2: beta\nL3: gamma");
313        Ok(())
314    }
315
316    #[tokio::test]
317    async fn accepts_string_offset_and_limit() -> anyhow::Result<()> {
318        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
319        fs.write_file("test.txt", "alpha\nbeta\ngamma").await?;
320
321        let tool = create_test_tool(fs, AgentCapabilities::full_access());
322        let result = tool
323            .execute(
324                &tool_ctx(),
325                json!({"path": "/workspace/test.txt", "offset": "2", "limit": "1"}),
326            )
327            .await?;
328
329        assert!(result.success);
330        assert_eq!(result.output, "L2: beta");
331        Ok(())
332    }
333
334    #[tokio::test]
335    async fn errors_on_offset_zero() -> anyhow::Result<()> {
336        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
337        fs.write_file("test.txt", "alpha").await?;
338
339        let tool = create_test_tool(fs, AgentCapabilities::full_access());
340        let result = tool
341            .execute(
342                &tool_ctx(),
343                json!({"path": "/workspace/test.txt", "offset": 0}),
344            )
345            .await?;
346
347        assert!(!result.success);
348        assert!(result.output.contains("1-indexed"));
349        Ok(())
350    }
351
352    #[tokio::test]
353    async fn errors_on_limit_zero() -> anyhow::Result<()> {
354        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
355        fs.write_file("test.txt", "alpha").await?;
356
357        let tool = create_test_tool(fs, AgentCapabilities::full_access());
358        let result = tool
359            .execute(
360                &tool_ctx(),
361                json!({"path": "/workspace/test.txt", "limit": 0}),
362            )
363            .await?;
364
365        assert!(!result.success);
366        assert!(result.output.contains("greater than zero"));
367        Ok(())
368    }
369
370    #[tokio::test]
371    async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> {
372        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
373        fs.write_file("short.txt", "only").await?;
374
375        let tool = create_test_tool(fs, AgentCapabilities::full_access());
376        let result = tool
377            .execute(
378                &tool_ctx(),
379                json!({"path": "/workspace/short.txt", "offset": 100}),
380            )
381            .await?;
382
383        assert!(!result.success);
384        assert!(result.output.contains("offset exceeds file length"));
385        Ok(())
386    }
387
388    #[tokio::test]
389    async fn errors_on_nonexistent_file() -> anyhow::Result<()> {
390        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
391
392        let tool = create_test_tool(fs, AgentCapabilities::full_access());
393        let result = tool
394            .execute(&tool_ctx(), json!({"path": "/workspace/nope.txt"}))
395            .await?;
396
397        assert!(!result.success);
398        assert!(result.output.contains("File not found"));
399        Ok(())
400    }
401
402    #[tokio::test]
403    async fn errors_on_directory() -> anyhow::Result<()> {
404        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
405        fs.create_dir("/workspace/subdir").await?;
406
407        let tool = create_test_tool(fs, AgentCapabilities::full_access());
408        let result = tool
409            .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
410            .await?;
411
412        assert!(!result.success);
413        assert!(result.output.contains("is a directory"));
414        Ok(())
415    }
416
417    #[tokio::test]
418    async fn errors_on_permission_denied() -> anyhow::Result<()> {
419        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
420        fs.write_file("secret.txt", "secret").await?;
421
422        let tool = create_test_tool(fs, AgentCapabilities::none());
423        let result = tool
424            .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
425            .await?;
426
427        assert!(!result.success);
428        assert!(result.output.contains("Permission denied"));
429        Ok(())
430    }
431
432    #[tokio::test]
433    async fn respects_denied_paths() -> anyhow::Result<()> {
434        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
435        fs.write_file("secrets/key.txt", "API_KEY=secret").await?;
436
437        let caps =
438            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
439
440        let tool = create_test_tool(fs, caps);
441        let result = tool
442            .execute(&tool_ctx(), json!({"path": "/workspace/secrets/key.txt"}))
443            .await?;
444
445        assert!(!result.success);
446        assert!(result.output.contains("Permission denied"));
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn handles_crlf_line_endings() -> anyhow::Result<()> {
452        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
453        fs.write_file_bytes("crlf.txt", b"one\r\ntwo\r\n").await?;
454
455        let tool = create_test_tool(fs, AgentCapabilities::full_access());
456        let result = tool
457            .execute(&tool_ctx(), json!({"path": "/workspace/crlf.txt"}))
458            .await?;
459
460        assert!(result.success);
461        assert_eq!(result.output, "L1: one\nL2: two\nL3: ");
462        Ok(())
463    }
464
465    #[tokio::test]
466    async fn handles_non_utf8() -> anyhow::Result<()> {
467        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
468        fs.write_file_bytes(
469            "bin.txt",
470            &[0xff, 0xfe, b'\n', b'p', b'l', b'a', b'i', b'n', b'\n'],
471        )
472        .await?;
473
474        let tool = create_test_tool(fs, AgentCapabilities::full_access());
475        let result = tool
476            .execute(&tool_ctx(), json!({"path": "/workspace/bin.txt"}))
477            .await?;
478
479        assert!(result.success);
480        assert!(result.output.contains("L2: plain"));
481        Ok(())
482    }
483
484    #[tokio::test]
485    async fn truncates_long_lines() -> anyhow::Result<()> {
486        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
487        let long_line = "x".repeat(MAX_LINE_LENGTH + 50);
488        fs.write_file("long.txt", &long_line).await?;
489
490        let tool = create_test_tool(fs, AgentCapabilities::full_access());
491        let result = tool
492            .execute(&tool_ctx(), json!({"path": "/workspace/long.txt"}))
493            .await?;
494
495        assert!(result.success);
496        let expected = "x".repeat(MAX_LINE_LENGTH);
497        assert_eq!(result.output, format!("L1: {expected}"));
498        Ok(())
499    }
500
501    #[tokio::test]
502    async fn handles_special_characters() -> anyhow::Result<()> {
503        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
504        fs.write_file("special.txt", "特殊字符\néàü\n🎉emoji")
505            .await?;
506
507        let tool = create_test_tool(fs, AgentCapabilities::full_access());
508        let result = tool
509            .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
510            .await?;
511
512        assert!(result.success);
513        assert!(result.output.contains("特殊字符"));
514        assert!(result.output.contains("éàü"));
515        assert!(result.output.contains("🎉emoji"));
516        Ok(())
517    }
518
519    #[tokio::test]
520    async fn respects_limit_with_more_lines() -> anyhow::Result<()> {
521        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
522        let content: String = (1..=100)
523            .map(|i| format!("line {i}"))
524            .collect::<Vec<_>>()
525            .join("\n");
526        fs.write_file("many.txt", &content).await?;
527
528        let tool = create_test_tool(fs, AgentCapabilities::full_access());
529        let result = tool
530            .execute(
531                &tool_ctx(),
532                json!({"path": "/workspace/many.txt", "offset": 50, "limit": 3}),
533            )
534            .await?;
535
536        assert!(result.success);
537        assert_eq!(result.output, "L50: line 50\nL51: line 51\nL52: line 52");
538        Ok(())
539    }
540
541    #[tokio::test]
542    async fn tool_metadata() {
543        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
544        let tool = create_test_tool(fs, AgentCapabilities::full_access());
545
546        assert_eq!(tool.name(), PrimitiveToolName::Read);
547        assert_eq!(tool.tier(), ToolTier::Observe);
548
549        let schema = tool.input_schema();
550        assert!(schema["properties"].get("path").is_some());
551        assert!(schema["properties"].get("offset").is_some());
552        assert!(schema["properties"].get("limit").is_some());
553    }
554
555    #[test]
556    fn read_lines_basic() {
557        let lines = read_lines("alpha\nbeta\ngamma", 1, 2000);
558        assert_eq!(
559            lines,
560            vec![
561                "L1: alpha".to_string(),
562                "L2: beta".to_string(),
563                "L3: gamma".to_string(),
564            ]
565        );
566    }
567
568    #[test]
569    fn read_lines_with_offset_and_limit() {
570        let lines = read_lines("a\nb\nc\nd\ne", 2, 2);
571        assert_eq!(lines, vec!["L2: b".to_string(), "L3: c".to_string()]);
572    }
573
574    #[test]
575    fn read_lines_offset_past_end_returns_empty() {
576        let lines = read_lines("only", 5, 10);
577        assert!(lines.is_empty());
578    }
579
580    #[test]
581    fn detect_media_type_images() {
582        assert_eq!(detect_media_type("photo.png"), Some("image/png"));
583        assert_eq!(detect_media_type("photo.PNG"), Some("image/png"));
584        assert_eq!(detect_media_type("photo.jpg"), Some("image/jpeg"));
585        assert_eq!(detect_media_type("photo.jpeg"), Some("image/jpeg"));
586        assert_eq!(detect_media_type("photo.gif"), Some("image/gif"));
587        assert_eq!(detect_media_type("photo.webp"), Some("image/webp"));
588        assert_eq!(detect_media_type("doc.pdf"), Some("application/pdf"));
589        assert_eq!(detect_media_type("code.rs"), None);
590        assert_eq!(detect_media_type("data.json"), None);
591    }
592
593    #[tokio::test]
594    async fn reads_image_as_document() -> anyhow::Result<()> {
595        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
596        // PNG magic bytes
597        let png_bytes = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
598        fs.write_file_bytes("image.png", &png_bytes).await?;
599
600        let tool = create_test_tool(fs, AgentCapabilities::full_access());
601        let result = tool
602            .execute(&tool_ctx(), json!({"path": "/workspace/image.png"}))
603            .await?;
604
605        assert!(result.success);
606        assert_eq!(result.documents.len(), 1);
607        assert_eq!(result.documents[0].media_type, "image/png");
608        Ok(())
609    }
610
611    #[tokio::test]
612    async fn reads_pdf_as_document() -> anyhow::Result<()> {
613        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
614        fs.write_file_bytes("doc.pdf", b"%PDF-1.4 fake").await?;
615
616        let tool = create_test_tool(fs, AgentCapabilities::full_access());
617        let result = tool
618            .execute(&tool_ctx(), json!({"path": "/workspace/doc.pdf"}))
619            .await?;
620
621        assert!(result.success);
622        assert_eq!(result.documents.len(), 1);
623        assert_eq!(result.documents[0].media_type, "application/pdf");
624        Ok(())
625    }
626
627    #[tokio::test]
628    async fn text_files_have_no_documents() -> anyhow::Result<()> {
629        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
630        fs.write_file("test.txt", "hello").await?;
631
632        let tool = create_test_tool(fs, AgentCapabilities::full_access());
633        let result = tool
634            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
635            .await?;
636
637        assert!(result.success);
638        assert!(result.documents.is_empty());
639        Ok(())
640    }
641}