1use std::sync::Arc;
17
18use crate::executor::ToolExecutor;
19use crate::{ToolCall, ToolError, ToolOutput};
20
21use super::OutputCompressor;
22
23#[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 #[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 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 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 #[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 #[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 assert_eq!(received.lock().unwrap().as_deref(), Some(raw.as_str()));
315 assert_eq!(out.summary, "COMPRESSED");
317 }
318
319 #[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 assert_eq!(out.summary, short);
338 }
339
340 #[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 assert_eq!(out.summary, raw);
375 }
376
377 #[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 #[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}