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.
4//!
5//! Divergences from TS (documented): the TS supports an injected `imageProcessor`
6//! (resize/convert). The v1 Rust port ships no processor — BMP images yield an
7//! "omitted" text notice (matching the TS no-processor path), and other supported
8//! images are base64-encoded inline. The `ReadImageProcessor` trait seam is kept
9//! for a future impl.
10
11use 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/// Input for the read tool. Mirrors TS `ReadToolInput`. `offset` is 1-indexed.
33#[derive(Debug, Clone, JsonSchema, Deserialize)]
34pub struct ReadInput {
35    /// Path to the file to read (relative or absolute).
36    pub path: String,
37    /// Line number to start reading from (1-indexed).
38    #[serde(default)]
39    pub offset: Option<u32>,
40    /// Maximum number of lines to read.
41    #[serde(default)]
42    pub limit: Option<u32>,
43}
44
45/// Structured details for the read tool. Mirrors TS `ReadToolDetails`.
46#[derive(Debug, Clone, serde::Serialize)]
47pub struct ReadToolDetails {
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub truncation: Option<TruncationResult>,
50}
51
52/// Options for `create_read_tool`. Mirrors TS `ReadToolOptions`. The Rust port
53/// keeps the `auto_resize_images` flag for forward-compat but has no processor.
54#[derive(Debug, Clone, Default)]
55pub struct ReadToolOptions {
56    /// Whether an injected image processor should resize images. Default true.
57    pub auto_resize_images: bool,
58}
59
60/// Optional image-conversion seam (v1 ships no impl). Mirrors TS
61/// `ReadImageProcessor`.
62pub 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
71/// Result of a successful [`ReadImageProcessor::process`].
72pub struct ProcessedImage {
73    pub data: String,
74    pub mime_type: String,
75    pub hints: Vec<String>,
76}
77
78/// The built-in `read` tool. Holds an `Arc<dyn ExecutionEnv>` (read-only view)
79/// and an optional image processor.
80pub 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
105/// Build the `read` tool from a context. Mirrors TS `createReadTool`.
106pub 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        // Image path.
147        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        // Text path.
157        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        // Mirror TS: `textContent.split("\n")` keeps a trailing "" when the
161        // content ends with '\n'; the line-count + slice math below uses the raw
162        // split (NOT `split_lines_for_counting`), so we keep it as-is.
163        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
255/// Build the image-path result, honoring an optional processor. Mirrors the TS
256/// image branch.
257fn 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    // No processor — BMP is omitted; others are inlined.
299    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
322/// Map a `FileError` into an `AgentError::Tool`, mirroring the TS `getOrThrow`
323/// surface (the message carries the path + code).
324fn file_err_to_agent(e: crate::error::FileError) -> AgentError {
325    AgentError::Tool(e.to_string())
326}