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;