1use std::sync::Arc;
23
24use async_trait::async_trait;
25
26use crate::plugin::{ContextTransform, Plugin, PluginCapabilities, TransformContext};
27use crate::tool::ToolRegistry;
28use crate::types::{AgentMessage, TextContent, ToolResultBlock, ToolResultContent};
29
30pub const DEFAULT_PER_TOOL_CHARS: usize = 32_000;
35
36const MARKER_BUDGET_CHARS: usize = 256;
41
42pub struct ToolResultBudget {
50 pub default_max_chars: usize,
52 registry: Arc<ToolRegistry>,
56}
57
58impl ToolResultBudget {
59 pub fn new(registry: Arc<ToolRegistry>) -> Self {
62 Self {
63 default_max_chars: DEFAULT_PER_TOOL_CHARS,
64 registry,
65 }
66 }
67
68 pub fn with_default_max_chars(mut self, chars: usize) -> Self {
71 self.default_max_chars = chars;
72 self
73 }
74
75 fn cap_for(&self, tool_name: &str) -> usize {
80 self.registry
81 .get(tool_name)
82 .and_then(|tool| tool.max_result_chars())
83 .unwrap_or(self.default_max_chars)
84 }
85}
86
87impl Plugin for ToolResultBudget {
88 fn name(&self) -> &'static str {
89 "tool_result_budget"
90 }
91 fn capabilities(&self) -> PluginCapabilities {
92 PluginCapabilities::context_transform()
93 }
94}
95
96#[async_trait]
97impl ContextTransform for ToolResultBudget {
98 async fn transform(
99 &self,
100 mut messages: Vec<AgentMessage>,
101 _cx: &TransformContext<'_>,
102 ) -> Vec<AgentMessage> {
103 let last_tool_idx = messages
108 .iter()
109 .enumerate()
110 .rev()
111 .find_map(|(idx, m)| matches!(m, AgentMessage::ToolResult { .. }).then_some(idx));
112
113 for (idx, message) in messages.iter_mut().enumerate() {
114 let AgentMessage::ToolResult {
115 tool_call_id,
116 tool_name,
117 content,
118 ..
119 } = message
120 else {
121 continue;
122 };
123 if Some(idx) == last_tool_idx {
124 continue;
125 }
126 let cap = self.cap_for(tool_name);
127 if cap == usize::MAX {
128 continue;
129 }
130 let original = content_chars(content);
131 if original <= cap {
132 continue;
133 }
134 if is_already_marker(content) {
135 continue;
136 }
137 let marker = render_marker(tool_call_id, tool_name, original, cap);
138 *content = ToolResultContent {
139 blocks: vec![ToolResultBlock::Text(TextContent { text: marker })],
140 };
141 }
142
143 messages
144 }
145}
146
147fn content_chars(content: &ToolResultContent) -> usize {
148 content
149 .blocks
150 .iter()
151 .map(|b| match b {
152 ToolResultBlock::Text(t) => t.text.len(),
153 ToolResultBlock::Image(_) => 0,
157 })
158 .sum()
159}
160
161const MARKER_PREFIX: &str = "[tool_result_budget: clipped";
164
165fn render_marker(tool_call_id: &str, tool_name: &str, original_chars: usize, cap: usize) -> String {
166 let body = format!(
167 "{MARKER_PREFIX} {tool_name} result of {original_chars} chars to {cap} cap; \
168 tool_call_id={tool_call_id}; rerun the tool to refetch the original output]"
169 );
170 if body.len() <= MARKER_BUDGET_CHARS {
171 body
172 } else {
173 let mut t = body;
176 t.truncate(MARKER_BUDGET_CHARS);
177 t
178 }
179}
180
181fn is_already_marker(content: &ToolResultContent) -> bool {
182 content.blocks.len() == 1
183 && matches!(
184 &content.blocks[0],
185 ToolResultBlock::Text(t) if t.text.starts_with(MARKER_PREFIX)
186 )
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::error::ToolError;
193 use crate::tool::{AgentTool, ToolResult, ToolUpdateSink};
194 use async_trait::async_trait;
195 use serde_json::Value;
196 use tokio_util::sync::CancellationToken;
197
198 struct FakeTool {
199 name: String,
200 cap: Option<usize>,
201 }
202
203 #[async_trait]
204 impl AgentTool for FakeTool {
205 fn name(&self) -> &str {
206 &self.name
207 }
208 fn description(&self) -> &str {
209 ""
210 }
211 fn parameters_schema(&self) -> Value {
212 serde_json::json!({"type": "object"})
213 }
214 fn max_result_chars(&self) -> Option<usize> {
215 self.cap
216 }
217 async fn execute(
218 &self,
219 _call_id: &str,
220 _args: Value,
221 _signal: CancellationToken,
222 _update: ToolUpdateSink,
223 ) -> Result<ToolResult, ToolError> {
224 unreachable!("not invoked in budget tests")
225 }
226 }
227
228 fn registry_with(tools: Vec<(&str, Option<usize>)>) -> Arc<ToolRegistry> {
229 let mut r = ToolRegistry::new();
230 for (name, cap) in tools {
231 r.register(Arc::new(FakeTool {
232 name: name.into(),
233 cap,
234 }));
235 }
236 Arc::new(r)
237 }
238
239 fn tool_result(id: &str, name: &str, body: String) -> AgentMessage {
240 AgentMessage::ToolResult {
241 tool_call_id: id.into(),
242 tool_name: name.into(),
243 content: ToolResultContent::text(body),
244 is_error: false,
245 narration: None,
246 details: None,
247 timestamp: None,
248 }
249 }
250
251 fn user(text: &str) -> AgentMessage {
252 AgentMessage::User {
253 content: crate::types::UserContent::Text(text.into()),
254 timestamp: None,
255 }
256 }
257
258 fn block_text(message: &AgentMessage) -> &str {
259 let AgentMessage::ToolResult { content, .. } = message else {
260 panic!("expected tool result");
261 };
262 let ToolResultBlock::Text(t) = &content.blocks[0] else {
263 panic!("expected text block");
264 };
265 &t.text
266 }
267
268 #[tokio::test]
269 async fn clips_oversize_tool_results_above_default_cap() {
270 let registry = registry_with(vec![("shell", None)]);
271 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
272 let big = "x".repeat(500);
273 let messages = vec![
274 user("hi"),
275 tool_result("a", "shell", big.clone()),
276 user("again"),
277 tool_result("b", "shell", big),
278 ];
279 let token = CancellationToken::new();
280 let cx = TransformContext::for_test(&token);
281 let out = budget.transform(messages, &cx).await;
282 assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
284 assert_eq!(block_text(&out[3]).len(), 500);
286 }
287
288 #[tokio::test]
289 async fn preserves_tool_results_within_cap() {
290 let registry = registry_with(vec![("shell", None)]);
291 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
292 let small = "x".repeat(50);
293 let messages = vec![
294 user("hi"),
295 tool_result("a", "shell", small.clone()),
296 user("again"),
297 tool_result("b", "shell", small),
298 ];
299 let token = CancellationToken::new();
300 let cx = TransformContext::for_test(&token);
301 let out = budget.transform(messages.clone(), &cx).await;
302 assert_eq!(out, messages);
303 }
304
305 #[tokio::test]
306 async fn per_tool_override_unlimited_keeps_verbatim() {
307 let registry = registry_with(vec![("publish", Some(usize::MAX))]);
308 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
309 let big = "x".repeat(500);
310 let messages = vec![
311 user("hi"),
312 tool_result("a", "publish", big.clone()),
313 user("more"),
314 user("again"),
315 ];
316 let token = CancellationToken::new();
317 let cx = TransformContext::for_test(&token);
318 let out = budget.transform(messages, &cx).await;
319 assert_eq!(block_text(&out[1]).len(), 500);
321 }
322
323 #[tokio::test]
324 async fn per_tool_override_smaller_clips_below_default() {
325 let registry = registry_with(vec![("verbose", Some(50))]);
326 let budget = ToolResultBudget::new(registry).with_default_max_chars(1_000_000);
327 let body = "x".repeat(200);
328 let messages = vec![
329 user("hi"),
330 tool_result("a", "verbose", body.clone()),
331 user("more"),
332 tool_result("b", "verbose", body),
333 ];
334 let token = CancellationToken::new();
335 let cx = TransformContext::for_test(&token);
336 let out = budget.transform(messages, &cx).await;
337 assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
338 }
339
340 #[tokio::test]
341 async fn idempotent_across_repeated_apply() {
342 let registry = registry_with(vec![("shell", None)]);
343 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
344 let big = "x".repeat(500);
345 let messages = vec![
346 user("hi"),
347 tool_result("a", "shell", big.clone()),
348 user("again"),
349 tool_result("b", "shell", big),
350 ];
351 let token = CancellationToken::new();
352 let cx = TransformContext::for_test(&token);
353 let once = budget.transform(messages, &cx).await;
354 let twice = budget.transform(once.clone(), &cx).await;
355 assert_eq!(once, twice);
356 }
357
358 #[tokio::test]
359 async fn unknown_tool_falls_back_to_default_cap() {
360 let registry = registry_with(vec![]);
361 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
362 let big = "x".repeat(500);
363 let messages = vec![
364 user("hi"),
365 tool_result("a", "synthetic", big.clone()),
366 user("again"),
367 tool_result("b", "synthetic", big),
368 ];
369 let token = CancellationToken::new();
370 let cx = TransformContext::for_test(&token);
371 let out = budget.transform(messages, &cx).await;
372 assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
373 }
374}