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
10const MAX_LINE_LENGTH: usize = 500;
12
13const 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 #[serde(
35 default = "defaults::offset",
36 deserialize_with = "super::deserialize_usize_from_string_or_int"
37 )]
38 offset: usize,
39 #[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 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 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 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 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
211fn 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 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}