1use 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
18const MAX_LINE_CHARS: usize = 2000;
20
21#[derive(Clone, Debug)]
26pub struct Read {
27 pub workspace: Arc<dyn Workspace>,
29 pub base: Option<PathBuf>,
32 pub confine: bool,
35 pub seen: Seen,
38}
39
40impl Default for Read {
41 fn default() -> Self {
43 Self {
44 workspace: Arc::new(Local),
45 base: None,
46 confine: false,
47 seen: Seen::default(),
48 }
49 }
50}
51
52const SEEN_TEXT_BYTES: usize = 8 * 1024 * 1024;
54
55#[derive(Clone, Debug, Default)]
65pub struct Seen(Arc<Mutex<Files>>);
66
67#[derive(Debug, Default)]
69struct Files {
70 hashes: HashMap<PathBuf, ContentHash>,
71 texts: VecDeque<(PathBuf, ContentHash, Arc<str>)>,
73 bytes: usize,
75}
76
77impl Files {
78 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 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 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 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 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 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#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
146#[serde(deny_unknown_fields)]
147pub struct ReadArgs {
148 pub path: String,
150 pub offset: Option<usize>,
152 pub limit: Option<usize>,
154}
155
156impl ReadArgs {
157 pub fn new(path: impl Into<String>) -> Self {
159 Self {
160 path: path.into(),
161 offset: None,
162 limit: None,
163 }
164 }
165
166 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 pub fn show(&self, text: &str) -> String {
184 self.shown(text).0
185 }
186
187 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 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 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
286fn 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
298pub fn show_lines(text: &str, first: usize, limit: usize, max_bytes: usize) -> String {
306 lines_from(text, first, limit, max_bytes).0
307}
308
309const MIN_LINE_CHARS: usize = 100;
311const CUT_MARK: &str = " […]";
313
314fn 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
321fn 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 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
391fn 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 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;