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