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::ToolSpec;
13use crate::tools::{resolve_within, Tool};
14
15pub struct EstimateTokens {
16    workspace: PathBuf,
17}
18
19impl EstimateTokens {
20    pub fn new(workspace: impl Into<PathBuf>) -> Self {
21        Self {
22            workspace: workspace.into(),
23        }
24    }
25
26    /// Estimate tokens using chars/4 heuristic.
27    fn estimate(&self, text: &str) -> (usize, usize, &'static str) {
28        let chars = text.len();
29        let tokens = (chars as f64 / 4.0).ceil() as usize;
30        (tokens, chars, "chars-over-4")
31    }
32}
33
34#[async_trait]
35impl Tool for EstimateTokens {
36    fn spec(&self) -> ToolSpec {
37        ToolSpec {
38            name: "estimate_tokens".into(),
39            description:
40                "Estimate the number of tokens in a piece of text or a file. Useful for budgeting transcript space."
41                    .into(),
42            parameters: json!({
43                "type": "object",
44                "properties": {
45                    "text": {
46                        "type": "string",
47                        "description": "Literal text to estimate tokens for"
48                    },
49                    "path": {
50                        "type": "string",
51                        "description": "Path to a file (workspace-relative) to estimate tokens for"
52                    }
53                },
54                "anyOf": [
55                    {"required": ["text"]},
56                    {"required": ["path"]}
57                ]
58            }),
59        }
60    }
61
62    fn is_readonly(&self) -> bool {
63        true
64    }
65
66    async fn execute(&self, arguments: Value) -> Result<String> {
67        let text_arg = arguments.get("text");
68        let path_arg = arguments.get("path");
69
70        // Check which argument is provided
71        let (tokens, chars, method) = match (text_arg, path_arg) {
72            (Some(text_val), None) => {
73                // text provided directly
74                let text = text_val.as_str().ok_or_else(|| Error::BadToolArgs {
75                    name: "estimate_tokens".into(),
76                    message: "text must be a string".into(),
77                })?;
78                self.estimate(text)
79            }
80            (None, Some(path_val)) => {
81                // path provided - read file contents
82                let path = path_val.as_str().ok_or_else(|| Error::BadToolArgs {
83                    name: "estimate_tokens".into(),
84                    message: "path must be a string".into(),
85                })?;
86
87                // Resolve and read the file (sandboxed)
88                let abs_path = resolve_within(&self.workspace, path)?;
89                let content =
90                    tokio::fs::read_to_string(&abs_path)
91                        .await
92                        .map_err(|e| Error::Tool {
93                            name: "estimate_tokens".into(),
94                            message: format!("failed to read file {}: {}", abs_path.display(), e),
95                        })?;
96                self.estimate(&content)
97            }
98            (Some(_), Some(_)) => {
99                // Both provided - prefer text (or error, as per spec)
100                return Err(Error::BadToolArgs {
101                    name: "estimate_tokens".into(),
102                    message: "provide exactly one of 'text' or 'path', not both".into(),
103                });
104            }
105            (None, None) => {
106                return Err(Error::BadToolArgs {
107                    name: "estimate_tokens".into(),
108                    message: "must provide either 'text' or 'path'".into(),
109                });
110            }
111        };
112
113        Ok(format!(
114            "tokens≈{} (chars={}, method={})",
115            tokens, chars, method
116        ))
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    /// Helper: create a temporary workspace dir.
125    fn tmp_workspace() -> (tempfile::TempDir, PathBuf) {
126        let tmp = tempfile::TempDir::new().unwrap();
127        let ws = tmp.path().to_path_buf();
128        (tmp, ws)
129    }
130
131    #[test]
132    fn estimate_text_basic() {
133        let tool = EstimateTokens::new("/tmp");
134        let text = "Hello, world! This is a test string.";
135        let (tokens, chars, method) = tool.estimate(text);
136
137        // 36 chars / 4 = 9 -> ceil = 9 tokens
138        assert_eq!(chars, 36);
139        assert_eq!(tokens, 9);
140        assert_eq!(method, "chars-over-4");
141    }
142
143    #[tokio::test]
144    async fn estimate_path_reads_file() {
145        let (_tmp, ws) = tmp_workspace();
146        let tool = EstimateTokens::new(&ws);
147
148        // Write a test file
149        let test_path = ws.join("test.txt");
150        let content = "This is a test file.\nIt has multiple lines.\n";
151        tokio::fs::write(&test_path, content).await.unwrap();
152
153        let result = tool.execute(json!({ "path": "test.txt" })).await.unwrap();
154        assert!(result.contains("tokens≈"));
155        assert!(result.contains("chars="));
156        assert!(result.contains("method=chars-over-4"));
157    }
158
159    #[tokio::test]
160    async fn estimate_path_outside_workspace() {
161        let (_tmp, ws) = tmp_workspace();
162        let tool = EstimateTokens::new(&ws);
163
164        // Try to read a path outside the workspace - should error
165        let result = tool.execute(json!({ "path": "../etc/passwd" })).await;
166        assert!(result.is_err());
167        let err = result.unwrap_err();
168        assert!(err.to_string().contains("escapes workspace"));
169    }
170
171    #[tokio::test]
172    async fn estimate_neither_arg_errors() {
173        let tool = EstimateTokens::new("/tmp");
174        let result = tool.execute(json!({})).await;
175        assert!(result.is_err());
176        let err = result.unwrap_err();
177        assert!(err
178            .to_string()
179            .contains("must provide either 'text' or 'path'"));
180    }
181
182    #[tokio::test]
183    async fn estimate_both_args_errors() {
184        let tool = EstimateTokens::new("/tmp");
185        let result = tool
186            .execute(json!({ "text": "hello", "path": "test.txt" }))
187            .await;
188        assert!(result.is_err());
189        let err = result.unwrap_err();
190        assert!(err.to_string().contains("provide exactly one of"));
191    }
192
193    #[tokio::test]
194    async fn estimate_text_direct() {
195        let tool = EstimateTokens::new("/tmp");
196        let result = tool
197            .execute(json!({ "text": "Hello, world!" }))
198            .await
199            .unwrap();
200        // "Hello, world!" = 13 chars -> 13/4 = 3.25 -> ceil = 4 tokens
201        assert!(result.contains("tokens≈4"));
202        assert!(result.contains("chars=13"));
203    }
204}