Skip to main content

traitclaw_core/traits/
output_processor.rs

1//! Tool output processing — pluggable post-processing of tool results.
2//!
3//! By default, [`TruncateProcessor`] limits tool output to 10,000 characters.
4
5/// Trait for processing tool output before adding it to the message context.
6///
7/// Implementations MUST be sync (no I/O) and fast.
8///
9/// # Deprecated
10///
11/// Use [`OutputTransformer`](crate::traits::output_transformer::OutputTransformer) instead.
12/// `OutputProcessor` is automatically bridged via a blanket impl.
13#[deprecated(
14    since = "0.3.0",
15    note = "Use OutputTransformer instead. OutputProcessor will be removed in v1.0.0. \
16            Existing impls work automatically via blanket impl."
17)]
18pub trait OutputProcessor: Send + Sync {
19    /// Process a tool output string and return the (possibly modified) result.
20    fn process(&self, output: String) -> String;
21}
22
23/// Returns output unchanged.
24pub struct NoopProcessor;
25
26#[allow(deprecated)]
27impl OutputProcessor for NoopProcessor {
28    fn process(&self, output: String) -> String {
29        output
30    }
31}
32
33/// Truncates output exceeding `max_chars` with a `"...\n[output truncated]"` suffix.
34pub struct TruncateProcessor {
35    /// Maximum number of characters before truncation.
36    max_chars: usize,
37}
38
39impl Default for TruncateProcessor {
40    fn default() -> Self {
41        Self { max_chars: 10_000 }
42    }
43}
44
45impl TruncateProcessor {
46    /// Create a processor with a custom character limit.
47    #[must_use]
48    pub fn new(max_chars: usize) -> Self {
49        Self { max_chars }
50    }
51}
52
53#[allow(deprecated)]
54impl OutputProcessor for TruncateProcessor {
55    fn process(&self, output: String) -> String {
56        // Count characters, not bytes, to avoid panicking on multi-byte UTF-8.
57        let char_count = output.chars().count();
58        if char_count <= self.max_chars {
59            output
60        } else {
61            // Find the byte offset of the Nth character boundary.
62            let byte_offset = output
63                .char_indices()
64                .nth(self.max_chars)
65                .map_or(output.len(), |(idx, _)| idx);
66            let mut truncated = output[..byte_offset].to_string();
67            truncated.push_str("...\n[output truncated]");
68            truncated
69        }
70    }
71}
72
73/// Composes multiple processors in order — output flows through each stage.
74#[allow(deprecated)]
75pub struct ChainProcessor {
76    processors: Vec<Box<dyn OutputProcessor>>,
77}
78
79#[allow(deprecated)]
80impl ChainProcessor {
81    /// Create a chain from a list of processors.
82    #[must_use]
83    pub fn new(processors: Vec<Box<dyn OutputProcessor>>) -> Self {
84        Self { processors }
85    }
86}
87
88#[allow(deprecated)]
89impl OutputProcessor for ChainProcessor {
90    fn process(&self, mut output: String) -> String {
91        for p in &self.processors {
92            output = p.process(output);
93        }
94        output
95    }
96}
97
98#[cfg(test)]
99#[allow(deprecated)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_noop_returns_unchanged() {
105        let p = NoopProcessor;
106        let input = "hello world".to_string();
107        assert_eq!(p.process(input.clone()), input);
108    }
109
110    #[test]
111    fn test_truncate_at_boundary() {
112        let p = TruncateProcessor::new(10);
113        let short = "12345".to_string();
114        assert_eq!(
115            p.process(short.clone()),
116            short,
117            "should not truncate short input"
118        );
119
120        let exact = "1234567890".to_string();
121        assert_eq!(
122            p.process(exact.clone()),
123            exact,
124            "should not truncate exact-length input"
125        );
126
127        let long = "12345678901".to_string();
128        let result = p.process(long);
129        assert!(
130            result.ends_with("[output truncated]"),
131            "should truncate: {result}"
132        );
133        assert!(
134            result.starts_with("1234567890"),
135            "should keep first 10 chars"
136        );
137    }
138
139    #[test]
140    fn test_chain_applies_in_order() {
141        struct UpperCase;
142        impl OutputProcessor for UpperCase {
143            fn process(&self, output: String) -> String {
144                output.to_uppercase()
145            }
146        }
147
148        let chain = ChainProcessor::new(vec![
149            Box::new(UpperCase),
150            Box::new(TruncateProcessor::new(5)),
151        ]);
152
153        let result = chain.process("hello world".to_string());
154        // First: "HELLO WORLD", then truncate to 5
155        assert!(result.starts_with("HELLO"), "got: {result}");
156        assert!(result.contains("[output truncated]"));
157    }
158}