1use std::sync::{Arc, RwLock};
15
16use async_trait::async_trait;
17use serde_json::{json, Value};
18
19use crate::error::{Error, Result};
20use crate::llm::ToolSpec;
21
22use super::{Tool, ToolSideEffect};
23
24pub const TOOL_SEARCH_TOOL_NAME: &str = "ToolSearchTool";
25
26pub type DeferredCatalog = Arc<RwLock<Vec<ToolSpec>>>;
30
31#[derive(Clone)]
34pub struct ToolSearchTool {
35 catalog: DeferredCatalog,
36}
37
38impl ToolSearchTool {
39 pub fn new(catalog: DeferredCatalog) -> Self {
40 Self { catalog }
41 }
42}
43
44#[async_trait]
45impl Tool for ToolSearchTool {
46 fn spec(&self) -> ToolSpec {
47 ToolSpec {
48 name: TOOL_SEARCH_TOOL_NAME.to_string(),
49 description: "Fetches full schema definitions for deferred tools so they can be \
50 called. Until fetched, only the name is known — there is no parameter schema, \
51 so the tool cannot be invoked. This tool takes a query, matches it against the \
52 deferred tool list, and returns the matched tools' complete JSONSchema \
53 definitions inside a <functions> block. Once a tool's schema appears in that \
54 result, it is callable exactly like any tool defined at the top of the prompt.\n\n\
55 Result format: each matched tool appears as one \
56 <function>{\"description\": \"...\", \"name\": \"...\", \"parameters\": {...}}\
57 </function> line inside the <functions> block.\n\n\
58 Query forms:\n\
59 - \"select:Read,Edit,Grep\" — fetch these exact tools by name\n\
60 - \"notebook jupyter\" — keyword search, up to max_results best matches\n\
61 - \"+slack send\" — require \"slack\" in the name, rank by remaining terms"
62 .to_string(),
63 parameters: json!({
64 "type": "object",
65 "properties": {
66 "query": {
67 "type": "string",
68 "description": "Query to find deferred tools. Use \"select:<tool_name>\" \
69 for direct selection, or keywords to search."
70 },
71 "max_results": {
72 "type": "integer",
73 "description": "Maximum number of results to return (default: 5)",
74 "default": 5
75 }
76 },
77 "required": ["query"]
78 }),
79 }
80 }
81
82 async fn execute(&self, arguments: Value) -> Result<String> {
83 let query = arguments
84 .get("query")
85 .and_then(|v| v.as_str())
86 .unwrap_or("")
87 .trim();
88 let max_results = arguments
89 .get("max_results")
90 .and_then(|v| v.as_u64())
91 .map(|n| n as usize)
92 .unwrap_or(5)
93 .clamp(1, 20);
94
95 if query.is_empty() {
96 return Err(Error::BadToolArgs {
97 name: TOOL_SEARCH_TOOL_NAME.to_string(),
98 message: "\"query\" must be a non-empty string".to_string(),
99 });
100 }
101
102 let catalog = self.catalog.read().map_err(|_| Error::BadToolArgs {
103 name: TOOL_SEARCH_TOOL_NAME.to_string(),
104 message: "deferred catalog lock poisoned".to_string(),
105 })?;
106
107 let matched = resolve(query, &catalog, max_results);
108
109 if matched.is_empty() {
110 return Ok(format!(
111 "No matching deferred tools found for query \"{query}\". \
112 Available: {}",
113 catalog
114 .iter()
115 .map(|s| s.name.as_str())
116 .collect::<Vec<_>>()
117 .join(", ")
118 ));
119 }
120
121 let names: Vec<&str> = matched.iter().map(|s| s.name.as_str()).collect();
126 Ok(serde_json::to_string(&names).unwrap_or_default())
127 }
128
129 fn side_effect_class(&self) -> ToolSideEffect {
130 ToolSideEffect::ReadOnly
131 }
132
133 fn is_deferred(&self) -> bool {
134 false }
136}
137
138pub fn resolve<'a>(query: &str, catalog: &'a [ToolSpec], max_results: usize) -> Vec<&'a ToolSpec> {
147 let q = query.trim().to_lowercase();
148
149 if let Some(names_str) = q.strip_prefix("select:") {
151 let mut result = Vec::new();
152 for name in names_str.split(',').map(|s| s.trim()) {
153 if let Some(spec) = catalog.iter().find(|s| s.name.to_lowercase() == name) {
154 result.push(spec);
155 }
156 }
157 result.truncate(max_results);
158 return result;
159 }
160
161 if let Some(spec) = catalog.iter().find(|s| s.name.to_lowercase() == q) {
163 return vec![spec];
164 }
165
166 let terms: Vec<&str> = q.split_whitespace().collect();
169 let mut scored: Vec<(usize, &ToolSpec)> = catalog
170 .iter()
171 .filter_map(|spec| {
172 let haystack = format!(
173 "{} {}",
174 spec.name.to_lowercase(),
175 spec.description.to_lowercase()
176 );
177 let score = terms.iter().filter(|&&t| haystack.contains(t)).count();
178 if score > 0 {
179 Some((score, spec))
180 } else {
181 None
182 }
183 })
184 .collect();
185
186 scored.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.name.cmp(&b.1.name)));
188 scored.truncate(max_results);
189 scored.into_iter().map(|(_, s)| s).collect()
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use serde_json::json;
196
197 fn make_spec(name: &str, desc: &str) -> ToolSpec {
198 ToolSpec {
199 name: name.to_string(),
200 description: desc.to_string(),
201 parameters: json!({"type": "object"}),
202 }
203 }
204
205 #[test]
206 fn select_prefix_returns_exact_matches() {
207 let catalog = vec![
208 make_spec("Read", "Read a file"),
209 make_spec("WebFetch", "Fetch a URL"),
210 make_spec("Write", "Write a file"),
211 ];
212 let result = resolve("select:WebFetch,Read", &catalog, 10);
213 assert_eq!(result.len(), 2);
214 assert_eq!(result[0].name, "WebFetch");
215 assert_eq!(result[1].name, "Read");
216 }
217
218 #[test]
219 fn select_skips_unknown_names() {
220 let catalog = vec![make_spec("Read", "Read a file")];
221 let result = resolve("select:Read,Unknown", &catalog, 10);
222 assert_eq!(result.len(), 1);
223 assert_eq!(result[0].name, "Read");
224 }
225
226 #[test]
227 fn exact_name_match_case_insensitive() {
228 let catalog = vec![make_spec("WebFetch", "Fetch a URL")];
229 let result = resolve("webfetch", &catalog, 10);
230 assert_eq!(result.len(), 1);
231 assert_eq!(result[0].name, "WebFetch");
232 }
233
234 #[test]
235 fn keyword_search_scores_by_term_matches() {
236 let catalog = vec![
237 make_spec("Read", "Read a UTF-8 file from disk"),
238 make_spec("WebFetch", "Fetch content from a URL via HTTP"),
239 make_spec("Write", "Write content to a file"),
240 ];
241 let result = resolve("file", &catalog, 10);
242 assert!(result.iter().any(|s| s.name == "Read"));
244 assert!(result.iter().any(|s| s.name == "Write"));
245 assert!(!result.iter().any(|s| s.name == "WebFetch"));
246 }
247
248 #[test]
249 fn max_results_is_respected() {
250 let catalog = (0..10)
251 .map(|i| make_spec(&format!("Tool{i}"), "do something useful"))
252 .collect::<Vec<_>>();
253 let result = resolve("useful", &catalog, 3);
254 assert_eq!(result.len(), 3);
255 }
256
257 #[tokio::test]
258 async fn execute_returns_json_array_of_names() {
259 let catalog: DeferredCatalog =
260 Arc::new(RwLock::new(vec![make_spec("WebFetch", "Fetch a URL")]));
261 let tool = ToolSearchTool::new(catalog);
262 let out = tool
263 .execute(json!({"query": "select:WebFetch"}))
264 .await
265 .unwrap();
266 let names: Vec<String> = serde_json::from_str(&out).expect("valid JSON array");
269 assert!(
270 names.contains(&"WebFetch".to_string()),
271 "missing WebFetch: {out}"
272 );
273 }
274
275 #[tokio::test]
276 async fn execute_no_match_returns_helpful_message() {
277 let catalog: DeferredCatalog =
278 Arc::new(RwLock::new(vec![make_spec("Read", "Read a file")]));
279 let tool = ToolSearchTool::new(catalog);
280 let out = tool
281 .execute(json!({"query": "select:Unknown"}))
282 .await
283 .unwrap();
284 assert!(
285 out.contains("No matching"),
286 "expected no-match message: {out}"
287 );
288 }
289
290 #[test]
293 fn select_prefix_truncates_at_max_results() {
294 let catalog = vec![
297 make_spec("A", "tool A"),
298 make_spec("B", "tool B"),
299 make_spec("C", "tool C"),
300 ];
301 let result = resolve("select:A,B,C", &catalog, 2);
302 assert_eq!(
303 result.len(),
304 2,
305 "select: results must be truncated to max_results"
306 );
307 }
308
309 #[test]
310 fn keyword_no_match_returns_empty() {
311 let catalog = vec![make_spec("Read", "Read a file")];
313 let result = resolve("xyzzy_nonexistent_term", &catalog, 10);
314 assert!(result.is_empty(), "zero-score tools must not appear");
315 }
316
317 #[test]
318 fn keyword_search_ranks_higher_score_first() {
319 let catalog = vec![
321 make_spec("AlphaRead", "Read files"),
322 make_spec("BetaReadWrite", "Read and write files"),
323 ];
324 let result = resolve("read write", &catalog, 10);
327 assert!(!result.is_empty());
328 assert_eq!(
329 result[0].name, "BetaReadWrite",
330 "higher score tool must rank first"
331 );
332 }
333
334 #[test]
335 fn keyword_search_stable_sort_by_name_asc_when_equal_score() {
336 let catalog = vec![
338 make_spec("ZTool", "do something useful"),
339 make_spec("ATool", "do something useful"),
340 ];
341 let result = resolve("something", &catalog, 10);
342 assert_eq!(result.len(), 2);
343 assert_eq!(
344 result[0].name, "ATool",
345 "equal-score tools must sort by name asc"
346 );
347 assert_eq!(result[1].name, "ZTool");
348 }
349
350 #[test]
351 fn exact_name_match_returns_exactly_one() {
352 let catalog = vec![
354 make_spec("Read", "Read a file"),
355 make_spec("ReadAll", "Read all files"),
356 ];
357 let result = resolve("Read", &catalog, 10);
358 assert_eq!(result.len(), 1, "exact match must return exactly 1 tool");
359 assert_eq!(result[0].name, "Read");
360 }
361
362 #[test]
363 fn is_deferred_returns_false() {
364 let catalog: DeferredCatalog = Arc::new(RwLock::new(vec![]));
366 let tool = ToolSearchTool::new(catalog);
367 assert!(
368 !tool.is_deferred(),
369 "ToolSearchTool must always be eager (not deferred)"
370 );
371 }
372
373 #[tokio::test]
374 async fn execute_max_results_clamps_to_min_1() {
375 let catalog: DeferredCatalog = Arc::new(RwLock::new(
377 (0..5)
378 .map(|i| make_spec(&format!("T{i}"), "useful thing"))
379 .collect(),
380 ));
381 let tool = ToolSearchTool::new(catalog);
382 let out = tool
383 .execute(json!({"query": "useful", "max_results": 0}))
384 .await
385 .unwrap();
386 let names: Vec<String> = serde_json::from_str(&out).expect("valid JSON array");
387 assert_eq!(names.len(), 1, "max_results=0 must be clamped to 1");
388 }
389
390 #[tokio::test]
391 async fn execute_empty_query_returns_error() {
392 let catalog: DeferredCatalog = Arc::new(RwLock::new(vec![make_spec("Read", "file")]));
394 let tool = ToolSearchTool::new(catalog);
395 let result = tool.execute(json!({"query": ""})).await;
396 assert!(result.is_err(), "empty query must return an error");
397 }
398}