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