Skip to main content

agent_sdk/primitive_tools/
read.rs

1use crate::llm::ContentSource;
2use crate::reminders::{append_reminder, builtin};
3use crate::{Environment, PrimitiveToolName, Tool, ToolContext, ToolResult, ToolTier};
4use anyhow::{Context, Result};
5use base64::Engine;
6use serde::Deserialize;
7use serde_json::{Value, json};
8use std::path::Path;
9use std::sync::Arc;
10
11use super::PrimitiveToolContext;
12
13/// Maximum tokens allowed per file read (approximately 4 chars per token)
14const MAX_TOKENS: usize = 25_000;
15const CHARS_PER_TOKEN: usize = 4;
16
17/// Tool for reading file contents
18pub struct ReadTool<E: Environment> {
19    ctx: PrimitiveToolContext<E>,
20}
21
22impl<E: Environment> ReadTool<E> {
23    #[must_use]
24    pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
25        Self {
26            ctx: PrimitiveToolContext::new(environment, capabilities),
27        }
28    }
29}
30
31#[derive(Debug, Deserialize)]
32struct ReadInput {
33    /// Path to the file to read (also accepts `file_path` for compatibility)
34    #[serde(alias = "file_path")]
35    path: String,
36    /// Optional line offset to start from (1-based).
37    /// Accepts either an integer or a numeric string such as "55".
38    #[serde(
39        default,
40        deserialize_with = "super::deserialize_optional_usize_from_string_or_int"
41    )]
42    offset: Option<usize>,
43    /// Optional number of lines to read.
44    /// Accepts either an integer or a numeric string such as "40".
45    #[serde(
46        default,
47        deserialize_with = "super::deserialize_optional_usize_from_string_or_int"
48    )]
49    limit: Option<usize>,
50}
51
52enum ReadContent {
53    Text(String),
54    NativeBinary { mime_type: &'static str },
55    UnsupportedBinary,
56}
57
58impl<E: Environment + 'static> Tool<()> for ReadTool<E> {
59    type Name = PrimitiveToolName;
60
61    fn name(&self) -> PrimitiveToolName {
62        PrimitiveToolName::Read
63    }
64
65    fn display_name(&self) -> &'static str {
66        "Read File"
67    }
68
69    fn description(&self) -> &'static str {
70        "Read text files directly, and attach supported images/PDFs for native model inspection. Can optionally specify offset and limit for text files."
71    }
72
73    fn tier(&self) -> ToolTier {
74        ToolTier::Observe
75    }
76
77    fn input_schema(&self) -> Value {
78        json!({
79            "type": "object",
80            "properties": {
81                "path": {
82                    "type": "string",
83                    "description": "Path to the file to read"
84                },
85                "offset": {
86                    "anyOf": [
87                        {"type": "integer"},
88                        {"type": "string", "pattern": "^[0-9]+$"}
89                    ],
90                    "description": "Line number to start from (1-based). Accepts either an integer or a numeric string. Optional. Only applies to text files."
91                },
92                "limit": {
93                    "anyOf": [
94                        {"type": "integer"},
95                        {"type": "string", "pattern": "^[0-9]+$"}
96                    ],
97                    "description": "Number of lines to read. Accepts either an integer or a numeric string. Optional. Only applies to text files."
98                }
99            },
100            "required": ["path"]
101        })
102    }
103
104    async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
105        let input: ReadInput = serde_json::from_value(input.clone())
106            .with_context(|| format!("Invalid input for read tool: {input}"))?;
107
108        let path = self.ctx.environment.resolve_path(&input.path);
109
110        if !self.ctx.capabilities.can_read(&path) {
111            return Ok(ToolResult::error(format!(
112                "Permission denied: cannot read '{path}'"
113            )));
114        }
115
116        let exists = self
117            .ctx
118            .environment
119            .exists(&path)
120            .await
121            .context("Failed to check file existence")?;
122
123        if !exists {
124            return Ok(ToolResult::error(format!("File not found: '{path}'")));
125        }
126
127        let is_dir = self
128            .ctx
129            .environment
130            .is_dir(&path)
131            .await
132            .context("Failed to check if path is directory")?;
133
134        if is_dir {
135            return Ok(ToolResult::error(format!(
136                "'{path}' is a directory, not a file"
137            )));
138        }
139
140        let bytes = self
141            .ctx
142            .environment
143            .read_file_bytes(&path)
144            .await
145            .context("Failed to read file")?;
146
147        let mut result = match classify_content(&path, &bytes) {
148            ReadContent::Text(content) => {
149                read_text_content(&path, &content, input.offset, input.limit)
150            }
151            ReadContent::NativeBinary { mime_type } => {
152                if input.offset.is_some() || input.limit.is_some() {
153                    ToolResult::error(format!(
154                        "offset and limit are only supported for text files. '{path}' is a {mime_type} file."
155                    ))
156                } else {
157                    ToolResult::success(format!(
158                        "Attached '{path}' ({mime_type}, {} bytes) for native model inspection.",
159                        bytes.len()
160                    ))
161                    .with_documents(vec![ContentSource::new(
162                        mime_type,
163                        base64::engine::general_purpose::STANDARD.encode(&bytes),
164                    )])
165                }
166            }
167            ReadContent::UnsupportedBinary => ToolResult::error(format!(
168                "'{path}' is a binary file in an unsupported format. The read tool currently supports text files, images (PNG/JPEG/GIF/WebP), and PDF documents."
169            )),
170        };
171
172        if result.success && result.output == "(empty file)" {
173            append_reminder(&mut result, builtin::READ_EMPTY_FILE_REMINDER);
174        }
175
176        if result.success {
177            append_reminder(&mut result, builtin::READ_SECURITY_REMINDER);
178        }
179
180        Ok(result)
181    }
182}
183
184fn read_text_content(
185    path: &str,
186    content: &str,
187    offset: Option<usize>,
188    limit: Option<usize>,
189) -> ToolResult {
190    let lines: Vec<&str> = content.lines().collect();
191    let total_lines = lines.len();
192    let offset = offset.unwrap_or(1).saturating_sub(1);
193    let selected_lines: Vec<&str> = lines.iter().copied().skip(offset).collect();
194
195    let limit = if let Some(user_limit) = limit {
196        user_limit
197    } else {
198        let selected_content_len: usize = selected_lines.iter().map(|line| line.len() + 1).sum();
199        let estimated_tokens = selected_content_len / CHARS_PER_TOKEN;
200
201        if estimated_tokens > MAX_TOKENS {
202            let suggested_limit = estimate_lines_for_tokens(&selected_lines, MAX_TOKENS);
203            return ToolResult::success(format!(
204                "File too large to read at once (~{estimated_tokens} tokens, max {MAX_TOKENS}).\n\
205                 Total lines: {total_lines}\n\n\
206                 Use 'offset' and 'limit' parameters to read specific portions.\n\
207                 Suggested: Start with offset=1, limit={suggested_limit} to read the first ~{MAX_TOKENS} tokens.\n\n\
208                 Example: {{\"path\": \"{path}\", \"offset\": 1, \"limit\": {suggested_limit}}}"
209            ));
210        }
211
212        selected_lines.len()
213    };
214
215    let selected_lines: Vec<String> = lines
216        .into_iter()
217        .skip(offset)
218        .take(limit)
219        .enumerate()
220        .map(|(i, line)| format!("{:>6}\t{}", offset + i + 1, line))
221        .collect();
222
223    let is_empty = selected_lines.is_empty();
224    let output = if is_empty {
225        "(empty file)".to_string()
226    } else {
227        let header = if offset > 0 || limit < total_lines {
228            format!(
229                "Showing lines {}-{} of {} total\n",
230                offset + 1,
231                (offset + selected_lines.len()).min(total_lines),
232                total_lines
233            )
234        } else {
235            String::new()
236        };
237        format!("{header}{}", selected_lines.join("\n"))
238    };
239
240    ToolResult::success(output)
241}
242
243fn classify_content(path: &str, bytes: &[u8]) -> ReadContent {
244    if let Some(mime_type) = detect_native_binary_mime(path, bytes) {
245        return ReadContent::NativeBinary { mime_type };
246    }
247
248    if let Ok(content) = std::str::from_utf8(bytes) {
249        return ReadContent::Text(content.to_string());
250    }
251
252    ReadContent::UnsupportedBinary
253}
254
255fn detect_native_binary_mime(path: &str, bytes: &[u8]) -> Option<&'static str> {
256    if bytes.starts_with(b"%PDF-") {
257        return Some("application/pdf");
258    }
259
260    if bytes.starts_with(&[0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n']) {
261        return Some("image/png");
262    }
263
264    if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
265        return Some("image/jpeg");
266    }
267
268    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
269        return Some("image/gif");
270    }
271
272    if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" {
273        return Some("image/webp");
274    }
275
276    let extension = Path::new(path)
277        .extension()
278        .and_then(|ext| ext.to_str())
279        .map(str::to_ascii_lowercase);
280
281    match extension.as_deref() {
282        Some("pdf") => Some("application/pdf"),
283        Some("png") => Some("image/png"),
284        Some("jpg" | "jpeg") => Some("image/jpeg"),
285        Some("gif") => Some("image/gif"),
286        Some("webp") => Some("image/webp"),
287        _ => None,
288    }
289}
290
291/// Estimate how many lines can fit within a token budget
292fn estimate_lines_for_tokens(lines: &[&str], max_tokens: usize) -> usize {
293    let max_chars = max_tokens * CHARS_PER_TOKEN;
294    let mut total_chars = 0;
295    let mut line_count = 0;
296
297    for line in lines {
298        let line_chars = line.len() + 1;
299        if total_chars + line_chars > max_chars {
300            break;
301        }
302        total_chars += line_chars;
303        line_count += 1;
304    }
305
306    line_count.max(1)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312    use crate::{AgentCapabilities, InMemoryFileSystem};
313
314    fn create_test_tool(
315        fs: Arc<InMemoryFileSystem>,
316        capabilities: AgentCapabilities,
317    ) -> ReadTool<InMemoryFileSystem> {
318        ReadTool::new(fs, capabilities)
319    }
320
321    fn tool_ctx() -> ToolContext<()> {
322        ToolContext::new(())
323    }
324
325    #[tokio::test]
326    async fn test_read_entire_file() -> anyhow::Result<()> {
327        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
328        fs.write_file("test.txt", "line 1\nline 2\nline 3").await?;
329
330        let tool = create_test_tool(fs, AgentCapabilities::full_access());
331        let result = tool
332            .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
333            .await?;
334
335        assert!(result.success);
336        assert!(result.output.contains("line 1"));
337        assert!(result.output.contains("line 2"));
338        assert!(result.output.contains("line 3"));
339        assert!(result.documents.is_empty());
340        Ok(())
341    }
342
343    #[tokio::test]
344    async fn test_read_with_offset() -> anyhow::Result<()> {
345        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
346        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
347            .await?;
348
349        let tool = create_test_tool(fs, AgentCapabilities::full_access());
350        let result = tool
351            .execute(
352                &tool_ctx(),
353                json!({"path": "/workspace/test.txt", "offset": 3}),
354            )
355            .await?;
356
357        assert!(result.success);
358        assert!(result.output.contains("Showing lines 3-5 of 5 total"));
359        assert!(result.output.contains("line 3"));
360        assert!(result.output.contains("line 4"));
361        assert!(result.output.contains("line 5"));
362        assert!(!result.output.contains("\tline 1"));
363        assert!(!result.output.contains("\tline 2"));
364        Ok(())
365    }
366
367    #[tokio::test]
368    async fn test_read_with_limit() -> anyhow::Result<()> {
369        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
370        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
371            .await?;
372
373        let tool = create_test_tool(fs, AgentCapabilities::full_access());
374        let result = tool
375            .execute(
376                &tool_ctx(),
377                json!({"path": "/workspace/test.txt", "limit": 2}),
378            )
379            .await?;
380
381        assert!(result.success);
382        assert!(result.output.contains("Showing lines 1-2 of 5 total"));
383        assert!(result.output.contains("line 1"));
384        assert!(result.output.contains("line 2"));
385        assert!(!result.output.contains("\tline 3"));
386        Ok(())
387    }
388
389    #[tokio::test]
390    async fn test_read_with_offset_and_limit() -> anyhow::Result<()> {
391        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
392        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
393            .await?;
394
395        let tool = create_test_tool(fs, AgentCapabilities::full_access());
396        let result = tool
397            .execute(
398                &tool_ctx(),
399                json!({"path": "/workspace/test.txt", "offset": 2, "limit": 2}),
400            )
401            .await?;
402
403        assert!(result.success);
404        assert!(result.output.contains("Showing lines 2-3 of 5 total"));
405        assert!(result.output.contains("line 2"));
406        assert!(result.output.contains("line 3"));
407        assert!(!result.output.contains("\tline 1"));
408        assert!(!result.output.contains("\tline 4"));
409        Ok(())
410    }
411
412    #[tokio::test]
413    async fn test_read_with_string_offset_and_limit() -> anyhow::Result<()> {
414        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
415        fs.write_file("test.txt", "line 1\nline 2\nline 3\nline 4\nline 5")
416            .await?;
417
418        let tool = create_test_tool(fs, AgentCapabilities::full_access());
419        let result = tool
420            .execute(
421                &tool_ctx(),
422                json!({"path": "/workspace/test.txt", "offset": "2", "limit": "2"}),
423            )
424            .await?;
425
426        assert!(result.success);
427        assert!(result.output.contains("Showing lines 2-3 of 5 total"));
428        assert!(result.output.contains("line 2"));
429        assert!(result.output.contains("line 3"));
430        assert!(!result.output.contains("\tline 1"));
431        assert!(!result.output.contains("\tline 4"));
432        Ok(())
433    }
434
435    #[tokio::test]
436    async fn test_read_nonexistent_file() -> anyhow::Result<()> {
437        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
438
439        let tool = create_test_tool(fs, AgentCapabilities::full_access());
440        let result = tool
441            .execute(&tool_ctx(), json!({"path": "/workspace/nonexistent.txt"}))
442            .await?;
443
444        assert!(!result.success);
445        assert!(result.output.contains("File not found"));
446        Ok(())
447    }
448
449    #[tokio::test]
450    async fn test_read_directory_returns_error() -> anyhow::Result<()> {
451        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
452        fs.create_dir("/workspace/subdir").await?;
453
454        let tool = create_test_tool(fs, AgentCapabilities::full_access());
455        let result = tool
456            .execute(&tool_ctx(), json!({"path": "/workspace/subdir"}))
457            .await?;
458
459        assert!(!result.success);
460        assert!(result.output.contains("is a directory"));
461        Ok(())
462    }
463
464    #[tokio::test]
465    async fn test_read_permission_denied() -> anyhow::Result<()> {
466        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
467        fs.write_file("secret.txt", "secret content").await?;
468
469        let tool = create_test_tool(fs, AgentCapabilities::none());
470        let result = tool
471            .execute(&tool_ctx(), json!({"path": "/workspace/secret.txt"}))
472            .await?;
473
474        assert!(!result.success);
475        assert!(result.output.contains("Permission denied"));
476        Ok(())
477    }
478
479    #[tokio::test]
480    async fn test_read_denied_path_via_capabilities() -> anyhow::Result<()> {
481        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
482        fs.write_file("secrets/api_key.txt", "API_KEY=secret")
483            .await?;
484
485        let caps =
486            AgentCapabilities::read_only().with_denied_paths(vec!["/workspace/secrets/**".into()]);
487
488        let tool = create_test_tool(fs, caps);
489        let result = tool
490            .execute(
491                &tool_ctx(),
492                json!({"path": "/workspace/secrets/api_key.txt"}),
493            )
494            .await?;
495
496        assert!(!result.success);
497        assert!(result.output.contains("Permission denied"));
498        Ok(())
499    }
500
501    #[tokio::test]
502    async fn test_read_allowed_path_restriction() -> anyhow::Result<()> {
503        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
504        fs.write_file("src/main.rs", "fn main() {} ").await?;
505        fs.write_file("config/settings.toml", "key = value").await?;
506
507        let caps = AgentCapabilities::read_only()
508            .with_denied_paths(vec![])
509            .with_allowed_paths(vec!["/workspace/src/**".into()]);
510
511        let tool = create_test_tool(Arc::clone(&fs), caps.clone());
512
513        let result = tool
514            .execute(&tool_ctx(), json!({"path": "/workspace/src/main.rs"}))
515            .await?;
516        assert!(result.success);
517
518        let tool = create_test_tool(fs, caps);
519        let result = tool
520            .execute(
521                &tool_ctx(),
522                json!({"path": "/workspace/config/settings.toml"}),
523            )
524            .await?;
525        assert!(!result.success);
526        assert!(result.output.contains("Permission denied"));
527        Ok(())
528    }
529
530    #[tokio::test]
531    async fn test_read_empty_file() -> anyhow::Result<()> {
532        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
533        fs.write_file("empty.txt", "").await?;
534
535        let tool = create_test_tool(fs, AgentCapabilities::full_access());
536        let result = tool
537            .execute(&tool_ctx(), json!({"path": "/workspace/empty.txt"}))
538            .await?;
539
540        assert!(result.success);
541        assert!(result.output.contains("(empty file)"));
542        Ok(())
543    }
544
545    #[tokio::test]
546    async fn test_read_large_file_with_pagination() -> anyhow::Result<()> {
547        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
548        let content: String = (1..=100)
549            .map(|i| format!("line {i}"))
550            .collect::<Vec<_>>()
551            .join("\n");
552        fs.write_file("large.txt", &content).await?;
553
554        let tool = create_test_tool(fs, AgentCapabilities::full_access());
555        let result = tool
556            .execute(
557                &tool_ctx(),
558                json!({"path": "/workspace/large.txt", "offset": 50, "limit": 10}),
559            )
560            .await?;
561
562        assert!(result.success);
563        assert!(result.output.contains("Showing lines 50-59 of 100 total"));
564        assert!(result.output.contains("line 50"));
565        assert!(result.output.contains("line 59"));
566        assert!(!result.output.contains("\tline 49"));
567        assert!(!result.output.contains("\tline 60"));
568        Ok(())
569    }
570
571    #[tokio::test]
572    async fn test_read_offset_beyond_file_length() -> anyhow::Result<()> {
573        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
574        fs.write_file("short.txt", "line 1\nline 2").await?;
575
576        let tool = create_test_tool(fs, AgentCapabilities::full_access());
577        let result = tool
578            .execute(
579                &tool_ctx(),
580                json!({"path": "/workspace/short.txt", "offset": 100}),
581            )
582            .await?;
583
584        assert!(result.success);
585        assert!(result.output.contains("(empty file)"));
586        Ok(())
587    }
588
589    #[tokio::test]
590    async fn test_read_file_with_special_characters() -> anyhow::Result<()> {
591        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
592        let content = "特殊字符\néàü\n🎉emoji\ntab\there";
593        fs.write_file("special.txt", content).await?;
594
595        let tool = create_test_tool(fs, AgentCapabilities::full_access());
596        let result = tool
597            .execute(&tool_ctx(), json!({"path": "/workspace/special.txt"}))
598            .await?;
599
600        assert!(result.success);
601        assert!(result.output.contains("特殊字符"));
602        assert!(result.output.contains("éàü"));
603        assert!(result.output.contains("🎉emoji"));
604        Ok(())
605    }
606
607    #[tokio::test]
608    async fn test_read_image_file_attaches_native_content() -> anyhow::Result<()> {
609        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
610        let png = vec![
611            0x89, b'P', b'N', b'G', b'\r', b'\n', 0x1a, b'\n', 1, 2, 3, 4,
612        ];
613        fs.write_file_bytes("image.png", &png).await?;
614
615        let tool = create_test_tool(fs, AgentCapabilities::full_access());
616        let result = tool
617            .execute(&tool_ctx(), json!({"path": "/workspace/image.png"}))
618            .await?;
619
620        assert!(result.success);
621        assert!(result.output.contains("Attached '/workspace/image.png'"));
622        assert_eq!(result.documents.len(), 1);
623        assert_eq!(result.documents[0].media_type, "image/png");
624        Ok(())
625    }
626
627    #[tokio::test]
628    async fn test_read_pdf_file_attaches_native_content() -> anyhow::Result<()> {
629        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
630        fs.write_file_bytes("doc.pdf", b"%PDF-1.7\nbody").await?;
631
632        let tool = create_test_tool(fs, AgentCapabilities::full_access());
633        let result = tool
634            .execute(&tool_ctx(), json!({"path": "/workspace/doc.pdf"}))
635            .await?;
636
637        assert!(result.success);
638        assert_eq!(result.documents.len(), 1);
639        assert_eq!(result.documents[0].media_type, "application/pdf");
640        Ok(())
641    }
642
643    #[tokio::test]
644    async fn test_read_binary_with_offset_returns_error() -> anyhow::Result<()> {
645        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
646        fs.write_file_bytes("doc.pdf", b"%PDF-1.7\nbody").await?;
647
648        let tool = create_test_tool(fs, AgentCapabilities::full_access());
649        let result = tool
650            .execute(
651                &tool_ctx(),
652                json!({"path": "/workspace/doc.pdf", "offset": 1}),
653            )
654            .await?;
655
656        assert!(!result.success);
657        assert!(result.output.contains("only supported for text files"));
658        Ok(())
659    }
660
661    #[tokio::test]
662    async fn test_read_unsupported_binary_returns_error() -> anyhow::Result<()> {
663        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
664        fs.write_file_bytes("archive.bin", &[0, 159, 146, 150])
665            .await?;
666
667        let tool = create_test_tool(fs, AgentCapabilities::full_access());
668        let result = tool
669            .execute(&tool_ctx(), json!({"path": "/workspace/archive.bin"}))
670            .await?;
671
672        assert!(!result.success);
673        assert!(result.output.contains("unsupported format"));
674        Ok(())
675    }
676
677    #[tokio::test]
678    async fn test_read_tool_metadata() {
679        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
680        let tool = create_test_tool(fs, AgentCapabilities::full_access());
681
682        assert_eq!(tool.name(), PrimitiveToolName::Read);
683        assert_eq!(tool.tier(), ToolTier::Observe);
684        assert!(tool.description().contains("Read"));
685
686        let schema = tool.input_schema();
687        assert!(schema.get("properties").is_some());
688        assert!(schema["properties"].get("path").is_some());
689    }
690
691    #[tokio::test]
692    async fn test_read_invalid_input() -> anyhow::Result<()> {
693        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
694        let tool = create_test_tool(fs, AgentCapabilities::full_access());
695
696        let result = tool.execute(&tool_ctx(), json!({})).await;
697
698        assert!(result.is_err());
699        Ok(())
700    }
701
702    #[tokio::test]
703    async fn test_read_large_file_exceeds_token_limit() -> anyhow::Result<()> {
704        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
705        let line = "x".repeat(100);
706        let content: String = (1..=1500)
707            .map(|i| format!("{i}: {line}"))
708            .collect::<Vec<_>>()
709            .join("\n");
710        fs.write_file("huge.txt", &content).await?;
711
712        let tool = create_test_tool(fs, AgentCapabilities::full_access());
713        let result = tool
714            .execute(&tool_ctx(), json!({"path": "/workspace/huge.txt"}))
715            .await?;
716
717        assert!(result.success);
718        assert!(result.output.contains("File too large to read at once"));
719        assert!(result.output.contains("Total lines: 1500"));
720        assert!(result.output.contains("offset"));
721        assert!(result.output.contains("limit"));
722        Ok(())
723    }
724
725    #[tokio::test]
726    async fn test_read_large_file_with_explicit_limit_bypasses_check() -> anyhow::Result<()> {
727        let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
728        let line = "x".repeat(100);
729        let content: String = (1..=1500)
730            .map(|i| format!("{i}: {line}"))
731            .collect::<Vec<_>>()
732            .join("\n");
733        fs.write_file("huge.txt", &content).await?;
734
735        let tool = create_test_tool(fs, AgentCapabilities::full_access());
736        let result = tool
737            .execute(
738                &tool_ctx(),
739                json!({"path": "/workspace/huge.txt", "offset": 1, "limit": 10}),
740            )
741            .await?;
742
743        assert!(result.success);
744        assert!(result.output.contains("Showing lines 1-10 of 1500 total"));
745        assert!(!result.output.contains("File too large"));
746        Ok(())
747    }
748
749    #[test]
750    fn test_estimate_lines_for_tokens() {
751        let long = "x".repeat(100);
752        let lines: Vec<&str> = vec!["short line", "another short line", &long];
753
754        let count = estimate_lines_for_tokens(&lines, 10);
755        assert_eq!(count, 2);
756
757        let count = estimate_lines_for_tokens(&lines, 1);
758        assert_eq!(count, 1);
759    }
760}