1use std::sync::Arc;
27
28use async_trait::async_trait;
29
30use crate::plugin::{ContextTransform, Plugin, PluginCapabilities, TransformContext};
31use crate::tool::ToolRegistry;
32use crate::types::{AgentMessage, TextContent, ToolResultBlock, ToolResultContent};
33
34pub const DEFAULT_PER_TOOL_CHARS: usize = 32_000;
39
40const MARKER_BUDGET_CHARS: usize = 256;
45
46pub struct ToolResultBudget {
54 pub default_max_chars: usize,
56 registry: Arc<ToolRegistry>,
60}
61
62impl ToolResultBudget {
63 pub fn new(registry: Arc<ToolRegistry>) -> Self {
66 Self {
67 default_max_chars: DEFAULT_PER_TOOL_CHARS,
68 registry,
69 }
70 }
71
72 pub fn with_default_max_chars(mut self, chars: usize) -> Self {
75 self.default_max_chars = chars;
76 self
77 }
78
79 fn cap_for(&self, tool_name: &str) -> usize {
84 self.registry
85 .get(tool_name)
86 .and_then(|tool| tool.max_result_chars())
87 .unwrap_or(self.default_max_chars)
88 }
89}
90
91impl Plugin for ToolResultBudget {
92 fn name(&self) -> &'static str {
93 "tool_result_budget"
94 }
95 fn capabilities(&self) -> PluginCapabilities {
96 PluginCapabilities::context_transform()
97 }
98}
99
100#[async_trait]
101impl ContextTransform for ToolResultBudget {
102 async fn transform(
103 &self,
104 mut messages: Vec<AgentMessage>,
105 _cx: &TransformContext<'_>,
106 ) -> Vec<AgentMessage> {
107 let fresh_batch_start = messages
113 .iter()
114 .rposition(|message| matches!(message, AgentMessage::Assistant { .. }))
115 .and_then(|index| match &messages[index] {
116 AgentMessage::Assistant { content, .. } if !content.tool_calls().is_empty() => {
117 Some(index + 1)
118 }
119 _ => None,
120 });
121
122 for (index, message) in messages.iter_mut().enumerate() {
123 let AgentMessage::ToolResult {
124 tool_call_id,
125 tool_name,
126 content,
127 ..
128 } = message
129 else {
130 continue;
131 };
132 if fresh_batch_start.is_some_and(|start| index >= start) {
133 continue;
134 }
135 let cap = self.cap_for(tool_name);
136 if cap == usize::MAX {
137 continue;
138 }
139 let original = content_chars(content);
140 if original <= cap {
141 continue;
142 }
143 if is_already_marker(content) {
144 continue;
145 }
146 *content = clip_content(content, tool_call_id, tool_name, original, cap);
147 }
148
149 messages
150 }
151}
152
153fn content_chars(content: &ToolResultContent) -> usize {
154 content
155 .blocks
156 .iter()
157 .map(|b| match b {
158 ToolResultBlock::Text(t) => t.text.len(),
159 ToolResultBlock::Image(_) => 0,
163 })
164 .sum()
165}
166
167fn clip_content(
168 content: &ToolResultContent,
169 tool_call_id: &str,
170 tool_name: &str,
171 original_chars: usize,
172 cap: usize,
173) -> ToolResultContent {
174 let marker = render_marker(tool_call_id, tool_name, original_chars, cap);
175 let projected = bounded_excerpt(&content.plain_text(), &marker, cap);
176 let mut blocks = vec![ToolResultBlock::Text(TextContent { text: projected })];
177 blocks.extend(
178 content
179 .blocks
180 .iter()
181 .filter(|block| matches!(block, ToolResultBlock::Image(_)))
182 .cloned(),
183 );
184 ToolResultContent { blocks }
185}
186
187fn bounded_excerpt(text: &str, marker: &str, cap: usize) -> String {
188 const SEPARATOR: &str = "\n\n";
189 let fixed = marker
190 .len()
191 .saturating_add(SEPARATOR.len().saturating_mul(2));
192 if cap <= fixed {
193 let marker_end = floor_char_boundary(marker, cap.min(marker.len()));
194 return marker[..marker_end].to_string();
195 }
196 let evidence = cap - fixed;
197 let head_budget = evidence.saturating_mul(3) / 4;
198 let tail_budget = evidence - head_budget;
199 let head_end = floor_char_boundary(text, head_budget.min(text.len()));
200 let tail_start = ceil_char_boundary(text, text.len().saturating_sub(tail_budget));
201 format!(
202 "{}{SEPARATOR}{marker}{SEPARATOR}{}",
203 &text[..head_end],
204 &text[tail_start..]
205 )
206}
207
208fn floor_char_boundary(text: &str, mut index: usize) -> usize {
209 while index > 0 && !text.is_char_boundary(index) {
210 index -= 1;
211 }
212 index
213}
214
215fn ceil_char_boundary(text: &str, mut index: usize) -> usize {
216 while index < text.len() && !text.is_char_boundary(index) {
217 index += 1;
218 }
219 index
220}
221
222const MARKER_PREFIX: &str = "[tool_result_budget: clipped";
225
226fn render_marker(tool_call_id: &str, tool_name: &str, original_chars: usize, cap: usize) -> String {
227 let body = format!(
228 "{MARKER_PREFIX} {tool_name} result of {original_chars} chars to {cap} cap; \
229 bounded head/tail evidence retained; tool_call_id={tool_call_id}; \
230 rerun only if the omitted middle is necessary]"
231 );
232 if body.len() <= MARKER_BUDGET_CHARS {
233 body
234 } else {
235 let mut t = body;
238 t.truncate(MARKER_BUDGET_CHARS);
239 t
240 }
241}
242
243fn is_already_marker(content: &ToolResultContent) -> bool {
244 content
245 .blocks
246 .iter()
247 .any(|block| matches!(block, ToolResultBlock::Text(t) if t.text.contains(MARKER_PREFIX)))
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::error::ToolError;
254 use crate::tool::{AgentTool, ToolResult, ToolUpdateSink};
255 use async_trait::async_trait;
256 use serde_json::Value;
257 use tokio_util::sync::CancellationToken;
258
259 struct FakeTool {
260 name: String,
261 cap: Option<usize>,
262 }
263
264 #[async_trait]
265 impl AgentTool for FakeTool {
266 fn name(&self) -> &str {
267 &self.name
268 }
269 fn description(&self) -> &str {
270 ""
271 }
272 fn parameters_schema(&self) -> Value {
273 serde_json::json!({"type": "object"})
274 }
275 fn max_result_chars(&self) -> Option<usize> {
276 self.cap
277 }
278 async fn execute(
279 &self,
280 _call_id: &str,
281 _args: Value,
282 _signal: CancellationToken,
283 _update: ToolUpdateSink,
284 ) -> Result<ToolResult, ToolError> {
285 unreachable!("not invoked in budget tests")
286 }
287 }
288
289 fn registry_with(tools: Vec<(&str, Option<usize>)>) -> Arc<ToolRegistry> {
290 let mut r = ToolRegistry::new();
291 for (name, cap) in tools {
292 r.register(Arc::new(FakeTool {
293 name: name.into(),
294 cap,
295 }));
296 }
297 Arc::new(r)
298 }
299
300 fn tool_result(id: &str, name: &str, body: String) -> AgentMessage {
301 AgentMessage::ToolResult {
302 tool_call_id: id.into(),
303 tool_name: name.into(),
304 content: ToolResultContent::text(body),
305 is_error: false,
306 narration: None,
307 details: None,
308 timestamp: None,
309 }
310 }
311
312 fn user(text: &str) -> AgentMessage {
313 AgentMessage::User {
314 content: crate::types::UserContent::Text(text.into()),
315 timestamp: None,
316 }
317 }
318
319 fn assistant_calls(calls: &[(&str, &str)]) -> AgentMessage {
320 AgentMessage::Assistant {
321 content: crate::types::AssistantContent::with_tool_calls(
322 None,
323 calls
324 .iter()
325 .map(|(id, name)| crate::tool::ToolCall {
326 id: (*id).into(),
327 name: (*name).into(),
328 arguments: serde_json::json!({}),
329 })
330 .collect(),
331 ),
332 stop_reason: crate::types::StopReason::ToolUse,
333 error_message: None,
334 timestamp: None,
335 usage: None,
336 }
337 }
338
339 fn block_text(message: &AgentMessage) -> &str {
340 let AgentMessage::ToolResult { content, .. } = message else {
341 panic!("expected tool result");
342 };
343 let ToolResultBlock::Text(t) = &content.blocks[0] else {
344 panic!("expected text block");
345 };
346 &t.text
347 }
348
349 #[tokio::test]
350 async fn clips_old_results_but_preserves_the_entire_fresh_batch() {
351 let registry = registry_with(vec![("shell", None)]);
352 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
353 let big = "x".repeat(500);
354 let messages = vec![
355 user("hi"),
356 assistant_calls(&[("a", "shell")]),
357 tool_result("a", "shell", big.clone()),
358 user("again"),
359 assistant_calls(&[("b", "shell"), ("c", "shell")]),
360 tool_result("b", "shell", big),
361 tool_result("c", "shell", "y".repeat(500)),
362 ];
363 let token = CancellationToken::new();
364 let cx = TransformContext::for_test(&token);
365 let out = budget.transform(messages, &cx).await;
366 assert!(block_text(&out[2]).contains(MARKER_PREFIX));
367 assert_eq!(block_text(&out[5]).len(), 500);
368 assert_eq!(block_text(&out[6]).len(), 500);
369 }
370
371 #[tokio::test]
372 async fn useful_excerpt_keeps_head_and_tail_within_cap() {
373 let registry = registry_with(vec![("web_fetch", None)]);
374 let budget = ToolResultBudget::new(registry).with_default_max_chars(400);
375 let body = format!("HEAD-{}-TAIL", "x".repeat(1_000));
376 let messages = vec![tool_result("fetch-1", "web_fetch", body)];
377 let token = CancellationToken::new();
378 let cx = TransformContext::for_test(&token);
379 let out = budget.transform(messages, &cx).await;
380 let projected = block_text(&out[0]);
381
382 assert!(projected.starts_with("HEAD-"));
383 assert!(projected.contains(MARKER_PREFIX));
384 assert!(projected.ends_with("-TAIL"));
385 assert!(projected.len() <= 400);
386 }
387
388 #[test]
389 fn bounded_excerpt_preserves_utf8_boundaries() {
390 let text = format!("start-{}-end", "🦀".repeat(200));
391 let excerpt = bounded_excerpt(&text, "[marker]", 200);
392 assert!(excerpt.starts_with("start-"));
393 assert!(excerpt.contains("[marker]"));
394 assert!(excerpt.ends_with("-end"));
395 assert!(excerpt.len() <= 200);
396 }
397
398 #[test]
399 fn bounded_excerpt_never_exceeds_a_tiny_cap() {
400 let excerpt = bounded_excerpt(
401 &"x".repeat(1_000),
402 &render_marker("a", "shell", 1_000, 50),
403 50,
404 );
405 assert!(excerpt.starts_with(MARKER_PREFIX));
406 assert!(excerpt.len() <= 50);
407 }
408
409 #[tokio::test]
410 async fn preserves_tool_results_within_cap() {
411 let registry = registry_with(vec![("shell", None)]);
412 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
413 let small = "x".repeat(50);
414 let messages = vec![
415 user("hi"),
416 tool_result("a", "shell", small.clone()),
417 user("again"),
418 tool_result("b", "shell", small),
419 ];
420 let token = CancellationToken::new();
421 let cx = TransformContext::for_test(&token);
422 let out = budget.transform(messages.clone(), &cx).await;
423 assert_eq!(out, messages);
424 }
425
426 #[tokio::test]
427 async fn per_tool_override_unlimited_keeps_verbatim() {
428 let registry = registry_with(vec![("publish", Some(usize::MAX))]);
429 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
430 let big = "x".repeat(500);
431 let messages = vec![
432 user("hi"),
433 tool_result("a", "publish", big.clone()),
434 user("more"),
435 user("again"),
436 ];
437 let token = CancellationToken::new();
438 let cx = TransformContext::for_test(&token);
439 let out = budget.transform(messages, &cx).await;
440 assert_eq!(block_text(&out[1]).len(), 500);
442 }
443
444 #[tokio::test]
445 async fn per_tool_override_smaller_clips_below_default() {
446 let registry = registry_with(vec![("verbose", Some(50))]);
447 let budget = ToolResultBudget::new(registry).with_default_max_chars(1_000_000);
448 let body = "x".repeat(200);
449 let messages = vec![
450 user("hi"),
451 tool_result("a", "verbose", body.clone()),
452 user("more"),
453 tool_result("b", "verbose", body),
454 ];
455 let token = CancellationToken::new();
456 let cx = TransformContext::for_test(&token);
457 let out = budget.transform(messages, &cx).await;
458 assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
459 assert!(block_text(&out[3]).starts_with(MARKER_PREFIX));
460 }
461
462 #[tokio::test]
463 async fn idempotent_across_repeated_apply() {
464 let registry = registry_with(vec![("shell", None)]);
465 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
466 let big = "x".repeat(500);
467 let messages = vec![
468 user("hi"),
469 tool_result("a", "shell", big.clone()),
470 user("again"),
471 tool_result("b", "shell", big),
472 ];
473 let token = CancellationToken::new();
474 let cx = TransformContext::for_test(&token);
475 let once = budget.transform(messages, &cx).await;
476 let twice = budget.transform(once.clone(), &cx).await;
477 assert_eq!(once, twice);
478 }
479
480 #[tokio::test]
481 async fn unknown_tool_falls_back_to_default_cap() {
482 let registry = registry_with(vec![]);
483 let budget = ToolResultBudget::new(registry).with_default_max_chars(100);
484 let big = "x".repeat(500);
485 let messages = vec![
486 user("hi"),
487 tool_result("a", "synthetic", big.clone()),
488 user("again"),
489 tool_result("b", "synthetic", big),
490 ];
491 let token = CancellationToken::new();
492 let cx = TransformContext::for_test(&token);
493 let out = budget.transform(messages, &cx).await;
494 assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
495 assert!(block_text(&out[3]).starts_with(MARKER_PREFIX));
496 }
497}