Skip to main content

zeph_tools/compression/
decorator.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `CompressedExecutor<E>` — decorator that post-processes tool output through a compressor.
5//!
6//! Wraps the ROOT executor (any `ToolExecutor` implementation). The compressor is applied
7//! only to successful `ToolOutput.summary` strings — on error, the raw result is returned
8//! unchanged.
9//!
10//! # Invariant (T4)
11//!
12//! Audit logging is performed by the wrapped tool implementations, not here. Because
13//! `CompressedExecutor` wraps *outside* the tool boundary, audit JSONL always records
14//! the raw pre-compression payload.
15
16use std::sync::Arc;
17
18use crate::executor::ToolExecutor;
19use crate::{ToolCall, ToolError, ToolOutput};
20
21use super::OutputCompressor;
22
23/// Decorator that runs a compressor on each successful tool output.
24///
25/// The `inner` executor is called first; its output is then passed to `compressor.compress`.
26/// If compression returns `Ok(None)` or `Err(...)`, the original `summary` is kept intact.
27/// Compression errors are logged as warnings but never propagate to the caller.
28///
29/// # Type parameters
30///
31/// - `E` — the wrapped [`ToolExecutor`]. Often `CompositeExecutor` or `DynExecutor`.
32///
33/// # Examples
34///
35/// ```rust,no_run
36/// use std::sync::Arc;
37/// use zeph_tools::compression::{CompressedExecutor, IdentityCompressor};
38/// // let executor = CompressedExecutor::new(inner_executor, Arc::new(IdentityCompressor), 200);
39/// ```
40#[derive(Debug)]
41pub struct CompressedExecutor<E: ToolExecutor> {
42    inner: E,
43    compressor: Arc<dyn OutputCompressor>,
44    min_lines_to_compress: usize,
45}
46
47impl<E: ToolExecutor> CompressedExecutor<E> {
48    /// Wrap `inner` with `compressor`.
49    ///
50    /// Outputs with fewer than `min_lines` lines skip the compressor entirely.
51    #[must_use]
52    pub fn new(inner: E, compressor: Arc<dyn OutputCompressor>, min_lines: usize) -> Self {
53        Self {
54            inner,
55            compressor,
56            min_lines_to_compress: min_lines,
57        }
58    }
59
60    /// Apply compression to `output`, logging on error and returning the original on failure.
61    async fn maybe_compress(&self, output: ToolOutput) -> ToolOutput {
62        let line_count = output.summary.lines().count();
63        if line_count < self.min_lines_to_compress {
64            return output;
65        }
66
67        match self
68            .compressor
69            .compress(&output.tool_name, &output.summary)
70            .await
71        {
72            Ok(Some(compressed)) => {
73                tracing::debug!(
74                    compressor = self.compressor.name(),
75                    tool = %output.tool_name.as_str(),
76                    original_len = output.summary.len(),
77                    compressed_len = compressed.len(),
78                    "CompressedExecutor: output compressed"
79                );
80                ToolOutput {
81                    summary: compressed,
82                    ..output
83                }
84            }
85            Ok(None) => output,
86            Err(e) => {
87                tracing::warn!(
88                    compressor = self.compressor.name(),
89                    error = %e,
90                    "CompressedExecutor: compression error, using raw output"
91                );
92                output
93            }
94        }
95    }
96}
97
98impl<E: ToolExecutor> ToolExecutor for CompressedExecutor<E> {
99    async fn execute(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
100        let result = self.inner.execute(response).await?;
101        match result {
102            Some(out) => Ok(Some(self.maybe_compress(out).await)),
103            None => Ok(None),
104        }
105    }
106
107    async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
108        let result = self.inner.execute_confirmed(response).await?;
109        match result {
110            Some(out) => Ok(Some(self.maybe_compress(out).await)),
111            None => Ok(None),
112        }
113    }
114
115    fn tool_definitions(&self) -> Vec<crate::registry::ToolDef> {
116        self.inner.tool_definitions()
117    }
118
119    async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
120        let result = self.inner.execute_tool_call(call).await?;
121        match result {
122            Some(out) => Ok(Some(self.maybe_compress(out).await)),
123            None => Ok(None),
124        }
125    }
126
127    async fn execute_tool_call_confirmed(
128        &self,
129        call: &ToolCall,
130    ) -> Result<Option<ToolOutput>, ToolError> {
131        let result = self.inner.execute_tool_call_confirmed(call).await?;
132        match result {
133            Some(out) => Ok(Some(self.maybe_compress(out).await)),
134            None => Ok(None),
135        }
136    }
137
138    fn set_skill_env(&self, env: Option<std::collections::HashMap<String, String>>) {
139        self.inner.set_skill_env(env);
140    }
141
142    fn set_effective_trust(&self, level: crate::SkillTrustLevel) {
143        self.inner.set_effective_trust(level);
144    }
145
146    fn is_tool_retryable(&self, tool_id: &str) -> bool {
147        self.inner.is_tool_retryable(tool_id)
148    }
149
150    fn is_tool_speculatable(&self, tool_id: &str) -> bool {
151        self.inner.is_tool_speculatable(tool_id)
152    }
153
154    fn requires_confirmation(&self, call: &ToolCall) -> bool {
155        self.inner.requires_confirmation(call)
156    }
157
158    fn checkpoint_undo(&self, n: usize) -> crate::executor::CheckpointActionResult {
159        self.inner.checkpoint_undo(n)
160    }
161
162    fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
163        self.inner.checkpoint_redo()
164    }
165
166    fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
167        self.inner.checkpoint_list()
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use std::collections::HashMap;
174    use std::pin::Pin;
175    use std::sync::{Arc, Mutex};
176
177    use zeph_common::ToolName;
178
179    use super::*;
180    use crate::compression::{CompressionError, OutputCompressor};
181    use crate::{SkillTrustLevel, ToolCall, ToolError, ToolOutput, registry::ToolDef};
182
183    /// Records the raw output it receives so the test can assert on it.
184    struct SpyExecutor {
185        received_summary: Arc<Mutex<Option<String>>>,
186        raw_output: String,
187    }
188
189    impl SpyExecutor {
190        fn new(raw: impl Into<String>) -> (Self, Arc<Mutex<Option<String>>>) {
191            let spy = Arc::new(Mutex::new(None));
192            (
193                Self {
194                    received_summary: Arc::clone(&spy),
195                    raw_output: raw.into(),
196                },
197                spy,
198            )
199        }
200    }
201
202    fn make_output(tool_name: ToolName, summary: String) -> ToolOutput {
203        ToolOutput {
204            tool_name,
205            summary,
206            blocks_executed: 0,
207            filter_stats: None,
208            diff: None,
209            streamed: false,
210            terminal_id: None,
211            locations: None,
212            raw_response: None,
213            claim_source: None,
214            ..Default::default()
215        }
216    }
217
218    impl ToolExecutor for SpyExecutor {
219        async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
220            Ok(Some(make_output(
221                ToolName::new("spy"),
222                self.raw_output.clone(),
223            )))
224        }
225
226        async fn execute_confirmed(&self, response: &str) -> Result<Option<ToolOutput>, ToolError> {
227            self.execute(response).await
228        }
229
230        fn tool_definitions(&self) -> Vec<ToolDef> {
231            vec![]
232        }
233
234        async fn execute_tool_call(
235            &self,
236            call: &ToolCall,
237        ) -> Result<Option<ToolOutput>, ToolError> {
238            let out = make_output(call.tool_id.clone(), self.raw_output.clone());
239            *self.received_summary.lock().unwrap() = Some(out.summary.clone());
240            Ok(Some(out))
241        }
242
243        async fn execute_tool_call_confirmed(
244            &self,
245            call: &ToolCall,
246        ) -> Result<Option<ToolOutput>, ToolError> {
247            self.execute_tool_call(call).await
248        }
249
250        fn set_skill_env(&self, _env: Option<HashMap<String, String>>) {}
251        fn set_effective_trust(&self, _level: SkillTrustLevel) {}
252        fn is_tool_retryable(&self, _tool_id: &str) -> bool {
253            false
254        }
255        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
256            false
257        }
258        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
259            false
260        }
261        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
262            crate::executor::CheckpointActionResult::unsupported()
263        }
264        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
265            crate::executor::CheckpointActionResult::unsupported()
266        }
267        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
268            crate::executor::CheckpointListResult::default()
269        }
270    }
271
272    /// Always replaces output with a fixed "compressed" string.
273    #[derive(Debug)]
274    struct StubCompressor;
275
276    impl OutputCompressor for StubCompressor {
277        fn compress<'a>(
278            &'a self,
279            _tool_name: &'a ToolName,
280            _output: &'a str,
281        ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CompressionError>> + Send + 'a>>
282        {
283            Box::pin(async move { Ok(Some("COMPRESSED".to_owned())) })
284        }
285
286        fn name(&self) -> &'static str {
287            "stub"
288        }
289    }
290
291    /// T4 invariant: audit (inner) receives raw output; LLM context receives compressed output.
292    ///
293    /// The inner executor (`SpyExecutor`) records what it emits before `CompressedExecutor`
294    /// applies the compressor. The assertion confirms that the inner layer saw the full raw
295    /// string, while the outer `CompressedExecutor` returns the shortened version.
296    #[tokio::test]
297    async fn t4_audit_sees_raw_llm_sees_compressed() {
298        let raw = "line\n".repeat(300);
299        let (spy, received) = SpyExecutor::new(raw.clone());
300        let executor = CompressedExecutor::new(spy, Arc::new(StubCompressor), 10);
301
302        let call = ToolCall {
303            tool_id: ToolName::new("spy"),
304            params: serde_json::Map::new(),
305            caller_id: None,
306            context: None,
307
308            tool_call_id: String::new(),
309            skill_name: None,
310        };
311        let out = executor.execute_tool_call(&call).await.unwrap().unwrap();
312
313        // Inner executor (audit layer) received the raw payload.
314        assert_eq!(received.lock().unwrap().as_deref(), Some(raw.as_str()));
315        // Outer executor (LLM context layer) received the compressed payload.
316        assert_eq!(out.summary, "COMPRESSED");
317    }
318
319    /// Output below the line-count threshold passes through without compression.
320    #[tokio::test]
321    async fn maybe_compress_skips_when_below_threshold() {
322        let short = "line\n".repeat(5);
323        let (spy, _received) = SpyExecutor::new(short.clone());
324        let executor = CompressedExecutor::new(spy, Arc::new(StubCompressor), 100);
325
326        let call = ToolCall {
327            tool_id: ToolName::new("spy"),
328            params: serde_json::Map::new(),
329            caller_id: None,
330            context: None,
331
332            tool_call_id: String::new(),
333            skill_name: None,
334        };
335        let out = executor.execute_tool_call(&call).await.unwrap().unwrap();
336        // StubCompressor would return "COMPRESSED" — but threshold not met, so raw passes through.
337        assert_eq!(out.summary, short);
338    }
339
340    /// Compressor error falls back to the raw (uncompressed) output.
341    #[tokio::test]
342    async fn compression_error_falls_back_to_raw() {
343        #[derive(Debug)]
344        struct ErrorCompressor;
345        impl OutputCompressor for ErrorCompressor {
346            fn compress<'a>(
347                &'a self,
348                _tool_name: &'a ToolName,
349                _output: &'a str,
350            ) -> Pin<Box<dyn Future<Output = Result<Option<String>, CompressionError>> + Send + 'a>>
351            {
352                Box::pin(async move { Err(CompressionError::CompileTimeout) })
353            }
354            fn name(&self) -> &'static str {
355                "error"
356            }
357        }
358
359        let raw = "line\n".repeat(300);
360        let (spy, _) = SpyExecutor::new(raw.clone());
361        let executor = CompressedExecutor::new(spy, Arc::new(ErrorCompressor), 10);
362
363        let call = ToolCall {
364            tool_id: ToolName::new("spy"),
365            params: serde_json::Map::new(),
366            caller_id: None,
367            context: None,
368
369            tool_call_id: String::new(),
370            skill_name: None,
371        };
372        let out = executor.execute_tool_call(&call).await.unwrap().unwrap();
373        // Error compressor → raw output preserved (T4 safety invariant).
374        assert_eq!(out.summary, raw);
375    }
376
377    /// Inner executor whose cross-cutting methods return distinguishable non-default
378    /// values, used to prove `CompressedExecutor` forwards rather than falling through
379    /// to the base `ToolExecutor` defaults.
380    #[derive(Debug)]
381    struct CheckpointStubExecutor;
382
383    impl ToolExecutor for CheckpointStubExecutor {
384        async fn execute(&self, _: &str) -> Result<Option<ToolOutput>, ToolError> {
385            Ok(None)
386        }
387        async fn execute_tool_call(&self, _: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
388            Ok(None)
389        }
390        fn requires_confirmation(&self, _call: &ToolCall) -> bool {
391            true
392        }
393        fn checkpoint_undo(&self, _n: usize) -> crate::executor::CheckpointActionResult {
394            crate::executor::CheckpointActionResult {
395                reverted_commands: 1,
396                restored: 2,
397                deleted: 3,
398                supported: true,
399                message: "stub-undo".to_owned(),
400            }
401        }
402        fn checkpoint_redo(&self) -> crate::executor::CheckpointActionResult {
403            crate::executor::CheckpointActionResult {
404                reverted_commands: 4,
405                restored: 5,
406                deleted: 6,
407                supported: true,
408                message: "stub-redo".to_owned(),
409            }
410        }
411        fn checkpoint_list(&self) -> crate::executor::CheckpointListResult {
412            crate::executor::CheckpointListResult {
413                entries: vec![],
414                redo_depth: 7,
415                supported: true,
416            }
417        }
418        async fn execute_tool_call_confirmed(
419            &self,
420            call: &ToolCall,
421        ) -> Result<Option<ToolOutput>, ToolError> {
422            self.execute_tool_call(call).await
423        }
424        fn is_tool_speculatable(&self, _tool_id: &str) -> bool {
425            false
426        }
427    }
428
429    /// Regression test for #6012: `requires_confirmation` and the checkpoint trio must be
430    /// forwarded to `self.inner`. Before the fix they fell through to the base
431    /// `ToolExecutor` defaults (`false` / `unsupported()`) regardless of the inner
432    /// executor's actual policy or checkpoint state.
433    #[test]
434    fn requires_confirmation_and_checkpoints_delegated_to_inner() {
435        let executor =
436            CompressedExecutor::new(CheckpointStubExecutor, Arc::new(StubCompressor), 10);
437
438        let call = ToolCall {
439            tool_id: ToolName::new("spy"),
440            params: serde_json::Map::new(),
441            caller_id: None,
442            context: None,
443
444            tool_call_id: String::new(),
445            skill_name: None,
446        };
447        assert!(executor.requires_confirmation(&call));
448
449        let undo = executor.checkpoint_undo(1);
450        assert!(undo.supported);
451        assert_eq!(undo.message, "stub-undo");
452
453        let redo = executor.checkpoint_redo();
454        assert!(redo.supported);
455        assert_eq!(redo.message, "stub-redo");
456
457        let list = executor.checkpoint_list();
458        assert!(list.supported);
459        assert_eq!(list.redo_depth, 7);
460    }
461}