Skip to main content

rig_core/tool/
facts.rs

1//! Facts a tool call publishes about what it did, beside the text the model
2//! reads: the files it read ([`FilesRead`]), the files it changed
3//! ([`FilesChanged`]), how a process it ran ended ([`ExitStatus`]) and
4//! whether its output was cut to fit ([`OutputCut`]). A
5//! file's [`ContentHash`] says which content the tool saw or left, so a
6//! host can tell whether a file changed since without reading it again; a
7//! change's [`FileState`]s also give each side's size and lines.
8//! They are [`ContextValue`]s a tool publishes with
9//! [`ToolContext::insert_result`](super::ToolContext::insert_result), so
10//! they reach the host in the call's [`ToolResultContext`](super::ToolResultContext)
11//! and the effect log, whatever the tool: a built-in one, a plugin's, or an
12//! MCP server's adapter. A host reads them back with
13//! [`ToolResultContext::get`](super::ToolResultContext::get).
14//!
15//! ```
16//! use rig_core::tool::{
17//!     ExitStatus, FileChange, FileRead, FilesChanged, FilesRead, ToolContext, content_hash,
18//! };
19//!
20//! let mut context = ToolContext::new();
21//! let readme = FileRead::new("/work/README.md", content_hash(b"# Work\n"));
22//! context.insert_result(FilesRead(vec![readme]))?;
23//! context.insert_result(FilesChanged(vec![FileChange::modified("/work/src/lib.rs", 3, 1)]))?;
24//! context.insert_result(ExitStatus::Exited(0))?;
25//! let published = context.result_context();
26//! assert_eq!(published.get::<ExitStatus>()?.map(|status| status.success()), Some(true));
27//! let changed = published.get::<FilesChanged>()?.unwrap_or_default();
28//! assert_eq!(changed.paths().collect::<Vec<_>>(), ["/work/src/lib.rs"]);
29//! let read = published.get::<FilesRead>()?.unwrap_or_default();
30//! assert_eq!(read.paths().collect::<Vec<_>>(), ["/work/README.md"]);
31//! assert_eq!(read.0.first().map(|file| &file.hash), Some(&content_hash(b"# Work\n")));
32//! # Ok::<(), rig_core::tool::ToolContextError>(())
33//! ```
34
35use std::fmt::{self, Write as _};
36
37use serde::{Deserialize, Serialize};
38
39use super::ContextValue;
40
41/// The SHA-256 of a file's bytes, as lowercase hex: which content a tool
42/// read or left ([`content_hash`]).
43#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
44#[serde(transparent)]
45pub struct ContentHash(pub String);
46
47/// The [`ContentHash`] of `bytes`.
48///
49/// ```
50/// use rig_core::tool::content_hash;
51///
52/// assert_eq!(
53///     content_hash(b"").0,
54///     "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
55/// );
56/// ```
57pub fn content_hash(bytes: &[u8]) -> ContentHash {
58    use sha2::Digest as _;
59    let digest = sha2::Sha256::digest(bytes);
60    ContentHash(digest.iter().map(|byte| format!("{byte:02x}")).collect())
61}
62
63/// The files a tool call read, in the order it read them, each once: a
64/// file it showed the model, or one it found matches in.
65#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
66#[serde(transparent)]
67pub struct FilesRead(pub Vec<FileRead>);
68
69impl ContextValue for FilesRead {
70    const KEY: &'static str = "files_read";
71}
72
73impl FilesRead {
74    /// The read files' paths, in order.
75    pub fn paths(&self) -> impl Iterator<Item = &str> {
76        self.0.iter().map(|file| file.path.as_str())
77    }
78}
79
80/// One file a tool call read.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct FileRead {
83    /// The file, as [`FileChange::path`] spells it.
84    pub path: String,
85    /// The hash of the bytes the tool read.
86    pub hash: ContentHash,
87    /// Which of its lines the tool showed, when it showed some of them
88    /// rather than matches in it.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub lines: Option<LinesShown>,
91}
92
93impl FileRead {
94    /// `path` was read, and held the content `hash` names.
95    pub fn new(path: impl Into<String>, hash: ContentHash) -> Self {
96        Self {
97            path: path.into(),
98            hash,
99            lines: None,
100        }
101    }
102
103    /// This read, having shown `lines` of the file.
104    #[must_use]
105    pub fn with_lines(mut self, lines: LinesShown) -> Self {
106        self.lines = Some(lines);
107        self
108    }
109}
110
111/// The lines of a file a tool showed: `count` lines from line `first`, of
112/// the `total` it has.
113///
114/// ```
115/// use rig_core::tool::LinesShown;
116///
117/// assert!(LinesShown { first: 1, count: 3, total: 3 }.whole());
118/// assert!(!LinesShown { first: 2, count: 2, total: 3 }.whole());
119/// ```
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
121pub struct LinesShown {
122    /// The first line shown, counted from 1.
123    pub first: usize,
124    /// How many lines were shown.
125    pub count: usize,
126    /// How many lines the file has.
127    pub total: usize,
128}
129
130impl LinesShown {
131    /// Whether every line of the file was shown.
132    pub fn whole(&self) -> bool {
133        self.first <= 1 && self.count >= self.total
134    }
135}
136
137/// The files a tool call changed, in the order it changed them.
138#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
139#[serde(transparent)]
140pub struct FilesChanged(pub Vec<FileChange>);
141
142impl ContextValue for FilesChanged {
143    const KEY: &'static str = "files_changed";
144}
145
146impl FilesChanged {
147    /// The changed files' paths, in order.
148    pub fn paths(&self) -> impl Iterator<Item = &str> {
149        self.0.iter().map(|change| change.path.as_str())
150    }
151}
152
153/// One file a tool call changed.
154#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
155pub struct FileChange {
156    /// The file: an absolute path when the tool knows the directory it ran
157    /// in, as rig-tools' file tools do, else as the call named it.
158    pub path: String,
159    /// What happened to it.
160    pub kind: ChangeKind,
161    /// Lines added.
162    pub added: usize,
163    /// Lines removed.
164    pub removed: usize,
165    /// The change as hunks, each line with its ending, with three lines of
166    /// context; none when the tool did not diff the file (such as one too
167    /// large to read). [`unified`](Self::unified) renders them as a diff.
168    #[serde(default, skip_serializing_if = "Vec::is_empty")]
169    pub hunks: Vec<Hunk>,
170    /// The file's text before the change, when the tool read it whole as
171    /// UTF-8 within its read limit; `None` for a created file.
172    /// [`after_text`](Self::after_text) is it with the hunks applied.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub before_text: Option<String>,
175    /// The file before the change, when the tool or a checkpoint around it
176    /// knows it; `None` for a created file.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub before: Option<FileState>,
179    /// The file the change left, when the tool knows it; `None` for a
180    /// deleted file.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub after: Option<FileState>,
183}
184
185/// A file on one side of a [`FileChange`]: as it was before the change, or
186/// as the change left it.
187///
188/// ```
189/// use rig_core::tool::{FileState, content_hash};
190///
191/// let state = FileState::of(b"one\ntwo");
192/// assert_eq!((state.bytes, state.lines), (7, Some(2)));
193/// assert_eq!(state.hash, Some(content_hash(b"one\ntwo")));
194/// ```
195#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
196pub struct FileState {
197    /// Its size in bytes.
198    pub bytes: u64,
199    /// Its lines, as [`str::lines`] counts them, when it was read (not
200    /// when it was too large to read).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub lines: Option<usize>,
203    /// The hash of its bytes, when it was read.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub hash: Option<ContentHash>,
206}
207
208impl FileState {
209    /// A file that held `bytes`, read whole.
210    pub fn of(bytes: &[u8]) -> Self {
211        let breaks = bytes.iter().filter(|byte| **byte == b'\n').count();
212        let unended = !bytes.is_empty() && !bytes.ends_with(b"\n");
213        Self {
214            bytes: bytes.len() as u64,
215            lines: Some(breaks + usize::from(unended)),
216            hash: Some(content_hash(bytes)),
217        }
218    }
219
220    /// A file of `bytes` bytes that was not read, such as one too large to.
221    pub fn unread(bytes: u64) -> Self {
222        Self {
223            bytes,
224            lines: None,
225            hash: None,
226        }
227    }
228}
229
230impl FileChange {
231    /// `path` was created with `lines` lines.
232    pub fn created(path: impl Into<String>, lines: usize) -> Self {
233        Self {
234            path: path.into(),
235            kind: ChangeKind::Created,
236            added: lines,
237            removed: 0,
238            hunks: Vec::new(),
239            before_text: None,
240            before: None,
241            after: None,
242        }
243    }
244
245    /// `path` was changed: `added` lines added and `removed` removed.
246    pub fn modified(path: impl Into<String>, added: usize, removed: usize) -> Self {
247        Self {
248            path: path.into(),
249            kind: ChangeKind::Modified,
250            added,
251            removed,
252            hunks: Vec::new(),
253            before_text: None,
254            before: None,
255            after: None,
256        }
257    }
258
259    /// `path`, which had `lines` lines, was deleted.
260    pub fn deleted(path: impl Into<String>, lines: usize) -> Self {
261        Self {
262            path: path.into(),
263            kind: ChangeKind::Deleted,
264            added: 0,
265            removed: lines,
266            hunks: Vec::new(),
267            before_text: None,
268            before: None,
269            after: None,
270        }
271    }
272
273    /// This change, made by `hunks`.
274    #[must_use]
275    pub fn with_hunks(mut self, hunks: Vec<Hunk>) -> Self {
276        self.hunks = hunks;
277        self
278    }
279
280    /// This change, made to a file that held `text`.
281    #[must_use]
282    pub fn with_before_text(mut self, text: impl Into<String>) -> Self {
283        self.before_text = Some(text.into());
284        self
285    }
286
287    /// The change as a unified diff, its hunks under a `---`/`+++` header
288    /// naming the path (`/dev/null` on the side with no file); empty when
289    /// it has no hunks.
290    ///
291    /// ```
292    /// use rig_core::tool::{FileChange, Hunk, HunkLine, Lines};
293    ///
294    /// let change = FileChange::modified("a.rs", 1, 1).with_hunks(vec![Hunk {
295    ///     old: Lines { start: 1, count: 2 },
296    ///     new: Lines { start: 1, count: 2 },
297    ///     lines: vec![
298    ///         HunkLine::Same("one\n".into()),
299    ///         HunkLine::Removed("two\n".into()),
300    ///         HunkLine::Added("2".into()),
301    ///     ],
302    /// }]);
303    /// assert_eq!(
304    ///     change.unified(),
305    ///     "--- a.rs\n+++ a.rs\n@@ -1,2 +1,2 @@\n one\n-two\n+2\n\\ No newline at end of file\n"
306    /// );
307    /// ```
308    pub fn unified(&self) -> String {
309        if self.hunks.is_empty() {
310            return String::new();
311        }
312        let side = |gone| {
313            if self.kind == gone {
314                "/dev/null"
315            } else {
316                self.path.as_str()
317            }
318        };
319        let mut out = format!(
320            "--- {}\n+++ {}\n",
321            side(ChangeKind::Created),
322            side(ChangeKind::Deleted)
323        );
324        for hunk in &self.hunks {
325            let _ = writeln!(out, "@@ -{} +{} @@", hunk.old, hunk.new);
326            for line in &hunk.lines {
327                out.push(line.sign());
328                out.push_str(line.text());
329                if !line.text().ends_with(['\r', '\n']) {
330                    out.push_str("\n\\ No newline at end of file\n");
331                }
332            }
333        }
334        out
335    }
336
337    /// The file's text after the change: [`before_text`](Self::before_text)
338    /// (empty for a created file) with the hunks applied. `None` for a
339    /// deleted file, when the text before is not known, or when the hunks
340    /// do not make the whole change (they do not count the lines
341    /// [`added`](Self::added) and [`removed`](Self::removed), or do not fit
342    /// the text before).
343    ///
344    /// ```
345    /// use rig_core::tool::{FileChange, Hunk, HunkLine, Lines};
346    ///
347    /// let change = FileChange::modified("a.rs", 1, 1)
348    ///     .with_before_text("one\ntwo\n")
349    ///     .with_hunks(vec![Hunk {
350    ///         old: Lines { start: 2, count: 1 },
351    ///         new: Lines { start: 2, count: 1 },
352    ///         lines: vec![HunkLine::Removed("two\n".into()), HunkLine::Added("2\n".into())],
353    ///     }]);
354    /// assert_eq!(change.after_text().as_deref(), Some("one\n2\n"));
355    /// assert_eq!(change.changed_lines().collect::<Vec<_>>(), [2]);
356    /// ```
357    pub fn after_text(&self) -> Option<String> {
358        let before = match self.kind {
359            ChangeKind::Deleted => return None,
360            ChangeKind::Created => "",
361            ChangeKind::Modified => self.before_text.as_deref()?,
362        };
363        let lines = self.hunks.iter().flat_map(|hunk| &hunk.lines);
364        let counted = |sign| lines.clone().filter(|line| line.sign() == sign).count();
365        if (counted('+'), counted('-')) != (self.added, self.removed) {
366            return None;
367        }
368        let mut old = text_lines(before);
369        let mut next = 1;
370        let mut out = String::with_capacity(before.len());
371        for hunk in &self.hunks {
372            while next < hunk.old.start {
373                out.push_str(old.next()?);
374                next += 1;
375            }
376            for line in &hunk.lines {
377                if let HunkLine::Added(text) = line {
378                    out.push_str(text);
379                    continue;
380                }
381                if old.next()? != line.text() {
382                    return None;
383                }
384                next += 1;
385                if let HunkLine::Same(text) = line {
386                    out.push_str(text);
387                }
388            }
389        }
390        out.extend(old);
391        Some(out)
392    }
393
394    /// The lines the change added or rewrote, counted from 1 in the file
395    /// after it.
396    pub fn changed_lines(&self) -> impl Iterator<Item = usize> + '_ {
397        self.hunks.iter().flat_map(|hunk| {
398            let new = hunk.lines.iter().filter(|line| line.sign() != '-');
399            new.zip(hunk.new.start..)
400                .filter_map(|(line, at)| matches!(line, HunkLine::Added(_)).then_some(at))
401        })
402    }
403
404    /// This change, made to the file `before`.
405    #[must_use]
406    pub fn with_before(mut self, before: FileState) -> Self {
407        self.before = Some(before);
408        self
409    }
410
411    /// This change, leaving the file `after`.
412    #[must_use]
413    pub fn with_after(mut self, after: FileState) -> Self {
414        self.after = Some(after);
415        self
416    }
417}
418
419/// One run of a [`FileChange`]: the lines it removed and added, between
420/// the lines around them it kept.
421#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
422pub struct Hunk {
423    /// The lines of the file before the change that it spans.
424    pub old: Lines,
425    /// The lines of the file after the change that it spans.
426    pub new: Lines,
427    /// Its lines in order, each with its line ending (`\n`, `\r\n` or a
428    /// lone `\r`; none on a last line that has none).
429    pub lines: Vec<HunkLine>,
430}
431
432/// Lines of a file that a [`Hunk`] spans: `count` lines from line `start`,
433/// counted from 1. An empty span starts at the line it comes before. Shown
434/// as a unified diff's range (`3,2`; `3`; `2,0` for an empty one before
435/// line 3).
436#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
437pub struct Lines {
438    /// The first line.
439    pub start: usize,
440    /// How many lines.
441    pub count: usize,
442}
443
444impl fmt::Display for Lines {
445    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
446        match self.count {
447            1 => write!(f, "{}", self.start),
448            0 => write!(f, "{},0", self.start.saturating_sub(1)),
449            count => write!(f, "{},{count}", self.start),
450        }
451    }
452}
453
454/// A line of a [`Hunk`], with its ending. Its wire form is the line as a
455/// unified diff shows it: its sign (` `, `-` or `+`), then its text.
456#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
457#[serde(into = "String", try_from = "String")]
458pub enum HunkLine {
459    /// A line the change kept, around those it changed.
460    Same(String),
461    /// A line of the file before that the change removed.
462    Removed(String),
463    /// A line of the file after that the change added.
464    Added(String),
465}
466
467impl HunkLine {
468    /// The line's text, with its ending.
469    pub fn text(&self) -> &str {
470        match self {
471            Self::Same(text) | Self::Removed(text) | Self::Added(text) => text,
472        }
473    }
474
475    /// Its sign in a unified diff: ` `, `-` or `+`.
476    pub fn sign(&self) -> char {
477        match self {
478            Self::Same(_) => ' ',
479            Self::Removed(_) => '-',
480            Self::Added(_) => '+',
481        }
482    }
483}
484
485impl From<HunkLine> for String {
486    fn from(line: HunkLine) -> Self {
487        let sign = line.sign();
488        let (HunkLine::Same(mut text) | HunkLine::Removed(mut text) | HunkLine::Added(mut text)) =
489            line;
490        text.insert(0, sign);
491        text
492    }
493}
494
495impl TryFrom<String> for HunkLine {
496    type Error = String;
497
498    fn try_from(mut line: String) -> Result<Self, Self::Error> {
499        let make = match line.chars().next() {
500            Some(' ') => Self::Same,
501            Some('-') => Self::Removed,
502            Some('+') => Self::Added,
503            _ => return Err(format!("a hunk line starts with ' ', '-' or '+': {line:?}")),
504        };
505        line.remove(0);
506        Ok(make(line))
507    }
508}
509
510/// `text`'s lines, each with its ending: `\n`, `\r\n` or a lone `\r`, as
511/// hunks count them.
512fn text_lines(mut text: &str) -> impl Iterator<Item = &str> {
513    std::iter::from_fn(move || {
514        let end = match text.find(['\r', '\n']) {
515            Some(at) if text.get(at..).is_some_and(|rest| rest.starts_with("\r\n")) => at + 2,
516            Some(at) => at + 1,
517            None => text.len(),
518        };
519        let (line, rest) = text.split_at_checked(end)?;
520        text = rest;
521        (!line.is_empty()).then_some(line)
522    })
523}
524
525/// What a tool call did to a file.
526#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
527#[serde(rename_all = "snake_case")]
528pub enum ChangeKind {
529    /// It did not exist before.
530    Created,
531    /// It existed and was changed.
532    Modified,
533    /// It existed and is gone.
534    Deleted,
535}
536
537/// A tool's output was cut to fit: the model's text keeps one end, or both
538/// ends and some lines from between, and says so in a line of its own. A
539/// front reads this fact, not that line's wording, to draw and count the
540/// cut.
541///
542/// ```
543/// use rig_core::tool::{KeptEnd, OutputCut};
544///
545/// let cut = OutputCut { kept: KeptEnd::Ends, kept_lines: 2000, total_lines: 5120, spill: None };
546/// assert_eq!(cut.cut_lines(), 3120);
547/// ```
548#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
549pub struct OutputCut {
550    /// Which end of the output the text keeps.
551    pub kept: KeptEnd,
552    /// How many lines of the output the text keeps, not counting the lines
553    /// that say what was cut.
554    pub kept_lines: usize,
555    /// How many lines the whole output had.
556    pub total_lines: usize,
557    /// The file that holds all of the output, when the tool kept it.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub spill: Option<String>,
560}
561
562impl ContextValue for OutputCut {
563    const KEY: &'static str = "output_cut";
564}
565
566impl OutputCut {
567    /// How many lines of the output the text leaves out.
568    pub fn cut_lines(&self) -> usize {
569        self.total_lines.saturating_sub(self.kept_lines)
570    }
571}
572
573/// Which part of a cut output a tool kept ([`OutputCut::kept`]).
574#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
575#[serde(rename_all = "snake_case")]
576pub enum KeptEnd {
577    /// Its first lines, such as a long file read from the top. The line
578    /// that says so comes last.
579    Start,
580    /// Its first and last lines, and lines from between, such as a
581    /// command's output with the errors from its middle. The line that says
582    /// so comes first.
583    Ends,
584}
585
586/// How a process a tool call ran ended.
587#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589pub enum ExitStatus {
590    /// It exited with this code.
591    Exited(i32),
592    /// A signal it did not expect ended it.
593    Signaled,
594    /// It ran past its time limit and was killed.
595    TimedOut,
596    /// The tool killed it for another reason, such as printing too much or
597    /// the call being cancelled.
598    Killed,
599}
600
601impl ContextValue for ExitStatus {
602    const KEY: &'static str = "exit_status";
603}
604
605impl ExitStatus {
606    /// Whether it exited with code 0.
607    pub fn success(&self) -> bool {
608        matches!(self, Self::Exited(0))
609    }
610
611    /// The code it exited with, if it exited.
612    pub fn code(&self) -> Option<i32> {
613        match self {
614            Self::Exited(code) => Some(*code),
615            Self::Signaled | Self::TimedOut | Self::Killed => None,
616        }
617    }
618}
619
620#[cfg(test)]
621mod tests;