Skip to main content

roboticus_agent/
tool_output_filter.rs

1//! Tool output noise filter — cleans tool output before it reaches the LLM.
2//!
3//! Applied *after* tool execution and truncation, *before* the observation is
4//! formatted into the ReAct loop. Reduces token waste from ANSI escape codes,
5//! progress bars, duplicate lines, and excessive whitespace.
6
7/// A single filter that transforms tool output text.
8pub trait ToolOutputFilter: Send + Sync {
9    fn name(&self) -> &str;
10    fn filter(&self, tool_name: &str, output: &str) -> String;
11}
12
13/// Ordered chain of filters applied sequentially.
14pub struct ToolOutputFilterChain {
15    filters: Vec<Box<dyn ToolOutputFilter>>,
16}
17
18impl ToolOutputFilterChain {
19    /// Construct the default filter chain used in the ReAct loop.
20    pub fn default_chain() -> Self {
21        Self {
22            filters: vec![
23                Box::new(AnsiStripper),
24                Box::new(ProgressLineFilter),
25                Box::new(DuplicateLineDeduper),
26                Box::new(WhitespaceNormalizer),
27            ],
28        }
29    }
30
31    /// Apply all filters in order, returning the cleaned output.
32    pub fn apply(&self, tool_name: &str, output: &str) -> String {
33        let mut buf = output.to_string();
34        for f in &self.filters {
35            buf = f.filter(tool_name, &buf);
36        }
37        buf
38    }
39}
40
41// ── Filter implementations ──────────────────────────────────────────────────
42
43/// Strip ANSI escape sequences (SGR, cursor, erase, etc.).
44pub struct AnsiStripper;
45
46impl ToolOutputFilter for AnsiStripper {
47    fn name(&self) -> &str {
48        "ansi_stripper"
49    }
50
51    fn filter(&self, _tool_name: &str, output: &str) -> String {
52        // Match ESC [ ... final_byte  and  ESC ] ... ST  (OSC sequences)
53        let mut result = String::with_capacity(output.len());
54        let mut chars = output.chars().peekable();
55        while let Some(ch) = chars.next() {
56            if ch == '\x1b' {
57                // CSI sequence: ESC [
58                if chars.peek() == Some(&'[') {
59                    chars.next(); // consume '['
60                    // consume until final byte (0x40-0x7E)
61                    while let Some(&c) = chars.peek() {
62                        chars.next();
63                        if c.is_ascii() && (0x40..=0x7E).contains(&(c as u8)) {
64                            break;
65                        }
66                    }
67                    continue;
68                }
69                // OSC sequence: ESC ]
70                if chars.peek() == Some(&']') {
71                    chars.next();
72                    // consume until ST (ESC \) or BEL (\x07)
73                    while let Some(c) = chars.next() {
74                        if c == '\x07' {
75                            break;
76                        }
77                        if c == '\x1b' && chars.peek() == Some(&'\\') {
78                            chars.next();
79                            break;
80                        }
81                    }
82                    continue;
83                }
84                // Single-char escape (ESC + one byte)
85                chars.next();
86                continue;
87            }
88            // Strip carriage return (common in progress output)
89            if ch == '\r' {
90                continue;
91            }
92            result.push(ch);
93        }
94        result
95    }
96}
97
98/// Remove lines that look like progress bars or spinners.
99pub struct ProgressLineFilter;
100
101impl ToolOutputFilter for ProgressLineFilter {
102    fn name(&self) -> &str {
103        "progress_line_filter"
104    }
105
106    fn filter(&self, _tool_name: &str, output: &str) -> String {
107        output
108            .lines()
109            .filter(|line| {
110                let trimmed = line.trim();
111                // Skip lines that are mostly progress bar characters
112                if trimmed.is_empty() {
113                    return true;
114                }
115                let progress_chars = trimmed
116                    .chars()
117                    .filter(|c| {
118                        matches!(
119                            c,
120                            '█' | '▓'
121                                | '▒'
122                                | '░'
123                                | '━'
124                                | '─'
125                                | '┃'
126                                | '│'
127                                | '⠋'
128                                | '⠙'
129                                | '⠹'
130                                | '⠸'
131                                | '⠼'
132                                | '⠴'
133                                | '⠦'
134                                | '⠧'
135                                | '⠇'
136                                | '⠏'
137                                | '/'
138                                | '\\'
139                                | '-'
140                                | '='
141                                | '>'
142                                | '#'
143                                | '.'
144                                | '●'
145                                | '○'
146                        )
147                    })
148                    .count();
149                let total = trimmed.chars().count();
150                // If >60% of the line is progress characters and it contains '%', skip it
151                if total > 5 && progress_chars * 100 / total > 60 {
152                    return false;
153                }
154                // Skip lines that are just a percentage update
155                if trimmed
156                    .chars()
157                    .all(|c| c.is_ascii_digit() || c == '%' || c == '.' || c == ' ')
158                    && trimmed.contains('%')
159                {
160                    return false;
161                }
162                true
163            })
164            .collect::<Vec<_>>()
165            .join("\n")
166    }
167}
168
169/// Remove consecutive duplicate lines (common in build/test output).
170pub struct DuplicateLineDeduper;
171
172impl ToolOutputFilter for DuplicateLineDeduper {
173    fn name(&self) -> &str {
174        "duplicate_line_deduper"
175    }
176
177    fn filter(&self, _tool_name: &str, output: &str) -> String {
178        let mut result = Vec::new();
179        let mut prev: Option<&str> = None;
180        let mut dup_count = 0u32;
181        for line in output.lines() {
182            if Some(line) == prev {
183                dup_count += 1;
184                continue;
185            }
186            if dup_count > 0 {
187                result.push(format!("  [... repeated {dup_count} more time(s)]"));
188                dup_count = 0;
189            }
190            result.push(line.to_string());
191            prev = Some(line);
192        }
193        if dup_count > 0 {
194            result.push(format!("  [... repeated {dup_count} more time(s)]"));
195        }
196        result.join("\n")
197    }
198}
199
200/// Collapse runs of blank lines to max 2, trim trailing whitespace per line.
201pub struct WhitespaceNormalizer;
202
203impl ToolOutputFilter for WhitespaceNormalizer {
204    fn name(&self) -> &str {
205        "whitespace_normalizer"
206    }
207
208    fn filter(&self, _tool_name: &str, output: &str) -> String {
209        let mut result = Vec::new();
210        let mut blank_run = 0u32;
211        for line in output.lines() {
212            let trimmed = line.trim_end();
213            if trimmed.is_empty() {
214                blank_run += 1;
215                if blank_run <= 2 {
216                    result.push(String::new());
217                }
218            } else {
219                blank_run = 0;
220                result.push(trimmed.to_string());
221            }
222        }
223        // Trim trailing blank lines
224        while result.last().is_some_and(|l| l.is_empty()) {
225            result.pop();
226        }
227        result.join("\n")
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn ansi_stripper_removes_sgr() {
237        let input = "\x1b[31mERROR\x1b[0m: something failed";
238        let chain = ToolOutputFilterChain::default_chain();
239        let out = chain.apply("test", input);
240        assert_eq!(out, "ERROR: something failed");
241    }
242
243    #[test]
244    fn progress_filter_removes_bars() {
245        let input = "Compiling foo\n███████████████░░░░░ 75%\nDone.";
246        let filter = ProgressLineFilter;
247        let out = filter.filter("test", input);
248        assert!(out.contains("Compiling foo"));
249        assert!(out.contains("Done."));
250        assert!(!out.contains("███"));
251    }
252
253    #[test]
254    fn deduper_collapses_repeats() {
255        let input = "line1\nline2\nline2\nline2\nline3";
256        let filter = DuplicateLineDeduper;
257        let out = filter.filter("test", input);
258        assert!(out.contains("line2"));
259        assert!(out.contains("[... repeated 2 more time(s)]"));
260        assert!(out.contains("line3"));
261    }
262
263    #[test]
264    fn whitespace_normalizer_collapses_blanks() {
265        let input = "a\n\n\n\n\nb\n\nc";
266        let filter = WhitespaceNormalizer;
267        let out = filter.filter("test", input);
268        // Max 2 blank lines between a and b
269        assert_eq!(out, "a\n\n\nb\n\nc");
270    }
271
272    #[test]
273    fn full_chain_applies_all() {
274        let input = "\x1b[32mok\x1b[0m\nfoo\nfoo\nfoo\n\n\n\n\nbar";
275        let chain = ToolOutputFilterChain::default_chain();
276        let out = chain.apply("test", input);
277        assert!(out.starts_with("ok"));
278        assert!(out.contains("[... repeated 2 more time(s)]"));
279        assert!(out.contains("bar"));
280    }
281}