Skip to main content

rig_tools/
read.rs

1//! The `read` tool.
2
3use std::collections::{HashMap, VecDeque};
4use std::fmt::Write as _;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex, PoisonError};
7
8use rig_core::tool::{
9    ContentHash, FileRead, FilesRead, KeptEnd, LinesShown, OutputCut, Scope, Tool, ToolContext,
10    ToolExecutionError, Touches, args_schema, content_hash,
11};
12use schemars::JsonSchema;
13use serde::Deserialize;
14
15use crate::workspace::{Local, Workspace, text_error};
16use crate::{MAX_BYTES, MAX_FILE_BYTES, MAX_LINES, blocking, clip};
17
18/// Characters of one line shown; the rest of a longer line is cut.
19const MAX_LINE_CHARS: usize = 2000;
20
21/// Reads a text file's lines as they are. Publishes the file it read
22/// ([`FilesRead`]), by its [resolved](Workspace) path, with the
23/// hash of the bytes it read and the lines it showed, and, when lines after
24/// those were left out, [`OutputCut`].
25#[derive(Clone, Debug)]
26pub struct Read {
27    /// Where the file is read.
28    pub workspace: Arc<dyn Workspace>,
29    /// The directory a relative path is read from; `None` is the
30    /// workspace's working directory.
31    pub base: Option<PathBuf>,
32    /// Whether a path that resolves outside `base` is refused, before
33    /// anything is read.
34    pub confine: bool,
35    /// Where each file read is noted, for an [`Edit`](crate::Edit) given
36    /// the same to tell whether a file changed since.
37    pub seen: Seen,
38}
39
40impl Default for Read {
41    /// Reads this machine's files, from this process's working directory.
42    fn default() -> Self {
43        Self {
44            workspace: Arc::new(Local),
45            base: None,
46            confine: false,
47            seen: Seen::default(),
48        }
49    }
50}
51
52/// Most bytes of file text a [`Seen`] keeps, for the files seen last.
53const SEEN_TEXT_BYTES: usize = 8 * 1024 * 1024;
54
55/// The hash ([`content_hash`]) of each file's text as [`Read`] last showed
56/// it or [`Edit`](crate::Edit) or [`Write`](crate::Write) last left it, by
57/// resolved path; shared by its clones. With it, an edit whose `old_text`
58/// is not found says whether the file changed since, and so needs reading
59/// again.
60///
61/// It also keeps the whole text of the files last seen whole, up to 8 MB
62/// of them, so an edit starts from it and only writes, if the file still
63/// holds that text ([`Workspace::write_if`]).
64#[derive(Clone, Debug, Default)]
65pub struct Seen(Arc<Mutex<Files>>);
66
67/// What a [`Seen`] holds.
68#[derive(Debug, Default)]
69struct Files {
70    hashes: HashMap<PathBuf, ContentHash>,
71    /// The texts kept, oldest first, each with its hash.
72    texts: VecDeque<(PathBuf, ContentHash, Arc<str>)>,
73    /// The bytes of `texts`.
74    bytes: usize,
75}
76
77impl Files {
78    /// Drops the text kept of `path`, if one is.
79    fn drop_text(&mut self, path: &Path) {
80        let Self { texts, bytes, .. } = self;
81        texts.retain(|(kept, _, text)| {
82            let same = kept == path;
83            if same {
84                *bytes -= text.len();
85            }
86            !same
87        });
88    }
89}
90
91impl Seen {
92    /// Notes that the file `path` held the text with `hash`.
93    pub fn saw(&self, path: &Path, hash: ContentHash) {
94        let mut seen = self.0.lock().unwrap_or_else(PoisonError::into_inner);
95        seen.hashes.insert(path.to_path_buf(), hash);
96    }
97
98    /// Notes that the file `path` held `text`, all of it: its hash, and the
99    /// text itself, which a text seen longer ago may make room for.
100    pub fn saw_whole(&self, path: &Path, text: &str) {
101        let hash = content_hash(text.as_bytes());
102        let mut seen = self.0.lock().unwrap_or_else(PoisonError::into_inner);
103        seen.hashes.insert(path.to_path_buf(), hash.clone());
104        seen.drop_text(path);
105        let Files { texts, bytes, .. } = &mut *seen;
106        if text.len() > SEEN_TEXT_BYTES {
107            return;
108        }
109        while *bytes + text.len() > SEEN_TEXT_BYTES
110            && let Some((_, _, old)) = texts.pop_front()
111        {
112            *bytes -= old.len();
113        }
114        *bytes += text.len();
115        texts.push_back((path.to_path_buf(), hash, Arc::from(text)));
116    }
117
118    /// Forgets what `path` held, as after a write of it that may or may
119    /// not have happened, so the next edit starts from the file itself.
120    pub fn forget(&self, path: &Path) {
121        let mut seen = self.0.lock().unwrap_or_else(PoisonError::into_inner);
122        seen.hashes.remove(path);
123        seen.drop_text(path);
124    }
125
126    /// The hash of the text `path` last held when it was seen, if it was.
127    pub fn hash(&self, path: &Path) -> Option<ContentHash> {
128        let seen = self.0.lock().unwrap_or_else(PoisonError::into_inner);
129        seen.hashes.get(path).cloned()
130    }
131
132    /// The whole text `path` held when it was last seen, and its hash, if
133    /// it was seen whole then and the text is still kept.
134    pub fn text(&self, path: &Path) -> Option<(ContentHash, Arc<str>)> {
135        let seen = self.0.lock().unwrap_or_else(PoisonError::into_inner);
136        let last = seen.hashes.get(path)?;
137        seen.texts
138            .iter()
139            .find(|(kept, hash, _)| kept == path && hash == last)
140            .map(|(_, hash, text)| (hash.clone(), text.clone()))
141    }
142}
143
144/// Arguments of [`Read`].
145#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct ReadArgs {
148    /// The file to read.
149    pub path: String,
150    /// The first line to read, from 1.
151    pub offset: Option<usize>,
152    /// How many lines to read.
153    pub limit: Option<usize>,
154}
155
156impl ReadArgs {
157    /// Read `path` from its start.
158    pub fn new(path: impl Into<String>) -> Self {
159        Self {
160            path: path.into(),
161            offset: None,
162            limit: None,
163        }
164    }
165
166    /// The same read, of at most `limit` lines from line `offset` (from 1).
167    pub fn lines(mut self, offset: usize, limit: usize) -> Self {
168        self.offset = Some(offset);
169        self.limit = Some(limit);
170        self
171    }
172
173    /// What [`Read`] shows of `text`, the file's whole text, for this read:
174    /// its lines from `offset` (default 1), at most `limit` of them (default
175    /// and most [`MAX_LINES`]) and [`MAX_BYTES`], as [`show_lines`] shows them.
176    ///
177    /// ```
178    /// use rig_tools::ReadArgs;
179    ///
180    /// let shown = ReadArgs::new("a.txt").lines(2, 1).show("one\ntwo\nthree\n");
181    /// assert_eq!(shown, "two\n[1 more lines; read on with offset 3]\n");
182    /// ```
183    pub fn show(&self, text: &str) -> String {
184        self.shown(text).0
185    }
186
187    /// [`Self::show`], and which lines it shows: what [`Read`] publishes
188    /// in its [`FileRead`], for code that shows a file as `read` would and
189    /// records what it showed.
190    ///
191    /// ```
192    /// use rig_tools::ReadArgs;
193    ///
194    /// let (_, lines) = ReadArgs::new("a.txt").lines(2, 1).shown("one\ntwo\nthree\n");
195    /// assert_eq!((lines.first, lines.count, lines.total), (2, 1, 3));
196    /// ```
197    pub fn shown(&self, text: &str) -> (String, LinesShown) {
198        let (first, limit) = self.span();
199        lines_from(text, first, limit, MAX_BYTES)
200    }
201
202    /// The first line this read shows, from 1, and how many lines at most.
203    fn span(&self) -> (usize, usize) {
204        let first = self.offset.unwrap_or(1).max(1);
205        (first, self.limit.unwrap_or(MAX_LINES).min(MAX_LINES))
206    }
207}
208
209impl Tool for Read {
210    const NAME: &'static str = "read";
211    type Args = ReadArgs;
212    type Output = String;
213    type Error = ToolExecutionError;
214
215    fn description(&self) -> String {
216        format!(
217            "Read a regular text file of at most {} MB. Returns its lines as they are, at most \
218             {MAX_LINES} lines or {} KB (lines too long for that are cut shorter, and a note \
219             says so), then the offset to read on from if it stopped early; \
220             use offset (from 1) and limit for part of a file, and `rg -n` in `shell` to find a \
221             line. Use `read` to look at a file, not `cat`, `head` or `sed` in `shell`, and read \
222             several files at once by calling `read` several times in one reply.",
223            MAX_FILE_BYTES / (1024 * 1024),
224            MAX_BYTES / 1024
225        )
226    }
227
228    fn parameters(&self) -> serde_json::Value {
229        args_schema::<ReadArgs>()
230    }
231
232    fn annotations(&self) -> rig_core::completion::ToolAnnotations {
233        rig_core::completion::ToolAnnotations::default()
234            .with_read_only(true)
235            .with_open_world(false)
236    }
237
238    fn footprint(&self, args: &ReadArgs) -> Touches {
239        Touches::Reads(Scope::Paths(vec![
240            self.workspace.resolve(self.base.as_deref(), &args.path),
241        ]))
242    }
243
244    async fn call(
245        &self,
246        context: &mut ToolContext,
247        args: ReadArgs,
248    ) -> Result<String, ToolExecutionError> {
249        let path = self
250            .workspace
251            .place(self.base.as_deref(), self.confine, &args.path)?;
252        let shown = path.to_string_lossy().into_owned();
253        let workspace = self.workspace.clone();
254        let seen = self.seen.clone();
255        let (output, read) = blocking(move || {
256            let (first, limit) = args.span();
257            let excerpt = workspace
258                .excerpt(&path, first, limit)
259                .map_err(|error| text_error(&shown, error))?;
260            let asked = excerpt.text.lines().take(limit).collect();
261            let (output, lines) = show(asked, first, excerpt.total, MAX_BYTES);
262            let hash = excerpt.hash;
263            // A read that got the whole file leaves its text for an edit.
264            let whole = excerpt
265                .whole
266                .as_deref()
267                .or((first == 1 && excerpt.total <= limit).then_some(excerpt.text.as_str()))
268                .filter(|whole| content_hash(whole.as_bytes()) == hash);
269            match whole {
270                Some(whole) => seen.saw_whole(&path, whole),
271                None => seen.saw(&path, hash.clone()),
272            }
273            let read = FileRead::new(shown, hash).with_lines(lines);
274            Ok::<_, ToolExecutionError>((output, read))
275        })
276        .await?;
277        let cut = read.lines.and_then(cut_of);
278        context.insert_result(FilesRead(vec![read]))?;
279        if let Some(cut) = cut {
280            context.insert_result(cut)?;
281        }
282        Ok(output)
283    }
284}
285
286/// How a read that showed `lines` was cut: from its first line on, the
287/// lines after those it showed, which its last line names.
288fn cut_of(lines: LinesShown) -> Option<OutputCut> {
289    let from_first = lines.total.checked_sub(lines.first.saturating_sub(1))?;
290    (lines.count < from_first).then_some(OutputCut {
291        kept: KeptEnd::Start,
292        kept_lines: lines.count,
293        total_lines: from_first,
294        spill: None,
295    })
296}
297
298/// `text`'s lines from line `first` (counted from 1) as [`Read`] shows
299/// them: as they are, at most `limit` lines and about `max_bytes` bytes,
300/// and a last line saying how many lines were left out and the offset to
301/// read on from, or that `first` is past the end. Lines longer than a
302/// width are cut, marked `[…]`, and counted in a note: 2000 characters, or
303/// less when that lets the asked lines fit `max_bytes`, as in a file of
304/// long lines, but never below 100 characters.
305pub fn show_lines(text: &str, first: usize, limit: usize, max_bytes: usize) -> String {
306    lines_from(text, first, limit, max_bytes).0
307}
308
309/// The narrowest a line is cut to so that more lines fit.
310const MIN_LINE_CHARS: usize = 100;
311/// What marks a line that was cut.
312const CUT_MARK: &str = " […]";
313
314/// [`show_lines`], and which lines it shows.
315fn lines_from(text: &str, first: usize, limit: usize, max_bytes: usize) -> (String, LinesShown) {
316    let first = first.max(1);
317    let asked = text.lines().skip(first - 1).take(limit).collect();
318    show(asked, first, text.lines().count(), max_bytes)
319}
320
321/// [`lines_from`] given the lines `asked`, from line `first` (from 1) of
322/// a file of `total` lines.
323fn show(asked: Vec<&str>, first: usize, total: usize, max_bytes: usize) -> (String, LinesShown) {
324    let width = width(&asked, max_bytes);
325    let mut out = String::new();
326    let mut shown = 0;
327    let mut cut = 0;
328    for line in &asked {
329        let kept = clip(line, width);
330        let clipped = kept.len() < line.len();
331        let mark = if clipped { CUT_MARK.len() } else { 0 };
332        // The first line always shows, so a read always makes progress.
333        if shown > 0 && out.len() + kept.len() + mark + 1 > max_bytes {
334            break;
335        }
336        out.push_str(kept);
337        if clipped {
338            out.push_str(CUT_MARK);
339            cut += 1;
340        }
341        out.push('\n');
342        shown += 1;
343    }
344    let last = first - 1 + shown;
345    if cut > 0 {
346        let _ = writeln!(
347            out,
348            "[{cut} {} cut at {width} characters{}]",
349            if cut == 1 { "line was" } else { "lines were" },
350            if width < MAX_LINE_CHARS {
351                format!(
352                    " to fit {} KB; a smaller limit shows more of each",
353                    max_bytes / 1024
354                )
355            } else {
356                String::new()
357            }
358        );
359    }
360    if last < total {
361        let full = shown < asked.len();
362        let _ = writeln!(
363            out,
364            "[{} more lines; read on with offset {}{}]",
365            total - last,
366            last + 1,
367            if full {
368                format!(", as this read reached its {} KB", max_bytes / 1024)
369            } else {
370                String::new()
371            }
372        );
373    }
374    if total == 0 {
375        out.push_str("[empty file]\n");
376    } else if first > total {
377        out.push_str(&format!(
378            "[offset {first} is past the end: the file has {total} lines]\n"
379        ));
380    }
381    (
382        out,
383        LinesShown {
384            first,
385            count: shown,
386            total,
387        },
388    )
389}
390
391/// The width the lines of `asked` are cut at to be shown in `max_bytes`:
392/// [`MAX_LINE_CHARS`] when they fit so, else the widest of at least
393/// [`MIN_LINE_CHARS`] at which they do. When even that is too wide, the
394/// lines are cut at [`MIN_LINE_CHARS`] only if most of them are longer, so
395/// a file of ordinary lines with a few long ones keeps its lines whole and
396/// is read on in pieces.
397fn width(asked: &[&str], max_bytes: usize) -> usize {
398    let size = |width: usize| -> usize {
399        asked
400            .iter()
401            .map(|line| {
402                let kept = clip(line, width);
403                let mark = if kept.len() < line.len() {
404                    CUT_MARK.len()
405                } else {
406                    0
407                };
408                kept.len() + mark + 1
409            })
410            .sum()
411    };
412    if size(MAX_LINE_CHARS) <= max_bytes {
413        return MAX_LINE_CHARS;
414    }
415    if size(MIN_LINE_CHARS) > max_bytes {
416        let long = asked
417            .iter()
418            .filter(|line| line.chars().nth(MIN_LINE_CHARS).is_some())
419            .count();
420        return if long * 2 > asked.len() {
421            MIN_LINE_CHARS
422        } else {
423            MAX_LINE_CHARS
424        };
425    }
426    // The widest that fits: `low` always does, `high` never.
427    let (mut low, mut high) = (MIN_LINE_CHARS, MAX_LINE_CHARS);
428    while high - low > 1 {
429        let middle = low + (high - low) / 2;
430        if size(middle) <= max_bytes {
431            low = middle;
432        } else {
433            high = middle;
434        }
435    }
436    low
437}
438
439#[cfg(test)]
440mod tests;