Skip to main content

clark_agent/
tool_result_budget.rs

1//! Per-tool-result content cap.
2//!
3//! `TokenBudget` (the global trim) only fires when the *total* context
4//! crosses the budget — by then, an oversized historical tool output can
5//! have polluted multiple turns of cache and crowded out other observations.
6//! `ToolResultBudget` runs immediately after structural history repair in the
7//! `ContextTransform` chain and clips older per-tool results before global
8//! pressure builds up. The newest tool-result batch is always preserved
9//! verbatim for the model turn that must reason about it.
10//!
11//! Cheapest-first ordering follows the loop's lazy-degradation rule: the
12//! least-disruptive compression layer fires first; later layers only
13//! see what survived. After structural validity is restored, per-tool
14//! clipping is cheaper than recompacting and cheaper than summarizing,
15//! so it earns the first compression slot.
16//!
17//! The full result stays in the persisted event log
18//! (`AgentEvent::ToolExecutionEnd` carries the original) and in the
19//! in-memory context. Only the projection sent to the provider is
20//! clipped, so resume reconstructs the original messages and re-applies
21//! this transform — no destructive edits, no new persistence shape. Oversized
22//! text keeps bounded head-and-tail evidence around the clipping marker so the
23//! model can often answer from the first fetch instead of blindly repeating
24//! the same expensive call.
25
26use 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
34/// Default per-tool cap when neither the tool nor the deployment
35/// declares one. 32 kchars ≈ 8k tokens by the char heuristic — large
36/// enough that ordinary tool output stays verbatim, small enough that
37/// a single runaway result can't pin a whole turn.
38pub const DEFAULT_PER_TOOL_CHARS: usize = 32_000;
39
40/// Maximum size of the marker substring inserted into clipped content.
41/// The marker carries the original size so the model can decide
42/// whether to re-run the tool, but it must not itself be a budget
43/// problem on transcripts with many clipped results.
44const MARKER_BUDGET_CHARS: usize = 256;
45
46/// `ContextTransform` that caps older individual `ToolResult` content blocks
47/// per turn, ahead of any global budget pass.
48///
49/// Looks up `AgentTool::max_result_chars()` for each tool name to get
50/// the per-tool cap; falls back to `default_max_chars` when the tool
51/// doesn't declare one. `Some(usize::MAX)` from a tool means "leave
52/// verbatim" — no clip happens for that tool.
53pub struct ToolResultBudget {
54    /// Cap applied to tools whose `max_result_chars()` returns `None`.
55    pub default_max_chars: usize,
56    /// Used to resolve per-tool overrides via `AgentTool::max_result_chars()`.
57    /// Shared with `LoopConfig.tools` (same `Arc`) so the plugin sees
58    /// whatever registry the rest of the loop sees.
59    registry: Arc<ToolRegistry>,
60}
61
62impl ToolResultBudget {
63    /// Construct with the default per-tool cap. The registry should
64    /// be the same `Arc` handed to `AgentBuilder::tools_arc`.
65    pub fn new(registry: Arc<ToolRegistry>) -> Self {
66        Self {
67            default_max_chars: DEFAULT_PER_TOOL_CHARS,
68            registry,
69        }
70    }
71
72    /// Override the global per-tool cap. Tools that declare their own
73    /// `max_result_chars()` are unaffected.
74    pub fn with_default_max_chars(mut self, chars: usize) -> Self {
75        self.default_max_chars = chars;
76        self
77    }
78
79    /// Effective cap for a given tool name. Looks up the tool in the
80    /// registry; if the tool declares an explicit override, use it,
81    /// otherwise fall back to the default. Tools not in the registry
82    /// (synthetic / aliased / removed-since) get the default.
83    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        // The latest assistant tool-call batch is the observation boundary for
108        // this provider request. Preserve every result in that batch verbatim:
109        // parallel siblings are equally fresh, and clipping any of them would
110        // make the model reason from incomplete new evidence. Earlier batches
111        // may be reduced to bounded head/tail evidence.
112        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            // Image blocks have no usable char-size signal and are
160            // rare; leave them untouched. A future audio/binary block
161            // would land here.
162            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
222/// Marker prefix used both to render new markers and to detect prior
223/// truncations so the transform stays idempotent across re-applies.
224const 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        // Defensive: truncate the marker itself if a pathological
236        // tool_call_id ever pushes it past the marker budget.
237        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        // Even though it's an old result, the unlimited cap keeps it.
441        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}