steer_tools/tools/
view.rs1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use steer_macros::tool;
5use thiserror::Error;
6use tokio::fs::File;
7use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
8
9use crate::result::FileContentResult;
10use crate::{ExecutionContext, ToolError};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct ViewParams {
14 pub file_path: String,
16 pub offset: Option<u64>,
18 pub limit: Option<u64>,
20}
21
22#[derive(Error, Debug)]
23enum ViewError {
24 #[error("Failed to open file '{path}': {source}")]
25 FileOpen {
26 path: String,
27 #[source]
28 source: std::io::Error,
29 },
30 #[error("Failed to get file metadata for '{path}': {source}")]
31 Metadata {
32 path: String,
33 #[source]
34 source: std::io::Error,
35 },
36 #[error("File read cancelled")]
37 Cancelled,
38 #[error("Error reading file line by line: {source}")]
39 ReadLine {
40 #[source]
41 source: std::io::Error,
42 },
43 #[error("Error reading file: {source}")]
44 Read {
45 #[source]
46 source: std::io::Error,
47 },
48}
49
50const MAX_READ_BYTES: usize = 50 * 1024; const MAX_LINE_LENGTH: usize = 2000; tool! {
54 ViewTool {
55 params: ViewParams,
56 output: FileContentResult,
57 variant: FileContent,
58 description: format!(r#"Reads a file from the local filesystem. The file_path parameter must be an absolute path, not a relative path.
59By default, it reads up to 2000 lines starting from the beginning of the file. You can optionally specify a line offset and limit
60(especially handy for long files), but it's recommended to read the whole file by not providing these parameters.
61Any lines longer than {} characters will be truncated."#, MAX_LINE_LENGTH),
62 name: "read_file",
63 require_approval: false
64 }
65
66 async fn run(
67 _tool: &ViewTool,
68 params: ViewParams,
69 context: &ExecutionContext,
70 ) -> Result<FileContentResult, ToolError> {
71 if context.is_cancelled() {
72 return Err(ToolError::Cancelled(VIEW_TOOL_NAME.to_string()));
73 }
74
75 let abs_path = if Path::new(¶ms.file_path).is_absolute() {
77 params.file_path.clone()
78 } else {
79 context.working_directory.join(¶ms.file_path)
80 .to_string_lossy()
81 .to_string()
82 };
83
84 view_file_internal(
85 &abs_path,
86 params.offset.map(|v| v as usize),
87 params.limit.map(|v| v as usize),
88 context,
89 )
90 .await
91 .map_err(|e| ToolError::io(VIEW_TOOL_NAME, e.to_string()))
92 }
93}
94
95async fn view_file_internal(
96 file_path: &str,
97 offset: Option<usize>,
98 limit: Option<usize>,
99 context: &ExecutionContext,
100) -> Result<FileContentResult, ViewError> {
101 let mut file = File::open(file_path)
102 .await
103 .map_err(|e| ViewError::FileOpen {
104 path: file_path.to_string(),
105 source: e,
106 })?;
107
108 let file_size = file
109 .metadata()
110 .await
111 .map_err(|e| ViewError::Metadata {
112 path: file_path.to_string(),
113 source: e,
114 })?
115 .len();
116
117 let mut buffer = Vec::new();
118 let start_line = offset.unwrap_or(1).max(1); let line_limit = limit;
120 #[allow(unused_assignments)]
121 let mut total_lines = 0;
122 let mut truncated = false;
123
124 if start_line > 1 || line_limit.is_some() {
125 let mut reader = BufReader::new(file);
127 let mut current_line_num = 1;
128 let mut lines_read = 0;
129 let mut lines = Vec::new();
130
131 loop {
132 if context.is_cancelled() {
134 return Err(ViewError::Cancelled);
135 }
136
137 let mut line = String::new();
138 match reader.read_line(&mut line).await {
139 Ok(0) => break, Ok(_) => {
141 if current_line_num >= start_line {
142 if line.len() > MAX_LINE_LENGTH {
144 line.truncate(MAX_LINE_LENGTH);
145 line.push_str("... [line truncated]");
146 }
147 lines.push(line.trim_end().to_string()); lines_read += 1;
149 if line_limit.is_some_and(|l| lines_read >= l) {
150 truncated = true;
151 break; }
153 }
154 current_line_num += 1;
155 }
156 Err(e) => {
157 return Err(ViewError::ReadLine { source: e });
158 }
159 }
160 }
161
162 total_lines = lines.len();
164 let numbered_lines: Vec<String> = lines
165 .into_iter()
166 .enumerate()
167 .map(|(i, line)| format!("{:5}\t{}", start_line + i, line))
168 .collect();
169
170 buffer = numbered_lines.join("\n").into_bytes();
171 } else {
172 let read_size = std::cmp::min(file_size as usize, MAX_READ_BYTES);
174 buffer.resize(read_size, 0);
175 let mut bytes_read = 0;
176 while bytes_read < read_size {
177 if context.is_cancelled() {
179 return Err(ViewError::Cancelled);
180 }
181 let n = file
182 .read(&mut buffer[bytes_read..])
183 .await
184 .map_err(|e| ViewError::Read { source: e })?;
185 if n == 0 {
186 break; }
188 bytes_read += n;
189 }
190 buffer.truncate(bytes_read);
191
192 let content = String::from_utf8_lossy(&buffer);
194 let lines: Vec<&str> = content.lines().collect();
195 total_lines = lines.len();
196 truncated = file_size as usize > MAX_READ_BYTES;
197 let numbered_lines: Vec<String> = lines
198 .into_iter()
199 .enumerate()
200 .map(|(i, line)| {
201 let truncated_line = if line.len() > MAX_LINE_LENGTH {
203 format!("{}... [line truncated]", &line[..MAX_LINE_LENGTH])
204 } else {
205 line.to_string()
206 };
207 format!("{:5}\t{}", i + 1, truncated_line)
208 })
209 .collect();
210
211 buffer = numbered_lines.join("\n").into_bytes();
212 }
213
214 let content = String::from_utf8_lossy(&buffer).to_string();
216 Ok(FileContentResult {
217 content,
218 file_path: file_path.to_string(),
219 line_count: total_lines,
220 truncated,
221 })
222}