1use 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 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 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
42 self.session_roots = Some(slot);
43 self
44 }
45
46 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 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 let (tokens, chars, method) = match (text_arg, path_arg) {
116 (Some(text_val), None) => {
117 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 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 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 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 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 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 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 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 assert!(result.contains("tokens≈4"));
250 assert!(result.contains("chars=13"));
251 }
252
253 #[test]
254 fn is_deferred_true() {
255 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 let tool = EstimateTokens::new("/tmp");
269 let text = "abcde"; 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 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 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 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}