recursive/tools/
estimate_tokens.rs1use 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 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 let (tokens, chars, method) = match (text_arg, path_arg) {
72 (Some(text_val), None) => {
73 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 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 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 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 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 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 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 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 assert!(result.contains("tokens≈4"));
202 assert!(result.contains("chars=13"));
203 }
204}