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    // `select:A,B,C` — return named tools in order, skipping unknowns.
91    if let Some(names_str) = query_trim.strip_prefix("select:") {
92        let requested: Vec<&str> = names_str.split(',').map(|s| s.trim()).collect();
93        let mut result = Vec::new();
94        for req in requested {
95            if let Some((spec, _)) = candidates
96                .iter()
97                .find(|(s, _)| s.name.to_lowercase() == req)
98            {
99                result.push(spec.name.clone());
100            }
101        }
102        result.truncate(max_results);
103        return result;
104    }
105
106    // Fast path: exact (case-insensitive) name match.
107    if let Some((spec, _)) = candidates
108        .iter()
109        .find(|(s, _)| s.name.to_lowercase() == query_trim)
110    {
111        return vec![spec.name.clone()];
112    }
113
114    // MCP prefix fast path: "mcp__server" matches every tool
115    // whose name starts with that prefix.
116    if query_trim.starts_with("mcp__") && query_trim.len() > 5 {
117        let mut hits: Vec<String> = candidates
118            .iter()
119            .filter(|(s, _)| s.name.to_lowercase().starts_with(query_trim))
120            .map(|(s, _)| s.name.clone())
121            .collect();
122        if !hits.is_empty() {
123            hits.truncate(max_results);
124            return hits;
125        }
126    }
127
128    // Parse query terms: split on whitespace, treat leading `+` as
129    // "required". The remaining terms are optional.
130    let raw_terms: Vec<&str> = query_trim.split_whitespace().collect();
131    let mut required: Vec<String> = Vec::new();
132    let mut optional: Vec<String> = Vec::new();
133    for term in raw_terms {
134        if let Some(stripped) = term.strip_prefix('+') {
135            if !stripped.is_empty() {
136                required.push(stripped.to_string());
137            }
138        } else {
139            optional.push(term.to_string());
140        }
141    }
142    let scoring_terms: Vec<String> = if !required.is_empty() {
143        required.iter().chain(optional.iter()).cloned().collect()
144    } else {
145        optional.clone()
146    };
147
148    if scoring_terms.is_empty() {
149        return Vec::new();
150    }
151
152    // Pre-filter: a tool only enters scoring if it satisfies ALL
153    // required terms. Without required terms, every candidate is
154    // considered.
155    let filtered: Vec<&SpecWithHint> = if required.is_empty() {
156        candidates.iter().collect()
157    } else {
158        candidates
159            .iter()
160            .filter(|(s, hint)| {
161                let parsed = parse_tool_name(&s.name);
162                let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
163                let desc = s.description.to_lowercase();
164                required.iter().all(|term| {
165                    parsed.parts.iter().any(|p| p == term)
166                        || parsed.parts.iter().any(|p| p.contains(term.as_str()))
167                        || word_boundary_match(term, &desc)
168                        || (!hint_lower.is_empty() && word_boundary_match(term, &hint_lower))
169                })
170            })
171            .collect()
172    };
173
174    let mut scored: Vec<(String, i32)> = filtered
175        .into_iter()
176        .map(|(spec, hint)| {
177            let parsed = parse_tool_name(&spec.name);
178            let desc_lower = spec.description.to_lowercase();
179            let hint_lower = hint.as_deref().unwrap_or("").to_lowercase();
180            let mut score: i32 = 0;
181            for term in &scoring_terms {
182                // Name part match (highest weight)
183                if parsed.parts.iter().any(|p| p == term) {
184                    score += if parsed.is_mcp {
185                        weights::NAME_EXACT_MCP
186                    } else {
187                        weights::NAME_EXACT_REGULAR
188                    };
189                } else if parsed.parts.iter().any(|p| p.contains(term.as_str())) {
190                    score += if parsed.is_mcp {
191                        weights::NAME_PARTIAL_MCP
192                    } else {
193                        weights::NAME_PARTIAL_REGULAR
194                    };
195                }
196                // Full name substring fallback (only when nothing
197                // else has scored yet for this term)
198                if score == 0 && parsed.full.contains(term.as_str()) {
199                    score += weights::NAME_FALLBACK;
200                }
201                // search_hint match (curated capability phrase)
202                if !hint_lower.is_empty() && word_boundary_match(term, &hint_lower) {
203                    score += weights::SEARCH_HINT;
204                }
205                // Description match
206                if word_boundary_match(term, &desc_lower) {
207                    score += weights::DESCRIPTION;
208                }
209            }
210            (spec.name.clone(), score)
211        })
212        .filter(|(_, score)| *score > 0)
213        .collect();
214
215    scored.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
216    scored.truncate(max_results);
217    scored.into_iter().map(|(name, _)| name).collect()
218}
219
220/// Split a tool name into searchable parts. Handles both MCP names
221/// (`mcp__server__action`) and CamelCase (`ReadFile`).
222struct ParsedName {
223    parts: Vec<String>,
224    full: String,
225    is_mcp: bool,
226}
227
228fn parse_tool_name(name: &str) -> ParsedName {
229    if let Some(stripped) = name.strip_prefix("mcp__") {
230        let lowered = stripped.to_lowercase();
231        let parts: Vec<String> = lowered
232            .split("__")
233            .flat_map(|p| p.split('_'))
234            .filter(|p| !p.is_empty())
235            .map(String::from)
236            .collect();
237        let full = lowered.replace("__", " ").replace('_', " ");
238        ParsedName {
239            parts,
240            full,
241            is_mcp: true,
242        }
243    } else {
244        // Split the original CamelCase boundary then on underscores.
245        let mut parts: Vec<String> = Vec::new();
246        let mut buf = String::new();
247        for ch in name.chars() {
248            if ch == '_' {
249                if !buf.is_empty() {
250                    parts.push(buf.to_lowercase());
251                    buf.clear();
252                }
253            } else if ch.is_uppercase() {
254                if !buf.is_empty() {
255                    parts.push(buf.to_lowercase());
256                    buf.clear();
257                }
258                buf.push(ch.to_ascii_lowercase());
259            } else {
260                buf.push(ch.to_ascii_lowercase());
261            }
262        }
263        if !buf.is_empty() {
264            parts.push(buf);
265        }
266        let full = parts.join(" ");
267        ParsedName {
268            parts,
269            full,
270            is_mcp: false,
271        }
272    }
273}
274
275/// Word-boundary regex match. Avoids substring false positives
276/// like "slack" matching "slacks" or "jack".
277fn word_boundary_match(term: &str, haystack: &str) -> bool {
278    if term.is_empty() {
279        return false;
280    }
281    let mut start = 0;
282    while let Some(pos) = haystack[start..].find(term) {
283        let abs = start + pos;
284        let before_ok = abs == 0 || !haystack.as_bytes()[abs - 1].is_ascii_alphanumeric();
285        let end = abs + term.len();
286        let after_ok = end == haystack.len() || !haystack.as_bytes()[end].is_ascii_alphanumeric();
287        if before_ok && after_ok {
288            return true;
289        }
290        start = abs + 1;
291    }
292    false
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use serde_json::json;
299
300    fn spec(name: &str, description: &str) -> ToolSpec {
301        ToolSpec {
302            name: name.to_string(),
303            description: description.to_string(),
304            parameters: json!({"type": "object", "properties": {}}),
305        }
306    }
307
308    fn specs() -> Vec<SpecWithHint> {
309        vec![
310            (
311                spec("ReadFile", "Read a UTF-8 text file under the workspace."),
312                Some("open file contents".to_string()),
313            ),
314            (
315                spec(
316                    "WriteFile",
317                    "Write content to a file. Overwrites if the file exists.",
318                ),
319                Some("save content to disk".to_string()),
320            ),
321            (
322                spec(
323                    "RunShell",
324                    "Execute a shell command and return stdout/stderr.",
325                ),
326                Some("bash command execution".to_string()),
327            ),
328            (
329                spec(
330                    "SearchFiles",
331                    "Find files whose path matches a glob pattern.",
332                ),
333                Some("glob filename lookup".to_string()),
334            ),
335            (
336                spec("WebFetch", "Fetch a URL and return its content as text."),
337                Some("download webpage html".to_string()),
338            ),
339            (
340                spec(
341                    "mcp__slack__send_message",
342                    "Send a message to a Slack channel.",
343                ),
344                None,
345            ),
346            (
347                spec(
348                    "mcp__github__create_pr",
349                    "Create a pull request on a GitHub repository.",
350                ),
351                None,
352            ),
353        ]
354    }
355
356    #[test]
357    fn exact_name_match_returns_single_result() {
358        let names = resolve("ReadFile", &specs(), 5);
359        assert_eq!(names, vec!["ReadFile".to_string()]);
360    }
361
362    #[test]
363    fn exact_name_match_is_case_insensitive() {
364        let names = resolve("readfile", &specs(), 5);
365        assert_eq!(names, vec!["ReadFile".to_string()]);
366    }
367
368    #[test]
369    fn mcp_prefix_returns_matching_tools() {
370        let names = resolve("mcp__slack", &specs(), 5);
371        assert_eq!(names, vec!["mcp__slack__send_message".to_string()]);
372    }
373
374    #[test]
375    fn keyword_search_uses_search_hint_weight() {
376        // "open" matches ReadFile's hint ("open file contents")
377        let names = resolve("open", &specs(), 5);
378        assert!(
379            names.first().map(|s| s.as_str()) == Some("ReadFile"),
380            "expected ReadFile first, got {:?}",
381            names
382        );
383    }
384
385    #[test]
386    fn keyword_search_splits_camel_case() {
387        // "shell" matches RunShell by name part
388        let names = resolve("shell", &specs(), 5);
389        assert!(
390            names.contains(&"RunShell".to_string()),
391            "expected RunShell in {:?}",
392            names
393        );
394    }
395
396    #[test]
397    fn required_term_filters_candidates() {
398        // +slack should require "slack" in name/description
399        let names = resolve("+slack send", &specs(), 5);
400        assert!(names.contains(&"mcp__slack__send_message".to_string()));
401    }
402
403    #[test]
404    fn required_term_excludes_non_matches() {
405        // +jupyter should match nothing (no jupyter in our specs)
406        let names = resolve("+jupyter notebook", &specs(), 5);
407        assert!(names.is_empty());
408    }
409
410    #[test]
411    fn empty_query_returns_empty() {
412        let names = resolve("", &specs(), 5);
413        assert!(names.is_empty());
414    }
415
416    #[test]
417    fn whitespace_only_query_returns_empty() {
418        let names = resolve("   ", &specs(), 5);
419        assert!(names.is_empty());
420    }
421
422    #[test]
423    fn max_results_caps_output() {
424        // Use a query that matches multiple MCP tools, then cap
425        // the output to 1. (A bare "mcp__" prefix is intentionally
426        // not a special case — see the fast path's `len() > 5`
427        // guard — so we use a keyword that hits both.)
428        let names = resolve("slack github", &specs(), 1);
429        assert_eq!(names.len(), 1);
430    }
431
432    #[test]
433    fn max_results_capped_at_internal_limit() {
434        let names = resolve("mcp__", &specs(), 1000);
435        assert!(names.len() <= MAX_RESULTS_CAP);
436    }
437
438    #[test]
439    fn word_boundary_prevents_substring_false_positive() {
440        // "shell" should NOT match a hypothetical "shellscript" via
441        // description substring (the spec below has "shellscript"
442        // in description; word-boundary match should not hit).
443        let s = vec![(
444            spec("shellscript", "Run a shellscript under the workspace."),
445            None,
446        )];
447        let names = resolve("shell", &s, 5);
448        // The name part "shell" matches "shellscript" via
449        // "contains", which is intentional (CamelCase parts use
450        // contains). The word-boundary check is for description
451        // and search_hint.
452        assert_eq!(names, vec!["shellscript".to_string()]);
453        // ... but description word-boundary should NOT match
454        // "shellscript" for the term "shell" because there is no
455        // word boundary.
456        let s2 = vec![(
457            spec("Unrelated", "Run a shellscript under the workspace."),
458            None,
459        )];
460        let names2 = resolve("shell", &s2, 5);
461        // "Unrelated" doesn't contain "shell" in any part, so
462        // description word-boundary should be the only signal —
463        // and that should not match.
464        assert!(names2.is_empty(), "expected empty, got {:?}", names2);
465    }
466
467    #[test]
468    fn parse_tool_name_handles_camel_case() {
469        let p = parse_tool_name("ReadFile");
470        assert_eq!(p.parts, vec!["read", "file"]);
471        assert_eq!(p.full, "read file");
472        assert!(!p.is_mcp);
473    }
474
475    #[test]
476    fn parse_tool_name_handles_mcp() {
477        let p = parse_tool_name("mcp__slack__send_message");
478        assert_eq!(p.parts, vec!["slack", "send", "message"]);
479        assert_eq!(p.full, "slack send message");
480        assert!(p.is_mcp);
481    }
482
483    #[test]
484    fn parse_tool_name_handles_snake_case() {
485        let p = parse_tool_name("my_tool_name");
486        assert_eq!(p.parts, vec!["my", "tool", "name"]);
487        assert!(!p.is_mcp);
488    }
489
490    #[test]
491    fn ordering_ranks_search_hint_above_description() {
492        // Two tools, one with a search_hint that matches, one
493        // without. The one with the hint should rank higher.
494        // Use the exact word "open" in both — word-boundary match
495        // does not collapse "opens" → "open".
496        let s = vec![
497            (
498                spec(
499                    "NoHintTool",
500                    "Something that can open a file in the editor.",
501                ),
502                None,
503            ),
504            (
505                spec("HintTool", "Does something else."),
506                Some("open a file".to_string()),
507            ),
508        ];
509        let names = resolve("open", &s, 5);
510        assert_eq!(
511            names.first().map(String::as_str),
512            Some("HintTool"),
513            "searchHint match should rank above description match: {:?}",
514            names
515        );
516    }
517
518    #[test]
519    fn no_match_returns_empty() {
520        let names = resolve("zzzzz_no_such_thing", &specs(), 5);
521        assert!(names.is_empty());
522    }
523
524    // ── word_boundary_match direct tests ──────────────────────────────────────
525
526    #[test]
527    fn word_boundary_empty_term_returns_false() {
528        assert!(!word_boundary_match("", "hello world"));
529    }
530
531    #[test]
532    fn word_boundary_full_word_matches() {
533        assert!(word_boundary_match("shell", "run shell command"));
534        assert!(word_boundary_match("read", "read a file"));
535    }
536
537    #[test]
538    fn word_boundary_word_at_start_of_string() {
539        // Term at start: before_ok = abs == 0
540        // Kills + with - at abs = start + pos (abs would be wrong)
541        assert!(word_boundary_match("hello", "hello world"));
542    }
543
544    #[test]
545    fn word_boundary_word_at_end_of_string() {
546        // Term at end: after_ok = end == haystack.len()
547        // Kills + with - at end = abs + term.len()
548        assert!(word_boundary_match("world", "hello world"));
549    }
550
551    #[test]
552    fn word_boundary_substring_not_at_boundary_returns_false() {
553        // "shell" in "shellscript" — no word boundary at end
554        assert!(!word_boundary_match("shell", "shellscript"));
555        // "read" in "thread" — no word boundary at start
556        assert!(!word_boundary_match("read", "thread"));
557    }
558
559    #[test]
560    fn word_boundary_second_occurrence_matches() {
561        // First occurrence has no word boundary, second does
562        // Kills mutations in start = abs + 1 (loop advance)
563        assert!(word_boundary_match("file", "afile file"));
564    }
565
566    #[test]
567    fn word_boundary_exact_match_whole_string() {
568        // Single-word haystack: before_ok = abs == 0, after_ok = end == len
569        assert!(word_boundary_match("read", "read"));
570    }
571
572    #[test]
573    fn word_boundary_separator_is_non_alphanumeric() {
574        // Separator can be dash, slash, dot, etc.
575        assert!(word_boundary_match("read", "read-file"));
576        assert!(word_boundary_match("file", "read-file"));
577        assert!(word_boundary_match("read", "read.write"));
578    }
579
580    // ── parse_tool_name edge cases ─────────────────────────────────────────────
581
582    #[test]
583    fn parse_tool_name_all_uppercase_splits_as_single() {
584        let p = parse_tool_name("EOF");
585        // All uppercase: 3 chars each uppercase — depends on CamelCase split heuristic
586        assert!(!p.parts.is_empty());
587        assert!(!p.is_mcp);
588    }
589
590    #[test]
591    fn parse_tool_name_mcp_empty_server_part() {
592        // "mcp__" only → no server or tool parts
593        let p = parse_tool_name("mcp__tool");
594        assert!(p.is_mcp);
595        assert!(p.parts.contains(&"tool".to_string()));
596    }
597}