Skip to main content

rpi_tools/tools/
read.rs

1//! Mirrors `packages/agent/src/harness/tools/read.ts` — the built-in `read`
2//! `AgentTool`. Reads a file (text or image), applies head-truncation with a
3//! continuation hint, and detects supported image MIME types. An optional
4//! `ReadImageProcessor` seam supports resize/convert implementations; without
5//! one, BMP images yield an omitted notice and other formats are inlined.
6
7use 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/// Input for the read tool. Mirrors TS `ReadToolInput`. `offset` is 1-indexed.
29#[derive(Debug, Clone, JsonSchema, Deserialize)]
30pub struct ReadInput {
31    /// Path to the file to read (relative or absolute).
32    pub path: String,
33    /// Line number to start reading from (1-indexed).
34    #[serde(default)]
35    pub offset: Option<u32>,
36    /// Maximum number of lines to read.
37    #[serde(default)]
38    pub limit: Option<u32>,
39}
40
41/// Structured details for the read tool. Mirrors TS `ReadToolDetails`.
42#[derive(Debug, Clone, serde::Serialize)]
43pub struct ReadToolDetails {
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub truncation: Option<TruncationResult>,
46}
47
48/// Options for `create_read_tool`. Mirrors TS `ReadToolOptions`.
49pub struct ReadToolOptions {
50    /// Whether an injected image processor should resize images. Default true.
51    pub auto_resize_images: bool,
52    /// Optional image conversion/resizing implementation.
53    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
83/// Optional image-conversion seam. Mirrors TS `ReadImageProcessor`.
84pub 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
93/// Result of a successful [`ReadImageProcessor::process`].
94pub struct ProcessedImage {
95    pub data: String,
96    pub mime_type: String,
97    pub hints: Vec<String>,
98}
99
100/// The built-in `read` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view)
101/// and an optional image processor.
102pub 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
127/// Build the `read` tool from a context. Mirrors TS `createReadTool`.
128pub 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        // Image path.
171        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        // Text path.
181        let text_content = String::from_utf8_lossy(&bytes).into_owned();
182        let all_lines: Vec<&str> = text_content.split('\n').collect();
183        // Mirror TS: `textContent.split("\n")` keeps a trailing "" when the
184        // content ends with '\n'; the line-count + slice math below uses the raw
185        // split (NOT `split_lines_for_counting`), so we keep it as-is.
186        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
278/// Build the image-path result, honoring an optional processor. Mirrors the TS
279/// image branch.
280fn 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    // No processor — BMP is omitted; others are inlined.
322    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
345/// Map a `FileError` into an `AgentError::Tool`, mirroring the TS `getOrThrow`
346/// surface (the message carries the path + code).
347fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
348    AgentError::Tool(e.to_string())
349}