Skip to main content

recursive/tools/
tool_search.rs

1//! ToolSearchTool — lets the model discover deferred tools on demand.
2//!
3//! When tool search is enabled, deferred tools are not sent with full schemas
4//! in the initial API request. Instead their names appear in an
5//! `<available-deferred-tools>` block. The model calls `ToolSearchTool` to
6//! retrieve the full JSON schema for one or more deferred tools; the result
7//! is a `<functions>` block identical in encoding to the tool list at the
8//! top of the prompt. Once a tool's schema has been returned it is callable
9//! exactly like any eagerly-loaded tool.
10//!
11//! The tool is always eager (not deferred itself) and is only registered when
12//! at least one deferred tool exists in the registry.
13
14use 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
26/// The deferred tool catalog shared between the registry and the tool.
27/// Updated by `ToolRegistry::freeze_deferred_specs()` after all tools are
28/// registered.
29pub type DeferredCatalog = Arc<RwLock<Vec<ToolSpec>>>;
30
31/// ToolSearchTool resolves keyword queries or `select:<name>` selectors
32/// against the deferred tool catalog and returns full JSON schemas.
33#[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        // Return the resolved names as a JSON array. The Anthropic provider's
122        // message serializer (`serialize_messages_anthropic`) detects this format
123        // and converts it to `tool_reference` content blocks, which the API uses
124        // to expand full tool schemas into the model's context window.
125        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 // ToolSearchTool itself is always eager
135    }
136}
137
138// ── Search logic ──────────────────────────────────────────────────────────────
139
140/// Resolve a query against the catalog, returning matching `ToolSpec`s.
141///
142/// Supports:
143/// - `select:A,B,C` — exact name lookup, preserves order, skips unknowns
144/// - `exact name` — single exact match
145/// - `keyword terms` — scored substring search across name + description
146pub fn resolve<'a>(query: &str, catalog: &'a [ToolSpec], max_results: usize) -> Vec<&'a ToolSpec> {
147    let q = query.trim().to_lowercase();
148
149    // select: prefix — direct name lookup
150    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    // Exact name match
162    if let Some(spec) = catalog.iter().find(|s| s.name.to_lowercase() == q) {
163        return vec![spec];
164    }
165
166    // Keyword scoring: score each tool by how many query terms appear in
167    // name + description (case-insensitive substring).
168    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    // Sort by score descending, then name ascending for stability.
187    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        // Both Read and Write mention "file"; WebFetch does not
243        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        // Output is a JSON array of resolved names — serialized as
267        // tool_reference blocks by serialize_messages_anthropic.
268        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    // ── Additional targeted tests ─────────────────────────────────────────────
291
292    #[test]
293    fn select_prefix_truncates_at_max_results() {
294        // kills `delete result.truncate(max_results)` — without truncation,
295        // all 3 specs would be returned even though max_results is 2.
296        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        // kills `replace score > 0 with score >= 0` (score 0 must be filtered out)
312        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        // kills mutation swapping sort order (ascending ↔ descending)
320        let catalog = vec![
321            make_spec("AlphaRead", "Read files"),
322            make_spec("BetaReadWrite", "Read and write files"),
323        ];
324        // "BetaReadWrite" matches both "read" and "write" → score 2
325        // "AlphaRead" matches only "read" → score 1
326        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        // kills mutation of `then(a.1.name.cmp(&b.1.name))` direction
337        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        // kills function-level replacement of the exact-match return
353        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        // kills `replace is_deferred -> bool with true`
365        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        // kills mutation of `clamp(1, 20)` lower bound (e.g. remove clamp → 0 results)
376        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        // kills function-level replacement of the empty-query guard
393        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}