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}