Skip to main content

everruns_core/capabilities/
tool_output_distillation.rs

1// Tool Output Distillation Capability
2//
3// Produces a compact, content-aware *inline view* of large tool results at
4// capture time, while keeping the full original recoverable via `read_file`.
5//
6// Motivation:
7// - Exec/sandbox tools already get a verbosity budget (`tool_output_sanitizer`)
8//   plus lossless persistence (`tool_output_persistence`). Their output is
9//   handled well.
10// - Tools that do NOT declare `persist_output` — most notably MCP tools and
11//   `web_fetch` — have neither. Their output enters history verbatim and is
12//   only capped by the 64 KiB hard-limit hook, which head-truncates and
13//   destroys the tail. This capability targets exactly that gap.
14//
15// Design decisions:
16// - Implemented as a `PostToolExecHook`, the same seam as
17//   `tool_output_persistence`. Capability hooks run BEFORE the final
18//   infrastructure hooks (persistence + hard-limit), so a distilled result is
19//   what the downstream hooks see.
20// - Reversibility is mandatory: we never replace content with a lossy view
21//   unless the full original was successfully persisted to the session VFS.
22//   We reuse `tool_output_persistence::{persist_output, annotate_truncated_output}`
23//   rather than reimplementing persistence, and inject the same `output_files`
24//   pointer. `PersistOutputHook` skips when `output_files` is already present,
25//   so the two never double-write.
26// - Routing is by content *shape*, not tool name: MCP tool names are arbitrary
27//   and namespaced, so name allowlists (used by compaction masking) do not
28//   generalize. We sniff the JSON value instead.
29// - Determinism: every transform is deterministic so identical output distills
30//   identically, preserving provider KV-cache reuse across turns.
31// - Skips tools that declare `persist_output` (exec/sandbox): their existing
32//   budget + persistence pipeline already solves the problem, and distilling
33//   their already-budgeted inline view would fight that design.
34
35use std::{io, sync::Arc};
36
37use async_trait::async_trait;
38use serde_json::{Value, json};
39
40use super::tool_output_persistence::{annotate_truncated_output, persist_output};
41use super::{Capability, CapabilityLocalization, CapabilityStatus};
42use crate::atoms::PostToolExecHook;
43use crate::tool_types::{ToolCall, ToolDefinition, ToolResult};
44use crate::traits::ToolContext;
45
46/// Only distill results whose serialized size exceeds this threshold. Smaller
47/// results are left verbatim (cheap, cache-stable, and rarely worth a VFS write).
48const MIN_DISTILL_BYTES: usize = 8 * 1024;
49
50/// Strings longer than this are clipped to a head+tail window. Also the array
51/// serialized-size threshold above which array sampling kicks in.
52const MAX_FIELD_BYTES: usize = 2 * 1024;
53
54/// Number of leading elements kept when sampling a large array.
55const SAMPLE_ROWS: usize = 5;
56
57/// Maximum recursion depth when walking a result value (DoS bound).
58const MAX_DEPTH: usize = 8;
59
60/// Maximum number of nodes visited while distilling (DoS bound).
61const MAX_NODES: usize = 100_000;
62
63/// Maximum serialized JSON bytes this hook will process before handing off to
64/// the final hard-limit hook. This must be checked before cloning or
65/// pretty-printing attacker-controlled tool output.
66const MAX_DISTILL_INPUT_BYTES: usize = 1024 * 1024;
67
68pub const TOOL_OUTPUT_DISTILLATION_CAPABILITY_ID: &str = "tool_output_distillation";
69
70/// Capability that distills large tool results into a compact inline view while
71/// persisting the full original for lossless retrieval.
72pub struct ToolOutputDistillationCapability;
73
74impl Capability for ToolOutputDistillationCapability {
75    fn id(&self) -> &str {
76        TOOL_OUTPUT_DISTILLATION_CAPABILITY_ID
77    }
78
79    fn name(&self) -> &str {
80        "Tool Output Distillation"
81    }
82
83    fn description(&self) -> &str {
84        "Distills large tool results (notably MCP and web fetch) into a compact, \
85         content-aware inline view while persisting the full original to the \
86         session filesystem for lossless retrieval via read_file."
87    }
88
89    fn localizations(&self) -> Vec<CapabilityLocalization> {
90        vec![CapabilityLocalization::text(
91            "uk",
92            "Стиснення виводу інструментів",
93            "Стискає великий вивід інструментів (зокрема MCP та web fetch) у компактний \
94             вигляд із урахуванням типу вмісту, зберігаючи повний оригінал у файловій \
95             системі сесії для отримання без втрат через read_file.",
96        )]
97    }
98
99    fn status(&self) -> CapabilityStatus {
100        CapabilityStatus::Available
101    }
102
103    fn dependencies(&self) -> Vec<&'static str> {
104        vec!["session_file_system"]
105    }
106
107    fn post_tool_exec_hooks(&self) -> Vec<Arc<dyn PostToolExecHook>> {
108        vec![Arc::new(DistillOutputHook)]
109    }
110}
111
112/// Hook that distills large non-exec tool results in place.
113pub struct DistillOutputHook;
114
115#[async_trait]
116impl PostToolExecHook for DistillOutputHook {
117    async fn after_exec(
118        &self,
119        tool_call: &ToolCall,
120        tool_def: &ToolDefinition,
121        result: &mut ToolResult,
122        context: &ToolContext,
123    ) {
124        // Exec/sandbox tools are already handled by the verbosity budget +
125        // tool_output_persistence. Distilling their budgeted inline view would
126        // fight that design, so leave them alone.
127        if tool_def.hints().persist_output == Some(true) {
128            return;
129        }
130
131        // Only operate on successful results. Errors are usually small and
132        // diagnostically important; keep them verbatim.
133        if result.error.is_some() {
134            return;
135        }
136
137        let Some(result_value) = result.result.as_mut() else {
138            return;
139        };
140
141        // Already recoverable (another hook persisted it, or a tool injected
142        // its own pointer). Nothing to do.
143        if result_value.get("output_files").is_some() {
144            return;
145        }
146
147        // Size gate: only act on large results. Serialize through a bounded
148        // writer before any clone or traversal so untrusted MCP/web_fetch
149        // output cannot force unbounded allocations in this pre-hard-limit hook.
150        let Ok(serialized) = serialize_json_bounded(result_value, MAX_DISTILL_INPUT_BYTES) else {
151            return;
152        };
153        if serialized.len() < MIN_DISTILL_BYTES {
154            return;
155        }
156
157        // Reversibility requires a session file store. Without one we cannot
158        // guarantee the original is recoverable, so we refuse to distill.
159        let Some(file_store) = context.file_store.as_ref() else {
160            return;
161        };
162
163        // Distill in place, keeping the pre-distill original for persistence.
164        let original = result_value.clone();
165        let mut stats = DistillStats::default();
166        distill_value(result_value, 0, &mut stats);
167        if !stats.changed {
168            // The shape walker found nothing to clip — e.g. a large object or
169            // array made entirely of sub-threshold fields. Such a result is
170            // still large; if it later exceeds the 64 KiB OutputHardLimitHook it
171            // would be head-truncated with no recovery pointer, losing the tail.
172            // Fall back to a head+tail window over the serialized value so the
173            // result is always bounded, persisted, and recoverable.
174            *result_value = Value::String(head_tail(&serialized, MAX_FIELD_BYTES));
175        }
176
177        // Persist the full original for lossless retrieval. Reuse the bounded
178        // size-gate serialization rather than pretty-printing the original into
179        // a second large string before persistence applies its own cap.
180        let original_len = serialized.len();
181        let persisted = persist_output(
182            file_store,
183            context.session_id,
184            &tool_call.id,
185            &serialized,
186            "",
187        )
188        .await;
189
190        let Some(stdout_path) = persisted.and_then(|p| p.stdout_path) else {
191            // Persistence failed: we cannot guarantee recovery, so restore the
192            // verbatim original instead of leaving a lossy, irrecoverable result.
193            *result_value = original;
194            return;
195        };
196
197        let display_path = file_store.display_path(&stdout_path);
198        inject_pointer(result_value, &display_path, original_len);
199    }
200}
201
202/// Tracks whether distillation actually changed anything and bounds traversal.
203#[derive(Default)]
204struct DistillStats {
205    changed: bool,
206    nodes: usize,
207}
208
209/// Recursively distill a JSON value in place: clip long strings, sample large
210/// arrays. Bounded by depth and node count.
211fn distill_value(value: &mut Value, depth: usize, stats: &mut DistillStats) {
212    if depth > MAX_DEPTH || stats.nodes >= MAX_NODES {
213        return;
214    }
215    stats.nodes += 1;
216
217    match value {
218        Value::String(s) => {
219            if s.len() > MAX_FIELD_BYTES {
220                *s = distill_text(s, MAX_FIELD_BYTES);
221                stats.changed = true;
222            }
223        }
224        Value::Array(arr) => {
225            // Avoid serializing the full array while walking untrusted output:
226            // long arrays are sampled based on element count alone.
227            if arr.len() > SAMPLE_ROWS {
228                let omitted = arr.len() - SAMPLE_ROWS;
229                arr.truncate(SAMPLE_ROWS);
230                for el in arr.iter_mut() {
231                    distill_value(el, depth + 1, stats);
232                }
233                arr.push(json!(format!(
234                    "[… {omitted} more item(s) elided — full output via read_file …]"
235                )));
236                stats.changed = true;
237            } else {
238                for el in arr.iter_mut() {
239                    distill_value(el, depth + 1, stats);
240                }
241            }
242        }
243        Value::Object(map) => {
244            for (_k, v) in map.iter_mut() {
245                distill_value(v, depth + 1, stats);
246            }
247        }
248        _ => {}
249    }
250}
251
252fn serialize_json_bounded(value: &Value, max_bytes: usize) -> Result<String, serde_json::Error> {
253    let mut writer = BoundedJsonWriter::new(max_bytes);
254    serde_json::to_writer(&mut writer, value)?;
255    writer.finish().map_err(serde_json::Error::io)
256}
257
258struct BoundedJsonWriter {
259    bytes: Vec<u8>,
260    max_bytes: usize,
261}
262
263impl BoundedJsonWriter {
264    fn new(max_bytes: usize) -> Self {
265        Self {
266            bytes: Vec::with_capacity(max_bytes.min(MIN_DISTILL_BYTES)),
267            max_bytes,
268        }
269    }
270
271    fn finish(self) -> io::Result<String> {
272        String::from_utf8(self.bytes).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))
273    }
274}
275
276impl io::Write for BoundedJsonWriter {
277    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
278        if self.bytes.len().saturating_add(buf.len()) > self.max_bytes {
279            // Intentional policy limit, not a real allocation failure — keep it
280            // out of `OutOfMemory` so OOM telemetry/handling isn't triggered.
281            return Err(io::Error::other(
282                "tool result JSON exceeds distillation byte limit",
283            ));
284        }
285        self.bytes.extend_from_slice(buf);
286        Ok(buf.len())
287    }
288
289    fn flush(&mut self) -> io::Result<()> {
290        Ok(())
291    }
292}
293
294/// Produce a compact view of a long text value.
295///
296/// Recognizes a unified diff and collapses it to a diffstat-style summary
297/// (file + hunk headers, with added/removed line counts). Otherwise keeps a
298/// head+tail window with a byte-elision marker, which preserves both the start
299/// and the end (unlike pure head truncation).
300fn distill_text(text: &str, max_bytes: usize) -> String {
301    if let Some(summary) = distill_unified_diff(text) {
302        return summary;
303    }
304    head_tail(text, max_bytes)
305}
306
307/// If `text` looks like a unified diff, summarize it; else `None`.
308fn distill_unified_diff(text: &str) -> Option<String> {
309    let is_diff = text.starts_with("diff --git ")
310        || text.contains("\ndiff --git ")
311        || text.starts_with("--- ") && text.contains("\n+++ ") && text.contains("\n@@ ");
312    if !is_diff {
313        return None;
314    }
315
316    let mut kept = Vec::new();
317    let mut added = 0usize;
318    let mut removed = 0usize;
319    for line in text.lines() {
320        if line.starts_with("diff --git ")
321            || line.starts_with("+++ ")
322            || line.starts_with("--- ")
323            || line.starts_with("@@ ")
324            || line.starts_with("rename ")
325            || line.starts_with("new file")
326            || line.starts_with("deleted file")
327        {
328            kept.push(line);
329        } else if line.starts_with('+') {
330            added += 1;
331        } else if line.starts_with('-') {
332            removed += 1;
333        }
334    }
335
336    let header = format!("[diff distilled: +{added} / -{removed} lines — full diff via read_file]");
337    let mut out =
338        String::with_capacity(header.len() + kept.iter().map(|l| l.len() + 1).sum::<usize>());
339    out.push_str(&header);
340    out.push('\n');
341    for line in kept {
342        out.push_str(line);
343        out.push('\n');
344    }
345    Some(out)
346}
347
348/// Keep a head+tail window of `text` within roughly `max_bytes`, on UTF-8
349/// boundaries, with a marker noting how many bytes were elided.
350fn head_tail(text: &str, max_bytes: usize) -> String {
351    if text.len() <= max_bytes {
352        return text.to_string();
353    }
354    let head_budget = max_bytes * 7 / 10;
355    let tail_budget = max_bytes - head_budget;
356
357    let mut head_end = head_budget.min(text.len());
358    while head_end > 0 && !text.is_char_boundary(head_end) {
359        head_end -= 1;
360    }
361    let mut tail_start = text.len().saturating_sub(tail_budget);
362    while tail_start < text.len() && !text.is_char_boundary(tail_start) {
363        tail_start += 1;
364    }
365    if tail_start < head_end {
366        tail_start = head_end;
367    }
368
369    let elided = tail_start.saturating_sub(head_end);
370    format!(
371        "{}\n… [{elided} bytes elided — full output via read_file] …\n{}",
372        &text[..head_end],
373        &text[tail_start..],
374    )
375}
376
377/// Inject the recovery pointer fields into a distilled result value, mirroring
378/// the contract of `tool_output_persistence` (`output_files` / `full_output`).
379fn inject_pointer(result_value: &mut Value, display_path: &str, original_len: usize) {
380    let note = annotate_truncated_output(
381        "Tool output was distilled to fit the context window.",
382        display_path,
383        original_len,
384    );
385
386    if let Some(obj) = result_value.as_object_mut() {
387        obj.insert("output_files".to_string(), json!([display_path]));
388        obj.insert("full_output".to_string(), json!(display_path));
389        obj.insert("distilled".to_string(), json!(true));
390        obj.insert("distill_note".to_string(), json!(note));
391    } else {
392        // Non-object top level (array / string): wrap in an envelope so the
393        // recovery pointer has somewhere to live.
394        let distilled = std::mem::replace(result_value, Value::Null);
395        *result_value = json!({
396            "distilled_output": distilled,
397            "output_files": [display_path],
398            "full_output": display_path,
399            "distilled": true,
400            "distill_note": note,
401        });
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    #[test]
410    fn test_capability_metadata() {
411        let cap = ToolOutputDistillationCapability;
412        assert_eq!(cap.id(), "tool_output_distillation");
413        assert!(!cap.post_tool_exec_hooks().is_empty());
414        assert!(cap.dependencies().contains(&"session_file_system"));
415    }
416
417    #[test]
418    fn test_head_tail_preserves_both_ends() {
419        let text = format!("START{}END", "x".repeat(10_000));
420        let out = head_tail(&text, 1024);
421        assert!(out.starts_with("START"));
422        assert!(out.ends_with("END"));
423        assert!(out.contains("bytes elided"));
424        assert!(out.len() < text.len());
425    }
426
427    #[test]
428    fn test_head_tail_noop_when_small() {
429        assert_eq!(head_tail("short", 1024), "short");
430    }
431
432    #[test]
433    fn test_head_tail_utf8_safe() {
434        // Multi-byte chars at the boundaries must not panic or split.
435        let text = "日本語".repeat(5_000);
436        let out = head_tail(&text, 1000);
437        assert!(out.contains("bytes elided"));
438        // Round-trips as valid UTF-8 (String guarantees it; assert non-empty ends).
439        assert!(!out.is_empty());
440    }
441
442    #[test]
443    fn test_distill_unified_diff_summarizes() {
444        let diff = "diff --git a/x.rs b/x.rs\n--- a/x.rs\n+++ b/x.rs\n@@ -1,3 +1,4 @@\n-old line\n+new line one\n+new line two\n context\n";
445        let summary = distill_unified_diff(diff).expect("recognized as diff");
446        assert!(summary.contains("diff --git a/x.rs b/x.rs"));
447        assert!(summary.contains("@@ -1,3 +1,4 @@"));
448        assert!(summary.contains("+2 / -1 lines"));
449        // Body content lines are dropped.
450        assert!(!summary.contains("new line one"));
451    }
452
453    #[test]
454    fn test_distill_non_diff_text_returns_none() {
455        assert!(distill_unified_diff("just some prose without diff markers").is_none());
456    }
457
458    #[test]
459    fn test_distill_value_samples_large_array() {
460        let big: Vec<Value> = (0..1000)
461            .map(|i| json!({"id": i, "name": format!("item-{i}")}))
462            .collect();
463        let mut value = json!({ "rows": big });
464        let mut stats = DistillStats::default();
465        distill_value(&mut value, 0, &mut stats);
466        assert!(stats.changed);
467        let rows = value["rows"].as_array().unwrap();
468        // SAMPLE_ROWS kept + 1 elision marker
469        assert_eq!(rows.len(), SAMPLE_ROWS + 1);
470        assert!(rows.last().unwrap().as_str().unwrap().contains("more item"));
471    }
472
473    #[test]
474    fn test_distill_value_clips_long_string_field() {
475        let mut value = json!({ "body": "y".repeat(50_000), "ok": true });
476        let mut stats = DistillStats::default();
477        distill_value(&mut value, 0, &mut stats);
478        assert!(stats.changed);
479        assert!(value["body"].as_str().unwrap().len() < 50_000);
480        // Small sibling field is untouched.
481        assert_eq!(value["ok"], json!(true));
482    }
483
484    #[test]
485    fn test_distill_value_noop_on_small() {
486        let mut value = json!({ "a": 1, "b": "small" });
487        let mut stats = DistillStats::default();
488        distill_value(&mut value, 0, &mut stats);
489        assert!(!stats.changed);
490    }
491
492    #[test]
493    fn test_distill_value_small_array_not_sampled() {
494        let mut value = json!({ "items": [1, 2, 3] });
495        let mut stats = DistillStats::default();
496        distill_value(&mut value, 0, &mut stats);
497        assert!(!stats.changed);
498        assert_eq!(value["items"].as_array().unwrap().len(), 3);
499    }
500
501    #[test]
502    fn test_serialize_json_bounded_rejects_oversized_output() {
503        let value = json!({ "body": "x".repeat(MAX_DISTILL_INPUT_BYTES + 1) });
504        let err = serialize_json_bounded(&value, MAX_DISTILL_INPUT_BYTES).unwrap_err();
505        assert!(err.is_io());
506    }
507
508    #[test]
509    fn test_distill_value_samples_long_array_without_serialized_size_gate() {
510        let mut value = json!({ "rows": [1, 2, 3, 4, 5, 6] });
511        let mut stats = DistillStats::default();
512        distill_value(&mut value, 0, &mut stats);
513        assert!(stats.changed);
514        assert_eq!(value["rows"].as_array().unwrap().len(), SAMPLE_ROWS + 1);
515    }
516
517    #[test]
518    fn test_inject_pointer_into_object() {
519        let mut value = json!({ "content": "distilled" });
520        inject_pointer(&mut value, "/workspace/outputs/abc.stdout", 50 * 1024);
521        assert_eq!(
522            value["output_files"],
523            json!(["/workspace/outputs/abc.stdout"])
524        );
525        assert_eq!(value["full_output"], json!("/workspace/outputs/abc.stdout"));
526        assert_eq!(value["distilled"], json!(true));
527        assert!(
528            value["distill_note"]
529                .as_str()
530                .unwrap()
531                .contains("read_file")
532        );
533    }
534
535    #[test]
536    fn test_inject_pointer_wraps_non_object() {
537        let mut value = json!(["a", "b"]);
538        inject_pointer(&mut value, "/workspace/outputs/x.stdout", 1024);
539        assert!(value.is_object());
540        assert_eq!(value["distilled_output"], json!(["a", "b"]));
541        assert_eq!(value["full_output"], json!("/workspace/outputs/x.stdout"));
542    }
543
544    #[test]
545    fn test_depth_bound_does_not_panic() {
546        // Build a deeply nested object beyond MAX_DEPTH.
547        let mut value = json!("x".repeat(50_000));
548        for _ in 0..50 {
549            value = json!({ "next": value });
550        }
551        let mut stats = DistillStats::default();
552        // Must terminate without panicking despite exceeding MAX_DEPTH.
553        distill_value(&mut value, 0, &mut stats);
554    }
555
556    // --- Hook-level integration tests (after_exec end to end) ---
557
558    use crate::error::Result;
559    use crate::session_file::{FileInfo, FileStat, GrepMatch, SessionFile};
560    use crate::tool_types::{BuiltinTool, DeferrablePolicy, ToolHints, ToolPolicy};
561    use crate::traits::SessionFileSystem;
562    use crate::typed_id::SessionId;
563    use chrono::Utc;
564    use std::collections::HashMap;
565    use std::sync::Mutex;
566    use uuid::Uuid;
567
568    #[derive(Default)]
569    struct MockFileStore {
570        files: Mutex<HashMap<String, String>>,
571        fail_writes: bool,
572    }
573
574    impl MockFileStore {
575        fn content(&self, path: &str) -> Option<String> {
576            self.files.lock().unwrap().get(path).cloned()
577        }
578    }
579
580    #[async_trait]
581    impl SessionFileSystem for MockFileStore {
582        fn is_mount_resolver(&self) -> bool {
583            false
584        }
585
586        async fn read_file(&self, _s: SessionId, _p: &str) -> Result<Option<SessionFile>> {
587            Ok(None)
588        }
589        async fn write_file(
590            &self,
591            _s: SessionId,
592            path: &str,
593            content: &str,
594            _encoding: &str,
595        ) -> Result<SessionFile> {
596            if self.fail_writes {
597                return Err(anyhow::anyhow!("write failed").into());
598            }
599            self.files
600                .lock()
601                .unwrap()
602                .insert(path.to_string(), content.to_string());
603            Ok(SessionFile {
604                id: Uuid::new_v4(),
605                session_id: Uuid::nil(),
606                path: path.to_string(),
607                name: path.rsplit('/').next().unwrap_or("").to_string(),
608                content: Some(content.to_string()),
609                encoding: "utf-8".to_string(),
610                is_directory: false,
611                is_readonly: false,
612                size_bytes: content.len() as i64,
613                created_at: Utc::now(),
614                updated_at: Utc::now(),
615            })
616        }
617        async fn delete_file(&self, _s: SessionId, _p: &str, _r: bool) -> Result<bool> {
618            Ok(false)
619        }
620        async fn list_directory(&self, _s: SessionId, _p: &str) -> Result<Vec<FileInfo>> {
621            Ok(vec![])
622        }
623        async fn stat_file(&self, _s: SessionId, _p: &str) -> Result<Option<FileStat>> {
624            Ok(None)
625        }
626        async fn grep_files(
627            &self,
628            _s: SessionId,
629            _pat: &str,
630            _pp: Option<&str>,
631        ) -> Result<Vec<GrepMatch>> {
632            Ok(vec![])
633        }
634        async fn create_directory(&self, _s: SessionId, _p: &str) -> Result<FileInfo> {
635            Err(anyhow::anyhow!("not implemented").into())
636        }
637    }
638
639    fn mcp_tool_def(persist_output: bool) -> ToolDefinition {
640        let mut hints = ToolHints::default();
641        if persist_output {
642            hints = hints.with_persist_output(true);
643        }
644        ToolDefinition::Builtin(BuiltinTool {
645            name: "mcp__server__big_query".to_string(),
646            display_name: None,
647            description: "an mcp tool".to_string(),
648            parameters: json!({}),
649            policy: ToolPolicy::Auto,
650            category: None,
651            deferrable: DeferrablePolicy::default(),
652            hints,
653            full_parameters: None,
654        })
655    }
656
657    fn tool_call() -> ToolCall {
658        ToolCall {
659            id: "call_123".to_string(),
660            name: "mcp__server__big_query".to_string(),
661            arguments: json!({}),
662        }
663    }
664
665    fn ctx_with_store(store: Arc<MockFileStore>) -> ToolContext {
666        let mut ctx = ToolContext::new(SessionId::from(Uuid::nil()));
667        ctx.file_store = Some(store);
668        ctx
669    }
670
671    fn big_result() -> ToolResult {
672        // A large array result, well over MIN_DISTILL_BYTES once serialized.
673        let rows: Vec<Value> = (0..2000)
674            .map(|i| json!({"id": i, "name": format!("row-number-{i}")}))
675            .collect();
676        ToolResult {
677            tool_call_id: "call_123".to_string(),
678            result: Some(json!({ "rows": rows })),
679            images: None,
680            error: None,
681            connection_required: None,
682            raw_output: None,
683        }
684    }
685
686    #[tokio::test]
687    async fn test_hook_distills_large_mcp_result_and_persists_original() {
688        let store = Arc::new(MockFileStore::default());
689        let ctx = ctx_with_store(store.clone());
690        let mut result = big_result();
691
692        DistillOutputHook
693            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
694            .await;
695
696        let value = result.result.as_ref().unwrap();
697        // Inline view is distilled: array sampled + pointer injected.
698        assert_eq!(value["distilled"], json!(true));
699        assert_eq!(
700            value["output_files"],
701            json!(["/workspace/outputs/call_123.stdout"])
702        );
703        let rows = value["rows"].as_array().unwrap();
704        assert_eq!(rows.len(), SAMPLE_ROWS + 1);
705        // Full original is recoverable from the VFS.
706        let persisted = store
707            .content("/outputs/call_123.stdout")
708            .expect("original persisted");
709        assert!(persisted.contains("row-number-1999"));
710    }
711
712    #[tokio::test]
713    async fn test_hook_fallback_persists_large_unsampleable_result() {
714        // A large object made entirely of sub-threshold fields: the shape walker
715        // changes nothing, but the result is still large. The fallback must
716        // bound it, persist the original, and inject a recovery pointer so it is
717        // never head-truncated irrecoverably by the hard-limit hook.
718        let store = Arc::new(MockFileStore::default());
719        let ctx = ctx_with_store(store.clone());
720        let mut map = serde_json::Map::new();
721        for i in 0..2000 {
722            map.insert(format!("field_{i}"), json!(format!("v{i}")));
723        }
724        let mut result = ToolResult {
725            tool_call_id: "call_123".to_string(),
726            result: Some(Value::Object(map)),
727            images: None,
728            error: None,
729            connection_required: None,
730            raw_output: None,
731        };
732
733        DistillOutputHook
734            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
735            .await;
736
737        let value = result.result.as_ref().unwrap();
738        assert_eq!(value["distilled"], json!(true));
739        assert_eq!(
740            value["output_files"],
741            json!(["/workspace/outputs/call_123.stdout"])
742        );
743        // Inline view is bounded (the wrapped head+tail string is small).
744        assert!(value["distilled_output"].as_str().unwrap().len() < 8 * 1024);
745        // Full original recoverable.
746        let persisted = store
747            .content("/outputs/call_123.stdout")
748            .expect("original persisted");
749        assert!(persisted.contains("field_1999"));
750    }
751
752    #[tokio::test]
753    async fn test_hook_skips_persist_output_tools() {
754        let store = Arc::new(MockFileStore::default());
755        let ctx = ctx_with_store(store.clone());
756        let mut result = big_result();
757
758        DistillOutputHook
759            .after_exec(&tool_call(), &mcp_tool_def(true), &mut result, &ctx)
760            .await;
761
762        // Untouched: exec/sandbox path is handled by budget + persistence.
763        assert!(result.result.as_ref().unwrap().get("distilled").is_none());
764        assert!(store.content("/outputs/call_123.stdout").is_none());
765    }
766
767    #[tokio::test]
768    async fn test_hook_skips_small_result() {
769        let store = Arc::new(MockFileStore::default());
770        let ctx = ctx_with_store(store.clone());
771        let mut result = ToolResult {
772            tool_call_id: "call_123".to_string(),
773            result: Some(json!({ "ok": true, "value": "small" })),
774            images: None,
775            error: None,
776            connection_required: None,
777            raw_output: None,
778        };
779
780        DistillOutputHook
781            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
782            .await;
783
784        assert!(result.result.as_ref().unwrap().get("distilled").is_none());
785        assert!(store.content("/outputs/call_123.stdout").is_none());
786    }
787
788    #[tokio::test]
789    async fn test_hook_no_file_store_leaves_verbatim() {
790        let mut ctx = ToolContext::new(SessionId::from(Uuid::nil()));
791        ctx.file_store = None;
792        let mut result = big_result();
793        let before = result.result.clone();
794
795        DistillOutputHook
796            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
797            .await;
798
799        // No recoverability available → no lossy distillation.
800        assert_eq!(result.result, before);
801    }
802
803    #[tokio::test]
804    async fn test_hook_restores_original_when_persist_fails() {
805        let store = Arc::new(MockFileStore {
806            fail_writes: true,
807            ..Default::default()
808        });
809        let ctx = ctx_with_store(store.clone());
810        let mut result = big_result();
811        let before = result.result.clone();
812
813        DistillOutputHook
814            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
815            .await;
816
817        // Persistence failed → verbatim original restored, never lossy-irrecoverable.
818        assert_eq!(result.result, before);
819        assert!(result.result.as_ref().unwrap().get("distilled").is_none());
820    }
821
822    #[tokio::test]
823    async fn test_hook_skips_error_results() {
824        let store = Arc::new(MockFileStore::default());
825        let ctx = ctx_with_store(store.clone());
826        let mut result = big_result();
827        result.error = Some("boom".to_string());
828
829        DistillOutputHook
830            .after_exec(&tool_call(), &mcp_tool_def(false), &mut result, &ctx)
831            .await;
832
833        assert!(result.result.as_ref().unwrap().get("distilled").is_none());
834    }
835}