agent_sdk/primitive_tools/
read.rs

1use crate::reminders::{append_reminder, builtin};
2use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::{Value, json};
7use std::sync::Arc;
8
9use super::PrimitiveToolContext;
10
11/// Maximum tokens allowed per file read (approximately 4 chars per token)
12const MAX_TOKENS: usize = 25_000;
13const CHARS_PER_TOKEN: usize = 4;
14
15/// Tool for reading file contents
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    /// Path to the file to read (also accepts `file_path` for compatibility)
32    #[serde(alias = "file_path")]
33    path: String,
34    /// Optional line offset to start from (1-based)
35    #[serde(default)]
36    offset: Option<usize>,
37    /// Optional number of lines to read
38    #[serde(default)]
39    limit: Option<usize>,
40}
41
42#[async_trait]
43impl<E: Environment + 'static> Tool<()> for ReadTool<E> {
44    fn name(&self) -> &'static str {
45        "read"
46    }
47
48    fn description(&self) -> &'static str {
49        "Read file contents. Can optionally specify offset and limit for large files."
50    }
51
52    fn tier(&self) -> ToolTier {
53        ToolTier::Observe
54    }
55
56    fn input_schema(&self) -> Value {
57        json!({
58            "type": "object",
59            "properties": {
60                "path": {
61                    "type": "string",
62                    "description": "Path to the file to read"
63                },
64                "offset": {
65                    "type": "integer",
66                    "description": "Line number to start from (1-based). Optional."
67                },
68                "limit": {
69                    "type": "integer",
70                    "description": "Number of lines to read. Optional."
71                }
72            },
73            "required": ["path"]
74        })
75    }
76
77    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
78        let input: ReadInput =
79            serde_json::from_value(input).context("Invalid input for read tool")?;
80
81        let path = self.ctx.environment.resolve_path(&input.path);
82
83        // Check capabilities
84        if !self.ctx.capabilities.can_read(&path) {
85            return Ok(ToolResult::error(format!(
86                "Permission denied: cannot read '{path}'"
87            )));
88        }
89
90        // Check if file exists
91        let exists = self
92            .ctx
93            .environment
94            .exists(&path)
95            .await
96            .context("Failed to check file existence")?;
97
98        if !exists {
99            return Ok(ToolResult::error(format!("File not found: '{path}'")));
100        }
101
102        // Check if it's a directory
103        let is_dir = self
104            .ctx
105            .environment
106            .is_dir(&path)
107            .await
108            .context("Failed to check if path is directory")?;
109
110        if is_dir {
111            return Ok(ToolResult::error(format!(
112                "'{path}' is a directory, not a file"
113            )));
114        }
115
116        // Read file
117        let content = self
118            .ctx
119            .environment
120            .read_file(&path)
121            .await
122            .context("Failed to read file")?;
123
124        // Apply offset and limit if specified
125        let lines: Vec<&str> = content.lines().collect();
126        let total_lines = lines.len();
127
128        let offset = input.offset.unwrap_or(1).saturating_sub(1); // Convert to 0-based
129
130        // Calculate the content that would be returned
131        let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
132
133        // Check if user specified a limit, otherwise we need to check token limit
134        let limit = if let Some(user_limit) = input.limit {
135            user_limit
136        } else {
137            // Estimate tokens for the selected content
138            let selected_content_len: usize =
139                selected_lines.iter().map(|line| line.len() + 1).sum(); // +1 for newline
140            let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
141
142            if estimated_tokens > MAX_TOKENS {
143                // File exceeds token limit, return helpful message
144                let suggested_limit = estimate_lines_for_tokens(&selected_lines, MAX_TOKENS);
145                return Ok(ToolResult::success(format!(
146                    "File too large to read at once (~{estimated_tokens} tokens, max {MAX_TOKENS}).\n\
147                     Total lines: {total_lines}\n\n\
148                     Use 'offset' and 'limit' parameters to read specific portions.\n\
149                     Suggested: Start with offset=1, limit={suggested_limit} to read the first ~{MAX_TOKENS} tokens.\n\n\
150                     Example: {{\"path\": \"{path}\", \"offset\": 1, \"limit\": {suggested_limit}}}"
151                )));
152            }
153            selected_lines.len()
154        };
155
156        let selected_lines: Vec<String> = lines
157            .into_iter()
158            .skip(offset)
159            .take(limit)
160            .enumerate()
161            .map(|(i, line)| format!("{:>6}\t{}", offset + i + 1, line))
162            .collect();
163
164        let is_empty = selected_lines.is_empty();
165        let output = if is_empty {
166            "(empty file)".to_string()
167        } else {
168            let header = if input.offset.is_some() || input.limit.is_some() {
169                format!(
170                    "Showing lines {}-{} of {} total\n",
171                    offset + 1,
172                    (offset + selected_lines.len()).min(total_lines),
173                    total_lines
174                )
175            } else {
176                String::new()
177            };
178            format!("{header}{}", selected_lines.join("\n"))
179        };
180
181        let mut result = ToolResult::success(output);
182
183        // Add empty file reminder if applicable
184        if is_empty {
185            append_reminder(&mut result, builtin::READ_EMPTY_FILE_REMINDER);
186        }
187
188        // Always add security reminder when reading files
189        append_reminder(&mut result, builtin::READ_SECURITY_REMINDER);
190
191        Ok(result)
192    }
193}
194
195/// Estimate how many lines can fit within a token budget
196fn estimate_lines_for_tokens(lines: &[&str], max_tokens: usize) -> usize {
197    let max_chars = max_tokens * CHARS_PER_TOKEN;
198    let mut total_chars = 0;
199    let mut line_count = 0;
200
201    for line in lines {
202        let line_chars = line.len() + 1; // +1 for newline
203        if total_chars + line_chars > max_chars {
204            break;
205        }
206        total_chars += line_chars;
207        line_count += 1;
208    }
209
210    // Return at least 1 to avoid suggesting limit=0
211    line_count.max(1)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::{AgentCapabilities, InMemoryFileSystem};
218
219    fn create_test_tool(
220        fs: Arc<InMemoryFileSystem>,
221        capabilities: AgentCapabilities,
222    ) -> ReadTool<InMemoryFileSystem> {
223        ReadTool::new(fs, capabilities)
224    }
225
226    fn tool_ctx() -> ToolContext<()> {
227        ToolContext::new(())
228    }
229
230    // ===================
231    // Unit Tests
232    // ===================
233
234    #[tokio::test]
235    async fn test_read_entire_file() -> anyhow::Result<()> {
236        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
237        fs.write_file("test.txt", "line 1\nline 2\nline 3").await?;
238
239        let tool = create_test_tool(fs, AgentCapabilities::full_access());
240        let result = tool
241            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
242            .await?;
243
244        assert!(result.success);
245        assert!(result.output.contains("line 1"));
246        assert!(result.output.contains("line 2"));
247        assert!(result.output.contains("line 3"));
248        Ok(())
249    }
250
251    #[tokio::test]
252    async fn test_read_with_offset() -> anyhow::Result<()> {
253        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
254        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
255            .await?;
256
257        let tool = create_test_tool(fs, AgentCapabilities::full_access());
258        let result = tool
259            .execute(
260                &tool_ctx(),
261                json!({"path": "/workspace/test.txt", "offset": 3}),
262            )
263            .await?;
264
265        assert!(result.success);
266        assert!(result.output.contains("Showing lines 3-5 of 5 total"));
267        assert!(result.output.contains("line 3"));
268        assert!(result.output.contains("line 4"));
269        assert!(result.output.contains("line 5"));
270        assert!(!result.output.contains("\tline 1")); // Should not include line 1
271        assert!(!result.output.contains("\tline 2")); // Should not include line 2
272        Ok(())
273    }
274
275    #[tokio::test]
276    async fn test_read_with_limit() -> anyhow::Result<()> {
277        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
278        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
279            .await?;
280
281        let tool = create_test_tool(fs, AgentCapabilities::full_access());
282        let result = tool
283            .execute(
284                &tool_ctx(),
285                json!({"path": "/workspace/test.txt", "limit": 2}),
286            )
287            .await?;
288
289        assert!(result.success);
290        assert!(result.output.contains("Showing lines 1-2 of 5 total"));
291        assert!(result.output.contains("line 1"));
292        assert!(result.output.contains("line 2"));
293        assert!(!result.output.contains("\tline 3"));
294        Ok(())
295    }
296
297    #[tokio::test]
298    async fn test_read_with_offset_and_limit() -> anyhow::Result<()> {
299        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
300        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
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!(result.output.contains("Showing lines 2-3 of 5 total"));
313        assert!(result.output.contains("line 2"));
314        assert!(result.output.contains("line 3"));
315        assert!(!result.output.contains("\tline 1"));
316        assert!(!result.output.contains("\tline 4"));
317        Ok(())
318    }
319
320    #[tokio::test]
321    async fn test_read_nonexistent_file() -> anyhow::Result<()> {
322        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
323
324        let tool = create_test_tool(fs, AgentCapabilities::full_access());
325        let result = tool
326            .execute(&tool_ctx(), json!({"path": "/workspace/nonexistent.txt"}))
327            .await?;
328
329        assert!(!result.success);
330        assert!(result.output.contains("File not found"));
331        Ok(())
332    }
333
334    #[tokio::test]
335    async fn test_read_directory_returns_error() -> anyhow::Result<()> {
336        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
337        fs.create_dir("/workspace/subdir").await?;
338
339        let tool = create_test_tool(fs, AgentCapabilities::full_access());
340        let result = tool
341            .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
342            .await?;
343
344        assert!(!result.success);
345        assert!(result.output.contains("is a directory"));
346        Ok(())
347    }
348
349    // ===================
350    // Integration Tests
351    // ===================
352
353    #[tokio::test]
354    async fn test_read_permission_denied() -> anyhow::Result<()> {
355        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
356        fs.write_file("secret.txt", "secret content").await?;
357
358        // Create read-only capabilities that deny all paths
359        let caps = AgentCapabilities::none();
360
361        let tool = create_test_tool(fs, caps);
362        let result = tool
363            .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
364            .await?;
365
366        assert!(!result.success);
367        assert!(result.output.contains("Permission denied"));
368        Ok(())
369    }
370
371    #[tokio::test]
372    async fn test_read_denied_path_via_capabilities() -> anyhow::Result<()> {
373        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
374        fs.write_file("secrets/api_key.txt", "API_KEY=secret")
375            .await?;
376
377        // Custom capabilities that deny secrets directory with absolute path pattern
378        let caps =
379            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
380
381        let tool = create_test_tool(fs, caps);
382        let result = tool
383            .execute(
384                &tool_ctx(),
385                json!({"path": "/workspace/secrets/api_key.txt"}),
386            )
387            .await?;
388
389        assert!(!result.success);
390        assert!(result.output.contains("Permission denied"));
391        Ok(())
392    }
393
394    #[tokio::test]
395    async fn test_read_allowed_path_restriction() -> anyhow::Result<()> {
396        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
397        fs.write_file("src/main.rs", "fn main() {}").await?;
398        fs.write_file("config/settings.toml", "key = value").await?;
399
400        // Only allow reading from src/
401        let caps = AgentCapabilities::read_only()
402            .with_denied_paths(vec![])
403            .with_allowed_paths(vec!["/workspace/src/**".into()]);
404
405        let tool = create_test_tool(Arc::clone(&fs), caps.clone());
406
407        // Should be able to read from src/
408        let result = tool
409            .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
410            .await?;
411        assert!(result.success);
412
413        // Should NOT be able to read from config/
414        let tool = create_test_tool(fs, caps);
415        let result = tool
416            .execute(
417                &tool_ctx(),
418                json!({"path": "/workspace/config/settings.toml"}),
419            )
420            .await?;
421        assert!(!result.success);
422        assert!(result.output.contains("Permission denied"));
423        Ok(())
424    }
425
426    // ===================
427    // Edge Cases
428    // ===================
429
430    #[tokio::test]
431    async fn test_read_empty_file() -> anyhow::Result<()> {
432        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
433        fs.write_file("empty.txt", "").await?;
434
435        let tool = create_test_tool(fs, AgentCapabilities::full_access());
436        let result = tool
437            .execute(&tool_ctx(), json!({"path": "/workspace/empty.txt"}))
438            .await?;
439
440        assert!(result.success);
441        assert!(result.output.contains("(empty file)"));
442        Ok(())
443    }
444
445    #[tokio::test]
446    async fn test_read_large_file_with_pagination() -> anyhow::Result<()> {
447        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
448
449        // Create a file with 100 lines
450        let content: String = (1..=100)
451            .map(|i| format!("line {i}"))
452            .collect::<Vec<_>>()
453            .join("\n");
454        fs.write_file("large.txt", &content).await?;
455
456        let tool = create_test_tool(fs, AgentCapabilities::full_access());
457
458        // Read lines 50-60
459        let result = tool
460            .execute(
461                &tool_ctx(),
462                json!({"path": "/workspace/large.txt", "offset": 50, "limit": 10}),
463            )
464            .await?;
465
466        assert!(result.success);
467        assert!(result.output.contains("Showing lines 50-59 of 100 total"));
468        assert!(result.output.contains("line 50"));
469        assert!(result.output.contains("line 59"));
470        assert!(!result.output.contains("\tline 49"));
471        assert!(!result.output.contains("\tline 60"));
472        Ok(())
473    }
474
475    #[tokio::test]
476    async fn test_read_offset_beyond_file_length() -> anyhow::Result<()> {
477        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
478        fs.write_file("short.txt", "line 1\nline 2").await?;
479
480        let tool = create_test_tool(fs, AgentCapabilities::full_access());
481        let result = tool
482            .execute(
483                &tool_ctx(),
484                json!({"path": "/workspace/short.txt", "offset": 100}),
485            )
486            .await?;
487
488        assert!(result.success);
489        assert!(result.output.contains("(empty file)"));
490        Ok(())
491    }
492
493    #[tokio::test]
494    async fn test_read_file_with_special_characters() -> anyhow::Result<()> {
495        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
496        let content = "特殊字符\néàü\n🎉emoji\ntab\there";
497        fs.write_file("special.txt", content).await?;
498
499        let tool = create_test_tool(fs, AgentCapabilities::full_access());
500        let result = tool
501            .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
502            .await?;
503
504        assert!(result.success);
505        assert!(result.output.contains("特殊字符"));
506        assert!(result.output.contains("éàü"));
507        assert!(result.output.contains("🎉emoji"));
508        Ok(())
509    }
510
511    #[tokio::test]
512    async fn test_read_tool_metadata() {
513        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
514        let tool = create_test_tool(fs, AgentCapabilities::full_access());
515
516        assert_eq!(tool.name(), "read");
517        assert_eq!(tool.tier(), ToolTier::Observe);
518        assert!(tool.description().contains("Read"));
519
520        let schema = tool.input_schema();
521        assert!(schema.get("properties").is_some());
522        assert!(schema["properties"].get("path").is_some());
523    }
524
525    #[tokio::test]
526    async fn test_read_invalid_input() -> anyhow::Result<()> {
527        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
528        let tool = create_test_tool(fs, AgentCapabilities::full_access());
529
530        // Missing required path field
531        let result = tool.execute(&tool_ctx(), json!({})).await;
532
533        assert!(result.is_err());
534        Ok(())
535    }
536
537    #[tokio::test]
538    async fn test_read_large_file_exceeds_token_limit() -> anyhow::Result<()> {
539        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
540
541        // Create a file that exceeds 25k tokens (~100k chars)
542        // Each line is ~100 chars, need ~1000 lines to exceed limit
543        let line = "x".repeat(100);
544        let content: String = (1..=1500)
545            .map(|i| format!("{i}: {line}"))
546            .collect::<Vec<_>>()
547            .join("\n");
548        fs.write_file("huge.txt", &content).await?;
549
550        let tool = create_test_tool(fs, AgentCapabilities::full_access());
551        let result = tool
552            .execute(&tool_ctx(), json!({"path": "/workspace/huge.txt"}))
553            .await?;
554
555        assert!(result.success);
556        assert!(result.output.contains("File too large to read at once"));
557        assert!(result.output.contains("Total lines: 1500"));
558        assert!(result.output.contains("offset"));
559        assert!(result.output.contains("limit"));
560        Ok(())
561    }
562
563    #[tokio::test]
564    async fn test_read_large_file_with_explicit_limit_bypasses_check() -> anyhow::Result<()> {
565        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
566
567        // Create a large file
568        let line = "x".repeat(100);
569        let content: String = (1..=1500)
570            .map(|i| format!("{i}: {line}"))
571            .collect::<Vec<_>>()
572            .join("\n");
573        fs.write_file("huge.txt", &content).await?;
574
575        let tool = create_test_tool(fs, AgentCapabilities::full_access());
576
577        // With explicit limit, should return the requested lines
578        let result = tool
579            .execute(
580                &tool_ctx(),
581                json!({"path": "/workspace/huge.txt", "offset": 1, "limit": 10}),
582            )
583            .await?;
584
585        assert!(result.success);
586        assert!(result.output.contains("Showing lines 1-10 of 1500 total"));
587        assert!(!result.output.contains("File too large"));
588        Ok(())
589    }
590
591    #[test]
592    fn test_estimate_lines_for_tokens() {
593        let lines: Vec<&str> = vec![
594            "short line",           // 11 chars
595            "another short line",   // 19 chars
596            "x".repeat(100).leak(), // 100 chars
597        ];
598
599        // With 10 tokens (40 chars), should fit first 2 lines (11 + 19 = 30 chars)
600        let count = estimate_lines_for_tokens(&lines, 10);
601        assert_eq!(count, 2);
602
603        // With 1 token (4 chars), should return at least 1
604        let count = estimate_lines_for_tokens(&lines, 1);
605        assert_eq!(count, 1);
606    }
607}