1use 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
46const MIN_DISTILL_BYTES: usize = 8 * 1024;
49
50const MAX_FIELD_BYTES: usize = 2 * 1024;
53
54const SAMPLE_ROWS: usize = 5;
56
57const MAX_DEPTH: usize = 8;
59
60const MAX_NODES: usize = 100_000;
62
63const MAX_DISTILL_INPUT_BYTES: usize = 1024 * 1024;
67
68pub const TOOL_OUTPUT_DISTILLATION_CAPABILITY_ID: &str = "tool_output_distillation";
69
70pub 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
112pub 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 if tool_def.hints().persist_output == Some(true) {
128 return;
129 }
130
131 if result.error.is_some() {
134 return;
135 }
136
137 let Some(result_value) = result.result.as_mut() else {
138 return;
139 };
140
141 if result_value.get("output_files").is_some() {
144 return;
145 }
146
147 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 let Some(file_store) = context.file_store.as_ref() else {
160 return;
161 };
162
163 let original = result_value.clone();
165 let mut stats = DistillStats::default();
166 distill_value(result_value, 0, &mut stats);
167 if !stats.changed {
168 *result_value = Value::String(head_tail(&serialized, MAX_FIELD_BYTES));
175 }
176
177 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 *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#[derive(Default)]
204struct DistillStats {
205 changed: bool,
206 nodes: usize,
207}
208
209fn 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 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 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
294fn 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
307fn 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
348fn 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
377fn 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 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 let text = "日本語".repeat(5_000);
436 let out = head_tail(&text, 1000);
437 assert!(out.contains("bytes elided"));
438 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 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 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 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 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 distill_value(&mut value, 0, &mut stats);
554 }
555
556 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 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 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 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 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 assert!(value["distilled_output"].as_str().unwrap().len() < 8 * 1024);
745 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 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 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 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}