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}