roboticus_agent/
tool_output_filter.rs1pub trait ToolOutputFilter: Send + Sync {
9 fn name(&self) -> &str;
10 fn filter(&self, tool_name: &str, output: &str) -> String;
11}
12
13pub struct ToolOutputFilterChain {
15 filters: Vec<Box<dyn ToolOutputFilter>>,
16}
17
18impl ToolOutputFilterChain {
19 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 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
41pub 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 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 if chars.peek() == Some(&'[') {
59 chars.next(); 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 if chars.peek() == Some(&']') {
71 chars.next();
72 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 chars.next();
86 continue;
87 }
88 if ch == '\r' {
90 continue;
91 }
92 result.push(ch);
93 }
94 result
95 }
96}
97
98pub 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 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 total > 5 && progress_chars * 100 / total > 60 {
152 return false;
153 }
154 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
169pub 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
200pub 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 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 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}