telos_agent/tools/builtin/
file_read.rs1use async_trait::async_trait;
4use serde_json::{Value, json};
5
6use crate::error::AgentError;
7use crate::tools::api::FileReadRecord;
8use crate::tools::api::{Tool, ToolContext, ToolDefinition, ToolOutput};
9
10use super::{
11 canonicalize_within_cwd, modified_timestamp_ms, optional_usize_any, required_string_any,
12 resolve_workspace_path,
13};
14
15pub struct FileReadTool;
17
18#[async_trait]
19impl Tool for FileReadTool {
20 fn definition(&self) -> ToolDefinition {
21 ToolDefinition {
22 name: "Read".into(),
23 description: "Read a UTF-8 text file. Use this before editing an existing file. \
24The returned `content` prefixes each line with its 1-indexed line number for display; \
25provide the original file text (without line-number prefixes) to `Edit` when editing."
26 .into(),
27 input_schema: json!({
28 "type": "object",
29 "properties": {
30 "file_path": { "type": "string", "description": "Path to the file to read, absolute or relative to cwd." },
31 "offset": { "type": "integer", "description": "1-indexed line number to start reading from." },
32 "limit": { "type": "integer", "description": "Maximum number of lines to read." }
33 },
34 "required": ["file_path"]
35 }),
36 }
37 }
38
39 fn prompt_text(&self) -> Option<&'static str> {
40 Some(
41 "Use Read to inspect UTF-8 text files. Always read a file before editing it. \
42Use `offset`/`limit` for large files. Content includes 1-indexed line numbers; provide original text to Edit.",
43 )
44 }
45
46 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
47 required_string_any(arguments, &["file_path", "path"]).map(|_| ())
48 }
49
50 fn is_concurrency_safe(&self, _arguments: &Value) -> bool {
51 true
52 }
53
54 async fn invoke(
55 &self,
56 arguments: Value,
57 context: ToolContext,
58 ) -> Result<ToolOutput, AgentError> {
59 let input_path = required_string_any(&arguments, &["file_path", "path"])?;
60 let path = resolve_workspace_path(&context.cwd, input_path)?;
61 let path = canonicalize_within_cwd(&context.cwd, &path).await?;
62 let metadata =
63 tokio::fs::metadata(&path).await.map_err(|err| AgentError::ToolExecution {
64 tool: "Read".into(),
65 message: friendly_file_error(&context.cwd, &path, err),
66 })?;
67 if metadata.len() > context.max_file_read_bytes as u64 {
68 return Err(AgentError::ToolExecution {
69 tool: "Read".into(),
70 message: format!(
71 "file exceeds maximum read size ({} bytes): {}",
72 context.max_file_read_bytes,
73 path.display()
74 ),
75 });
76 }
77 let bytes = tokio::fs::read(&path).await.map_err(|err| AgentError::ToolExecution {
78 tool: "Read".into(),
79 message: friendly_file_error(&context.cwd, &path, err),
80 })?;
81 if bytes.contains(&0) {
82 return Err(AgentError::ToolExecution {
83 tool: "Read".into(),
84 message: "file appears to be binary; Read only supports UTF-8 text in telos_agent"
85 .into(),
86 });
87 }
88 let content = String::from_utf8(bytes).map_err(|_| AgentError::ToolExecution {
89 tool: "Read".into(),
90 message: "file is not valid UTF-8 text".into(),
91 })?;
92 let timestamp_ms = modified_timestamp_ms(&path).await?;
93 let start_line =
95 optional_usize_any(&arguments, &["offset", "start_line"]).unwrap_or(1).max(1);
96 let max_lines = optional_usize_any(&arguments, &["limit", "max_lines"]);
97 let lines = content
100 .lines()
101 .enumerate()
102 .skip(start_line.saturating_sub(1))
103 .take(max_lines.unwrap_or(usize::MAX))
104 .map(|(idx, line)| format!("{}: {}", idx + 1, line))
105 .collect::<Vec<_>>();
106 let line_count = content.lines().count();
107 let is_partial_view = start_line > 1 || max_lines.is_some_and(|limit| limit < line_count);
108 context.read_file_state.lock().await.insert(
109 path.clone(),
110 FileReadRecord {
111 content: content.clone(),
112 timestamp_ms,
113 is_partial_view,
114 offset: Some(start_line),
115 limit: max_lines,
116 },
117 );
118
119 let rendered = if content.is_empty() {
120 "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>".to_string()
121 } else if lines.is_empty() {
122 format!(
123 "<system-reminder>Warning: the file exists but is shorter than the provided offset ({start_line}). The file has {line_count} lines.</system-reminder>"
124 )
125 } else {
126 lines.join("\n")
127 };
128
129 Ok(ToolOutput::json(json!({
130 "file_path": input_path,
131 "path": path,
132 "content": rendered,
133 "start_line": start_line,
134 "total_lines": line_count,
135 })))
136 }
137}
138
139fn friendly_file_error(
140 cwd: &std::path::Path,
141 path: &std::path::Path,
142 err: std::io::Error,
143) -> String {
144 if err.kind() == std::io::ErrorKind::NotFound {
145 format!(
146 "File does not exist. Current working directory: {}. Requested path: {}",
147 cwd.display(),
148 path.display()
149 )
150 } else {
151 err.to_string()
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use serde_json::json;
159 use std::collections::HashMap;
160
161 fn ctx(cwd: std::path::PathBuf, max_bytes: usize) -> ToolContext {
162 ToolContext {
163 session_id: "test".into(),
164 turn_id: 1,
165 tool_call_id: None,
166 cwd,
167 env: HashMap::new(),
168 messages: std::sync::Arc::new(vec![]),
169 progress: None,
170 read_file_state: std::sync::Arc::new(tokio::sync::Mutex::new(HashMap::new())),
171 timeout: None,
172 max_file_read_bytes: max_bytes,
173 }
174 }
175
176 #[tokio::test]
177 async fn rejects_oversized_file() {
178 let dir = std::env::temp_dir().join("tiny_agent_read_size_test");
179 let _ = std::fs::remove_dir_all(&dir);
180 std::fs::create_dir_all(&dir).unwrap();
181 std::fs::write(dir.join("big.txt"), "x".repeat(100)).unwrap();
182
183 let tool = FileReadTool;
184 let result = tool.invoke(json!({ "file_path": "big.txt" }), ctx(dir.clone(), 50)).await;
185 assert!(matches!(result, Err(AgentError::ToolExecution { .. })));
186 assert!(
187 result.unwrap_err().to_string().contains("maximum read size"),
188 "error should mention maximum read size"
189 );
190
191 let _ = std::fs::remove_dir_all(&dir);
192 }
193}