1use std::sync::Arc;
12
13use async_trait::async_trait;
14use rpi_agent::agent_tool::AgentTool;
15use rpi_agent::error::AgentError;
16use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
17use rpi_ai::types::{
18 ConstrainedSamplingConfig, ConstrainedStrictness, ImageContent, ImageContentType, Tool,
19};
20use schemars::JsonSchema;
21use serde::Deserialize;
22use tokio_util::sync::CancellationToken;
23
24use crate::env::ExecutionEnv;
25use crate::image::{detect_supported_image_mime_type, encode_base64};
26use crate::path_utils::resolve_read_tool_path;
27use crate::tools::tool_context::ExecutionToolContext;
28use crate::truncate::{
29 format_size, truncate_head, TruncationOptions, TruncationResult, DEFAULT_MAX_BYTES,
30 DEFAULT_MAX_LINES,
31};
32#[derive(Debug, Clone, JsonSchema, Deserialize)]
34pub struct ReadInput {
35 pub path: String,
37 #[serde(default)]
39 pub offset: Option<u32>,
40 #[serde(default)]
42 pub limit: Option<u32>,
43}
44
45#[derive(Debug, Clone, serde::Serialize)]
47pub struct ReadToolDetails {
48 #[serde(skip_serializing_if = "Option::is_none")]
49 pub truncation: Option<TruncationResult>,
50}
51
52#[derive(Debug, Clone, Default)]
55pub struct ReadToolOptions {
56 pub auto_resize_images: bool,
58}
59
60pub trait ReadImageProcessor: Send + Sync {
63 fn process(
64 &self,
65 bytes: &[u8],
66 mime_type: &str,
67 auto_resize: bool,
68 ) -> Result<ProcessedImage, String>;
69}
70
71pub struct ProcessedImage {
73 pub data: String,
74 pub mime_type: String,
75 pub hints: Vec<String>,
76}
77
78pub struct ReadTool {
81 schema: Tool,
82 env: Arc<dyn ExecutionEnv>,
83 options: ReadToolOptions,
84 image_processor: Option<Arc<dyn ReadImageProcessor>>,
85}
86
87impl ReadTool {
88 fn schema() -> Tool {
89 let params = schemars::schema_for!(ReadInput);
90 Tool {
91 name: "read".to_string(),
92 description: format!(
93 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). \
94 Images are sent as attachments. For text files, output is truncated to {DEFAULT_MAX_LINES} \
95 lines or {kb}KB (whichever is hit first). Use offset/limit for large files. When you need \
96 the full file, continue with offset until complete.",
97 kb = DEFAULT_MAX_BYTES / 1024
98 ),
99 parameters: rpi_ai::types::Schema::new(serde_json::to_value(params).unwrap_or_default()),
100 constrained_sampling: Some(ConstrainedSamplingConfig::JsonSchema { strict: ConstrainedStrictness::Prefer }),
101 }
102 }
103}
104
105pub fn create_read_tool(
107 context: &ExecutionToolContext,
108 options: Option<ReadToolOptions>,
109) -> Arc<dyn AgentTool> {
110 Arc::new(ReadTool {
111 schema: ReadTool::schema(),
112 env: context.env().clone(),
113 options: options.unwrap_or_default(),
114 image_processor: None,
115 })
116}
117
118#[async_trait]
119impl AgentTool for ReadTool {
120 fn schema(&self) -> &Tool {
121 &self.schema
122 }
123 fn label(&self) -> &str {
124 "read"
125 }
126
127 async fn execute(
128 &self,
129 _tool_call_id: &str,
130 params: serde_json::Value,
131 signal: CancellationToken,
132 _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
133 ) -> Result<AgentToolResult, AgentError> {
134 let input: ReadInput = serde_json::from_value(params)
135 .map_err(|e| AgentError::Validation(format!("read input invalid: {e}")))?;
136
137 let abs = resolve_read_tool_path(&*self.env, &input.path, Some(&signal))
138 .await
139 .map_err(file_err_to_agent)?;
140 let bytes = self
141 .env
142 .read_binary_file(&abs, Some(&signal))
143 .await
144 .map_err(file_err_to_agent)?;
145
146 if let Some(mime) = detect_supported_image_mime_type(&bytes) {
148 return Ok(image_result(
149 mime,
150 &bytes,
151 &self.options,
152 self.image_processor.as_deref(),
153 ));
154 }
155
156 let text_content = String::from_utf8(bytes)
158 .map_err(|e| AgentError::Tool(format!("read: invalid utf-8 in {}: {e}", input.path)))?;
159 let all_lines: Vec<&str> = text_content.split('\n').collect();
160 let total_file_lines = all_lines.len();
164 let start_line = input
165 .offset
166 .map(|o| (o.saturating_sub(1)) as usize)
167 .unwrap_or(0);
168 let start_line_display = start_line + 1;
169 if start_line >= all_lines.len() {
170 return Err(AgentError::Tool(format!(
171 "Offset {} is beyond end of file ({} lines total)",
172 input.offset.unwrap_or(1),
173 all_lines.len()
174 )));
175 }
176
177 let (selected_content, user_limited_lines): (String, Option<usize>) =
178 if let Some(limit) = input.limit {
179 let end_line = std::cmp::min(start_line + limit as usize, all_lines.len());
180 (
181 all_lines[start_line..end_line].join("\n"),
182 Some(end_line - start_line),
183 )
184 } else {
185 (all_lines[start_line..].join("\n"), None)
186 };
187
188 let truncation = truncate_head(&selected_content, TruncationOptions::default());
189 let (output_text, details): (String, Option<TruncationResult>) = if truncation
190 .first_line_exceeds_limit
191 {
192 let first_line_size = format_size(all_lines[start_line].len());
193 (
194 format!(
195 "[Line {start_line_display} is {first_line_size}, exceeds {} limit. \
196 Use bash: sed -n '{start_line_display}p' {} | head -c {DEFAULT_MAX_BYTES}]",
197 format_size(DEFAULT_MAX_BYTES),
198 input.path
199 ),
200 Some(truncation),
201 )
202 } else if truncation.truncated {
203 let end_line_display = start_line_display + truncation.output_lines - 1;
204 let next_offset = end_line_display + 1;
205 let mut text = truncation.content.clone();
206 match truncation.truncated_by {
207 Some(crate::truncate::TruncationLimit::Lines) => {
208 text.push_str(&format!(
209 "\n\n[Showing lines {start_line_display}-{end_line_display} of \
210 {total_file_lines}. Use offset={next_offset} to continue.]"
211 ));
212 }
213 _ => {
214 text.push_str(&format!(
215 "\n\n[Showing lines {start_line_display}-{end_line_display} of \
216 {total_file_lines} ({} limit). Use offset={next_offset} to continue.]",
217 format_size(DEFAULT_MAX_BYTES)
218 ));
219 }
220 }
221 (text, Some(truncation))
222 } else if let Some(ull) = user_limited_lines {
223 if start_line + ull < all_lines.len() {
224 let remaining = all_lines.len() - (start_line + ull);
225 let next_offset = start_line + ull + 1;
226 (
227 format!(
228 "{}\n\n[{remaining} more lines in file. Use offset={next_offset} to continue.]",
229 truncation.content
230 ),
231 None,
232 )
233 } else {
234 (truncation.content, None)
235 }
236 } else {
237 (truncation.content, None)
238 };
239
240 let details_value = match details {
241 Some(t) => serde_json::to_value(ReadToolDetails {
242 truncation: Some(t),
243 })
244 .unwrap_or(serde_json::Value::Null),
245 None => serde_json::Value::Null,
246 };
247 Ok(AgentToolResult {
248 content: vec![TextContentOrImage::text(output_text)],
249 details: details_value,
250 ..Default::default()
251 })
252 }
253}
254
255fn image_result(
258 mime: &str,
259 bytes: &[u8],
260 options: &ReadToolOptions,
261 processor: Option<&dyn ReadImageProcessor>,
262) -> AgentToolResult {
263 if let Some(proc) = processor {
264 match proc.process(bytes, mime, options.auto_resize_images) {
265 Ok(p) => {
266 let hints = if p.hints.is_empty() {
267 String::new()
268 } else {
269 format!("\n{}", p.hints.join("\n"))
270 };
271 return AgentToolResult {
272 content: vec![
273 TextContentOrImage::text(format!(
274 "Read image file [{}]{}",
275 p.mime_type, hints
276 )),
277 TextContentOrImage::Image(ImageContent {
278 kind: ImageContentType,
279 data: p.data,
280 mime_type: p.mime_type,
281 }),
282 ],
283 details: serde_json::Value::Null,
284 ..Default::default()
285 };
286 }
287 Err(message) => {
288 return AgentToolResult {
289 content: vec![TextContentOrImage::text(format!(
290 "Read image file [{mime}]\n{message}"
291 ))],
292 details: serde_json::Value::Null,
293 ..Default::default()
294 };
295 }
296 }
297 }
298 if mime == "image/bmp" {
300 return AgentToolResult {
301 content: vec![TextContentOrImage::text(
302 "Read image file [image/bmp]\n[Image omitted: configure an imageProcessor to convert BMP images.]",
303 )],
304 details: serde_json::Value::Null,
305 ..Default::default()
306 };
307 }
308 AgentToolResult {
309 content: vec![
310 TextContentOrImage::text(format!("Read image file [{mime}]")),
311 TextContentOrImage::Image(ImageContent {
312 kind: ImageContentType,
313 data: encode_base64(bytes),
314 mime_type: mime.to_string(),
315 }),
316 ],
317 details: serde_json::Value::Null,
318 ..Default::default()
319 }
320}
321
322fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
325 AgentError::Tool(e.to_string())
326}