Skip to main content

salvor_runtime/
compact.rs

1//! Error compaction: what a failed tool call puts into the model's context
2//! window for the built-in loop.
3//!
4//! The event log always records the **full** error (inside the tool-call
5//! completion; see [`crate::wire`]). Compaction governs only the
6//! `tool_result` content handed back to the model, in two deterministic
7//! steps:
8//!
9//! 1. **Truncation.** A message longer than [`COMPACT_MESSAGE_CAP`]
10//!    characters keeps its first [`COMPACT_HEAD_CHARS`] and last
11//!    [`COMPACT_TAIL_CHARS`] characters around an elision marker naming how
12//!    many characters were dropped. Head and tail both survive because
13//!    error text tends to put the *what* up front and the *why* (a cause
14//!    chain) at the end.
15//! 2. **Repeat collapse.** When the same tool fails with the identical full
16//!    message consecutively (no other dispatch result in between), the
17//!    second and every later occurrence is replaced by a one-line summary
18//!    naming the repeat count, instead of the same wall of text again.
19//!
20//! Both steps are pure functions of recorded data. That is not a style
21//! preference: the compacted content flows into the next model request,
22//! the request is hashed into `ModelCallRequested`, and the hash must
23//! reproduce bit for bit on replay. Any nondeterminism here would poison
24//! every replay downstream of a tool failure.
25//!
26//! Counting is in `char`s, not bytes, so truncation never splits a UTF-8
27//! sequence. `RunCtx` users own their context window and their own policy;
28//! these functions are exported for them to reuse or ignore.
29
30/// The maximum length, in characters, of an error message handed to the
31/// model uncompacted.
32pub const COMPACT_MESSAGE_CAP: usize = 512;
33
34/// How many leading characters survive truncation.
35pub const COMPACT_HEAD_CHARS: usize = 320;
36
37/// How many trailing characters survive truncation.
38pub const COMPACT_TAIL_CHARS: usize = 128;
39
40/// Truncates a long error message, keeping head and tail around an elision
41/// marker. Messages at or under [`COMPACT_MESSAGE_CAP`] characters pass
42/// through unchanged.
43#[must_use]
44pub fn compact_error_message(message: &str) -> String {
45    let total = message.chars().count();
46    if total <= COMPACT_MESSAGE_CAP {
47        return message.to_owned();
48    }
49    let head: String = message.chars().take(COMPACT_HEAD_CHARS).collect();
50    let tail: String = {
51        let skip = total - COMPACT_TAIL_CHARS;
52        message.chars().skip(skip).collect()
53    };
54    let elided = total - COMPACT_HEAD_CHARS - COMPACT_TAIL_CHARS;
55    format!("{head} [... {elided} chars elided ...] {tail}")
56}
57
58/// Tracks consecutive identical tool failures and produces the model-facing
59/// content for each dispatch result.
60///
61/// The loop owns one tracker per drive. Feed it every dispatch outcome in
62/// order: [`content_for_failure`](Self::content_for_failure) for failures,
63/// [`record_success`](Self::record_success) for anything else. Because it is
64/// rebuilt from the same recorded sequence on every replay, its output is
65/// identical across replays.
66#[derive(Debug, Default)]
67pub struct FailureTracker {
68    /// The (tool, full message) of the last failure, when the last dispatch
69    /// was a failure.
70    last: Option<(String, String)>,
71    /// How many consecutive times that failure has occurred.
72    count: u32,
73}
74
75impl FailureTracker {
76    /// A fresh tracker with no failure history.
77    #[must_use]
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Notes a dispatch that did not fail, breaking any repeat streak.
83    pub fn record_success(&mut self) {
84        self.last = None;
85        self.count = 0;
86    }
87
88    /// Notes a failure and returns the content the model should see: the
89    /// compacted message the first time, a repeat summary from the second
90    /// consecutive identical failure onward.
91    pub fn content_for_failure(&mut self, tool: &str, full_message: &str) -> String {
92        let same = self.last.as_ref().is_some_and(|(last_tool, last_message)| {
93            last_tool == tool && last_message == full_message
94        });
95        if same {
96            self.count += 1;
97            format!(
98                "tool `{tool}` has failed {count} consecutive times with the same error; \
99                 the full error is recorded in the run log",
100                count = self.count
101            )
102        } else {
103            self.last = Some((tool.to_owned(), full_message.to_owned()));
104            self.count = 1;
105            compact_error_message(full_message)
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    /// Short messages pass through untouched.
115    #[test]
116    fn short_messages_are_unchanged() {
117        let message = "connection refused";
118        assert_eq!(compact_error_message(message), message);
119        let at_cap = "x".repeat(COMPACT_MESSAGE_CAP);
120        assert_eq!(compact_error_message(&at_cap), at_cap);
121    }
122
123    /// Long messages keep head and tail around the elision marker, and the
124    /// marker names the exact number of characters dropped.
125    #[test]
126    fn long_messages_keep_head_and_tail() {
127        let head_part = "H".repeat(COMPACT_HEAD_CHARS);
128        let middle = "M".repeat(1000);
129        let tail_part = "T".repeat(COMPACT_TAIL_CHARS);
130        let message = format!("{head_part}{middle}{tail_part}");
131
132        let compacted = compact_error_message(&message);
133        assert!(compacted.starts_with(&head_part));
134        assert!(compacted.ends_with(&tail_part));
135        assert!(compacted.contains("[... 1000 chars elided ...]"));
136    }
137
138    /// Truncation counts characters, so multi-byte text never splits.
139    #[test]
140    fn truncation_is_char_safe() {
141        let total = COMPACT_MESSAGE_CAP + 100;
142        let elided = total - COMPACT_HEAD_CHARS - COMPACT_TAIL_CHARS;
143        let message = "\u{00e9}".repeat(total);
144        let compacted = compact_error_message(&message);
145        assert!(compacted.contains(&format!("[... {elided} chars elided ...]")));
146    }
147
148    /// Identical consecutive failures collapse into a counted summary; a
149    /// success or a different failure resets the streak.
150    #[test]
151    fn repeats_collapse_and_streaks_reset() {
152        let mut tracker = FailureTracker::new();
153        let first = tracker.content_for_failure("search", "boom");
154        assert_eq!(first, "boom");
155        let second = tracker.content_for_failure("search", "boom");
156        assert!(second.contains("2 consecutive times"), "{second}");
157        let third = tracker.content_for_failure("search", "boom");
158        assert!(third.contains("3 consecutive times"), "{third}");
159
160        // A different message is a fresh failure, not a repeat.
161        let different = tracker.content_for_failure("search", "bang");
162        assert_eq!(different, "bang");
163
164        // A success breaks the streak entirely.
165        tracker.record_success();
166        let after_success = tracker.content_for_failure("search", "bang");
167        assert_eq!(after_success, "bang");
168    }
169
170    /// The same message from a different tool is not a repeat.
171    #[test]
172    fn repeats_are_per_tool() {
173        let mut tracker = FailureTracker::new();
174        let _ = tracker.content_for_failure("search", "boom");
175        let other_tool = tracker.content_for_failure("fetch", "boom");
176        assert_eq!(other_tool, "boom");
177    }
178}