Skip to main content

recursive/tools/
estimate_tokens.rs

1//! Token estimation tool: estimate token count for text or file contents.
2//!
3//! Uses a simple char/4 heuristic — good enough for budget planning across
4//! GPT/Claude/DeepSeek models (all converge to ~3.5-4.5 chars/token in English,
5//! code is closer to 3).
6
7use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10
11use crate::error::{Error, Result};
12use crate::llm::{estimate_tokens as llm_estimate_tokens, ToolSpec};
13use crate::tools::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
14
15pub struct EstimateTokens {
16    workspace: PathBuf,
17    extra_roots: Vec<(PathBuf, AccessTier)>,
18    session_roots: Option<SharedSandboxRoots>,
19}
20
21impl EstimateTokens {
22    pub fn new(workspace: impl Into<PathBuf>) -> Self {
23        Self {
24            workspace: workspace.into(),
25            extra_roots: Vec::new(),
26            session_roots: None,
27        }
28    }
29
30    /// Append additional allowed sandbox roots. See
31    /// [`crate::tools::fs::ReadFile::with_extra_roots`].
32    pub fn with_extra_roots(
33        mut self,
34        extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
35    ) -> Self {
36        self.extra_roots.extend(extra);
37        self
38    }
39
40    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
41    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
42        self.session_roots = Some(slot);
43        self
44    }
45
46    /// Convenience: attach the shared slot only when `Some`.
47    pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
48        if let Some(s) = slot {
49            self.session_roots = Some(s);
50        }
51        self
52    }
53
54    fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
55        let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
56        v.push((self.workspace.clone(), AccessTier::ReadWrite));
57        v.extend(self.extra_roots.iter().cloned());
58        if let Some(slot) = &self.session_roots {
59            if let Ok(roots) = slot.read() {
60                v.extend(roots.iter().cloned());
61            }
62        }
63        v
64    }
65
66    /// Estimate tokens using chars/4 heuristic.
67    fn estimate(&self, text: &str) -> (usize, usize, &'static str) {
68        let tokens = llm_estimate_tokens(text) as usize;
69        let chars = text.len();
70        (tokens, chars, "chars-over-4")
71    }
72}
73
74#[async_trait]
75impl Tool for EstimateTokens {
76    fn is_deferred(&self) -> bool {
77        true
78    }
79
80    fn spec(&self) -> ToolSpec {
81        ToolSpec {
82            name: "estimate_tokens".into(),
83            description:
84                "Estimate the number of tokens in a piece of text or a file. Useful for budgeting transcript space."
85                    .into(),
86            parameters: json!({
87                "type": "object",
88                "properties": {
89                    "text": {
90                        "type": "string",
91                        "description": "Literal text to estimate tokens for"
92                    },
93                    "path": {
94                        "type": "string",
95                        "description": "Path to a file (workspace-relative) to estimate tokens for"
96                    }
97                },
98                "anyOf": [
99                    {"required": ["text"]},
100                    {"required": ["path"]}
101                ]
102            }),
103        }
104    }
105
106    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
107        crate::tools::ToolSideEffect::ReadOnly
108    }
109
110    async fn execute(&self, arguments: Value) -> Result<String> {
111        let text_arg = arguments.get("text");
112        let path_arg = arguments.get("path");
113
114        // Check which argument is provided
115        let (tokens, chars, method) = match (text_arg, path_arg) {
116            (Some(text_val), None) => {
117                // text provided directly
118                let text = text_val.as_str().ok_or_else(|| Error::BadToolArgs {
119                    name: "estimate_tokens".into(),
120                    message: "text must be a string".into(),
121                })?;
122                self.estimate(text)
123            }
124            (None, Some(path_val)) => {
125                // path provided - read file contents
126                let path = path_val.as_str().ok_or_else(|| Error::BadToolArgs {
127                    name: "estimate_tokens".into(),
128                    message: "path must be a string".into(),
129                })?;
130
131                // Resolve and read the file (sandboxed)
132                let abs_path = resolve_within_any(&self.all_roots(), path, false)?;
133                let content =
134                    tokio::fs::read_to_string(&abs_path)
135                        .await
136                        .map_err(|e| Error::Tool {
137                            name: "estimate_tokens".into(),
138                            call_id: None,
139                            message: format!("failed to read file {}: {}", abs_path.display(), e),
140                        })?;
141                self.estimate(&content)
142            }
143            (Some(_), Some(_)) => {
144                // Both provided - prefer text (or error, as per spec)
145                return Err(Error::BadToolArgs {
146                    name: "estimate_tokens".into(),
147                    message: "provide exactly one of 'text' or 'path', not both".into(),
148                });
149            }
150            (None, None) => {
151                return Err(Error::BadToolArgs {
152                    name: "estimate_tokens".into(),
153                    message: "must provide either 'text' or 'path'".into(),
154                });
155            }
156        };
157
158        Ok(format!(
159            "tokens≈{} (chars={}, method={})",
160            tokens, chars, method
161        ))
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    /// Helper: create a temporary workspace dir.
170    fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
171        let tmp = tempfile::TempDir::new().unwrap();
172        let ws = tmp.path().to_path_buf();
173        (tmp, ws)
174    }
175
176    #[test]
177    fn estimate_text_basic() {
178        let tool = EstimateTokens::new("/tmp");
179        let text = "Hello, world! This is a test string.";
180        let (tokens, chars, method) = tool.estimate(text);
181
182        // 36 chars / 4 = 9 -> ceil = 9 tokens
183        assert_eq!(chars, 36);
184        assert_eq!(tokens, 9);
185        assert_eq!(method, "chars-over-4");
186    }
187
188    #[tokio::test]
189    async fn estimate_path_reads_file() {
190        let (_tmp, ws) = tmp_workspace();
191        let tool = EstimateTokens::new(&ws);
192
193        // Write a test file
194        let test_path = ws.join("test.txt");
195        let content = "This is a test file.\nIt has multiple lines.\n";
196        tokio::fs::write(&test_path, content).await.unwrap();
197
198        let result = tool.execute(json!({ "path": "test.txt" })).await.unwrap();
199        assert!(result.contains("tokens≈"));
200        assert!(result.contains("chars="));
201        assert!(result.contains("method=chars-over-4"));
202    }
203
204    #[tokio::test]
205    async fn estimate_path_outside_workspace() {
206        let (_tmp, ws) = tmp_workspace();
207        let tool = EstimateTokens::new(&ws);
208
209        // Try to read a path outside the workspace - should error
210        let result = tool.execute(json!({ "path": "../etc/passwd" })).await;
211        assert!(result.is_err());
212        let err = result.unwrap_err();
213        assert!(
214            err.to_string().contains("escapes"),
215            "expected escape error, got: {err}"
216        );
217    }
218
219    #[tokio::test]
220    async fn estimate_neither_arg_errors() {
221        let tool = EstimateTokens::new("/tmp");
222        let result = tool.execute(json!({})).await;
223        assert!(result.is_err());
224        let err = result.unwrap_err();
225        assert!(err
226            .to_string()
227            .contains("must provide either 'text' or 'path'"));
228    }
229
230    #[tokio::test]
231    async fn estimate_both_args_errors() {
232        let tool = EstimateTokens::new("/tmp");
233        let result = tool
234            .execute(json!({ "text": "hello", "path": "test.txt" }))
235            .await;
236        assert!(result.is_err());
237        let err = result.unwrap_err();
238        assert!(err.to_string().contains("provide exactly one of"));
239    }
240
241    #[tokio::test]
242    async fn estimate_text_direct() {
243        let tool = EstimateTokens::new("/tmp");
244        let result = tool
245            .execute(json!({ "text": "Hello, world!" }))
246            .await
247            .unwrap();
248        // "Hello, world!" = 13 chars -> 13/4 = 3.25 -> ceil = 4 tokens
249        assert!(result.contains("tokens≈4"));
250        assert!(result.contains("chars=13"));
251    }
252
253    #[test]
254    fn is_deferred_true() {
255        // kills `replace <impl Tool for EstimateTokens>::is_deferred -> bool with false`
256        use crate::tools::Tool;
257        let tool = EstimateTokens::new("/tmp");
258        assert!(
259            tool.is_deferred(),
260            "EstimateTokens must be deferred (low-frequency tool)"
261        );
262    }
263
264    #[test]
265    fn estimate_uses_ceil_not_floor() {
266        // kills `floor()` or truncation mutations in `(chars as f64 / 4.0).ceil() as usize`
267        // 5 chars / 4.0 = 1.25 → ceil = 2, floor = 1
268        let tool = EstimateTokens::new("/tmp");
269        let text = "abcde"; // exactly 5 chars
270        let (tokens, chars, _) = tool.estimate(text);
271        assert_eq!(chars, 5);
272        assert_eq!(
273            tokens, 2,
274            "5 chars must round up to 2 tokens (ceil), not 1 (floor)"
275        );
276    }
277
278    #[tokio::test]
279    async fn non_string_text_arg_errors() {
280        // kills `text_val.as_str().ok_or_else(...)` guard removal mutation
281        let tool = EstimateTokens::new("/tmp");
282        let res = tool.execute(json!({ "text": 42 })).await;
283        assert!(
284            matches!(res, Err(crate::error::Error::BadToolArgs { .. })),
285            "non-string 'text' must return BadToolArgs"
286        );
287    }
288
289    #[tokio::test]
290    async fn non_string_path_arg_errors() {
291        // kills `path_val.as_str().ok_or_else(...)` guard removal mutation
292        let (tmp, ws) = tmp_workspace();
293        let tool = EstimateTokens::new(ws);
294        let res = tool.execute(json!({ "path": 99 })).await;
295        assert!(
296            matches!(res, Err(crate::error::Error::BadToolArgs { .. })),
297            "non-string 'path' must return BadToolArgs"
298        );
299        drop(tmp);
300    }
301
302    #[tokio::test]
303    async fn output_includes_method_field() {
304        // kills `method` field removal mutation in the format string
305        let tool = EstimateTokens::new("/tmp");
306        let result = tool.execute(json!({ "text": "test" })).await.unwrap();
307        assert!(
308            result.contains("method="),
309            "output must include method field; got: {result}"
310        );
311    }
312}