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, a single oversized tool output has
5//! already polluted multiple turns of cache and crowded out other
6//! observations. `ToolResultBudget` runs immediately after structural
7//! history repair in the `ContextTransform` chain and clips
8//! per-tool-result before global pressure ever builds up.
9//!
10//! Cheapest-first ordering follows the loop's lazy-degradation rule: the
11//! least-disruptive compression layer fires first; later layers only
12//! see what survived. After structural validity is restored, per-tool
13//! clipping is cheaper than recompacting and cheaper than summarizing,
14//! so it earns the first compression slot.
15//!
16//! The full result stays in the persisted event log
17//! (`AgentEvent::ToolExecutionEnd` carries the original) and in the
18//! in-memory context. Only the projection sent to the provider is
19//! clipped, so resume reconstructs the original messages and re-applies
20//! this transform — no destructive edits, no new persistence shape.
21
22use 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
30/// Default per-tool cap when neither the tool nor the deployment
31/// declares one. 32 kchars ≈ 8k tokens by the char heuristic — large
32/// enough that ordinary tool output stays verbatim, small enough that
33/// a single runaway result can't pin a whole turn.
34pub const DEFAULT_PER_TOOL_CHARS: usize = 32_000;
35
36/// Maximum size of the marker substring that replaces clipped content.
37/// The marker carries the original size so the model can decide
38/// whether to re-run the tool, but it must not itself be a budget
39/// problem on transcripts with many clipped results.
40const MARKER_BUDGET_CHARS: usize = 256;
41
42/// `ContextTransform` that caps the size of individual `ToolResult`
43/// content blocks per turn, ahead of any global budget pass.
44///
45/// Looks up `AgentTool::max_result_chars()` for each tool name to get
46/// the per-tool cap; falls back to `default_max_chars` when the tool
47/// doesn't declare one. `Some(usize::MAX)` from a tool means "leave
48/// verbatim" — no clip happens for that tool.
49pub struct ToolResultBudget {
50    /// Cap applied to tools whose `max_result_chars()` returns `None`.
51    pub default_max_chars: usize,
52    /// Used to resolve per-tool overrides via `AgentTool::max_result_chars()`.
53    /// Shared with `LoopConfig.tools` (same `Arc`) so the plugin sees
54    /// whatever registry the rest of the loop sees.
55    registry: Arc<ToolRegistry>,
56}
57
58impl ToolResultBudget {
59    /// Construct with the default per-tool cap. The registry should
60    /// be the same `Arc` handed to `AgentBuilder::tools_arc`.
61    pub fn new(registry: Arc<ToolRegistry>) -> Self {
62        Self {
63            default_max_chars: DEFAULT_PER_TOOL_CHARS,
64            registry,
65        }
66    }
67
68    /// Override the global per-tool cap. Tools that declare their own
69    /// `max_result_chars()` are unaffected.
70    pub fn with_default_max_chars(mut self, chars: usize) -> Self {
71        self.default_max_chars = chars;
72        self
73    }
74
75    /// Effective cap for a given tool name. Looks up the tool in the
76    /// registry; if the tool declares an explicit override, use it,
77    /// otherwise fall back to the default. Tools not in the registry
78    /// (synthetic / aliased / removed-since) get the default.
79    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        // Skip the very last tool result so the most recent observation
104        // stays verbatim — the model needs full fidelity on the freshest
105        // result it's reasoning about. Anything older that overflows
106        // gets clipped.
107        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            // Image blocks have no usable char-size signal and are
154            // rare; leave them untouched. A future audio/binary block
155            // would land here.
156            ToolResultBlock::Image(_) => 0,
157        })
158        .sum()
159}
160
161/// Marker prefix used both to render new markers and to detect prior
162/// truncations so the transform stays idempotent across re-applies.
163const 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        // Defensive: truncate the marker itself if a pathological
174        // tool_call_id ever pushes it past the marker budget.
175        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        // First (older) tool result clipped.
283        assert!(block_text(&out[1]).starts_with(MARKER_PREFIX));
284        // Last (newer) tool result preserved verbatim.
285        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        // Even though it's an old result, the unlimited cap keeps it.
320        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}