Skip to main content

recursive/llm/
search.rs

1//! Tool search engine: keyword-based discovery of deferred tools.
2//!
3//! Ported from `claude-code/src/tools/ToolSearchTool/ToolSearchTool.ts`
4//! (`searchToolsWithKeywords`, lines 186-302). The pure-function
5//! `resolve(query, specs) -> Vec<String>` design is preserved so the
6//! algorithm is straightforward to test in isolation.
7//!
8//! Two phases:
9//! 1. **Fast path**: exact name match (case-insensitive) returns
10//!    immediately. Handles the `select:<tool_name>` style where the
11//!    model passes a bare tool name.
12//! 2. **Scored keyword search**: split the query into terms, score
13//!    each candidate tool by name parts, `search_hint`, and
14//!    description word boundaries, return the top N by score.
15//!
16//! The `+` prefix on a term marks it as required (must appear in
17//! name or description); the remaining terms are ranked.
18
19use crate::llm::ToolSpec;
20
21/// Default number of results returned when the caller doesn't
22/// specify a `max_results` value.
23pub const DEFAULT_MAX_RESULTS: usize = 5;
24
25/// Hard cap on the number of results, regardless of caller request.
26pub const MAX_RESULTS_CAP: usize = 20;
27
28/// A `(spec, search_hint)` pair, as produced by
29/// `ToolRegistry::partition_by_eagerness`. The hint is local
30/// metadata; the spec is the wire shape sent to the model.
31pub type SpecWithHint = (ToolSpec, Option<String>);
32
33/// Resolves a free-text query into a ranked list of deferred tool
34/// names. Implementations are pure functions of their inputs — no
35/// I/O, no async — so they are easy to unit-test.
36pub trait ToolSearchEngine: Send + Sync + std::fmt::Debug {
37    /// Return up to `max_results` tool names matching `query`.
38    /// `max_results` of `None` means "use the default cap".
39    fn resolve(&self, query: &str, candidates: &[SpecWithHint]) -> Vec<String>;
40}
41
42/// Score constants, mirroring the weights in fake-cc's
43/// `searchToolsWithKeywords`:
44///   - exact part match in name (regular tool): 10
45///   - exact part match in name (MCP tool):    12
46///   - partial part match in name (regular):   5
47///   - partial part match in name (MCP):       6
48///   - full name substring match (fallback):   3
49///   - `search_hint` word-boundary match:      4
50///   - description word-boundary match:        2
51mod weights {
52    pub const NAME_EXACT_REGULAR: i32 = 10;
53    pub const NAME_EXACT_MCP: i32 = 12;
54    pub const NAME_PARTIAL_REGULAR: i32 = 5;
55    pub const NAME_PARTIAL_MCP: i32 = 6;
56    pub const NAME_FALLBACK: i32 = 3;
57    pub const SEARCH_HINT: i32 = 4;
58    pub const DESCRIPTION: i32 = 2;
59}
60
61/// The default search engine: keyword + CamelCase + `searchHint`
62/// weighted scoring, as described in fake-cc's
63/// `src/tools/ToolSearchTool/ToolSearchTool.ts:186-302`.
64#[derive(Debug, Default, Clone)]
65pub struct KeywordSearchEngine;
66
67impl KeywordSearchEngine {
68    pub fn new() -> Self {
69        Self
70    }
71}
72
73impl ToolSearchEngine for KeywordSearchEngine {
74    fn resolve(&self, query: &str, candidates: &[SpecWithHint]) -> Vec<String> {
75        resolve(query, candidates, DEFAULT_MAX_RESULTS)
76    }
77}
78
79/// Internal entry point exposed for tests so they can pass an
80/// explicit `max_results` without going through the trait.
81pub(crate) fn resolve(query: &str, candidates: &[SpecWithHint], max_results: usize) -> Vec<String> {
82    let max_results = max_results.clamp(1, MAX_RESULTS_CAP);
83    let query_lower = query.to_lowercase();
84    let query_trim = query_lower.trim();
85
86    if query_trim.is_empty() {
87        return Vec::new();
88    }
89
90    // Fast path: exact (case-insensitive) name match.
91    if let Some((spec, _)) = candidates
92        .iter()
93        .find(|(s, _)| s.name.to_lowercase() == query_trim)
94    {
95        return vec![spec.name.clone()];
96    }
97
98    // MCP prefix fast path: "mcp__server" matches every tool
99    // whose name starts with that prefix.
100    if query_trim.starts_with("mcp__") && query_trim.len() > 5 {
101        let mut hits: Vec<String> = candidates
102            .iter()
103            .filter(|(s, _)| s.name.to_lowercase().starts_with(query_trim))
104            .map(|(s, _)| s.name.clone())
105            .collect();
106        if !hits.is_empty() {
107            hits.truncate(max_results);
108            return hits;
109        }
110    }
111
112    // Parse query terms: split on whitespace, treat leading `+` as
113    // "required". The remaining terms are optional.
114    let raw_terms: Vec<&str> = query_trim.split_whitespace().collect();
115    let mut required: Vec<String> = Vec::new();
116    let mut optional: Vec<String> = Vec::new();
117    for term in raw_terms {
118        if let Some(stripped) = term.strip_prefix('+') {
119            if !stripped.is_empty() {
120                required.push(stripped.to_string());
121            }
122        } else {
123            optional.push(term.to_string());
124        }
125    }
126    let scoring_terms: Vec<String> = if !required.is_empty() {
127        required.iter().chain(optional.iter()).cloned().collect()
128    } else {
129        optional.clone()
130    };
131
132    if scoring_terms.is_empty() {
133        return Vec::new();
134    }
135
136    // Pre-filter: a tool only enters scoring if it satisfies ALL
137    // required terms. Without required terms, every candidate is
138    // considered.
139    let filtered: Vec<&SpecWithHint> = if required.is_empty() {
140        candidates.iter().collect()
141    } else {
142        candidates
143            .iter()
144            .filter(|(s, hint)| {
145                let parsed = parse_tool_name(&s.name);
146                let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
147                let desc = s.description.to_lowercase();
148                required.iter().all(|term| {
149                    parsed.parts.iter().any(|p| p == term)
150                        || parsed.parts.iter().any(|p| p.contains(term.as_str()))
151                        || word_boundary_match(term, &desc)
152                        || (!hint_lower.is_empty() && word_boundary_match(term, &hint_lower))
153                })
154            })
155            .collect()
156    };
157
158    let mut scored: Vec<(String, i32)> = filtered
159        .into_iter()
160        .map(|(spec, hint)| {
161            let parsed = parse_tool_name(&spec.name);
162            let desc_lower = spec.description.to_lowercase();
163            let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
164            let mut score: i32 = 0;
165            for term in &scoring_terms {
166                // Name part match (highest weight)
167                if parsed.parts.iter().any(|p| p == term) {
168                    score += if parsed.is_mcp {
169                        weights::NAME_EXACT_MCP
170                    } else {
171                        weights::NAME_EXACT_REGULAR
172                    };
173                } else if parsed.parts.iter().any(|p| p.contains(term.as_str())) {
174                    score += if parsed.is_mcp {
175                        weights::NAME_PARTIAL_MCP
176                    } else {
177                        weights::NAME_PARTIAL_REGULAR
178                    };
179                }
180                // Full name substring fallback (only when nothing
181                // else has scored yet for this term)
182                if score == 0 && parsed.full.contains(term.as_str()) {
183                    score += weights::NAME_FALLBACK;
184                }
185                // search_hint match (curated capability phrase)
186                if !hint_lower.is_empty() && word_boundary_match(term, &hint_lower) {
187                    score += weights::SEARCH_HINT;
188                }
189                // Description match
190                if word_boundary_match(term, &desc_lower) {
191                    score += weights::DESCRIPTION;
192                }
193            }
194            (spec.name.clone(), score)
195        })
196        .filter(|(_, score)| *score > 0)
197        .collect();
198
199    scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
200    scored.truncate(max_results);
201    scored.into_iter().map(|(name, _)| name).collect()
202}
203
204/// Split a tool name into searchable parts. Handles both MCP names
205/// (`mcp__server__action`) and CamelCase (`ReadFile`).
206struct ParsedName {
207    parts: Vec<String>,
208    full: String,
209    is_mcp: bool,
210}
211
212fn parse_tool_name(name: &str) -> ParsedName {
213    if let Some(stripped) = name.strip_prefix("mcp__") {
214        let lowered = stripped.to_lowercase();
215        let parts: Vec<String> = lowered
216            .split("__")
217            .flat_map(|p| p.split('_'))
218            .filter(|p| !p.is_empty())
219            .map(String::from)
220            .collect();
221        let full = lowered.replace("__", " ").replace('_', " ");
222        ParsedName {
223            parts,
224            full,
225            is_mcp: true,
226        }
227    } else {
228        // Split the original CamelCase boundary then on underscores.
229        let mut parts: Vec<String> = Vec::new();
230        let mut buf = String::new();
231        for ch in name.chars() {
232            if ch == '_' {
233                if !buf.is_empty() {
234                    parts.push(buf.to_lowercase());
235                    buf.clear();
236                }
237            } else if ch.is_uppercase() {
238                if !buf.is_empty() {
239                    parts.push(buf.to_lowercase());
240                    buf.clear();
241                }
242                buf.push(ch.to_ascii_lowercase());
243            } else {
244                buf.push(ch.to_ascii_lowercase());
245            }
246        }
247        if !buf.is_empty() {
248            parts.push(buf);
249        }
250        let full = parts.join(" ");
251        ParsedName {
252            parts,
253            full,
254            is_mcp: false,
255        }
256    }
257}
258
259/// Word-boundary regex match. Avoids substring false positives
260/// like "slack" matching "slacks" or "jack".
261fn word_boundary_match(term: &str, haystack: &str) -> bool {
262    if term.is_empty() {
263        return false;
264    }
265    let mut start = 0;
266    while let Some(pos) = haystack[start..].find(term) {
267        let abs = start + pos;
268        let before_ok = abs == 0 || !haystack.as_bytes()[abs - 1].is_ascii_alphanumeric();
269        let end = abs + term.len();
270        let after_ok = end == haystack.len() || !haystack.as_bytes()[end].is_ascii_alphanumeric();
271        if before_ok && after_ok {
272            return true;
273        }
274        start = abs + 1;
275    }
276    false
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use serde_json::json;
283
284    fn spec(name: &str, description: &str) -> ToolSpec {
285        ToolSpec {
286            name: name.to_string(),
287            description: description.to_string(),
288            parameters: json!({"type": "object", "properties": {}}),
289        }
290    }
291
292    fn specs() -> Vec<SpecWithHint> {
293        vec![
294            (
295                spec("ReadFile", "Read a UTF-8 text file under the workspace."),
296                Some("open file contents".to_string()),
297            ),
298            (
299                spec(
300                    "WriteFile",
301                    "Write content to a file. Overwrites if the file exists.",
302                ),
303                Some("save content to disk".to_string()),
304            ),
305            (
306                spec(
307                    "RunShell",
308                    "Execute a shell command and return stdout/stderr.",
309                ),
310                Some("bash command execution".to_string()),
311            ),
312            (
313                spec(
314                    "SearchFiles",
315                    "Find files whose path matches a glob pattern.",
316                ),
317                Some("glob filename lookup".to_string()),
318            ),
319            (
320                spec("WebFetch", "Fetch a URL and return its content as text."),
321                Some("download webpage html".to_string()),
322            ),
323            (
324                spec(
325                    "mcp__slack__send_message",
326                    "Send a message to a Slack channel.",
327                ),
328                None,
329            ),
330            (
331                spec(
332                    "mcp__github__create_pr",
333                    "Create a pull request on a GitHub repository.",
334                ),
335                None,
336            ),
337        ]
338    }
339
340    #[test]
341    fn exact_name_match_returns_single_result() {
342        let names = resolve("ReadFile", &specs(), 5);
343        assert_eq!(names, vec!["ReadFile".to_string()]);
344    }
345
346    #[test]
347    fn exact_name_match_is_case_insensitive() {
348        let names = resolve("readfile", &specs(), 5);
349        assert_eq!(names, vec!["ReadFile".to_string()]);
350    }
351
352    #[test]
353    fn mcp_prefix_returns_matching_tools() {
354        let names = resolve("mcp__slack", &specs(), 5);
355        assert_eq!(names, vec!["mcp__slack__send_message".to_string()]);
356    }
357
358    #[test]
359    fn keyword_search_uses_search_hint_weight() {
360        // "open" matches ReadFile's hint ("open file contents")
361        let names = resolve("open", &specs(), 5);
362        assert!(
363            names.first().map(|s| s.as_str()) == Some("ReadFile"),
364            "expected ReadFile first, got {:?}",
365            names
366        );
367    }
368
369    #[test]
370    fn keyword_search_splits_camel_case() {
371        // "shell" matches RunShell by name part
372        let names = resolve("shell", &specs(), 5);
373        assert!(
374            names.contains(&"RunShell".to_string()),
375            "expected RunShell in {:?}",
376            names
377        );
378    }
379
380    #[test]
381    fn required_term_filters_candidates() {
382        // +slack should require "slack" in name/description
383        let names = resolve("+slack send", &specs(), 5);
384        assert!(names.contains(&"mcp__slack__send_message".to_string()));
385    }
386
387    #[test]
388    fn required_term_excludes_non_matches() {
389        // +jupyter should match nothing (no jupyter in our specs)
390        let names = resolve("+jupyter notebook", &specs(), 5);
391        assert!(names.is_empty());
392    }
393
394    #[test]
395    fn empty_query_returns_empty() {
396        let names = resolve("", &specs(), 5);
397        assert!(names.is_empty());
398    }
399
400    #[test]
401    fn whitespace_only_query_returns_empty() {
402        let names = resolve("   ", &specs(), 5);
403        assert!(names.is_empty());
404    }
405
406    #[test]
407    fn max_results_caps_output() {
408        // Use a query that matches multiple MCP tools, then cap
409        // the output to 1. (A bare "mcp__" prefix is intentionally
410        // not a special case — see the fast path's `len() > 5`
411        // guard — so we use a keyword that hits both.)
412        let names = resolve("slack github", &specs(), 1);
413        assert_eq!(names.len(), 1);
414    }
415
416    #[test]
417    fn max_results_capped_at_internal_limit() {
418        let names = resolve("mcp__", &specs(), 1000);
419        assert!(names.len() <= MAX_RESULTS_CAP);
420    }
421
422    #[test]
423    fn word_boundary_prevents_substring_false_positive() {
424        // "shell" should NOT match a hypothetical "shellscript" via
425        // description substring (the spec below has "shellscript"
426        // in description; word-boundary match should not hit).
427        let s = vec![(
428            spec("shellscript", "Run a shellscript under the workspace."),
429            None,
430        )];
431        let names = resolve("shell", &s, 5);
432        // The name part "shell" matches "shellscript" via
433        // "contains", which is intentional (CamelCase parts use
434        // contains). The word-boundary check is for description
435        // and search_hint.
436        assert_eq!(names, vec!["shellscript".to_string()]);
437        // ... but description word-boundary should NOT match
438        // "shellscript" for the term "shell" because there is no
439        // word boundary.
440        let s2 = vec![(
441            spec("Unrelated", "Run a shellscript under the workspace."),
442            None,
443        )];
444        let names2 = resolve("shell", &s2, 5);
445        // "Unrelated" doesn't contain "shell" in any part, so
446        // description word-boundary should be the only signal —
447        // and that should not match.
448        assert!(names2.is_empty(), "expected empty, got {:?}", names2);
449    }
450
451    #[test]
452    fn parse_tool_name_handles_camel_case() {
453        let p = parse_tool_name("ReadFile");
454        assert_eq!(p.parts, vec!["read", "file"]);
455        assert_eq!(p.full, "read file");
456        assert!(!p.is_mcp);
457    }
458
459    #[test]
460    fn parse_tool_name_handles_mcp() {
461        let p = parse_tool_name("mcp__slack__send_message");
462        assert_eq!(p.parts, vec!["slack", "send", "message"]);
463        assert_eq!(p.full, "slack send message");
464        assert!(p.is_mcp);
465    }
466
467    #[test]
468    fn parse_tool_name_handles_snake_case() {
469        let p = parse_tool_name("my_tool_name");
470        assert_eq!(p.parts, vec!["my", "tool", "name"]);
471        assert!(!p.is_mcp);
472    }
473
474    #[test]
475    fn ordering_ranks_search_hint_above_description() {
476        // Two tools, one with a search_hint that matches, one
477        // without. The one with the hint should rank higher.
478        // Use the exact word "open" in both — word-boundary match
479        // does not collapse "opens" → "open".
480        let s = vec![
481            (
482                spec(
483                    "NoHintTool",
484                    "Something that can open a file in the editor.",
485                ),
486                None,
487            ),
488            (
489                spec("HintTool", "Does something else."),
490                Some("open a file".to_string()),
491            ),
492        ];
493        let names = resolve("open", &s, 5);
494        assert_eq!(
495            names.first().map(String::as_str),
496            Some("HintTool"),
497            "searchHint match should rank above description match: {:?}",
498            names
499        );
500    }
501
502    #[test]
503    fn no_match_returns_empty() {
504        let names = resolve("zzzzz_no_such_thing", &specs(), 5);
505        assert!(names.is_empty());
506    }
507}