Skip to main content

llm_stack/tool/
processor.rs

1//! Post-execution processing of tool results.
2//!
3//! The [`ToolResultProcessor`] trait allows callers to transform tool
4//! output **after** execution but **before** it enters the conversation
5//! context. Common uses:
6//!
7//! - Structural pruning (strip HTML, truncate large results)
8//! - Token budget enforcement (cap results at N tokens)
9//! - Format normalization (convert tables to markdown)
10//!
11//! # Example
12//!
13//! ```rust
14//! use llm_stack::tool::{ToolResultProcessor, ProcessedResult};
15//! use llm_stack::context::estimate_tokens;
16//!
17//! struct TruncateProcessor {
18//!     max_chars: usize,
19//! }
20//!
21//! impl ToolResultProcessor for TruncateProcessor {
22//!     fn process(&self, _tool_name: &str, output: &str) -> ProcessedResult {
23//!         if output.len() <= self.max_chars {
24//!             return ProcessedResult::unchanged();
25//!         }
26//!         let truncated = &output[..self.max_chars];
27//!         ProcessedResult {
28//!             content: format!("{truncated}\n[truncated — original was {} chars]", output.len()),
29//!             was_processed: true,
30//!             original_tokens_est: estimate_tokens(output),
31//!             processed_tokens_est: estimate_tokens(truncated) + 10,
32//!         }
33//!     }
34//! }
35//! ```
36
37/// Processes tool results before they enter the conversation context.
38///
39/// Implementations receive the tool name (for per-tool dispatch) and the
40/// raw output string. They return a [`ProcessedResult`] indicating whether
41/// the content was modified and providing token estimates for observability.
42///
43/// The processor runs synchronously. For heavyweight transformations (e.g.,
44/// calling another LLM for semantic extraction), consider doing the work
45/// inside the tool handler itself and using the processor only for
46/// structural operations.
47pub trait ToolResultProcessor: Send + Sync {
48    /// Process a tool result, optionally transforming its content.
49    ///
50    /// # Arguments
51    ///
52    /// * `tool_name` — The name of the tool that produced this result.
53    /// * `output` — The raw output string from tool execution.
54    ///
55    /// Return [`ProcessedResult::unchanged()`] to pass through unmodified.
56    fn process(&self, tool_name: &str, output: &str) -> ProcessedResult;
57}
58
59/// The result of processing a tool's output.
60#[derive(Debug, Clone)]
61pub struct ProcessedResult {
62    /// The (possibly transformed) content to use in the conversation.
63    pub content: String,
64    /// Whether the content was modified by the processor.
65    pub was_processed: bool,
66    /// Estimated token count of the original output.
67    pub original_tokens_est: u32,
68    /// Estimated token count of the processed output.
69    pub processed_tokens_est: u32,
70}
71
72impl ProcessedResult {
73    /// Create a result indicating no processing was performed.
74    ///
75    /// The content field is left empty — callers should use the original
76    /// output when `was_processed` is false.
77    pub fn unchanged() -> Self {
78        Self {
79            content: String::new(),
80            was_processed: false,
81            original_tokens_est: 0,
82            processed_tokens_est: 0,
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    struct UpperCaseProcessor;
92
93    impl ToolResultProcessor for UpperCaseProcessor {
94        fn process(&self, _tool_name: &str, output: &str) -> ProcessedResult {
95            ProcessedResult {
96                content: output.to_uppercase(),
97                was_processed: true,
98                original_tokens_est: crate::context::estimate_tokens(output),
99                processed_tokens_est: crate::context::estimate_tokens(output),
100            }
101        }
102    }
103
104    struct SelectiveProcessor;
105
106    impl ToolResultProcessor for SelectiveProcessor {
107        fn process(&self, tool_name: &str, output: &str) -> ProcessedResult {
108            if tool_name == "web_search" && output.len() > 100 {
109                ProcessedResult {
110                    content: output[..100].to_string(),
111                    was_processed: true,
112                    original_tokens_est: crate::context::estimate_tokens(output),
113                    processed_tokens_est: 25,
114                }
115            } else {
116                ProcessedResult::unchanged()
117            }
118        }
119    }
120
121    #[test]
122    fn test_unchanged_result() {
123        let result = ProcessedResult::unchanged();
124        assert!(!result.was_processed);
125        assert!(result.content.is_empty());
126        assert_eq!(result.original_tokens_est, 0);
127        assert_eq!(result.processed_tokens_est, 0);
128    }
129
130    #[test]
131    fn test_processor_transforms_output() {
132        let processor = UpperCaseProcessor;
133        let result = processor.process("any_tool", "hello world");
134        assert!(result.was_processed);
135        assert_eq!(result.content, "HELLO WORLD");
136    }
137
138    #[test]
139    fn test_selective_processor_skips_non_matching() {
140        let processor = SelectiveProcessor;
141        let result = processor.process("calculator", "42");
142        assert!(!result.was_processed);
143    }
144
145    #[test]
146    fn test_selective_processor_truncates_matching() {
147        let processor = SelectiveProcessor;
148        let long_output = "x".repeat(200);
149        let result = processor.process("web_search", &long_output);
150        assert!(result.was_processed);
151        assert_eq!(result.content.len(), 100);
152    }
153
154    #[test]
155    fn test_selective_processor_skips_short_matching() {
156        let processor = SelectiveProcessor;
157        let result = processor.process("web_search", "short");
158        assert!(!result.was_processed);
159    }
160
161    #[test]
162    fn test_processor_is_object_safe() {
163        // Verify the trait works as a trait object
164        let processor: Box<dyn ToolResultProcessor> = Box::new(UpperCaseProcessor);
165        let result = processor.process("tool", "test");
166        assert_eq!(result.content, "TEST");
167    }
168
169    #[test]
170    fn test_processor_token_estimates() {
171        let processor = UpperCaseProcessor;
172        let result = processor.process("tool", "Hello world!!");
173        // 13 chars → ceil(13/4) = 4 tokens
174        assert_eq!(result.original_tokens_est, 4);
175        assert_eq!(result.processed_tokens_est, 4);
176    }
177}